Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines
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

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
This commit is contained in:
2026-08-12 12:21:08 +02:00
commit 103ad311b7
71 changed files with 4843 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Entrypoint for the aptly-server image. Everything derived (rendered config,
# GPG keyring, htpasswd) is expected to already exist under APTLY_RUN_DIR,
# written by aptly-init in a preceding initContainer / compose service.
#
# Any argument other than "aptly api serve" (the default) is exec'd verbatim,
# so the same image is also usable as a one-off CLI/debug container:
# docker run --rm -it aptly-server bash
# docker run --rm aptly-server aptly version
set -euo pipefail
APTLY_ROOT_DIR="${APTLY_ROOT_DIR:-/var/lib/aptly}"
APTLY_RUN_DIR="${APTLY_RUN_DIR:-/run/aptly}"
APTLY_CONFIG="${APTLY_CONFIG:-${APTLY_RUN_DIR}/aptly.yaml}"
APTLY_API_LISTEN="${APTLY_API_LISTEN:-127.0.0.1:8080}"
mkdir -p "${APTLY_ROOT_DIR}"
if [[ "$#" -eq 0 ]]; then
set -- aptly api serve "-listen=${APTLY_API_LISTEN}" "-config=${APTLY_CONFIG}"
elif [[ "$1" == "aptly" && "$#" -eq 1 ]]; then
set -- aptly api serve "-listen=${APTLY_API_LISTEN}" "-config=${APTLY_CONFIG}"
fi
exec "$@"
+180
View File
@@ -0,0 +1,180 @@
#!/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"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# aptly-mirror-refresh — periodic content refresh for mirrors declared in
# state.yaml: fetch the mirror, snapshot it, switch every publish target that
# references it, then prune old snapshots. Intended to be run by an external
# scheduler (cron, a Compose --profile, a k8s CronJob you wire up yourself —
# this chart does not ship one, see docs/versioning.md "Nicht im Scope").
#
# Only touches mirrors listed in --mirrors (or all mirrors in state.yaml if
# omitted). Safe to run concurrently with aptly-reconcile against the same
# instance (both go through the REST API); NOT safe to run two instances of
# this script concurrently against the same mirror, since both would try to
# update+snapshot it at once — use your scheduler's concurrency policy
# (Gitea/k8s CronJob: concurrencyPolicy=Forbid) to prevent that.
#
# Env:
# APTLY_STATE_FILE path to state.yaml (required)
# APTLY_MIRRORS comma-separated mirror names to refresh (default: all)
# APTLY_KEEP_SNAPSHOTS how many historical snapshots to retain per mirror (default: 5)
# APTLY_SNAPSHOT_PREFIX snapshot name template prefix (default: mirror name)
# plus lib/common.sh's APTLY_URL / auth vars
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq yq date
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
KEEP="${APTLY_KEEP_SNAPSHOTS:-5}"
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
wanted_mirrors=()
if [[ -n "${APTLY_MIRRORS:-}" ]]; then
IFS=',' read -ra wanted_mirrors <<< "$APTLY_MIRRORS"
fi
wants() {
local name="$1"
[[ "${#wanted_mirrors[@]}" -eq 0 ]] && return 0
for m in "${wanted_mirrors[@]}"; do [[ "$m" == "$name" ]] && return 0; done
return 1
}
api_ready
failures=0
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<mirror_count; i++)); do
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$m")"
wants "$name" || continue
log "updating mirror '${name}'"
resp="$(api PUT "/api/mirrors/${name}" -H 'Content-Type: application/json' -d '{"ForceUpdate": true}')" \
|| { warn "mirror update failed for ${name}"; failures=$((failures+1)); continue; }
task_id="$(jq -r '.ID // empty' <<<"$resp")"
if ! wait_task "$task_id"; then
warn "mirror update task failed for ${name}, skipping snapshot/publish"
failures=$((failures+1))
continue
fi
snap_name="${name}-${STAMP}"
log "snapshotting '${name}' -> '${snap_name}'"
resp="$(api POST "/api/mirrors/${name}/snapshots" -H 'Content-Type: application/json' \
-d "$(jq -n --arg n "$snap_name" '{Name: $n}')")" \
|| { warn "snapshot failed for ${name}"; failures=$((failures+1)); continue; }
# Switch every publish target in state.yaml whose sources reference this mirror's snapshots.
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
for ((j=0; j<publish_count; j++)); do
p="$(jq ".publish[$j]" <<<"$STATE_JSON")"
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
[[ "$kind" == "snapshot" ]] || continue
refs_mirror="$(jq --arg m "$name" '[.sources[] | select(.name | startswith($m + "-"))] | length > 0' <<<"$p")"
[[ "$refs_mirror" == "true" ]] || continue
prefix="$(jq -r '.prefix // ""' <<<"$p")"
dist="$(jq -r '.distribution' <<<"$p")"
escaped_prefix="$(api_prefix "$prefix")"
component="$(jq -r '.sources[0].component // "main"' <<<"$p")"
log "switching publish prefix='${prefix}' distribution='${dist}' -> snapshot '${snap_name}'"
resp="$(api PUT "/api/publish/${escaped_prefix}/${dist}" -H 'Content-Type: application/json' \
-d "$(jq -n --arg n "$snap_name" --arg c "$component" '{Snapshots: [{Name: $n, Component: $c}]}')")" \
|| { warn "publish switch failed for ${prefix}/${dist}"; failures=$((failures+1)); continue; }
task_id="$(jq -r '.ID // empty' <<<"$resp")"
wait_task "$task_id" || { warn "publish switch task failed for ${prefix}/${dist}"; failures=$((failures+1)); }
done
# Prune old snapshots for this mirror beyond APTLY_KEEP_SNAPSHOTS, oldest first.
all_snaps="$(api GET "/api/snapshots" | jq -r --arg m "$name" \
'[.[] | select(.Name | startswith($m + "-"))] | sort_by(.CreatedAt) | .[].Name')"
total="$(wc -l <<<"$all_snaps" | tr -d ' ')"
to_prune=$(( total > KEEP ? total - KEEP : 0 ))
if [[ "$to_prune" -gt 0 ]]; then
log "pruning ${to_prune} old snapshot(s) for '${name}' (keeping ${KEEP})"
head -n "$to_prune" <<<"$all_snaps" | while IFS= read -r s; do
[[ -z "$s" ]] && continue
api DELETE "/api/snapshots/${s}" >/dev/null 2>&1 \
|| warn "could not drop snapshot ${s} (likely still referenced by a publish — will retry next run)"
done
fi
done
if [[ "$failures" -gt 0 ]]; then
die "${failures} mirror(s) failed to refresh — see warnings above"
fi
log "mirror refresh complete"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# aptly-pack — build a .deb without requiring Debian packaging knowledge.
#
# Two modes, auto-detected:
# 1. A debian/ directory exists in --source-dir -> delegate to the real
# Debian toolchain: `dpkg-buildpackage -us -uc -b` (binary-only, unsigned;
# signing is aptly's job at publish time, not the package's).
# 2. Otherwise -> nfpm (https://nfpm.goreleaser.com/), driven by a plain
# nfpm.yaml describing name/version/files — no debian/ dir needed.
#
# Only runs inside the aptly-deb-builder image, which has both toolchains.
#
# Usage:
# aptly-pack [--source-dir .] [--config nfpm.yaml] [--output-dir dist]
#
# For the nfpm path, target architecture is whatever `arch:` says in the
# nfpm.yaml itself (nfpm has no --arch flag — a config describes one arch;
# for multi-arch packages, run aptly-pack once per arch-specific config).
#
# Prints the path(s) of the produced .deb file(s) on stdout, one per line —
# meant to be fed straight into aptly-push:
# aptly-push --repo stable $(aptly-pack)
set -euo pipefail
log() { printf '[aptly-pack] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
SOURCE_DIR="."
CONFIG="nfpm.yaml"
OUTPUT_DIR="dist"
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--config) CONFIG="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd -- "$OUTPUT_DIR" && pwd)"
if [[ -d "${SOURCE_DIR}/debian" ]]; then
log "found ${SOURCE_DIR}/debian -> building with dpkg-buildpackage"
command -v dpkg-buildpackage >/dev/null 2>&1 || die "dpkg-buildpackage not found (wrong image?)"
(
cd "$SOURCE_DIR"
dpkg-buildpackage -us -uc -b
)
# dpkg-buildpackage drops artifacts one level above the source tree.
parent_dir="$(cd -- "${SOURCE_DIR}/.." && pwd)"
found=0
for f in "${parent_dir}"/*.deb; do
[[ -e "$f" ]] || continue
mv -- "$f" "$OUTPUT_DIR/"
printf '%s\n' "${OUTPUT_DIR}/$(basename -- "$f")"
found=1
done
[[ "$found" -eq 1 ]] || die "dpkg-buildpackage produced no .deb file"
else
cfg="${SOURCE_DIR}/${CONFIG}"
[[ -f "$cfg" ]] || die "no ${SOURCE_DIR}/debian and no nfpm config at ${cfg} — nothing to build"
command -v nfpm >/dev/null 2>&1 || die "nfpm not found (wrong image?)"
log "packaging with nfpm (config: ${cfg})"
(
cd "$SOURCE_DIR"
nfpm package --config "$(basename -- "$cfg")" --target "$OUTPUT_DIR" --packager deb
)
found=0
for f in "${OUTPUT_DIR}"/*.deb; do
[[ -e "$f" ]] || continue
printf '%s\n' "$f"
found=1
done
[[ "$found" -eq 1 ]] || die "nfpm produced no .deb file"
fi
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# aptly-push — upload one or more .deb/.dsc/.changes files into an aptly local
# repo and (by default) refresh the publish that serves it. Works unchanged
# against every security.preset in the Helm chart's matrix: pass credentials
# via APTLY_USER/APTLY_PASSWORD, APTLY_TOKEN, or neither for an open repo.
#
# Usage:
# aptly-push --repo stable [--prefix ""] [--distribution stable] \
# [--no-publish] [--no-force-replace] [--no-sign] FILE.deb [FILE2.deb ...]
#
# Signing note: aptly-push runs as an external actor (CI, a developer's
# laptop) and generally has no access to the aptly-server pod's GNUPGHOME or
# /run/aptly/signing.json — only aptly-init and aptly-reconcile, which run
# inside that pod, do. So by default aptly-push sends NO Signing field at all
# on the publish-refresh call, letting the server use its own configured
# default signer (this is the correct behaviour for a signed repo). Pass
# --no-sign (or APTLY_GPG_SIGN=false) only when you know the target repo is
# genuinely unsigned (aptly.gpg.enabled: false) — otherwise the update call
# will fail with a clear "no GPG key" error rather than silently publishing
# unsigned.
#
# Env (all overridable by the matching flag):
# APTLY_URL (required, from lib/common.sh) APTLY_REPO APTLY_PREFIX APTLY_DISTRIBUTION APTLY_GPG_SIGN
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq
REPO="${APTLY_REPO:-}"
PREFIX="${APTLY_PREFIX:-}"
DISTRIBUTION="${APTLY_DISTRIBUTION:-}"
DO_PUBLISH=true
FORCE_REPLACE=true
SIGN="${APTLY_GPG_SIGN:-true}"
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--distribution) DISTRIBUTION="$2"; shift 2 ;;
--no-publish) DO_PUBLISH=false; shift ;;
--no-force-replace) FORCE_REPLACE=false; shift ;;
--no-sign) SIGN=false; shift ;;
--) shift; FILES+=("$@"); break ;;
-*) die "unknown flag: $1" ;;
*) FILES+=("$1"); shift ;;
esac
done
[[ -n "$REPO" ]] || die "--repo (or APTLY_REPO) is required"
[[ "${#FILES[@]}" -gt 0 ]] || die "no files given"
for f in "${FILES[@]}"; do [[ -f "$f" ]] || die "file not found: $f"; done
UPLOAD_DIR="push-$(date +%s)-$$"
log "uploading ${#FILES[@]} file(s) into upload directory '${UPLOAD_DIR}'"
cleanup() { api DELETE "/api/files/${UPLOAD_DIR}" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for f in "${FILES[@]}"; do
base="$(basename -- "$f")"
log " -> ${base}"
api POST "/api/files/${UPLOAD_DIR}" -F "file=@${f}" >/dev/null
done
log "including uploaded files into repo '${REPO}'"
qs="forceReplace=$([[ "$FORCE_REPLACE" == "true" ]] && echo 1 || echo 0)"
resp="$(api POST "/api/repos/${REPO}/file/${UPLOAD_DIR}?${qs}")"
failed="$(jq -r '(.FailedFiles // []) | length' <<<"$resp")"
if [[ "$failed" -gt 0 ]]; then
jq -r '.FailedFiles[]' <<<"$resp" | while IFS= read -r ff; do log "FAILED: ${ff}"; done
jq -r '(.Report.Warnings // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r w; do log "warning: ${w}"; done
die "${failed} file(s) were rejected by the repo — see above"
fi
jq -r '(.Report.AddedLines // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r l; do log "added: ${l}"; done
log "included successfully into '${REPO}'"
if [[ "$DO_PUBLISH" != "true" ]]; then
log "skipping publish refresh (--no-publish)"
exit 0
fi
[[ -n "$DISTRIBUTION" ]] || { warn "no --distribution/APTLY_DISTRIBUTION given, skipping publish refresh"; exit 0; }
escaped_prefix="$(api_prefix "$PREFIX")"
log "refreshing publish prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}'"
update_body="{}"
[[ "$SIGN" == "false" ]] && update_body='{"Signing":{"Skip":true}}'
code_body="$(api_status POST "/api/publish/${escaped_prefix}/${DISTRIBUTION}/update" \
-H 'Content-Type: application/json' -d "$update_body")"
code="$(head -1 <<<"$code_body")"
body="$(tail -n +2 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
warn "no publish exists at prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}' yet."
warn "the package is in the repo but not yet reachable by clients — run aptly-reconcile" \
"(or 'helm upgrade') to create the publish target once, then re-run this push."
exit 0
elif [[ "$code" != 2* ]]; then
warn "publish refresh failed (HTTP ${code}): ${body}"
warn "the package IS in the repo '${REPO}' — only the publish step failed." \
"If this repo is signed, check that the server has a usable signing key" \
"(see docs/security.md); if it is meant to be unsigned, re-run with --no-sign."
exit 0
fi
task_id="$(jq -r '.ID // empty' <<<"$body")"
if ! wait_task "$task_id"; then
warn "publish refresh task failed — package is in the repo but not yet published, see task output above"
exit 0
fi
log "published successfully"
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# aptly-reconcile — converges a running aptly instance towards a declarative
# state.yaml document. Talks ONLY to the REST API, never to the aptly CLI:
# `aptly api serve` holds the LevelDB single-writer lock, and a CLI invocation
# against the same rootDir would either fail to acquire it or (with -no-lock)
# corrupt the database. Safe to run repeatedly (Helm post-install/post-upgrade
# hook, a plain Job, or by hand).
#
# Contract (state.yaml):
#
# localRepos:
# - name: internal-stable
# comment: ""
# defaultDistribution: stable
# defaultComponent: main
#
# mirrors:
# - name: debian-trixie
# archiveURL: http://deb.debian.org/debian
# distribution: trixie
# components: [main, contrib]
# architectures: [amd64, arm64]
# filter: ""
# filterWithDeps: false
# downloadSources: false
# downloadUdebs: false
# downloadInstaller: false
# ignoreSignatures: false
# keyrings: [] # usually empty: verification uses GNUPGHOME's
# # default keyring, already populated by aptly-init
# # from aptly.gpgKeys / state.gpgKeys.
#
# publish:
# - name: stable-root # reconcile-local handle, never sent to the API
# prefix: "" # "" = repo root
# distribution: stable
# sourceKind: local # local | snapshot
# sources: [{name: internal-stable, component: main}]
# architectures: [amd64, arm64]
# acquireByHash: true
# skipContents: false
# skipBz2: false
#
# Known, deliberate limitation: components[] on an EXISTING mirror cannot be
# changed via the API (aptly has no such endpoint) — changing them requires
# dropping and recreating the mirror by hand. Everything else in a mirror
# definition, and all of a local repo's metadata, IS kept in sync on every run.
#
# Env:
# APTLY_STATE_FILE path to state.yaml (required)
# APTLY_FAIL_ON_ERROR "true"/"false" (default: false) — false logs and
# continues past a failed item instead of aborting the
# whole run (an unreachable upstream mirror must not
# break `helm upgrade`)
# APTLY_DRY_RUN "true"/"false" (default: false) — print the planned
# calls without making them
# plus everything lib/common.sh's api()/wait_task() need (APTLY_URL, ...)
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq yq
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
FAIL_ON_ERROR="${APTLY_FAIL_ON_ERROR:-false}"
DRY_RUN="${APTLY_DRY_RUN:-false}"
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
item_failed=0
on_error() {
local what="$1"
item_failed=1
if [[ "$FAIL_ON_ERROR" == "true" ]]; then
die "reconcile failed on: ${what}"
else
warn "reconcile step failed, continuing (APTLY_FAIL_ON_ERROR=false): ${what}"
fi
}
do_api() {
# do_api METHOD PATH JSON_BODY -> prints body, returns curl's exit status
local method="$1" path="$2" body="$3"
if [[ "$DRY_RUN" == "true" ]]; then
log "[dry-run] ${method} ${path} ${body}"
printf '{}'
return 0
fi
api "$method" "$path" -H 'Content-Type: application/json' -d "$body"
}
api_ready
# ---------------------------------------------------------------------------
# Local repos
# ---------------------------------------------------------------------------
repo_count="$(jq '(.localRepos // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<repo_count; i++)); do
repo="$(jq ".localRepos[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$repo")"
body="$(jq '{Name: .name, Comment: (.comment // ""), DefaultDistribution: (.defaultDistribution // ""), DefaultComponent: (.defaultComponent // "")}' <<<"$repo")"
code_body="$(api_status GET "/api/repos/${name}")"
code="$(head -1 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
log "creating local repo '${name}'"
do_api POST "/api/repos" "$body" >/dev/null || on_error "create local repo ${name}"
else
log "syncing local repo '${name}' metadata"
do_api PUT "/api/repos/${name}" "$body" >/dev/null || on_error "edit local repo ${name}"
fi
done
# ---------------------------------------------------------------------------
# Mirrors
# ---------------------------------------------------------------------------
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<mirror_count; i++)); do
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$m")"
code_body="$(api_status GET "/api/mirrors/${name}")"
code="$(head -1 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
body="$(jq '{
Name: .name, ArchiveURL: .archiveURL, Distribution: .distribution,
Filter: (.filter // ""), Components: (.components // []),
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
DownloadInstaller: (.downloadInstaller // false), DownloadAppStream: (.downloadAppStream // false),
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
}' <<<"$m")"
log "creating mirror '${name}'"
do_api POST "/api/mirrors" "$body" >/dev/null || on_error "create mirror ${name}"
else
body="$(jq '{
ArchiveURL: .archiveURL, Filter: (.filter // ""),
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
DownloadInstaller: (.downloadInstaller // false),
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
}' <<<"$m")"
log "syncing mirror '${name}' definition (note: components[] cannot be changed post-creation)"
do_api POST "/api/mirrors/${name}" "$body" >/dev/null || on_error "edit mirror ${name}"
fi
done
# ---------------------------------------------------------------------------
# Publish targets
# ---------------------------------------------------------------------------
existing_publishes="$(api GET "/api/publish")"
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<publish_count; i++)); do
p="$(jq ".publish[$i]" <<<"$STATE_JSON")"
handle="$(jq -r '.name' <<<"$p")"
prefix="$(jq -r '.prefix // ""' <<<"$p")"
dist="$(jq -r '.distribution' <<<"$p")"
escaped_prefix="$(api_prefix "$prefix")"
# GET /api/publish returns the root prefix as the literal string "." (not
# ""), matching aptly's own storage convention — confirmed empirically, see
# docs/packaging.md. Nested prefixes are returned with real slashes, not
# underscore-escaped, so only the root case needs translating here.
prefix_for_compare="$prefix"
[[ -z "$prefix_for_compare" ]] && prefix_for_compare="."
found="$(jq --arg prefix "$prefix_for_compare" --arg dist "$dist" \
'[.[] | select(.Prefix == $prefix and .Distribution == $dist)] | length > 0' \
<<<"$existing_publishes")"
sources="$(jq '[.sources[] | {Component: .component, Name: .name}]' <<<"$p")"
signing="$(jq -n --slurpfile sj <(cat "${APTLY_SIGNING_JSON:-/run/aptly/signing.json}" 2>/dev/null || echo '{"skip":true}') '
($sj[0] // {skip:true}) as $s
| if $s.skip then {Skip: true}
else {Skip: false, GpgKey: ($s.gpgKey // ""), PassphraseFile: ($s.passphraseFile // "")} end')"
if [[ "$found" != "true" ]]; then
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
{
SourceKind: ($p.sourceKind // "local"),
Sources: $sources,
Distribution: $p.distribution,
Architectures: ($p.architectures // []),
AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false),
SkipBz2: ($p.skipBz2 // false),
Signing: $signing
}')"
log "creating publish '${handle}' at prefix='${prefix}' distribution='${dist}'"
do_api POST "/api/publish/${escaped_prefix}" "$body" >/dev/null || on_error "create publish ${handle}"
else
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
if [[ "$kind" == "local" ]]; then
body="$(jq -n --argjson signing "$signing" --argjson p "$p" '
{ Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
log "refreshing publish '${handle}' (local repo -> re-publish pending changes)"
resp="$(do_api POST "/api/publish/${escaped_prefix}/${dist}/update" "$body")" || { on_error "update publish ${handle}"; continue; }
else
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
{ Snapshots: $sources, Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
log "switching publish '${handle}' to configured snapshot(s)"
resp="$(do_api PUT "/api/publish/${escaped_prefix}/${dist}" "$body")" || { on_error "switch publish ${handle}"; continue; }
fi
if [[ "$DRY_RUN" != "true" ]]; then
task_id="$(jq -r '.ID // empty' <<<"$resp")"
[[ -n "$task_id" ]] && { wait_task "$task_id" || on_error "task for publish ${handle}"; }
fi
fi
done
if [[ "$item_failed" -eq 1 ]]; then
log "completed with warnings (APTLY_FAIL_ON_ERROR=false) — see above"
else
log "reconcile completed successfully"
fi
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# aptly-release — aptly-pack + aptly-push in one call. The whole point of the
# deb-builder image: one command turns a source tree into a package on the
# repo, whichever of the four security presets is in effect.
#
# Usage: aptly-release [aptly-pack flags] -- [aptly-push flags]
# Anything before "--" goes to aptly-pack; anything after goes to aptly-push
# (files are appended automatically, do not pass them yourself).
#
# Example:
# aptly-release --config nfpm.yaml -- --repo stable --distribution stable
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
log() { printf '[aptly-release] %s\n' "$*" >&2; }
pack_args=()
push_args=()
target=pack_args
for a in "$@"; do
if [[ "$a" == "--" && "$target" == "pack_args" ]]; then
target=push_args
continue
fi
if [[ "$target" == "pack_args" ]]; then pack_args+=("$a"); else push_args+=("$a"); fi
done
log "packaging..."
mapfile -t debs < <("${SCRIPT_DIR}/aptly-pack" "${pack_args[@]}")
[[ "${#debs[@]}" -gt 0 ]] || { log "aptly-pack produced no output"; exit 1; }
for d in "${debs[@]}"; do log " built: ${d}"; done
log "pushing..."
exec "${SCRIPT_DIR}/aptly-push" "${push_args[@]}" "${debs[@]}"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Shared helpers for aptly-init, aptly-reconcile, aptly-mirror-refresh, aptly-push.
# Sourced, not executed: no shebang execution, no `set` calls here (callers set their own).
log() { printf '[%s] %s\n' "$(basename "${0:-common.sh}")" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
warn() { log "WARN: $*"; }
# ---------------------------------------------------------------------------
# aptly REST API client
#
# Auth resolution order (first match wins):
# APTLY_TOKEN -> Authorization: Bearer <token>
# APTLY_USER + APTLY_PASSWORD -> HTTP Basic
# neither -> no Authorization header (open / read-only repo)
# ---------------------------------------------------------------------------
: "${APTLY_URL:?APTLY_URL must be set, e.g. http://aptly:8080 or https://apt.example.com}"
APTLY_URL="${APTLY_URL%/}"
APTLY_CURL_RETRIES="${APTLY_CURL_RETRIES:-5}"
APTLY_CURL_RETRY_DELAY="${APTLY_CURL_RETRY_DELAY:-3}"
_auth_args=()
if [[ -n "${APTLY_TOKEN:-}" ]]; then
_auth_args=(-H "Authorization: Bearer ${APTLY_TOKEN}")
elif [[ -n "${APTLY_USER:-}" && -n "${APTLY_PASSWORD:-}" ]]; then
_auth_args=(-u "${APTLY_USER}:${APTLY_PASSWORD}")
fi
# api METHOD PATH [curl-data-args...]
# PATH must start with /api/... Prints the response body on stdout.
# Non-2xx is a hard failure (die) — callers that need to inspect the status
# themselves should use api_status instead.
#
# --path-as-is is not optional: aptly's root-publish-prefix convention is the
# literal path segment ".", and curl's URL parser applies RFC 3986 dot-segment
# removal by default — silently rewriting ".../publish/." to ".../publish/"
# and ".../publish/./stable" to ".../publish/stable" before the request is
# even sent, which then 404s or hits the wrong (prefix-less) route.
api() {
local method="$1" path="$2"; shift 2
local out
# Deliberately no --retry-all-errors: that would also retry a deterministic
# 401 (wrong credentials) or 404 several times before giving up, turning an
# instant "wrong password" into a ~15s hang. Plain --retry already covers
# the transient cases that matter (connection errors, 429, 5xx).
out="$(curl -fsS --path-as-is --retry "$APTLY_CURL_RETRIES" --retry-delay "$APTLY_CURL_RETRY_DELAY" \
-X "$method" "${_auth_args[@]}" "${APTLY_URL}${path}" "$@")" \
|| die "API call failed: ${method} ${path}"
printf '%s' "$out"
}
# api_status METHOD PATH [curl-data-args...]
# Prints "HTTPSTATUS<newline>BODY". Never dies on non-2xx — caller decides.
api_status() {
local method="$1" path="$2"; shift 2
curl -sS --path-as-is -o /tmp/api_status.body -w '%{http_code}' -X "$method" \
"${_auth_args[@]}" "${APTLY_URL}${path}" "$@" > /tmp/api_status.code \
|| die "API call errored (connection): ${method} ${path}"
printf '%s\n' "$(cat /tmp/api_status.code)"
cat /tmp/api_status.body
}
# api_ready: block until /api/ready answers 200, or die after timeout.
api_ready() {
local timeout="${APTLY_WAIT_TIMEOUT:-600}" waited=0
log "waiting for ${APTLY_URL}/api/ready (timeout ${timeout}s)"
until curl -fsS -o /dev/null "${APTLY_URL}/api/ready"; do
(( waited >= timeout )) && die "aptly not ready after ${timeout}s"
sleep 3
waited=$(( waited + 3 ))
done
log "aptly is ready (waited ${waited}s)"
}
# wait_task TASK_ID: poll /api/tasks/:id until State is 2 (succeeded) or 3 (failed).
# State enum per aptly's task package: 0 init, 1 running, 2 succeeded, 3 failed.
wait_task() {
local id="$1" timeout="${APTLY_TASK_TIMEOUT:-3600}" waited=0 state
[[ -z "$id" || "$id" == "null" ]] && return 0
while true; do
state="$(api GET "/api/tasks/${id}" | jq -r '.State')"
case "$state" in
2) return 0 ;;
3)
log "task ${id} failed, output:"
api GET "/api/tasks/${id}/output" >&2 || true
return 1
;;
esac
(( waited >= timeout )) && die "task ${id} did not finish within ${timeout}s (last state: ${state})"
sleep 2
waited=$(( waited + 2 ))
done
}
# api_prefix PREFIX: map a human-readable publish prefix to aptly's URL-escaped form.
# "" -> "." "debian/stable" -> "debian_stable"
# See aptly's api/publish.go: ParsePrefix / EscapePrefix semantics.
api_prefix() {
local p="$1"
if [[ -z "$p" ]]; then
printf '.'
else
printf '%s' "${p//\//_}"
fi
}
require_cmd() {
for c in "$@"; do
command -v "$c" >/dev/null 2>&1 || die "required command not found: $c"
done
}