Files
aptly-containerized/rootfs/usr/local/bin/aptly-init
T
f.weber 103ad311b7
CI / lint (push) Failing after 24s
CI / smoke-test (push) Failing after 2m4s
Release image / release (push) Successful in 23m18s
Release chart / release (push) Successful in 7s
Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines
Provides a self-contained, containerized aptly (Debian repo manager)
stack with independently releasable image and Helm chart versions.

- images/: aptly-server (aptly built from source, cross-compiled) and
  aptly-deb-builder (nfpm + dpkg-buildpackage) container images
- rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack
  scripts consumed identically by Compose and the Helm chart, driven
  by one declarative state.yaml contract
- compose/: test (ephemeral, open) and production docker-compose
  stacks with an nginx read/auth sidecar
- charts/aptly/: aptly-native Helm chart covering every security
  posture from fully open to authenticated read+write, Ingress and
  Gateway API support (usable in parallel for migration scenarios),
  metrics, and declarative repo/mirror/publish reconciliation via a
  Helm hook
- .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test)
  plus separately tagged image (image/v*) and chart (chart/v*)
  releases, weekly rebuilds, and a preflight workflow validating the
  runner's Docker/Helm-OCI capabilities
- pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's
  signKey annotation (Helm can't verify Ed25519 keys)
- docs/, README.md, charts/aptly/README.md: usage, security, and
  versioning documentation
2026-08-12 12:21:08 +02:00

181 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# aptly-init — runs once as an initContainer (Helm) or a one-shot service (Compose).
# Produces everything the aptly and nginx containers consume, so neither of them
# has to guess about start-order or generate secrets themselves:
#
# /run/aptly/aptly.yaml rendered aptly config (env placeholders resolved)
# /run/aptly/htpasswd nginx basic-auth file (hashed here, never in a template)
# /run/aptly/gnupg/ GNUPGHOME, imported signing key + trusted keys
# /run/aptly/signing.json the one source of truth for "how do I sign a publish"
# /run/aptly/pub/signing-key.asc armored public key, served by nginx
#
# Inputs (all optional unless noted):
# APTLY_CONFIG_SRC path to the un-interpolated aptly config (required)
# APTLY_CONFIG_DST path to write the rendered config (required)
# APTLY_USERS_FILE plaintext "user:pass" lines, one per line, to hash into htpasswd
# APTLY_HTPASSWD_SRC pre-hashed htpasswd file to use as-is (wins over APTLY_USERS_FILE)
# APTLY_INTERNAL_USER username always appended to htpasswd (used by the reconcile job)
# APTLY_INTERNAL_PASSWORD password for APTLY_INTERNAL_USER
# APTLY_GPG_PRIVATE_KEY_FILE armored private key
# APTLY_GPG_SECRET_KEYRING_FILE binary secring.gpg (alternative to the above)
# APTLY_GPG_PASSPHRASE_FILE file containing the passphrase, if any
# APTLY_GPG_PUBLIC_KEY_FILE armored public key (derived from the private key if absent)
# APTLY_GPG_KEYS_DIR directory of extra *.asc files to import as trusted (mirror) keys
# APTLY_GPG_ENABLED "true"/"false" — false disables signing entirely (see below)
# APTLY_RUN_DIR defaults to /run/aptly
set -euo pipefail
# Deliberately does NOT source lib/common.sh: that helper requires APTLY_URL
# (it's built for the REST-API scripts), and aptly-init makes no API calls —
# coupling it to that requirement would make aptly-init fail outside a
# context that sets APTLY_URL, which is wrong for an initContainer.
log() { printf '[aptly-init] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
warn() { log "WARN: $*"; }
RUN_DIR="${APTLY_RUN_DIR:-/run/aptly}"
GNUPGHOME="${RUN_DIR}/gnupg"
mkdir -p "${RUN_DIR}" "${RUN_DIR}/pub"
install -d -m 0700 "${GNUPGHOME}"
export GNUPGHOME
# Ensure the publish directory exists before nginx ever tries to mount it.
# On Kubernetes, nginx's subPath mount of data/public happens at container
# creation, after this initContainer completes but with no other guarantee
# the directory has been created yet on a fresh, empty volume — aptly itself
# only creates it lazily on first publish.
if [[ -n "${APTLY_ROOT_DIR:-}" ]]; then
mkdir -p "${APTLY_ROOT_DIR}/public"
fi
# ---------------------------------------------------------------------------
# 1. Render the aptly config: resolve ${VAR} placeholders against the process
# environment. Bare `envsubst` (no argument) blanks every variable it does
# not know about, which would silently wreck a GPG passphrase containing a
# literal "$". We restrict substitution to a computed SHELL-FORMAT list of
# currently-exported vars instead.
# ---------------------------------------------------------------------------
: "${APTLY_CONFIG_SRC:?APTLY_CONFIG_SRC must point at the source aptly config}"
: "${APTLY_CONFIG_DST:?APTLY_CONFIG_DST must point at the rendered output path}"
mkdir -p "$(dirname -- "$APTLY_CONFIG_DST")"
shell_format="$(env | cut -d= -f1 | grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sed 's/^/${/;s/$/}/' | tr '\n' ' ')"
envsubst "${shell_format}" < "$APTLY_CONFIG_SRC" > "$APTLY_CONFIG_DST"
chmod 0640 "$APTLY_CONFIG_DST"
# Strip comment lines first: a source config documenting its own ${VAR}
# syntax in a comment would otherwise trip this check on itself.
# shellcheck disable=SC2016 # single quotes are deliberate: this is a regex, not shell expansion
if unresolved="$(grep -v '^[[:space:]]*#' "$APTLY_CONFIG_DST" \
| grep -o '\${[A-Za-z_][A-Za-z0-9_]*}' | sort -u)"; then
while IFS= read -r v; do
[[ -n "$v" ]] && log "WARN: unresolved placeholder ${v} left in rendered config"
done <<< "$unresolved"
fi
log "rendered config -> ${APTLY_CONFIG_DST}"
# ---------------------------------------------------------------------------
# 2. htpasswd. Hashing happens here, not in a Helm template: a template using
# sprig's htpasswd would pick a new random bcrypt salt on every render,
# changing the Secret on every `helm upgrade` and restart-looping the pod
# via the checksum/secret annotation. The Secret therefore carries
# plaintext "user:pass" lines (or a ready-made htpasswd, for the
# ExternalSecrets path) and this script does the one-time hashing.
# ---------------------------------------------------------------------------
HTPASSWD_OUT="${RUN_DIR}/htpasswd"
: > "$HTPASSWD_OUT"
if [[ -n "${APTLY_HTPASSWD_SRC:-}" && -s "${APTLY_HTPASSWD_SRC}" ]]; then
cat "${APTLY_HTPASSWD_SRC}" >> "$HTPASSWD_OUT"
log "using pre-hashed htpasswd from ${APTLY_HTPASSWD_SRC}"
elif [[ -n "${APTLY_USERS_FILE:-}" && -s "${APTLY_USERS_FILE}" ]]; then
while IFS=: read -r user pass; do
[[ -z "$user" || "$user" == \#* || -z "${pass:-}" ]] && continue
printf '%s:%s\n' "$user" "$(openssl passwd -apr1 -- "$pass")" >> "$HTPASSWD_OUT"
done < "${APTLY_USERS_FILE}"
log "hashed $(wc -l < "$HTPASSWD_OUT") user(s) from ${APTLY_USERS_FILE}"
fi
if [[ -n "${APTLY_INTERNAL_USER:-}" && -n "${APTLY_INTERNAL_PASSWORD:-}" ]]; then
printf '%s:%s\n' "$APTLY_INTERNAL_USER" "$(openssl passwd -apr1 -- "$APTLY_INTERNAL_PASSWORD")" >> "$HTPASSWD_OUT"
log "appended internal user '${APTLY_INTERNAL_USER}' (used by the reconcile job / cron)"
fi
# World-readable, not 0640: the nginx container reads this file as its own
# UID (101 upstream, or whatever securityContext.runAsUser is set to in the
# Helm chart), which has no relation to the aptly UID that wrote it. In
# Kubernetes, pod-level fsGroup would put both UIDs in a shared supplementary
# group instead — Compose has no equivalent, so this file has to be
# world-readable. It contains only apr1-hashed passwords, not plaintext.
chmod 0644 "$HTPASSWD_OUT"
# ---------------------------------------------------------------------------
# 3. GPG. GNUPGHOME lives on an in-memory emptyDir: the private key never
# touches a PersistentVolume, and the keyring is re-derived from Secrets
# on every start, so key rotation is just "restart the pod".
# ---------------------------------------------------------------------------
gpg_enabled="${APTLY_GPG_ENABLED:-true}"
signing_json="${RUN_DIR}/signing.json"
if [[ "$gpg_enabled" != "true" ]]; then
printf '{"skip": true}\n' > "$signing_json"
log "signing disabled (APTLY_GPG_ENABLED=false) -> ${signing_json}"
else
imported_any=false
if [[ -n "${APTLY_GPG_PRIVATE_KEY_FILE:-}" && -s "${APTLY_GPG_PRIVATE_KEY_FILE}" ]]; then
gpg --batch --import "${APTLY_GPG_PRIVATE_KEY_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
imported_any=true
fi
if [[ -n "${APTLY_GPG_SECRET_KEYRING_FILE:-}" && -s "${APTLY_GPG_SECRET_KEYRING_FILE}" ]]; then
gpg --batch --import "${APTLY_GPG_SECRET_KEYRING_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
imported_any=true
fi
if [[ "$imported_any" != "true" ]]; then
warn_msg="APTLY_GPG_ENABLED=true but no private key was provided"
log "WARN: ${warn_msg} — publishing will fail signature checks unless aptly.gpg.enabled is also false"
printf '{"skip": true}\n' > "$signing_json"
else
# Auto-detect the key id so nothing downstream has to guess it.
key_id="$(gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1=="sec"{print $5; exit}')"
[[ -z "$key_id" ]] && die "GPG key(s) imported but no secret key id could be detected"
passphrase_file=""
if [[ -n "${APTLY_GPG_PASSPHRASE_FILE:-}" && -s "${APTLY_GPG_PASSPHRASE_FILE}" ]]; then
passphrase_file="${APTLY_GPG_PASSPHRASE_FILE}"
fi
jq -n --arg keyId "$key_id" --arg keyring "" --arg secretKeyring "" \
--arg passphraseFile "$passphrase_file" \
'{skip: false, batch: true, gpgKey: $keyId} + (if $passphraseFile != "" then {passphraseFile: $passphraseFile} else {} end)' \
> "$signing_json"
log "signing key detected: ${key_id} -> ${signing_json}"
# Export the public key so nginx can serve it, and clients can `signed-by=` it.
pub_out="${RUN_DIR}/pub/signing-key.asc"
if [[ -n "${APTLY_GPG_PUBLIC_KEY_FILE:-}" && -s "${APTLY_GPG_PUBLIC_KEY_FILE}" ]]; then
cp "${APTLY_GPG_PUBLIC_KEY_FILE}" "$pub_out"
else
gpg --batch --armor --export "$key_id" > "$pub_out"
fi
log "public key exported -> ${pub_out}"
fi
# Trusted keys for mirror verification (aptly.gpgKeys[] in values / mirrors.gpgKeys in state.yaml)
if [[ -n "${APTLY_GPG_KEYS_DIR:-}" && -d "${APTLY_GPG_KEYS_DIR}" ]]; then
shopt -s nullglob
for f in "${APTLY_GPG_KEYS_DIR}"/*.asc "${APTLY_GPG_KEYS_DIR}"/*.gpg; do
[[ -e "$f" ]] || continue
if gpg --batch --import "$f" 2>&1 | while read -r l; do log "gpg: $l"; done; then
log "imported trusted key from ${f}"
else
warn "failed to import trusted key ${f} (continuing — mirrors referencing it will fail signature checks)"
fi
done
shopt -u nullglob
fi
fi
chmod -R go-rwx "${GNUPGHOME}" 2>/dev/null || true
log "done"