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