mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 21:26:44 +00:00
Brings up the package-cache compose service alongside the existing
registry-cache, gated by the same `cache` profile. Bootstrap helper
(scripts/docker/cache/package.sh) auto-rotates the admin password from
the well-known default, accepts the Sonatype CE EULA (3.71+ blocks
artifact downloads with HTTP 403 until /v1/system/eula is POSTed —
disclaimer must be sent verbatim with its unicode quotes), enables
anonymous read, creates one default blobstore, and registers the MVP
proxy set: apt-debian, apt-ubuntu, pypi-proxy, npm-proxy, helm-bitnami,
raw-githubusercontent. The runner gets pip.conf / .npmrc /
sources.list snippets bind-mounted in (each defaulting to /dev/null
when the cache profile is inactive) so the deploy is byte-identical
with cache off.
Cache plumbing:
- scripts/meta/env/cache/{registry,package}.sh: per-cache env defaults,
hostname-derived admin password seed (uses `hostname` command, not
$HOSTNAME shell var, so the value is identical across interactive
shells, non-interactive subshells, and Python subprocesses).
- scripts/docker/cache/{registry-ca,package}.sh: container-side
bootstrap helpers, both moved under scripts/docker/cache/.
- scripts/system/cache/clean.sh + `make cache-clean`: wipes both
/var/cache/infinito/core/cache/{registry,package} dirs and stops
the cache containers.
- cli/deploy/development/compose.py: Profile gate exports the three
new INFINITO_PACKAGE_CACHE_* client-snippet paths and runs the
bootstrap helper after the package-cache reports healthy.
Compose service uses sonatype/nexus3:3.91.1-ubi pinned, runs as
root (UBI image refuses to chown a fresh bind-mount), and forces
NEXUS_SECURITY_RANDOMPASSWORD=false so the bootstrap can log in with
admin/admin123 before rotating; the random-password mode otherwise
auto-completes onboarding silently and leaves no readable secret on
disk for an automated bootstrap to pick up.
Tests: tests/unit/cli/deploy/development/test_compose.py grows two
new assertions (cache services listed in `up` args when local; build
tests pin CI=true to retain ci-only assertions).
Closes req-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
10 KiB
Bash
Executable File
225 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# shellcheck shell=bash
|
||
#
|
||
# Idempotent bootstrap for the Sonatype Nexus 3 OSS proxy repositories
|
||
# served by the `package-cache` compose service. Invoked from
|
||
# cli/deploy/development/up.py after the stack is healthy whenever the
|
||
# `cache` profile is active.
|
||
#
|
||
# Reads the auto-generated admin password from the bind-mounted
|
||
# /nexus-data, rotates it to ${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD},
|
||
# creates one default blobstore with quota
|
||
# ${INFINITO_PACKAGE_CACHE_BLOBSTORE_MAX}, and creates the MVP set of
|
||
# proxy repositories. Repeated invocations no-op once the system is
|
||
# converged.
|
||
#
|
||
# See docs/requirements/012-package-cache-nexus3-oss.md.
|
||
|
||
set -euo pipefail
|
||
|
||
: "${INFINITO_PACKAGE_CACHE_HOST_PATH:?Source scripts/meta/env/cache/package.sh first}"
|
||
: "${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD:?Source scripts/meta/env/cache/package.sh first}"
|
||
: "${INFINITO_PACKAGE_CACHE_BLOBSTORE_MAX:?Source scripts/meta/env/cache/package.sh first}"
|
||
|
||
# All REST calls go through `docker exec` against the package-cache
|
||
# container's curl. This sidesteps host-vs-sandbox network namespace
|
||
# differences (the published port is reachable from a normal host
|
||
# shell but not from the sandbox the dev tooling may run in) and
|
||
# avoids depending on whether BIND_IP forwards to localhost. The
|
||
# nexus3 UBI image ships curl, not wget.
|
||
PKGCACHE_CONTAINER="infinito-package-cache"
|
||
NEXUS_REST="http://127.0.0.1:8081/service/rest"
|
||
|
||
# Marker file lives INSIDE the container at /nexus-data, not at the
|
||
# host bind-mount path. Writing through `docker exec` works regardless
|
||
# of host-side sandbox restrictions on /var/cache; the bind-mount means
|
||
# the file is still visible from the host for inspection.
|
||
BOOTSTRAP_DONE_FILE="/nexus-data/.infinito-bootstrap-done"
|
||
|
||
# Default admin password baked into Nexus when
|
||
# `NEXUS_SECURITY_RANDOMPASSWORD=false` (set in compose.yml). The
|
||
# helper logs in with this on first boot and rotates to the operator's
|
||
# INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD so subsequent calls authenticate
|
||
# with the configured value. Random-password mode is unusable here
|
||
# because Nexus auto-completes onboarding silently and leaves no
|
||
# readable secret on disk for an automated bootstrap to pick up.
|
||
NEXUS_DEFAULT_PASSWORD="admin123"
|
||
|
||
log() { printf '[package-cache-bootstrap] %s\n' "$*" >&2; }
|
||
|
||
nexus_curl() { docker exec "${PKGCACHE_CONTAINER}" curl "$@"; }
|
||
|
||
# Wait until Nexus's REST API answers 200 on /v1/status. Compose
|
||
# already gates infinito on package-cache health, but bootstrap can
|
||
# also be called manually before infinito starts; the wait keeps
|
||
# both paths reliable.
|
||
wait_for_nexus() {
|
||
local _attempt
|
||
for _attempt in $(seq 1 120); do
|
||
if nexus_curl -fsS "${NEXUS_REST}/v1/status" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
sleep 2
|
||
done
|
||
log "Nexus REST not reachable at ${NEXUS_REST}/v1/status after 4 minutes"
|
||
return 1
|
||
}
|
||
|
||
# Resolve the admin password to use for REST calls. Two cases:
|
||
# 1. Already bootstrapped (BOOTSTRAP_DONE_FILE exists): use
|
||
# INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD directly.
|
||
# 2. First bootstrap: log in with the well-known default `admin123`
|
||
# (compose.yml pins NEXUS_SECURITY_RANDOMPASSWORD=false), rotate to
|
||
# the operator's password, mark done.
|
||
rotate_admin_password() {
|
||
ADMIN_USER="admin"
|
||
if docker exec "${PKGCACHE_CONTAINER}" test -f "${BOOTSTRAP_DONE_FILE}"; then
|
||
log "Already bootstrapped; using stored admin password"
|
||
ADMIN_PASS="${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD}"
|
||
return 0
|
||
fi
|
||
|
||
log "Rotating admin password from default '${NEXUS_DEFAULT_PASSWORD}'"
|
||
if ! nexus_curl -fsS -u "admin:${NEXUS_DEFAULT_PASSWORD}" \
|
||
-H "Content-Type: text/plain" \
|
||
-X PUT "${NEXUS_REST}/v1/security/users/admin/change-password" \
|
||
--data-binary "${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD}"; then
|
||
# Default password may already have been rotated by a previous
|
||
# (partially-completed) bootstrap. Probe with the configured
|
||
# password before giving up.
|
||
if nexus_curl -fsS -o /dev/null -u "admin:${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD}" \
|
||
"${NEXUS_REST}/v1/repositories" >/dev/null 2>&1; then
|
||
log "Default rotation rejected but configured password authenticates; assuming previous bootstrap, marking done"
|
||
else
|
||
log "Cannot authenticate with default '${NEXUS_DEFAULT_PASSWORD}' nor with configured INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD."
|
||
log "Wipe ${INFINITO_PACKAGE_CACHE_HOST_PATH} (e.g. via 'make cache-clean') and retry."
|
||
return 1
|
||
fi
|
||
fi
|
||
docker exec "${PKGCACHE_CONTAINER}" touch "${BOOTSTRAP_DONE_FILE}"
|
||
ADMIN_PASS="${INFINITO_PACKAGE_CACHE_ADMIN_PASSWORD}"
|
||
}
|
||
|
||
# Ensure a single named blobstore "default" with quota equal to
|
||
# INFINITO_PACKAGE_CACHE_BLOBSTORE_MAX. POST returns 201 on create,
|
||
# 400/409 if the name is taken; both are acceptable.
|
||
ensure_blobstore() {
|
||
# Quota expects MB integer; convert from "Ng".
|
||
local quota_gb="${INFINITO_PACKAGE_CACHE_BLOBSTORE_MAX%g}"
|
||
local quota_mb=$((quota_gb * 1024))
|
||
local payload
|
||
payload="$(printf '{"name":"default","path":"default","softQuota":{"type":"spaceUsedQuota","limit":%d}}' "${quota_mb}")"
|
||
local code
|
||
code="$(nexus_curl -sS -o /dev/null -w '%{http_code}' \
|
||
-u "${ADMIN_USER}:${ADMIN_PASS}" \
|
||
-H "Content-Type: application/json" \
|
||
-X POST "${NEXUS_REST}/v1/blobstores/file" \
|
||
--data "${payload}" || true)"
|
||
case "${code}" in
|
||
201 | 204) log "blobstore default created (HTTP ${code})" ;;
|
||
400 | 409) log "blobstore default already exists (HTTP ${code})" ;;
|
||
*)
|
||
log "unexpected HTTP ${code} creating blobstore"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Create one proxy repo. The Nexus REST API differs slightly per
|
||
# format, so we accept the per-format payload from the caller.
|
||
ensure_proxy_repo() {
|
||
local format="$1" name="$2" payload="$3"
|
||
local code
|
||
code="$(nexus_curl -sS -o /dev/null -w '%{http_code}' \
|
||
-u "${ADMIN_USER}:${ADMIN_PASS}" \
|
||
-H "Content-Type: application/json" \
|
||
-X POST "${NEXUS_REST}/v1/repositories/${format}/proxy" \
|
||
--data "${payload}" || true)"
|
||
case "${code}" in
|
||
201 | 204) log "${format} proxy ${name} created (HTTP ${code})" ;;
|
||
400 | 409) log "${format} proxy ${name} already exists (HTTP ${code})" ;;
|
||
*)
|
||
log "unexpected HTTP ${code} creating ${format} proxy ${name}"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
ensure_all_proxies() {
|
||
local storage='"storage":{"blobStoreName":"default","strictContentTypeValidation":true},"proxy":{"contentMaxAge":1440,"metadataMaxAge":1440},"negativeCache":{"enabled":true,"timeToLive":1440},"httpClient":{"blocked":false,"autoBlock":true}'
|
||
|
||
# apt-debian. Distribution required for apt format.
|
||
ensure_proxy_repo apt apt-debian "$(printf '{"name":"apt-debian","online":true,%s,"proxy":{"remoteUrl":"http://deb.debian.org/debian","contentMaxAge":1440,"metadataMaxAge":1440},"apt":{"distribution":"bookworm","flat":false}}' "${storage}")"
|
||
# apt-ubuntu.
|
||
ensure_proxy_repo apt apt-ubuntu "$(printf '{"name":"apt-ubuntu","online":true,%s,"proxy":{"remoteUrl":"http://archive.ubuntu.com/ubuntu","contentMaxAge":1440,"metadataMaxAge":1440},"apt":{"distribution":"jammy","flat":false}}' "${storage}")"
|
||
# pypi.
|
||
ensure_proxy_repo pypi pypi-proxy "$(printf '{"name":"pypi-proxy","online":true,%s,"proxy":{"remoteUrl":"https://pypi.org/","contentMaxAge":1440,"metadataMaxAge":1440}}' "${storage}")"
|
||
# npm.
|
||
ensure_proxy_repo npm npm-proxy "$(printf '{"name":"npm-proxy","online":true,%s,"proxy":{"remoteUrl":"https://registry.npmjs.org/","contentMaxAge":1440,"metadataMaxAge":1440}}' "${storage}")"
|
||
# helm-bitnami. Registered for future helm-driven roles; no client
|
||
# wiring today since no role pulls helm charts.
|
||
ensure_proxy_repo helm helm-bitnami "$(printf '{"name":"helm-bitnami","online":true,%s,"proxy":{"remoteUrl":"https://charts.bitnami.com/bitnami","contentMaxAge":1440,"metadataMaxAge":1440}}' "${storage}")"
|
||
# raw-githubusercontent.
|
||
ensure_proxy_repo raw raw-githubusercontent "$(printf '{"name":"raw-githubusercontent","online":true,%s,"proxy":{"remoteUrl":"https://raw.githubusercontent.com/","contentMaxAge":1440,"metadataMaxAge":1440},"raw":{"contentDisposition":"ATTACHMENT"}}' "${storage}")"
|
||
}
|
||
|
||
# Accept the Sonatype Nexus Repository - Community Edition EULA. Since
|
||
# 3.71+ the CE build refuses to serve cached artifacts (HTTP 403 with a
|
||
# `You must accept the End User License Agreement` body) until the EULA
|
||
# flag is flipped via REST. The simple index (and other metadata) is
|
||
# unaffected, so the symptom is "pip resolves but every .whl GET 403s".
|
||
# Repeated POSTs are accepted (204) once the flag is true.
|
||
ensure_eula_accepted() {
|
||
local code
|
||
# Sonatype rejects (HTTP 500) any payload whose `disclaimer` field is
|
||
# not byte-identical to the canonical text — including the curly
|
||
# unicode single quotes around `accepted:false` / `accepted:true`.
|
||
# Hence the SC1112 suppression: the unicode quotes are intentional.
|
||
# shellcheck disable=SC1112
|
||
code="$(nexus_curl -sS -o /dev/null -w '%{http_code}' \
|
||
-u "${ADMIN_USER}:${ADMIN_PASS}" \
|
||
-H "Content-Type: application/json" \
|
||
-X POST "${NEXUS_REST}/v1/system/eula" \
|
||
--data '{"accepted":true,"disclaimer":"Use of Sonatype Nexus Repository - Community Edition is governed by the End User License Agreement at https://links.sonatype.com/products/nxrm/ce-eula. By returning the value from ‘accepted:false’ to ‘accepted:true’, you acknowledge that you have read and agree to the End User License Agreement at https://links.sonatype.com/products/nxrm/ce-eula."}' || true)"
|
||
case "${code}" in
|
||
200 | 204) log "EULA accepted (HTTP ${code})" ;;
|
||
*)
|
||
log "unexpected HTTP ${code} accepting EULA"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# Enable anonymous read access globally so client tools (pip, npm,
|
||
# apt, helm) can hit /repository/<name>/... without credentials baked
|
||
# into pip.conf / .npmrc / sources.list. Nexus 3 ships with anonymous
|
||
# disabled; without this PUT every proxy GET returns 401 and pip
|
||
# prompts for a password (EOFError under non-interactive use).
|
||
ensure_anonymous_access() {
|
||
local code
|
||
code="$(nexus_curl -sS -o /dev/null -w '%{http_code}' \
|
||
-u "${ADMIN_USER}:${ADMIN_PASS}" \
|
||
-H "Content-Type: application/json" \
|
||
-X PUT "${NEXUS_REST}/v1/security/anonymous" \
|
||
--data '{"enabled":true,"userId":"anonymous","realmName":"NexusAuthorizingRealm"}' || true)"
|
||
case "${code}" in
|
||
200 | 204) log "anonymous access enabled (HTTP ${code})" ;;
|
||
*)
|
||
log "unexpected HTTP ${code} enabling anonymous access"
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
main() {
|
||
wait_for_nexus
|
||
rotate_admin_password
|
||
ensure_eula_accepted
|
||
ensure_anonymous_access
|
||
ensure_blobstore
|
||
ensure_all_proxies
|
||
log "bootstrap done"
|
||
}
|
||
|
||
main "$@"
|