Files
aptly-containerized/rootfs/usr/local/bin/aptly-reconcile
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

219 lines
9.5 KiB
Bash
Executable File

#!/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