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
+25
@@ -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 "$@"
|
||||
Executable
+180
@@ -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"
|
||||
Executable
+110
@@ -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"
|
||||
Executable
+77
@@ -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
|
||||
Executable
+117
@@ -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"
|
||||
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
|
||||
Executable
+34
@@ -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[@]}"
|
||||
Executable
+113
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user