Public Access
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
This commit is contained in:
Executable
+218
@@ -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
|
||||
Reference in New Issue
Block a user