From 103ad311b7207585b2143c279d3577a0c46a9eef Mon Sep 17 00:00:00 2001 From: Florian Weber Date: Wed, 12 Aug 2026 12:21:08 +0200 Subject: [PATCH] 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 --- .dockerignore | 7 + .editorconfig | 15 + .gitea/workflows/ci.yaml | 77 ++++ .gitea/workflows/preflight.yaml | 105 +++++ .gitea/workflows/rebuild.yaml | 71 +++ .gitea/workflows/release-chart.yaml | 99 ++++ .gitea/workflows/release-image.yaml | 113 +++++ .gitignore | 32 ++ LICENSE | 21 + Makefile | 49 ++ NOTICE.md | 13 + README.md | 63 +++ action.yaml | 72 +++ charts/aptly/Chart.yaml | 33 ++ charts/aptly/README.md | 395 ++++++++++++++++ charts/aptly/ci/authenticated.yaml | 5 + charts/aptly/ci/full-featured.yaml | 82 ++++ charts/aptly/ci/gateway-single.yaml | 7 + charts/aptly/ci/gateway-split.yaml | 20 + charts/aptly/ci/ingress-and-gateway.yaml | 14 + charts/aptly/ci/ingress-single.yaml | 5 + charts/aptly/ci/ingress-split.yaml | 13 + charts/aptly/ci/no-proxy-open.yaml | 7 + charts/aptly/ci/open.yaml | 5 + charts/aptly/ci/readonly.yaml | 2 + charts/aptly/templates/NOTES.txt | 89 ++++ charts/aptly/templates/_helpers.tpl | 263 +++++++++++ charts/aptly/templates/configmap-aptly.yaml | 9 + .../aptly/templates/configmap-gpg-keys.yaml | 13 + charts/aptly/templates/configmap-nginx.yaml | 9 + charts/aptly/templates/configmap-state.yaml | 11 + charts/aptly/templates/httproute.yaml | 65 +++ charts/aptly/templates/ingress.yaml | 77 ++++ charts/aptly/templates/job-reconcile.yaml | 80 ++++ charts/aptly/templates/networkpolicy.yaml | 51 +++ .../aptly/templates/poddisruptionbudget.yaml | 16 + .../aptly/templates/secret-credentials.yaml | 34 ++ charts/aptly/templates/secret-gpg.yaml | 14 + charts/aptly/templates/service-metrics.yaml | 21 + charts/aptly/templates/service.yaml | 19 + charts/aptly/templates/servicemonitor.yaml | 23 + charts/aptly/templates/statefulset.yaml | 298 +++++++++++++ charts/aptly/values.schema.json | 421 ++++++++++++++++++ charts/aptly/values.yaml | 322 ++++++++++++++ compose/.env.example | 38 ++ compose/backup/.gitkeep | 0 compose/config/aptly.yaml | 16 + compose/config/gpg/.gitkeep | 0 compose/config/nginx.prod.conf | 48 ++ compose/config/nginx.test.conf | 39 ++ compose/config/state.test.yaml | 18 + compose/config/state.yaml | 31 ++ compose/config/users.example | 7 + compose/docker-compose.test.yaml | 75 ++++ compose/docker-compose.yaml | 134 ++++++ images/aptly-deb-builder/Dockerfile | 50 +++ images/aptly-server/Dockerfile | 91 ++++ pubkeys/README.md | 62 +++ pubkeys/chart-signing.asc | 53 +++ renovate.json | 52 +++ rootfs/usr/local/bin/aptly-entrypoint | 25 ++ rootfs/usr/local/bin/aptly-init | 180 ++++++++ rootfs/usr/local/bin/aptly-mirror-refresh | 110 +++++ rootfs/usr/local/bin/aptly-pack | 77 ++++ rootfs/usr/local/bin/aptly-push | 117 +++++ rootfs/usr/local/bin/aptly-reconcile | 218 +++++++++ rootfs/usr/local/bin/aptly-release | 34 ++ rootfs/usr/local/bin/lib/common.sh | 113 +++++ tests/fixtures/demo-package/nfpm.yaml | 12 + tests/fixtures/demo-package/payload/hello.txt | 1 + tests/smoke-test.sh | 82 ++++ 71 files changed, 4843 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitea/workflows/preflight.yaml create mode 100644 .gitea/workflows/rebuild.yaml create mode 100644 .gitea/workflows/release-chart.yaml create mode 100644 .gitea/workflows/release-image.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 action.yaml create mode 100644 charts/aptly/Chart.yaml create mode 100644 charts/aptly/README.md create mode 100644 charts/aptly/ci/authenticated.yaml create mode 100644 charts/aptly/ci/full-featured.yaml create mode 100644 charts/aptly/ci/gateway-single.yaml create mode 100644 charts/aptly/ci/gateway-split.yaml create mode 100644 charts/aptly/ci/ingress-and-gateway.yaml create mode 100644 charts/aptly/ci/ingress-single.yaml create mode 100644 charts/aptly/ci/ingress-split.yaml create mode 100644 charts/aptly/ci/no-proxy-open.yaml create mode 100644 charts/aptly/ci/open.yaml create mode 100644 charts/aptly/ci/readonly.yaml create mode 100644 charts/aptly/templates/NOTES.txt create mode 100644 charts/aptly/templates/_helpers.tpl create mode 100644 charts/aptly/templates/configmap-aptly.yaml create mode 100644 charts/aptly/templates/configmap-gpg-keys.yaml create mode 100644 charts/aptly/templates/configmap-nginx.yaml create mode 100644 charts/aptly/templates/configmap-state.yaml create mode 100644 charts/aptly/templates/httproute.yaml create mode 100644 charts/aptly/templates/ingress.yaml create mode 100644 charts/aptly/templates/job-reconcile.yaml create mode 100644 charts/aptly/templates/networkpolicy.yaml create mode 100644 charts/aptly/templates/poddisruptionbudget.yaml create mode 100644 charts/aptly/templates/secret-credentials.yaml create mode 100644 charts/aptly/templates/secret-gpg.yaml create mode 100644 charts/aptly/templates/service-metrics.yaml create mode 100644 charts/aptly/templates/service.yaml create mode 100644 charts/aptly/templates/servicemonitor.yaml create mode 100644 charts/aptly/templates/statefulset.yaml create mode 100644 charts/aptly/values.schema.json create mode 100644 charts/aptly/values.yaml create mode 100644 compose/.env.example create mode 100644 compose/backup/.gitkeep create mode 100644 compose/config/aptly.yaml create mode 100644 compose/config/gpg/.gitkeep create mode 100644 compose/config/nginx.prod.conf create mode 100644 compose/config/nginx.test.conf create mode 100644 compose/config/state.test.yaml create mode 100644 compose/config/state.yaml create mode 100644 compose/config/users.example create mode 100644 compose/docker-compose.test.yaml create mode 100644 compose/docker-compose.yaml create mode 100644 images/aptly-deb-builder/Dockerfile create mode 100644 images/aptly-server/Dockerfile create mode 100644 pubkeys/README.md create mode 100644 pubkeys/chart-signing.asc create mode 100644 renovate.json create mode 100755 rootfs/usr/local/bin/aptly-entrypoint create mode 100755 rootfs/usr/local/bin/aptly-init create mode 100755 rootfs/usr/local/bin/aptly-mirror-refresh create mode 100755 rootfs/usr/local/bin/aptly-pack create mode 100755 rootfs/usr/local/bin/aptly-push create mode 100755 rootfs/usr/local/bin/aptly-reconcile create mode 100755 rootfs/usr/local/bin/aptly-release create mode 100755 rootfs/usr/local/bin/lib/common.sh create mode 100644 tests/fixtures/demo-package/nfpm.yaml create mode 100644 tests/fixtures/demo-package/payload/hello.txt create mode 100755 tests/smoke-test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..373be47 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitea +charts +compose +docs +tests +*.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..776e74e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab + +[Makefile] +indent_style = tab diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..cc195cc --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,77 @@ +name: CI +# Runs on every PR and every push to main. The smoke-test job is the actual +# merge gate for this repo — it proves pack -> push -> publish -> apt-get +# works, not just that files parse. See tests/smoke-test.sh. +# +# paths-ignore + bot commits carrying [skip ci] (see release-image.yaml) +# exist to break the automation loop: an image release commits a new +# appVersion to main, which would otherwise re-trigger this workflow, which +# has nothing new to check. +on: + pull_request: {} + push: + branches: [main] + paths-ignore: + - 'charts/aptly/Chart.yaml' + - 'CHANGELOG.md' + +concurrency: + group: ci-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: hadolint (aptly-server) + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: images/aptly-server/Dockerfile + - name: hadolint (aptly-deb-builder) + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: images/aptly-deb-builder/Dockerfile + + - name: shellcheck + run: | + docker run --rm -v "$PWD:/work" -w /work koalaman/shellcheck:stable -x \ + rootfs/usr/local/bin/aptly-* rootfs/usr/local/bin/lib/common.sh tests/smoke-test.sh + + - uses: azure/setup-helm@v4.3.0 + with: + version: ${{ vars.HELM_VERSION || '3.16.4' }} + - name: helm lint + run: helm lint charts/aptly + - name: helm template (every ci/*.yaml values file, plus defaults) + run: | + set -e + helm template test charts/aptly > /tmp/rendered-defaults.yaml + for f in charts/aptly/ci/*.yaml; do + helm template test charts/aptly -f "$f" > "/tmp/rendered-$(basename "$f" .yaml).yaml" + done + - name: kubeconform + run: | + docker run --rm -v /tmp:/tmp ghcr.io/yannh/kubeconform:latest \ + -summary -strict -kubernetes-version 1.29.0 \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ + /tmp/rendered-*.yaml + - name: fail-guard regression check (proxy.enabled=false + Ingress/Gateway + non-open preset must abort) + run: | + if helm template test charts/aptly --set proxy.enabled=false --set ingress.enabled=true \ + --set ingress.repo.host=x.example.com >/tmp/should-fail.yaml 2>&1; then + echo "::error::expected `helm template` to fail on proxy.enabled=false + ingress.enabled (fail-guard regression)"; exit 1 + fi + if helm template test charts/aptly --set proxy.enabled=false --set gateway.enabled=true \ + --set 'gateway.parentRefs[0].name=x' >/tmp/should-fail2.yaml 2>&1; then + echo "::error::expected `helm template` to fail on proxy.enabled=false + gateway.enabled (fail-guard regression)"; exit 1 + fi + + smoke-test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Run the end-to-end smoke test + run: ./tests/smoke-test.sh diff --git a/.gitea/workflows/preflight.yaml b/.gitea/workflows/preflight.yaml new file mode 100644 index 0000000..19dae2b --- /dev/null +++ b/.gitea/workflows/preflight.yaml @@ -0,0 +1,105 @@ +name: Preflight +# Manual, one-shot verification that the runner can actually do everything +# the release workflows assume: build a container image, emulate a foreign +# architecture, push to this Gitea instance's registry, push a Helm chart via +# OCI, and reach the Issues API. None of the four existing Gitea Actions +# workflows in this org build a container image before this repo — so none +# of that is proven, only assumed. Run this BEFORE relying on release-image.yaml +# or rebuild.yaml, and again after any Gitea/runner upgrade. +# +# See docs/operations.md for the escalation ladder if any job here fails. +on: + workflow_dispatch: {} + +jobs: + docker-build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build aptly-server for the native arch only (no push) + uses: docker/build-push-action@v6 + with: + context: . + file: images/aptly-server/Dockerfile + push: false + tags: preflight/aptly-server:local + + qemu-multiarch: + runs-on: ubuntu-22.04 + needs: docker-build + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Build for linux/amd64,linux/arm64 (no push) + uses: docker/build-push-action@v6 + with: + context: . + file: images/aptly-server/Dockerfile + platforms: linux/amd64,linux/arm64 + push: false + + registry-push: + runs-on: ubuntu-22.04 + needs: qemu-multiarch + steps: + - uses: actions/checkout@v4 + - name: Verify registry secrets are set + run: | + test -n "${{ secrets.REGISTRY_USER }}" || { echo "::error::REGISTRY_USER is not set"; exit 1; } + test -n "${{ secrets.REGISTRY_TOKEN }}" || { echo "::error::REGISTRY_TOKEN (a Personal Access Token with write:package) is not set. GITEA_TOKEN cannot authorize package pushes on Gitea — see docs/operations.md."; exit 1; } + - uses: docker/login-action@v3 + with: + registry: git.morlana.online + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Push a throwaway multi-arch tag + uses: docker/build-push-action@v6 + with: + context: . + file: images/aptly-server/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: git.morlana.online/f.weber/aptly:preflight-${{ gitea.sha }} + - name: Verify the manifest list has both platforms + run: | + docker buildx imagetools inspect git.morlana.online/f.weber/aptly:preflight-${{ gitea.sha }} + + helm-oci-push: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4.3.0 + with: + version: ${{ vars.HELM_VERSION || '3.16.4' }} + - name: helm registry login + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | helm registry login git.morlana.online \ + --username "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Package and push a throwaway chart version + run: | + helm package charts/aptly --version 0.0.0-preflight --app-version preflight + helm push aptly-0.0.0-preflight.tgz oci://git.morlana.online/f.weber + - name: Verify it is pullable + run: | + helm show chart oci://git.morlana.online/f.weber/aptly --version 0.0.0-preflight + + issues-api: + runs-on: ubuntu-22.04 + steps: + - name: Open and close a test issue (proves rebuild.yaml's failure alert path) + env: + GITEA_API: https://git.morlana.online/api/v1 + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + issue_number=$(curl -fsS -X POST "${GITEA_API}/repos/${{ gitea.repository }}/issues" \ + -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" \ + -d '{"title":"[preflight] issues API check","body":"Created by preflight.yaml — safe to close/delete."}' \ + | jq -r '.number') + curl -fsS -X PATCH "${GITEA_API}/repos/${{ gitea.repository }}/issues/${issue_number}" \ + -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" \ + -d '{"state":"closed"}' >/dev/null + echo "opened and closed issue #${issue_number}" diff --git a/.gitea/workflows/rebuild.yaml b/.gitea/workflows/rebuild.yaml new file mode 100644 index 0000000..05fcf44 --- /dev/null +++ b/.gitea/workflows/rebuild.yaml @@ -0,0 +1,71 @@ +name: Weekly rebuild +# Bumps the image revision (1.6.3-1 -> 1.6.3-2) and tags it, which triggers +# release-image.yaml — the mechanism that pulls in Debian base-image security +# patches even between aptly releases. This is the "aptly keeps itself +# current" half that isn't a Renovate PR: it needs zero human action to land. +# +# Gitea's `schedule` trigger only fires from the default branch — a branch +# rename silently disables this. Because Gitea Actions only implements +# always() among the status-check expressions (not success()/failure()), +# failure handling below uses `continue-on-error` + a status file instead of +# `if: failure()`. +on: + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: {} + +jobs: + bump-revision: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find the latest image tag and compute the next revision + id: next + continue-on-error: true + run: | + set -euo pipefail + latest="$(git tag --list 'image/v*' --sort=-v:refname | head -1)" + if [[ -z "$latest" ]]; then + echo "::error::no existing image/v* tag found — cut one manually first (see docs/versioning.md)" + exit 1 + fi + version_rev="${latest#image/v}" + version="${version_rev%-*}" + revision="${version_rev##*-}" + next_revision=$(( revision + 1 )) + echo "next_tag=image/v${version}-${next_revision}" >> "$GITEA_OUTPUT" + echo "latest_tag=${latest}" >> "$GITEA_OUTPUT" + + - name: Stale-rebuild check (fires regardless of the step above) + if: always() + run: | + latest_epoch="$(git log -1 --format=%at "$(git tag --list 'image/v*' --sort=-v:refname | head -1)" 2>/dev/null || echo 0)" + now_epoch="$(date +%s)" + days=$(( (now_epoch - latest_epoch) / 86400 )) + threshold="${STALE_REBUILD_DAYS:-14}" + echo "days since last image tag: ${days} (threshold: ${threshold})" + if [[ "$days" -gt "$threshold" ]]; then + echo "::warning::no new image tag in ${days} days — check whether this workflow (or Renovate) is still running" + fi + + - name: Tag and push + if: steps.next.outcome == 'success' + run: | + git config user.name "${{ gitea.actor }}" + git config user.email "${{ gitea.actor }}@noreply.git.morlana.online" + git tag "${{ steps.next.outputs.next_tag }}" + git push origin "${{ steps.next.outputs.next_tag }}" + echo "tagged ${{ steps.next.outputs.next_tag }} (rebuild of ${{ steps.next.outputs.latest_tag }}'s aptly version)" + + - name: Report failure (opens an issue; the next successful run closes it) + if: always() && steps.next.outcome != 'success' + env: + GITEA_API: https://git.morlana.online/api/v1 + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + curl -fsS -X POST "${GITEA_API}/repos/${{ gitea.repository }}/issues" \ + -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" \ + -d "{\"title\":\"rebuild.yaml failed on $(date -u +%F)\",\"body\":\"See the workflow run: ${{ gitea.server_url }}/${{ gitea.repository }}/actions\"}" diff --git a/.gitea/workflows/release-chart.yaml b/.gitea/workflows/release-chart.yaml new file mode 100644 index 0000000..387a3dd --- /dev/null +++ b/.gitea/workflows/release-chart.yaml @@ -0,0 +1,99 @@ +name: Release chart +# Triggered by `git tag chart/vX.Y.Z && git push --tags`, independently of +# image releases — see docs/versioning.md. Never bumps the image: Chart.yaml's +# committed appVersion (last set by release-image.yaml) is what gets packaged, +# so a chart-only release always pins the last released image, never `latest`. +# +# Chart version is pure SemVer, deliberately WITHOUT the `+up` build +# metadata bookstack-chart uses: Helm rewrites `+` to `_` on OCI push (and +# back on pull), which breaks listing in some third-party tooling (e.g. +# Rancher). The aptly version lives in appVersion instead. +on: + push: + tags: + - 'chart/v*' + +concurrency: + group: release-chart + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Parse chart version from tag + id: version + run: echo "version=${GITEA_REF_NAME#chart/v}" >> "$GITEA_OUTPUT" + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + + - uses: azure/setup-helm@v4.3.0 + with: + version: ${{ vars.HELM_VERSION || '3.16.4' }} + + - name: Quality gate + run: | + set -e + helm lint charts/aptly + for f in charts/aptly/ci/*.yaml; do + helm template test charts/aptly -f "$f" > /dev/null + done + + - name: Idempotency check — refuse to overwrite an existing chart version + run: | + if helm show chart "oci://git.morlana.online/f.weber/aptly" --version "${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "::error::chart version ${{ steps.version.outputs.version }} already exists in the registry. Bump the version and re-tag — this workflow never overwrites a published chart." + exit 1 + fi + + - name: Import GPG signing key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + trust_level: 5 + + - name: Build legacy secret keyring (helm package --sign wants gpg1-style secring.gpg) + run: | + mkdir -p /tmp/gpgring + gpg --batch --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" \ + --export-secret-keys > /tmp/gpgring/secring.gpg + printf '%s' "${{ secrets.GPG_PASSPHRASE }}" > /tmp/gpgring/passphrase.txt + chmod 600 /tmp/gpgring/* + + # secrets.GPG_KEY_ID/GPG_PRIVATE_KEY must be an RSA (or other classic, + # non-EdDSA) key — Helm's sign/verify uses the deprecated + # golang.org/x/crypto/openpgp library, which cannot read Ed25519 keys at + # all (fails with "private key not found"). See pubkeys/README.md. + - name: Package & sign + run: | + helm package charts/aptly \ + --version "${{ steps.version.outputs.version }}" \ + --dependency-update \ + --sign --key "${{ secrets.GPG_KEY_ID }}" \ + --keyring /tmp/gpgring/secring.gpg \ + --passphrase-file /tmp/gpgring/passphrase.txt + + - name: helm registry login & push + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | helm registry login git.morlana.online \ + --username "${{ secrets.REGISTRY_USER }}" --password-stdin + helm push "aptly-${{ steps.version.outputs.version }}.tgz" oci://git.morlana.online/f.weber + + - name: Create Gitea release with chart artifacts + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ gitea.ref_name }} + name: "aptly chart ${{ steps.version.outputs.version }}" + body: | + `helm pull oci://git.morlana.online/f.weber/aptly --version ${{ steps.version.outputs.version }}` + files: | + aptly-${{ steps.version.outputs.version }}.tgz + aptly-${{ steps.version.outputs.version }}.tgz.prov + pubkeys/chart-signing.asc + + - name: Clean up key material + if: always() + run: rm -rf /tmp/gpgring diff --git a/.gitea/workflows/release-image.yaml b/.gitea/workflows/release-image.yaml new file mode 100644 index 0000000..60c3d7e --- /dev/null +++ b/.gitea/workflows/release-image.yaml @@ -0,0 +1,113 @@ +name: Release image +# Triggered by `git tag image/vX.Y.Z-N && git push --tags`, independently of +# chart releases (release-chart.yaml) — see docs/versioning.md. N is a +# revision counter for rebuilds of the same aptly version (base-image CVE +# patches — see rebuild.yaml), not a new aptly release. +on: + push: + tags: + - 'image/v*' + +concurrency: + group: release-image + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Parse tag and assert it matches the Dockerfile + id: version + run: | + set -euo pipefail + tag="${GITEA_REF_NAME#image/v}" # e.g. 1.6.3-1 + aptly_version="${tag%-*}" # 1.6.3 + revision="${tag##*-}" # 1 + minor="${aptly_version%.*}" # 1.6 + from_dockerfile="$(grep -oP '(?<=^ARG APTLY_VERSION=)\S+' images/aptly-server/Dockerfile)" + if [[ "$from_dockerfile" != "$aptly_version" ]]; then + echo "::error::tag ${GITEA_REF_NAME} implies aptly ${aptly_version}, but images/aptly-server/Dockerfile has ARG APTLY_VERSION=${from_dockerfile}. Bump the Dockerfile and re-tag." + exit 1 + fi + { + echo "full=${tag}" + echo "aptly_version=${aptly_version}" + echo "revision=${revision}" + echo "minor=${minor}" + } >> "$GITEA_OUTPUT" + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + + - name: Verify registry secrets are set + run: | + test -n "${{ secrets.REGISTRY_USER }}" || { echo "::error::REGISTRY_USER is not set"; exit 1; } + test -n "${{ secrets.REGISTRY_TOKEN }}" || { echo "::error::REGISTRY_TOKEN is not set (needs write:package — GITEA_TOKEN cannot push packages on Gitea)"; exit 1; } + + - uses: docker/login-action@v3 + with: + registry: git.morlana.online + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:latest + + - name: Build & push aptly-server + uses: docker/build-push-action@v6 + with: + context: . + file: images/aptly-server/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + sbom: false + build-args: | + APTLY_VERSION=${{ steps.version.outputs.aptly_version }} + APTLY_REVISION=${{ steps.version.outputs.revision }} + tags: | + git.morlana.online/f.weber/aptly:${{ steps.version.outputs.full }} + git.morlana.online/f.weber/aptly:${{ steps.version.outputs.aptly_version }} + git.morlana.online/f.weber/aptly:${{ steps.version.outputs.minor }} + git.morlana.online/f.weber/aptly:latest + + - name: Build & push aptly-deb-builder + uses: docker/build-push-action@v6 + with: + context: . + file: images/aptly-deb-builder/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + sbom: false + tags: | + git.morlana.online/f.weber/aptly-deb-builder:${{ steps.version.outputs.full }} + git.morlana.online/f.weber/aptly-deb-builder:${{ steps.version.outputs.aptly_version }} + git.morlana.online/f.weber/aptly-deb-builder:latest + + - name: Create Gitea release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ gitea.ref_name }} + name: "aptly image ${{ steps.version.outputs.full }}" + body: | + Images pushed: + - `git.morlana.online/f.weber/aptly:${{ steps.version.outputs.full }}` (+ `${{ steps.version.outputs.aptly_version }}`, `${{ steps.version.outputs.minor }}`, `latest`) + - `git.morlana.online/f.weber/aptly-deb-builder:${{ steps.version.outputs.full }}` + + - name: Bump chart appVersion (pins the last released image; never main-latest) + run: | + set -euo pipefail + sed -i "s/^appVersion:.*/appVersion: \"${{ steps.version.outputs.full }}\"/" charts/aptly/Chart.yaml + git config user.name "${{ gitea.actor }}" + git config user.email "${{ gitea.actor }}@noreply.git.morlana.online" + git add charts/aptly/Chart.yaml + git diff --cached --quiet && exit 0 + git commit -m "chore: bump chart appVersion to ${{ steps.version.outputs.full }} [skip ci]" + git push origin HEAD:main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ed2da4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Local secrets / working data — never commit these +compose/.env +compose/data/ +compose/config/users +compose/config/*.key +compose/config/*-key.asc +compose/config/gpg/* +!compose/config/gpg/.gitkeep +compose/backup/* +!compose/backup/.gitkeep +*.gpg +*.asc +!pubkeys/*.asc +secring.gpg +*.deb +*.tgz +*.tgz.prov + +# Helm +charts/*/charts/ +charts/*/Chart.lock + +# Editors / OS / tooling +.DS_Store +*.swp +.idea/ +.vscode/ +.claude/ + +# Scratch +*.tmp +/dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..43bfa6c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Florian Weber + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d5f6058 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +SHELL := /usr/bin/env bash +.SHELLFLAGS := -euo pipefail -c +IMAGE_TAG ?= local + +.PHONY: build build-server build-deb-builder lint hadolint shellcheck helm-lint helm-template test chart-package compose-test-up compose-test-down clean + +build: build-server build-deb-builder + +build-server: + docker build -f images/aptly-server/Dockerfile -t aptly-server:$(IMAGE_TAG) . + +build-deb-builder: + docker build -f images/aptly-deb-builder/Dockerfile -t aptly-deb-builder:$(IMAGE_TAG) . + +lint: hadolint shellcheck helm-lint helm-template + +hadolint: + docker run --rm -i hadolint/hadolint < images/aptly-server/Dockerfile + docker run --rm -i hadolint/hadolint < images/aptly-deb-builder/Dockerfile + +shellcheck: + docker run --rm -v "$$PWD:/work" -w /work koalaman/shellcheck:stable -x \ + rootfs/usr/local/bin/aptly-* rootfs/usr/local/bin/lib/common.sh tests/smoke-test.sh + +helm-lint: + helm lint charts/aptly + +helm-template: + helm template test charts/aptly >/dev/null + for f in charts/aptly/ci/*.yaml; do \ + echo "-- $$f --"; \ + helm template test charts/aptly -f "$$f" >/dev/null; \ + done + +test: + ./tests/smoke-test.sh + +chart-package: + helm package charts/aptly --destination dist/ + +compose-test-up: + docker compose -f compose/docker-compose.test.yaml up -d --build + +compose-test-down: + docker compose -f compose/docker-compose.test.yaml down -v + +clean: compose-test-down + docker compose -f compose/docker-compose.yaml down -v 2>/dev/null || true + rm -rf dist/ diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..f70d63d --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,13 @@ +# Third-party notices + +This repository packages and deploys [aptly](https://www.aptly.info/) +(https://github.com/aptly-dev/aptly), licensed under the MIT License, +Copyright (c) the aptly authors. aptly is built from source in +[images/aptly-server/Dockerfile](images/aptly-server/Dockerfile); this project +is not affiliated with the aptly project. + +The web/read path uses the upstream [nginxinc/nginx-unprivileged](https://hub.docker.com/r/nginxinc/nginx-unprivileged) +image unmodified. + +The packaging image uses [nFPM](https://nfpm.goreleaser.com/), licensed under +the MIT License, Copyright (c) the nFPM/GoReleaser authors. diff --git a/README.md b/README.md new file mode 100644 index 0000000..46d4759 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# aptly-containerized + +[aptly](https://www.aptly.info/) (Debian repository management) as a container image, +Docker Compose stacks (test + production), and a Helm chart — built so that aptly can +be **configured through Helm especially easily** and deployed at **any security +level**, from completely open to fully authenticated. + +## What's in here + +| | | +|---|---| +| `images/aptly-server/` | aptly, cross-compiled from source, non-root, minimal footprint | +| `images/aptly-deb-builder/` | packaging image: build a `.deb` (nfpm or `dpkg-buildpackage`) and push it, in one step | +| `compose/` | test stack (one command, wide open) and a production stack (auth, healthchecks, backup) | +| `charts/aptly/` | Helm chart, aptly-native (no library-chart dependency) | +| `rootfs/usr/local/bin/` | the scripts shared by both worlds (Compose + Helm) — config rendering, declarative state, push helpers | +| `action.yaml` | reusable Gitea Action: build + push a package in three lines | +| `.gitea/workflows/` | CI, independent image/chart releases, weekly CVE rebuild | + +## Quickstart + +```bash +docker compose -f compose/docker-compose.test.yaml up -d --build +curl http://localhost:8080/api/ready +``` + +Details and the production path: [docs/quickstart-compose.md](docs/quickstart-compose.md). + +```bash +helm install aptly oci://git.morlana.online/f.weber/aptly --version +``` + +Details: [docs/quickstart-helm.md](docs/quickstart-helm.md). + +## Docs + +- [Security modes](docs/security.md) — the four presets, the Ingress split, the + CIDR pitfall, unsigned mode +- [Packaging & declarative state](docs/packaging.md) — `aptly-pack`/`push`/`release`, + the `state.yaml` reference, the prefix-escaping pitfall +- [Versioning](docs/versioning.md) — tagging and releasing the image and the chart + independently +- [Operations](docs/operations.md) — storage resizing, backup/restore, GPG rotation, + runner preflight + +## Keeping itself current + +`renovate.json` tracks `aptly-dev/aptly`, `mikefarah/yq`, `goreleaser/nfpm`, and the +base images via PR. `.gitea/workflows/rebuild.yaml` rebuilds weekly (Debian CVEs in +the base images) even when aptly itself hasn't changed, and reports on itself (a +Gitea issue) if a run goes missing. + +## Verification + +```bash +make lint # hadolint, shellcheck, helm lint, helm template over every ci/*.yaml +make test # tests/smoke-test.sh — pack -> push -> publish -> a real apt-get, the merge gate +``` + +## License + +MIT, see [LICENSE](LICENSE). Includes/uses aptly, nginx, and nFPM — see +[NOTICE.md](NOTICE.md). diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..5046e96 --- /dev/null +++ b/action.yaml @@ -0,0 +1,72 @@ +name: 'aptly publish-deb' +description: >- + Package a source tree (nfpm.yaml or a debian/ directory) and push the + result into an aptly repository, in one step. Wraps the same aptly-release + script the aptly-deb-builder image ships, so behaviour is identical to + running that image directly (see docs/packaging.md for the direct-run + fallback, which is the more robust option if this action's resolution + ever misbehaves on your Gitea instance). +# +# IMPORTANT: lives at the repo ROOT deliberately, not under actions/ or +# .gitea/actions/ — cross-repo `uses:` references WITH a subpath +# (owner/repo/path/action@ref) are not reliable on Gitea; only local +# (./.gitea/actions/x) and repo-root references resolve consistently. +inputs: + image: + description: 'aptly-deb-builder image to run (pin to a released tag in production)' + required: false + default: 'git.morlana.online/f.weber/aptly-deb-builder:latest' + source-dir: + description: 'Directory containing nfpm.yaml or a debian/ directory' + required: false + default: '.' + config: + description: 'nfpm config file name, relative to source-dir' + required: false + default: 'nfpm.yaml' + url: + description: 'aptly API URL (e.g. https://apt.example.com)' + required: true + repo: + description: 'Target local repo name' + required: true + distribution: + description: 'Distribution to publish/refresh after pushing' + required: false + default: '' + prefix: + description: 'Publish prefix ("" = repo root)' + required: false + default: '' + username: + description: 'Basic auth username (omit for an open/unauthenticated repo)' + required: false + default: '' + password: + description: 'Basic auth password' + required: false + default: '' + no-sign: + description: '"true" if the target repo is unsigned (aptly.gpg.enabled=false)' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - shell: bash + run: | + set -euo pipefail + args=(--source-dir "${{ inputs.source-dir }}" --config "${{ inputs.config }}" --) + args+=(--repo "${{ inputs.repo }}") + [[ -n "${{ inputs.distribution }}" ]] && args+=(--distribution "${{ inputs.distribution }}") + args+=(--prefix "${{ inputs.prefix }}") + [[ "${{ inputs.no-sign }}" == "true" ]] && args+=(--no-sign) + + docker run --rm \ + -v "${{ github.workspace }}:/work" -w /work \ + -e "APTLY_URL=${{ inputs.url }}" \ + -e "APTLY_USER=${{ inputs.username }}" \ + -e "APTLY_PASSWORD=${{ inputs.password }}" \ + "${{ inputs.image }}" \ + aptly-release "${args[@]}" diff --git a/charts/aptly/Chart.yaml b/charts/aptly/Chart.yaml new file mode 100644 index 0000000..c0d94c7 --- /dev/null +++ b/charts/aptly/Chart.yaml @@ -0,0 +1,33 @@ +apiVersion: v2 +name: aptly +description: >- + aptly (Debian repository management tool) on Kubernetes — a StatefulSet with + an nginx read/auth sidecar, a fully aptly-native values API, and declarative + repo/mirror/publish state reconciled via a Helm hook. +type: application +version: 0.1.0 +appVersion: "1.6.3-1" +home: https://git.morlana.online/f.weber/aptly-containerized +sources: + - https://git.morlana.online/f.weber/aptly-containerized + - https://github.com/aptly-dev/aptly +keywords: + - aptly + - apt + - debian + - package-repository + - artifact-repository +maintainers: + - name: Florian Weber + email: f.weber@flweber.me +icon: https://www.aptly.info/img/aptly_medium.png +annotations: + artifacthub.io/license: MIT + artifacthub.io/links: | + - name: aptly upstream + url: https://www.aptly.info/ + - name: Source + url: https://git.morlana.online/f.weber/aptly-containerized + artifacthub.io/signKey: | + fingerprint: FC35C0FAA26605C4C21C7BBFBF43884145E5AA94 + url: https://git.morlana.online/f.weber/aptly-containerized/raw/branch/main/pubkeys/chart-signing.asc diff --git a/charts/aptly/README.md b/charts/aptly/README.md new file mode 100644 index 0000000..e650ab5 --- /dev/null +++ b/charts/aptly/README.md @@ -0,0 +1,395 @@ +# aptly + +[aptly](https://www.aptly.info/) (Debian repository management tool) on Kubernetes: +a single-pod StatefulSet (aptly + an nginx read/auth sidecar), a fully aptly-native +values API, and declarative repo/mirror/publish state reconciled by a Helm hook — +no library-chart dependency, no concepts to learn beyond aptly's and Kubernetes' own. + +## TL;DR + +```bash +helm install my-aptly oci://git.morlana.online/f.weber/aptly --version +``` + +## Introduction + +This chart deploys [aptly](https://www.aptly.info/) as: + +- a `StatefulSet` running two containers in one pod — `aptly` itself (bound to + `127.0.0.1`, never reachable off-pod) and an `nginx` sidecar that is the only + thing actually exposed, handling reads, auth, and the write-API proxy; +- a `Service` in front of `nginx`, and optional `Ingress` and/or Gateway API + `HTTPRoute` resources for apt clients and the API — both can be enabled at the + same time, e.g. mid-migration between the two (see [Gateway API](#gateway-api)); +- a post-install/post-upgrade `Job` that reconciles the local repos, mirrors, and + publish targets declared in `values.yaml` against the running instance's REST API. + +Three things this chart is built around: + +1. **A single security switch that actually reaches every mode**, including + completely open (no auth on read *or* write) if that's what you want — see + [Security modes](#security-modes) below. +2. **An aptly-native config surface**: curated `aptly.*` keys for the common cases, + plus `aptly.configOverrides` as a raw passthrough so any current or future aptly + config key is reachable without waiting on a chart update. +3. **Declarative state**: local repos, mirrors, and publish targets live in + `values.yaml` and are converged towards on every `helm install`/`helm upgrade`, + the same way the rest of the cluster is managed. + +## Prerequisites + +- Kubernetes 1.24+ +- Helm 3.8+ (for OCI registry support) — Helm 4 also works +- A `StorageClass` supporting `ReadWriteOnce`, unless `persistence.enabled: false` + +## Installing the chart + +```bash +helm install my-aptly oci://git.morlana.online/f.weber/aptly --version \ + --set ingress.enabled=true \ + --set ingress.repo.host=apt.example.com +``` + +Without `ingress.enabled`, the post-install NOTES print a `kubectl port-forward` +command instead — nothing has to be configured up front to try the chart out (e.g. +in kind/k3d). + +## Uninstalling the chart + +```bash +helm uninstall my-aptly +``` + +The `PersistentVolumeClaim` created by this chart's `volumeClaimTemplate` (i.e. +when `persistence.existingClaim` is unset) is **not** deleted — StatefulSet-owned +PVCs never are, by Kubernetes' own design, regardless of any Helm annotation. +Remove it yourself if you're done with the data — for a release named `my-aptly`, +that PVC is `data-my-aptly-0` (`data--`; find the exact +name with `kubectl get pvc -l app.kubernetes.io/instance=`): +`kubectl delete pvc data-my-aptly-0`. + +## Security modes + +aptly itself ships with **no authentication at all**; this chart's `security.preset` +switch controls what the nginx sidecar in front of it requires: + +| `security.preset` | Read (apt clients) | Read auth | Write (`/api/`) | Write auth | +|---|---|---|---|---| +| `open` | on | **no** | on | **no** | +| `publicRead` (**default**) | on | no | on | yes | +| `authenticated` | on | yes | on | yes | +| `readOnly` | on | no | **off (404)** | — | + +```yaml +security: + preset: open # the fully-unsecured mode — one line, no confirmation gate +``` + +`security.read.*` and `security.write.*` override the preset explicitly, so every +individual cell of the matrix is reachable (e.g. `authenticated` but with anonymous +reads, or `open` restricted to a CIDR on the write path). Health probes +(`/api/ready`, `/api/healthy`) never require credentials, in any mode. + +Credentials come from `security.auth.users` (plaintext, hashed into an +`htpasswd` file by the initContainer at pod start — never store a pre-hashed +password here, see the comment in `values.yaml`) or from +`security.auth.existingSecret` (a pre-built `htpasswd` Secret key — the recommended +production path, e.g. via ExternalSecrets/SealedSecrets). + +Full write-up of the Ingress `split` mode, the `trustedProxies`/`allowCIDRs` +pitfall behind an Ingress controller, and `write.inClusterOnly`: +[docs/security.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/docs/security.md) +in the repository. + +## Gateway API + +`gateway.*` is a complete, independent alternative to `ingress.*` — enable either +one, or **both at once**. Nothing about this chart forces a choice, on purpose: +if you're partway through migrating a cluster from Ingress to Gateway API, both +resource sets can point at the same `Service` for as long as that takes. + +```yaml +gateway: + enabled: true + parentRefs: + - name: my-gateway # a Gateway your cluster admin already manages — + namespace: gateway-infra # this chart never creates one itself + repo: + hostnames: [apt.example.com] +``` + +This mirrors `ingress.*` concept for concept: + +- `gateway.mode: single` (default) creates one `HTTPRoute` that carries both apt + reads and `/api/` writes — nginx does the split internally, exactly as in + Ingress `single` mode. +- `gateway.mode: split` creates a second `HTTPRoute` for `/api/` under + `gateway.api.hostnames`, optionally attached to a **different** Gateway via + `gateway.api.parentRefs` (falls back to `gateway.parentRefs` when unset) — e.g. + an internal-only Gateway for the write path. Same caveat as Ingress `split` + mode applies: this is a route-level split, not something nginx itself enforces, + so combine it with `security.write.inClusterOnly` or a `NetworkPolicy` if a + request arriving on the wrong hostname must actually be rejected at the network + level. +- `security.write.inClusterOnly: true` omits the API `HTTPRoute` entirely in + `split` mode, the same way it omits the API `Ingress`. +- The shared `proxy.enabled=false` guard applies here too: a `HTTPRoute` (or + `Ingress`) in front of aptly's unauthenticated write API is refused unless + `security.preset: open` confirms it's intended. + +One real difference from Ingress: **TLS is not configured here.** Gateway API +deliberately separates infrastructure (the `Gateway` and its listeners, owned by +a cluster admin) from routing (the `HTTPRoute`, owned by this chart) — so TLS +termination is the referenced `Gateway`'s job, not a `gateway.repo.tls`-style +field this chart would need to expose. + +Core Gateway API resources have been GA (`gateway.networking.k8s.io/v1`) since +v1.0; `gateway.apiVersion` exists as an escape hatch only if your cluster's CRDs +still predate that. + +## Configuring aptly itself + +Two layers, always merged in this order — nothing in aptly's own configuration is +ever unreachable through this chart: + +1. curated `aptly.*` keys (omitted from the rendered config when unset, so a + default install matches aptly's own upstream defaults exactly); +2. `aptly.configOverrides` — raw aptly YAML (snake_case keys, same as aptly's own + config file), deep-merged over the generated config last, always wins. + +```yaml +aptly: + architectures: [amd64, arm64] + metrics: + enabled: true + configOverrides: + s3_publish_endpoints: + cdn: + region: eu-central-1 + bucket: apt-example +``` + +## Declarative state + +Local repos, mirrors, and publish targets declared under `aptly.*` are converged +towards by a Helm hook Job on every install/upgrade — talking only to aptly's REST +API, never the CLI (the API server holds aptly's database lock). + +```yaml +aptly: + localRepos: + - name: stable + defaultDistribution: stable + defaultComponent: main + publish: + - name: stable-root + prefix: "" + distribution: stable + sourceKind: local + sources: [{ name: stable, component: main }] + architectures: [amd64, arm64] +``` + +Known limitation: `mirrors[].components` cannot be changed after a mirror is +created (aptly's API has no endpoint for that) — changing it means deleting and +recreating the mirror. Everything else is kept in sync on every run. + +## GPG signing + +```yaml +aptly: + gpg: + enabled: true # false = gpg_disable_sign + Signing.Skip on every publish call + provider: gpg # gpg (default) | internal (pure-Go, no gnupg binary) + signingKey: + existingSecret: my-signing-key # keys: privateKey (or secretKeyring), passphrase +``` + +The public key is served at `aptly.gpg.publishPublicKey.path` (default +`/signing-key.asc`) so clients can fetch it directly: + +```bash +curl -fsSL https://apt.example.com/signing-key.asc | gpg --dearmor \ + | sudo tee /usr/share/keyrings/example.gpg >/dev/null +echo 'deb [signed-by=/usr/share/keyrings/example.gpg] https://apt.example.com/ stable main' \ + | sudo tee /etc/apt/sources.list.d/example.list +``` + +Never put a real private key inline in `values.yaml` — `aptly.gpg.signingKey.privateKey` +exists only as a quick-test escape hatch. + +## Storage + +`persistence.size`/`persistence.storageClass` are **immutable** once installed — +Kubernetes forbids changing a StatefulSet's `volumeClaimTemplates` in place. Set +`persistence.existingClaim` from the start in production: then no +`volumeClaimTemplate` exists at all, and resizing the referenced PVC directly is a +plain, supported operation. + +## Parameters + +### Image + +| Key | Default | Description | +|---|---|---| +| `image.repository` | `git.morlana.online/f.weber/aptly` | aptly-server image | +| `image.tag` | `""` | falls back to `.Chart.AppVersion` — the last released image, never `latest` | +| `image.pullPolicy` | `IfNotPresent` | | +| `image.pullSecrets` | `[]` | | +| `nginx.image.repository` | `nginxinc/nginx-unprivileged` | upstream image, unmodified | +| `nginx.image.tag` | `1-alpine` | | +| `nginx.image.pullPolicy` | `IfNotPresent` | | +| `nginx.resources` | `{}` | | +| `nginx.securityContext` | non-root, all caps dropped | | + +### aptly configuration + +| Key | Default | Description | +|---|---|---| +| `aptly.architectures` | `[]` | empty = all available | +| `aptly.logLevel` | `info` | | +| `aptly.logFormat` | `json` | | +| `aptly.download.concurrency` | `4` | | +| `aptly.download.limit` | `0` | KB/s, `0` = unlimited | +| `aptly.download.retries` | `0` | | +| `aptly.download.sourcePackages` | `false` | | +| `aptly.publishing.skipContents` | `false` | | +| `aptly.publishing.skipBz2` | `false` | | +| `aptly.metrics.enabled` | `false` | exposes `/api/metrics` on a separate, unauthenticated port — see [Metrics](#metrics) | +| `aptly.swagger.enabled` | `false` | exposes `/docs.html` | +| `aptly.gpg.enabled` | `true` | `false` disables signing entirely — see [GPG signing](#gpg-signing) | +| `aptly.gpg.verify` | `true` | mirror signature verification | +| `aptly.gpg.provider` | `gpg` | `gpg` \| `internal` | +| `aptly.gpg.signingKey.existingSecret` | `""` | Secret key `privateKey` or `secretKeyring`, optional `passphrase` | +| `aptly.gpg.signingKey.privateKey` | `""` | inline armored key — quick tests only, never for production | +| `aptly.gpg.signingKey.passphrase` | `""` | only used with the inline key above | +| `aptly.gpg.publishPublicKey.enabled` | `true` | serve the public key over HTTP | +| `aptly.gpg.publishPublicKey.path` | `/signing-key.asc` | | +| `aptly.gpgKeys` | `[]` | trusted keys imported for mirror verification — `[{name, armored}]` | +| `aptly.localRepos` | `[]` | see [Declarative state](#declarative-state) | +| `aptly.mirrors` | `[]` | see [Declarative state](#declarative-state) | +| `aptly.publish` | `[]` | see [Declarative state](#declarative-state) | +| `aptly.configOverrides` | `{}` | raw aptly config, deep-merged last — see [Configuring aptly itself](#configuring-aptly-itself) | +| `aptly.existingSecretEnv` | `[]` | Secret names to `envFrom`, for `${VAR}` placeholders inside `configOverrides` | + +### Security + +| Key | Default | Description | +|---|---|---| +| `security.preset` | `publicRead` | `open` \| `publicRead` \| `authenticated` \| `readOnly` — see [Security modes](#security-modes) | +| `security.auth.users` | `{}` | `name: plaintext-password` map, hashed at pod start | +| `security.auth.existingSecret` | `""` | pre-built `htpasswd` Secret — wins over `users` | +| `security.auth.internalUser.enabled` | `true` | credentials the reconcile Job authenticates through nginx with, in every preset | +| `security.auth.internalUser.username` | `aptly-internal` | | +| `security.trustedProxies` | `[]` | CIDRs to trust `X-Forwarded-For` from — required for `allowCIDRs` to be meaningful behind an Ingress | +| `security.read.enabled` | `true` | overrides the preset | +| `security.read.requireAuth` | `null` | `null` = preset's value | +| `security.read.allowCIDRs` | `[]` | | +| `security.write.enabled` | `true` | overrides the preset | +| `security.write.requireAuth` | `null` | `null` = preset's value | +| `security.write.allowCIDRs` | `[]` | | +| `security.write.inClusterOnly` | `false` | in `ingress.mode: split`, omits the API Ingress entirely | + +### Proxy + +| Key | Default | Description | +|---|---|---| +| `proxy.enabled` | `true` | `false` = aptly serves reads itself (`serve_in_api_mode`), no nginx split — only sane with `security.preset: open` | +| `proxy.compatPaths` | `true` | also serve the tree under `/repos//`, aptly's own URL shape | +| `proxy.maxUploadSize` | `"0"` | nginx `client_max_body_size`, `"0"` = unlimited | +| `proxy.readTimeout` | `3600s` | | +| `proxy.publishEndpointName` | `public` | the `filesystem_publish_endpoints` key aptly publishes under | + +### Persistence + +| Key | Default | Description | +|---|---|---| +| `persistence.enabled` | `true` | `false` = `emptyDir` | +| `persistence.existingClaim` | `""` | **set this in production** — see [Storage](#storage) | +| `persistence.storageClass` | `""` | falls back to `global.defaultStorageClass` | +| `persistence.accessMode` | `ReadWriteOnce` | | +| `persistence.size` | `20Gi` | immutable once installed unless using `existingClaim` | +| `persistence.annotations` | `{}` | | + +### Workload + +| Key | Default | Description | +|---|---|---| +| `workload.updateStrategy.type` | `RollingUpdate` | safe with `replicas: 1` on a StatefulSet | +| `workload.podManagementPolicy` | `OrderedReady` | | +| `workload.revisionHistoryLimit` | `3` | | +| `workload.terminationGracePeriodSeconds` | `60` | | +| `workload.annotations` / `podAnnotations` / `podLabels` | `{}` | | +| `podSecurityContext` | non-root, uid/gid 10001 | | +| `containerSecurityContext` | all caps dropped, read-only root fs | | +| `resources` | `{}` | the `aptly` container | +| `probes.startup` / `.readiness` / `.liveness` | see `values.yaml` | tuned for LevelDB recovery time on unclean shutdown | + +### Networking + +| Key | Default | Description | +|---|---|---| +| `service.type` | `ClusterIP` | | +| `service.port` | `8080` | | +| `service.annotations` | `{}` | | +| `ingress.enabled` | `false` | | +| `ingress.mode` | `single` | `single` \| `split` — see [docs/security.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/docs/security.md) | +| `ingress.className` | `""` | | +| `ingress.annotations` | body-size/read-timeout defaults for nginx-ingress | | +| `ingress.repo.host` / `.path` / `.pathType` / `.tls` | | apt-client-facing Ingress | +| `ingress.api.enabled` / `.host` / `.className` / `.annotations` / `.tls` | | `split` mode only | +| `gateway.enabled` | `false` | independent of `ingress.enabled` — both may be `true` at once, see [Gateway API](#gateway-api) | +| `gateway.apiVersion` | `gateway.networking.k8s.io/v1` | override only for pre-GA clusters | +| `gateway.mode` | `single` | `single` \| `split` — same meaning as `ingress.mode` | +| `gateway.parentRefs` | `[]` | required when `gateway.enabled: true` — `[{name, namespace, sectionName}]` | +| `gateway.repo.hostnames` / `.path` / `.pathType` | | apt-client-facing `HTTPRoute`; `pathType` is Gateway API's own enum (`PathPrefix`/`Exact`/`RegularExpression`), distinct from `ingress.repo.pathType`'s | +| `gateway.api.enabled` / `.hostnames` / `.parentRefs` | | `split` mode only; `.parentRefs` falls back to `gateway.parentRefs` when unset | +| `networkPolicy.enabled` | `false` | | +| `networkPolicy.allowedNamespaces` | `[]` | empty = no ingress restriction | +| `networkPolicy.extraIngress` | `[]` | | +| `networkPolicy.egress.allowAll` | `true` | disabling this breaks mirrors unless you add `egress.extra` rules yourself | + +### Metrics + +| Key | Default | Description | +|---|---|---| +| `metrics.service.enabled` | `false` | separate, unauthenticated port | +| `metrics.service.port` | `9090` | | +| `metrics.serviceMonitor.enabled` | `false` | requires the Prometheus Operator CRDs | +| `metrics.serviceMonitor.interval` | `30s` | | + +### Reconcile + +| Key | Default | Description | +|---|---|---| +| `reconcile.enabled` | `true` | | +| `reconcile.mode` | `hook` | `hook` \| `job` (GitOps-friendly, hashed name) \| `manual` | +| `reconcile.failOnError` | `false` | `true` makes an unreachable mirror fail the release | +| `reconcile.timeoutSeconds` | `600` | | +| `reconcile.image` | `{}` | overrides `repository`/`tag`/`pullPolicy`; defaults to the main `image` | +| `reconcile.resources` | `{}` | | + +### Pod disruption & scheduling + +| Key | Default | Description | +|---|---|---| +| `podDisruptionBudget.enabled` | `false` | | +| `podDisruptionBudget.maxUnavailable` | `1` | never set `minAvailable` here — with `replicas: 1` it blocks every node drain forever | +| `nodeSelector` / `tolerations` / `affinity` / `topologySpreadConstraints` / `priorityClassName` | | standard scheduling escape hatches | + +### Escape hatches + +| Key | Default | Description | +|---|---|---| +| `extraEnv` / `extraEnvFrom` | `[]` | on the `aptly` container | +| `extraVolumes` / `extraVolumeMounts` | `[]` | | +| `extraInitContainers` / `extraContainers` | `[]` | | +| `global.imageRegistry` | `""` | prefixes both `image.repository` and `nginx.image.repository` | +| `global.imagePullSecrets` | `[]` | | +| `global.defaultStorageClass` | `""` | | + +## License + +MIT. See [LICENSE](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/LICENSE) +and [NOTICE.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/NOTICE.md) +in the repository. diff --git a/charts/aptly/ci/authenticated.yaml b/charts/aptly/ci/authenticated.yaml new file mode 100644 index 0000000..5774c5d --- /dev/null +++ b/charts/aptly/ci/authenticated.yaml @@ -0,0 +1,5 @@ +security: + preset: authenticated + auth: + users: + ci: "changeme" diff --git a/charts/aptly/ci/full-featured.yaml b/charts/aptly/ci/full-featured.yaml new file mode 100644 index 0000000..54cbd58 --- /dev/null +++ b/charts/aptly/ci/full-featured.yaml @@ -0,0 +1,82 @@ +# Exercises: existingClaim, existingSecret (auth + gpg), metrics + ServiceMonitor, +# networkPolicy, PDB, declarative state (gpgKeys/localRepos/mirrors/publish), +# configOverrides passthrough, extra* escape hatches. +persistence: + existingClaim: my-existing-pvc + +security: + preset: authenticated + auth: + existingSecret: my-htpasswd-secret + trustedProxies: ["10.0.0.0/8"] + write: + allowCIDRs: ["10.42.0.0/16"] + +aptly: + architectures: [amd64, arm64] + metrics: + enabled: true + gpg: + signingKey: + existingSecret: my-signing-key-secret + gpgKeys: + - name: debian-archive + armored: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + dGVzdA== + -----END PGP PUBLIC KEY BLOCK----- + localRepos: + - name: stable + comment: "Production repo" + defaultDistribution: stable + defaultComponent: main + mirrors: + - name: debian-security + archiveURL: http://security.debian.org/debian-security + distribution: trixie-security + components: [main] + architectures: [amd64, arm64] + publish: + - name: stable-root + prefix: "" + distribution: stable + sourceKind: local + sources: [{ name: stable, component: main }] + architectures: [amd64, arm64] + acquireByHash: true + configOverrides: + download_concurrency: 8 + existingSecretEnv: ["some-other-secret"] + +metrics: + service: + enabled: true + serviceMonitor: + enabled: true + +networkPolicy: + enabled: true + allowedNamespaces: ["ci", "monitoring"] + +podDisruptionBudget: + enabled: true + maxUnavailable: 1 + +reconcile: + mode: job + +extraEnv: + - name: FOO + value: bar +extraVolumes: + - name: extra + emptyDir: {} +extraVolumeMounts: + - name: extra + mountPath: /extra +nodeSelector: + kubernetes.io/os: linux +tolerations: + - key: "example" + operator: "Exists" +priorityClassName: "high-priority" diff --git a/charts/aptly/ci/gateway-single.yaml b/charts/aptly/ci/gateway-single.yaml new file mode 100644 index 0000000..f4edbe3 --- /dev/null +++ b/charts/aptly/ci/gateway-single.yaml @@ -0,0 +1,7 @@ +gateway: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-infra + repo: + hostnames: [apt.example.com] diff --git a/charts/aptly/ci/gateway-split.yaml b/charts/aptly/ci/gateway-split.yaml new file mode 100644 index 0000000..2c83bce --- /dev/null +++ b/charts/aptly/ci/gateway-split.yaml @@ -0,0 +1,20 @@ +gateway: + enabled: true + mode: split + parentRefs: + - name: my-gateway + namespace: gateway-infra + repo: + hostnames: [apt.example.com] + api: + enabled: true + hostnames: [aptly-api.example.com] + parentRefs: + - name: internal-gateway + namespace: gateway-infra + +security: + preset: authenticated + auth: + users: + ci: "changeme" diff --git a/charts/aptly/ci/ingress-and-gateway.yaml b/charts/aptly/ci/ingress-and-gateway.yaml new file mode 100644 index 0000000..25b0186 --- /dev/null +++ b/charts/aptly/ci/ingress-and-gateway.yaml @@ -0,0 +1,14 @@ +# Mid-migration scenario: Ingress and Gateway API enabled at the same time, +# both pointing at the same Service. Proves the two never conflict. +ingress: + enabled: true + repo: + host: apt.example.com + +gateway: + enabled: true + parentRefs: + - name: my-gateway + namespace: gateway-infra + repo: + hostnames: [apt.example.com] diff --git a/charts/aptly/ci/ingress-single.yaml b/charts/aptly/ci/ingress-single.yaml new file mode 100644 index 0000000..24ba90d --- /dev/null +++ b/charts/aptly/ci/ingress-single.yaml @@ -0,0 +1,5 @@ +ingress: + enabled: true + mode: single + repo: + host: apt.example.com diff --git a/charts/aptly/ci/ingress-split.yaml b/charts/aptly/ci/ingress-split.yaml new file mode 100644 index 0000000..a1d646a --- /dev/null +++ b/charts/aptly/ci/ingress-split.yaml @@ -0,0 +1,13 @@ +ingress: + enabled: true + mode: split + repo: + host: apt.example.com + api: + enabled: true + host: aptly-api.example.com +security: + preset: authenticated + auth: + users: + ci: "changeme" diff --git a/charts/aptly/ci/no-proxy-open.yaml b/charts/aptly/ci/no-proxy-open.yaml new file mode 100644 index 0000000..25dd186 --- /dev/null +++ b/charts/aptly/ci/no-proxy-open.yaml @@ -0,0 +1,7 @@ +security: + preset: open +proxy: + enabled: false +aptly: + gpg: + enabled: false diff --git a/charts/aptly/ci/open.yaml b/charts/aptly/ci/open.yaml new file mode 100644 index 0000000..8250aab --- /dev/null +++ b/charts/aptly/ci/open.yaml @@ -0,0 +1,5 @@ +security: + preset: open +aptly: + gpg: + enabled: false diff --git a/charts/aptly/ci/readonly.yaml b/charts/aptly/ci/readonly.yaml new file mode 100644 index 0000000..baca0ff --- /dev/null +++ b/charts/aptly/ci/readonly.yaml @@ -0,0 +1,2 @@ +security: + preset: readOnly diff --git a/charts/aptly/templates/NOTES.txt b/charts/aptly/templates/NOTES.txt new file mode 100644 index 0000000..88598a0 --- /dev/null +++ b/charts/aptly/templates/NOTES.txt @@ -0,0 +1,89 @@ +{{- $fullname := include "aptly.fullname" . -}} +{{- $sec := include "aptly.security" . | fromJson -}} +aptly ({{ .Chart.AppVersion }}, chart {{ .Chart.Version }}) is deploying as {{ $fullname }}-0 in {{ .Release.Namespace }}. + +{{- if eq .Values.security.preset "open" }} + + *** security.preset: open *** + Both reading AND writing (the mutating /api/ path) are reachable with NO + authentication from anything that can reach the Service — this is exactly + the "komplett unabgesichert" mode, working as configured. Nothing further + to set up; just make sure this is really what you want before exposing it + beyond your own network. +{{- end }} + +--- Check it's up ------------------------------------------------------- + + kubectl exec -n {{ .Release.Namespace }} {{ $fullname }}-0 -c aptly -- \ + curl -fsS http://127.0.0.1:8080/api/ready + + kubectl logs -n {{ .Release.Namespace }} job/{{ $fullname }}-reconcile + # (only present right after install/upgrade in `hook` mode) + +--- Reach it ------------------------------------------------------------- + +{{- if .Values.ingress.enabled }} +{{- with .Values.ingress.repo.host }} + + https://{{ . }}/ (Ingress) +{{- end }} +{{- end }} +{{- if .Values.gateway.enabled }} +{{- range .Values.gateway.repo.hostnames }} + + https://{{ . }}/ (Gateway API) +{{- end }} +{{- end }} +{{- if not (or .Values.ingress.enabled .Values.gateway.enabled) }} + + kubectl port-forward -n {{ .Release.Namespace }} svc/{{ $fullname }} 8080:{{ .Values.service.port }} + # then use http://127.0.0.1:8080/ below +{{- end }} + +--- Configure apt on a client --------------------------------------------- + +{{- $host := "apt.example.com" }} +{{- if and .Values.ingress.enabled .Values.ingress.repo.host }} +{{- $host = .Values.ingress.repo.host }} +{{- else if and .Values.gateway.enabled .Values.gateway.repo.hostnames }} +{{- $host = first .Values.gateway.repo.hostnames }} +{{- end }} +{{- if .Values.aptly.gpg.enabled }} +{{- if .Values.aptly.gpg.publishPublicKey.enabled }} + + curl -fsSL https://{{ $host }}{{ .Values.aptly.gpg.publishPublicKey.path }} \ + | gpg --dearmor | sudo tee /usr/share/keyrings/{{ include "aptly.name" . }}.gpg >/dev/null + + echo 'deb [signed-by=/usr/share/keyrings/{{ include "aptly.name" . }}.gpg] https://{{ $host }}/ ' \ + | sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list +{{- else }} + + aptly.gpg.enabled=true but aptly.gpg.publishPublicKey.enabled=false — the + signing key is not being served; distribute it to clients yourself. + echo 'deb [signed-by=/path/to/your-key.gpg] https://{{ $host }}/ ' \ + | sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list +{{- end }} +{{- else }} + + echo 'deb [trusted=yes] https://{{ $host }}/ ' \ + | sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list +{{- end }} +{{- if $sec.ra }} + + Reads require credentials in this preset ({{ .Values.security.preset }}): + echo 'machine {{ $host }} login password ' \ + | sudo tee -a /etc/apt/auth.conf.d/{{ include "aptly.name" . }}.conf +{{- end }} + +--- Resizing storage later ------------------------------------------------- + + persistence.size on an already-installed StatefulSet is IMMUTABLE via + `helm upgrade` (Kubernetes forbids changing volumeClaimTemplates in place). + For production, set persistence.existingClaim to a PVC you manage yourself + — resizing that is a plain PVC edit. See docs/operations.md for the + recovery procedure if you need to resize a chart-managed PVC anyway. +{{- if and .Values.podDisruptionBudget.enabled (le (int .Values.podDisruptionBudget.maxUnavailable) 0) }} + + *** podDisruptionBudget.maxUnavailable is 0 with replicas=1 — this blocks + every voluntary node drain forever. *** +{{- end }} diff --git a/charts/aptly/templates/_helpers.tpl b/charts/aptly/templates/_helpers.tpl new file mode 100644 index 0000000..24f7fd9 --- /dev/null +++ b/charts/aptly/templates/_helpers.tpl @@ -0,0 +1,263 @@ +{{/* +Standard name/label helpers, bookstack-chart style. +*/}} +{{- define "aptly.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "aptly.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "aptly.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "aptly.labels" -}} +helm.sh/chart: {{ include "aptly.chart" . }} +{{ include "aptly.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{- define "aptly.selectorLabels" -}} +app.kubernetes.io/name: {{ include "aptly.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Image helpers — global.imageRegistry prefixes the repository when set, +matching the bookstack-chart convention. +*/}} +{{- define "aptly.image" -}} +{{- $registry := .Values.global.imageRegistry -}} +{{- $repo := .Values.image.repository -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion -}} +{{- if $registry -}} +{{- printf "%s/%s:%s" $registry $repo $tag -}} +{{- else -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} +{{- end -}} + +{{- define "aptly.nginxImage" -}} +{{- $registry := .Values.global.imageRegistry -}} +{{- $repo := .Values.nginx.image.repository -}} +{{- $tag := .Values.nginx.image.tag -}} +{{- if $registry -}} +{{- printf "%s/%s:%s" $registry $repo $tag -}} +{{- else -}} +{{- printf "%s:%s" $repo $tag -}} +{{- end -}} +{{- end -}} + +{{/* +Shared by templates/ingress.yaml and templates/httproute.yaml: proxy.enabled=false +means aptly's unauthenticated write API sits directly behind whatever routes to +it, on a single listener with no path-based auth split possible — refuse to wire +that up to an Ingress OR a Gateway API HTTPRoute unless security.preset=open +confirms it's intended. +*/}} +{{- define "aptly.exposureGuard" -}} +{{- if and (not .Values.proxy.enabled) (or .Values.ingress.enabled .Values.gateway.enabled) (ne .Values.security.preset "open") -}} +{{- fail "proxy.enabled=false publishes aptly's unauthenticated write API through the Ingress/HTTPRoute (no path-based auth split is possible on a single listener). Set security.preset=open to confirm this is intended, or keep proxy.enabled=true." -}} +{{- end -}} +{{- end -}} + +{{- define "aptly.imagePullSecrets" -}} +{{- $secrets := concat (.Values.global.imagePullSecrets | default list) (.Values.image.pullSecrets | default list) -}} +{{- if $secrets }} +imagePullSecrets: +{{- range $secrets }} + - name: {{ if kindIs "map" . }}{{ .name }}{{ else }}{{ . }}{{ end }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +Whether aptly serves the published tree itself (no nginx proxy in front). +Only sane in combination with security.preset=open — enforced in +templates/statefulset.yaml (the aptly config) and templates/ingress.yaml +(the hard `fail` guard). +*/}} +{{- define "aptly.serveInApiMode" -}} +{{- if .Values.proxy.enabled -}}false{{- else -}}true{{- end -}} +{{- end -}} + +{{- define "aptly.apiListen" -}} +{{- if .Values.proxy.enabled -}}127.0.0.1:8080{{- else -}}0.0.0.0:8080{{- end -}} +{{- end -}} + +{{/* +Resolve security.preset + explicit read/write overrides into a plain dict +{r, ra, w, wa} (read-enabled, read-auth, write-enabled, write-auth). Both +nginx.conf.tpl and templates/ingress.yaml key off this so preset expansion +lives in exactly one place. +*/}} +{{- define "aptly.security" -}} +{{- $p := .Values.security.preset -}} +{{- $presets := dict + "open" (dict "r" true "ra" false "w" true "wa" false) + "publicRead" (dict "r" true "ra" false "w" true "wa" true) + "authenticated" (dict "r" true "ra" true "w" true "wa" true) + "readOnly" (dict "r" true "ra" false "w" false "wa" false) +-}} +{{- $base := index $presets $p -}} +{{- if not $base -}} +{{- fail (printf "security.preset: unknown value %q (must be one of open, publicRead, authenticated, readOnly)" $p) -}} +{{- end -}} +{{- $d := deepCopy $base -}} +{{- if kindIs "bool" .Values.security.read.enabled }}{{- $_ := set $d "r" .Values.security.read.enabled -}}{{- end -}} +{{- if kindIs "bool" .Values.security.read.requireAuth }}{{- $_ := set $d "ra" .Values.security.read.requireAuth -}}{{- end -}} +{{- if kindIs "bool" .Values.security.write.enabled }}{{- $_ := set $d "w" .Values.security.write.enabled -}}{{- end -}} +{{- if kindIs "bool" .Values.security.write.requireAuth }}{{- $_ := set $d "wa" .Values.security.write.requireAuth -}}{{- end -}} +{{- $d | toJson -}} +{{- end -}} + +{{/* +Render the aptly config (YAML) from the curated values.aptly.* keys, then +deep-merge aptly.configOverrides on top so every current/future aptly config +key stays reachable without a chart change. Curated keys are OMITTED when +unset, so a default install matches aptly's own upstream defaults exactly +(see utils/config.go in aptly-dev/aptly for the canonical defaults). +*/}} +{{- define "aptly.config" -}} +{{- $v := .Values.aptly -}} +{{- $c := dict "root_dir" "/var/lib/aptly" -}} +{{- with $v.logLevel }}{{- $_ := set $c "log_level" . -}}{{- end -}} +{{- with $v.logFormat }}{{- $_ := set $c "log_format" . -}}{{- end -}} +{{- if $v.architectures }}{{- $_ := set $c "architectures" $v.architectures -}}{{- end -}} +{{- with $v.download.concurrency }}{{- $_ := set $c "download_concurrency" . -}}{{- end -}} +{{- with $v.download.limit }}{{- $_ := set $c "download_limit" . -}}{{- end -}} +{{- with $v.download.retries }}{{- $_ := set $c "download_retries" . -}}{{- end -}} +{{- if kindIs "bool" $v.download.sourcePackages }}{{- $_ := set $c "download_sourcepackages" $v.download.sourcePackages -}}{{- end -}} +{{- $_ := set $c "gpg_provider" ($v.gpg.provider | default "gpg") -}} +{{- $_ := set $c "gpg_disable_sign" (not $v.gpg.enabled) -}} +{{- $_ := set $c "gpg_disable_verify" (not $v.gpg.verify) -}} +{{- if kindIs "bool" $v.publishing.skipContents }}{{- $_ := set $c "skip_contents_publishing" $v.publishing.skipContents -}}{{- end -}} +{{- if kindIs "bool" $v.publishing.skipBz2 }}{{- $_ := set $c "skip_bz2_publishing" $v.publishing.skipBz2 -}}{{- end -}} +{{- $_ := set $c "enable_metrics_endpoint" ($v.metrics.enabled | default false) -}} +{{- $_ := set $c "enable_swagger_endpoint" ($v.swagger.enabled | default false) -}} +{{- $_ := set $c "serve_in_api_mode" (eq (include "aptly.serveInApiMode" .) "true") -}} +{{- $_ := set $c "filesystem_publish_endpoints" (dict $.Values.proxy.publishEndpointName (dict "root_dir" "/var/lib/aptly/public" "link_method" "hardlink")) -}} +{{- $merged := mergeOverwrite $c (deepCopy ($v.configOverrides | default dict)) -}} +{{- toYaml $merged -}} +{{- end -}} + +{{/* +Render nginx's server{} block (mounted at /etc/nginx/conf.d/default.conf, +which the base image's own nginx.conf already includes from inside its own +http{} block — this template must therefore emit ONLY a server{} block, see +compose/config/nginx.*.conf for the same constraint hit empirically). +*/}} +{{- define "aptly.nginxConf" -}} +{{- $sec := include "aptly.security" . | fromJson -}} +{{- $p := .Values.proxy -}} +server { + listen 8080; + server_name _; + client_max_body_size {{ $p.maxUploadSize }}; + absolute_redirect off; +{{- range .Values.security.trustedProxies }} + set_real_ip_from {{ . }}; +{{- end }} +{{- if .Values.security.trustedProxies }} + real_ip_header X-Forwarded-For; + real_ip_recursive on; +{{- end }} + + location = /healthz { access_log off; return 200 "ok\n"; } + location = /api/ready { access_log off; proxy_pass http://127.0.0.1:8080; } + location = /api/healthy { access_log off; proxy_pass http://127.0.0.1:8080; } + +{{- if $sec.w }} + location /api/ { +{{- if .Values.security.write.allowCIDRs }} +{{- if and .Values.security.write.allowCIDRs (not .Values.security.trustedProxies) }} + # WARNING: write.allowCIDRs is set without security.trustedProxies. Behind + # an Ingress controller, $remote_addr is the CONTROLLER's pod IP, not the + # real client — this will match every client on earth. Set + # trustedProxies to the controller's CIDR, or use networkPolicy instead. +{{- end }} +{{- range .Values.security.write.allowCIDRs }} + allow {{ . }}; +{{- end }} + deny all; + satisfy {{ if $sec.wa }}any{{ else }}all{{ end }}; +{{- end }} +{{- if $sec.wa }} + auth_basic "aptly"; + auth_basic_user_file /run/aptly/htpasswd; +{{- else }} + auth_basic off; +{{- end }} + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_request_buffering off; + proxy_read_timeout {{ .Values.proxy.readTimeout }}; + proxy_send_timeout {{ .Values.proxy.readTimeout }}; + } +{{- else }} + location /api/ { return 404; } +{{- end }} + + location = /signing-key.asc { + alias /run/aptly/pub/signing-key.asc; + default_type text/plain; + } + +{{- if $sec.r }} + location / { +{{- if $sec.ra }} + auth_basic "aptly"; + auth_basic_user_file /run/aptly/htpasswd; +{{- end }} + root /var/lib/aptly/public; + autoindex on; + autoindex_exact_size off; + + location ~* /(InRelease|Release|Release\.gpg|Packages(\.[a-z0-9]+)?|Sources(\.[a-z0-9]+)?)$ { +{{- if $sec.ra }} + auth_basic "aptly"; + auth_basic_user_file /run/aptly/htpasswd; +{{- end }} + root /var/lib/aptly/public; + add_header Cache-Control "no-cache" always; + } + } +{{- if .Values.proxy.compatPaths }} + location /repos/{{ .Values.proxy.publishEndpointName }}/ { +{{- if $sec.ra }} + auth_basic "aptly"; + auth_basic_user_file /run/aptly/htpasswd; +{{- end }} + alias /var/lib/aptly/public/; + autoindex on; + } +{{- end }} +{{- else }} + location / { return 404; } +{{- end }} +} +{{- if .Values.metrics.service.enabled }} + +# Separate, unauthenticated listener so scraping never needs the write-path +# credentials and a ServiceMonitor never needs a basicAuth secret. +server { + listen 9090; + server_name _; + location = /api/metrics { proxy_pass http://127.0.0.1:8080; } + location / { return 404; } +} +{{- end }} +{{- end -}} diff --git a/charts/aptly/templates/configmap-aptly.yaml b/charts/aptly/templates/configmap-aptly.yaml new file mode 100644 index 0000000..fb19441 --- /dev/null +++ b/charts/aptly/templates/configmap-aptly.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aptly.fullname" . }}-config + labels: + {{- include "aptly.labels" . | nindent 4 }} +data: + aptly.yaml: | +{{- include "aptly.config" . | nindent 4 }} diff --git a/charts/aptly/templates/configmap-gpg-keys.yaml b/charts/aptly/templates/configmap-gpg-keys.yaml new file mode 100644 index 0000000..71a129a --- /dev/null +++ b/charts/aptly/templates/configmap-gpg-keys.yaml @@ -0,0 +1,13 @@ +{{- if .Values.aptly.gpgKeys }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aptly.fullname" . }}-gpg-keys + labels: + {{- include "aptly.labels" . | nindent 4 }} +data: + {{- range .Values.aptly.gpgKeys }} + {{ .name }}.asc: | + {{- .armored | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/aptly/templates/configmap-nginx.yaml b/charts/aptly/templates/configmap-nginx.yaml new file mode 100644 index 0000000..15437fc --- /dev/null +++ b/charts/aptly/templates/configmap-nginx.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aptly.fullname" . }}-nginx + labels: + {{- include "aptly.labels" . | nindent 4 }} +data: + default.conf: | +{{- include "aptly.nginxConf" . | nindent 4 }} diff --git a/charts/aptly/templates/configmap-state.yaml b/charts/aptly/templates/configmap-state.yaml new file mode 100644 index 0000000..6e5b2b7 --- /dev/null +++ b/charts/aptly/templates/configmap-state.yaml @@ -0,0 +1,11 @@ +{{- if .Values.reconcile.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "aptly.fullname" . }}-state + labels: + {{- include "aptly.labels" . | nindent 4 }} +data: + state.yaml: | +{{- dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | nindent 4 }} +{{- end }} diff --git a/charts/aptly/templates/httproute.yaml b/charts/aptly/templates/httproute.yaml new file mode 100644 index 0000000..f41a995 --- /dev/null +++ b/charts/aptly/templates/httproute.yaml @@ -0,0 +1,65 @@ +{{- include "aptly.exposureGuard" . -}} +{{- if .Values.gateway.enabled }} +{{- $fullname := include "aptly.fullname" . -}} +{{- $svcName := $fullname -}} +{{- if not .Values.gateway.parentRefs }} +{{- fail "gateway.enabled=true requires gateway.parentRefs to reference at least one existing Gateway — this chart never creates a Gateway itself." -}} +{{- end }} +--- +# Repo HTTPRoute — apt clients. In `single` mode this is also where /api/ +# traffic arrives; nginx does the read/write split internally (see +# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this +# is a route-level split only: nginx does not itself reject /api/ requests +# that arrive via this hostname, so combine `split` mode with +# security.write.inClusterOnly or a NetworkPolicy if you need that enforced. +# TLS is configured on the referenced Gateway's listener, not here. +apiVersion: {{ .Values.gateway.apiVersion }} +kind: HTTPRoute +metadata: + name: {{ $fullname }} + labels: + {{- include "aptly.labels" . | nindent 4 }} +spec: + parentRefs: + {{- toYaml .Values.gateway.parentRefs | nindent 4 }} + {{- with .Values.gateway.repo.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - matches: + - path: + type: {{ .Values.gateway.repo.pathType }} + value: {{ .Values.gateway.repo.path }} + backendRefs: + - name: {{ $svcName }} + port: {{ .Values.service.port }} +{{- if and (eq .Values.gateway.mode "split") .Values.gateway.api.enabled (not .Values.security.write.inClusterOnly) }} +--- +# API HTTPRoute (split mode) — a separate hostname/Gateway so you can put a +# different Gateway, mTLS, or WAF policy in front of the mutating API than +# the public read path gets. +apiVersion: {{ .Values.gateway.apiVersion }} +kind: HTTPRoute +metadata: + name: {{ $fullname }}-api + labels: + {{- include "aptly.labels" . | nindent 4 }} +spec: + {{- $apiParentRefs := .Values.gateway.api.parentRefs | default .Values.gateway.parentRefs }} + parentRefs: + {{- toYaml $apiParentRefs | nindent 4 }} + {{- with .Values.gateway.api.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ $svcName }} + port: {{ .Values.service.port }} +{{- end }} +{{- end }} diff --git a/charts/aptly/templates/ingress.yaml b/charts/aptly/templates/ingress.yaml new file mode 100644 index 0000000..d455c0e --- /dev/null +++ b/charts/aptly/templates/ingress.yaml @@ -0,0 +1,77 @@ +{{- include "aptly.exposureGuard" . -}} +{{- if .Values.ingress.enabled }} +{{- $fullname := include "aptly.fullname" . -}} +{{- $svcName := $fullname -}} +--- +# Repo Ingress — apt clients. In `single` mode this is also where /api/ +# traffic arrives; nginx does the read/write split internally (see +# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this +# is a DNS/Ingress-level split only: nginx does not itself reject /api/ +# requests that arrive via this host, so combine `split` mode with +# security.write.inClusterOnly or a NetworkPolicy if you need that enforced. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullname }} + labels: + {{- include "aptly.labels" . | nindent 4 }} + annotations: + {{- toYaml .Values.ingress.annotations | nindent 4 }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.repo.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - {{- with .Values.ingress.repo.host }} + host: {{ . | quote }} + {{- end }} + http: + paths: + - path: {{ .Values.ingress.repo.path }} + pathType: {{ .Values.ingress.repo.pathType }} + backend: + service: + name: {{ $svcName }} + port: + name: http +{{- if and (eq .Values.ingress.mode "split") .Values.ingress.api.enabled (not .Values.security.write.inClusterOnly) }} +--- +# API Ingress (split mode) — a separate host so you can put a different +# ingressClass, mTLS, or WAF policy in front of the mutating API than the +# public read path gets. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullname }}-api + labels: + {{- include "aptly.labels" . | nindent 4 }} + annotations: + {{- toYaml (merge .Values.ingress.api.annotations .Values.ingress.annotations) | nindent 4 }} +spec: + {{- $apiClass := .Values.ingress.api.className | default .Values.ingress.className }} + {{- with $apiClass }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.api.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - {{- with .Values.ingress.api.host }} + host: {{ . | quote }} + {{- end }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ $svcName }} + port: + name: http +{{- end }} +{{- end }} diff --git a/charts/aptly/templates/job-reconcile.yaml b/charts/aptly/templates/job-reconcile.yaml new file mode 100644 index 0000000..2ce8601 --- /dev/null +++ b/charts/aptly/templates/job-reconcile.yaml @@ -0,0 +1,80 @@ +{{- if .Values.reconcile.enabled }} +{{- $fullname := include "aptly.fullname" . -}} +{{- $img := .Values.reconcile.image -}} +{{- $repo := $img.repository | default .Values.image.repository -}} +{{- $tag := $img.tag | default .Values.image.tag | default .Chart.AppVersion -}} +{{- $registry := .Values.global.imageRegistry -}} +{{- $image := ternary (printf "%s/%s:%s" $registry $repo $tag) (printf "%s:%s" $repo $tag) (ne $registry "") -}} +apiVersion: batch/v1 +kind: Job +metadata: + {{- if eq .Values.reconcile.mode "hook" }} + name: {{ $fullname }}-reconcile + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "5" + # Deliberately no hook-failed: a failed Job stays around for `kubectl + # logs`/`kubectl describe job` instead of vanishing before anyone can + # read why reconciliation didn't converge. + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + {{- else }} + # A plain (non-hook) Job named after the desired state's hash, for GitOps + # controllers (ArgoCD/Flux) that reconcile hooks poorly: it only re-runs + # when aptly.{gpgKeys,localRepos,mirrors,publish} actually change. + name: {{ $fullname }}-reconcile-{{ dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | sha256sum | trunc 8 }} + {{- end }} + labels: + {{- include "aptly.labels" . | nindent 4 }} +spec: + backoffLimit: 3 + activeDeadlineSeconds: {{ mul .Values.reconcile.timeoutSeconds 2 }} + {{- if ne .Values.reconcile.mode "hook" }} + ttlSecondsAfterFinished: 86400 + {{- end }} + template: + metadata: + labels: + {{- include "aptly.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: reconcile + spec: + restartPolicy: Never + {{- include "aptly.imagePullSecrets" . | nindent 6 }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: reconcile + image: {{ $image }} + imagePullPolicy: {{ $img.pullPolicy | default .Values.image.pullPolicy }} + command: ["/usr/local/bin/aptly-reconcile"] + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + env: + - name: APTLY_URL + value: "http://{{ $fullname }}:{{ .Values.service.port }}" + - name: APTLY_STATE_FILE + value: /state.yaml + - name: APTLY_FAIL_ON_ERROR + value: {{ .Values.reconcile.failOnError | quote }} + - name: APTLY_WAIT_TIMEOUT + value: {{ .Values.reconcile.timeoutSeconds | quote }} + {{- if .Values.security.auth.internalUser.enabled }} + - name: APTLY_USER + value: {{ .Values.security.auth.internalUser.username | quote }} + - name: APTLY_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $fullname }}-credentials + key: internal-password + {{- end }} + volumeMounts: + - name: state + mountPath: /state.yaml + subPath: state.yaml + readOnly: true + resources: + {{- toYaml .Values.reconcile.resources | nindent 12 }} + volumes: + - name: state + configMap: + name: {{ $fullname }}-state +{{- end }} diff --git a/charts/aptly/templates/networkpolicy.yaml b/charts/aptly/templates/networkpolicy.yaml new file mode 100644 index 0000000..a4c9baf --- /dev/null +++ b/charts/aptly/templates/networkpolicy.yaml @@ -0,0 +1,51 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "aptly.fullname" . }} + labels: + {{- include "aptly.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "aptly.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + {{- if .Values.networkPolicy.allowedNamespaces }} + # Restricted to these namespaces (plus this one). NOTE: this applies to + # the whole nginx:8080 endpoint — read and write share one port, so this + # cannot itself express "reads are public, writes are cluster-only" any + # more precisely than security.write.allowCIDRs can (see the warning + # rendered into nginx.conf for that). Use it to fence the Service off + # from unrelated namespaces, not as a read/write split. + - from: + - podSelector: {} + {{- range .Values.networkPolicy.allowedNamespaces }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . }} + {{- end }} + {{- else }} + - {} + {{- end }} + {{- with .Values.networkPolicy.extraIngress }} + {{- toYaml . | nindent 4 }} + {{- end }} + egress: + {{- if .Values.networkPolicy.egress.allowAll }} + - {} + {{- else }} + - to: + - namespaceSelector: {} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- with .Values.networkPolicy.egress.extra }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/aptly/templates/poddisruptionbudget.yaml b/charts/aptly/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..6dbf37d --- /dev/null +++ b/charts/aptly/templates/poddisruptionbudget.yaml @@ -0,0 +1,16 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "aptly.fullname" . }} + labels: + {{- include "aptly.labels" . | nindent 4 }} +spec: + # With replicas fixed at 1 (see templates/statefulset.yaml), a + # minAvailable:1 budget would block every voluntary node drain forever — + # maxUnavailable is the only sane knob here. + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + selector: + matchLabels: + {{- include "aptly.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/aptly/templates/secret-credentials.yaml b/charts/aptly/templates/secret-credentials.yaml new file mode 100644 index 0000000..c196cf2 --- /dev/null +++ b/charts/aptly/templates/secret-credentials.yaml @@ -0,0 +1,34 @@ +{{- $fullname := include "aptly.fullname" . -}} +{{- $secretName := printf "%s-credentials" $fullname -}} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- $internalPassword := "" -}} +{{- if and $existing (hasKey $existing.data "internal-password") -}} +{{- $internalPassword = index $existing.data "internal-password" | b64dec -}} +{{- else -}} +{{- $internalPassword = randAlphaNum 32 -}} +{{- end -}} +{{- $userLines := list -}} +{{- range $name, $pass := .Values.security.auth.users -}} +{{- $userLines = append $userLines (printf "%s:%s" $name $pass) -}} +{{- end -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: + {{- include "aptly.labels" . | nindent 4 }} + annotations: + # Reused across `helm upgrade` (via `lookup` above) and preserved across + # `helm uninstall` so aptly-reconcile keeps working without a manual step + # after a reinstall. Never regenerate this key from a template — see + # rootfs/usr/local/bin/aptly-init for why hashing must not happen here. + helm.sh/resource-policy: keep +type: Opaque +stringData: + internal-password: {{ $internalPassword | quote }} + {{- if $userLines }} + users: | + {{- range $userLines }} + {{ . }} + {{- end }} + {{- end }} diff --git a/charts/aptly/templates/secret-gpg.yaml b/charts/aptly/templates/secret-gpg.yaml new file mode 100644 index 0000000..6e66aa3 --- /dev/null +++ b/charts/aptly/templates/secret-gpg.yaml @@ -0,0 +1,14 @@ +{{- if and .Values.aptly.gpg.enabled (not .Values.aptly.gpg.signingKey.existingSecret) (.Values.aptly.gpg.signingKey.privateKey) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "aptly.fullname" . }}-gpg + labels: + {{- include "aptly.labels" . | nindent 4 }} +type: Opaque +stringData: + privateKey: {{ .Values.aptly.gpg.signingKey.privateKey | quote }} + {{- if .Values.aptly.gpg.signingKey.passphrase }} + passphrase: {{ .Values.aptly.gpg.signingKey.passphrase | quote }} + {{- end }} +{{- end }} diff --git a/charts/aptly/templates/service-metrics.yaml b/charts/aptly/templates/service-metrics.yaml new file mode 100644 index 0000000..9898bec --- /dev/null +++ b/charts/aptly/templates/service-metrics.yaml @@ -0,0 +1,21 @@ +{{- if .Values.metrics.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aptly.fullname" . }}-metrics + labels: + {{- include "aptly.labels" . | nindent 4 }} + {{- with .Values.metrics.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + selector: + {{- include "aptly.selectorLabels" . | nindent 4 }} + ports: + - name: metrics + port: {{ .Values.metrics.service.port }} + targetPort: metrics + protocol: TCP +{{- end }} diff --git a/charts/aptly/templates/service.yaml b/charts/aptly/templates/service.yaml new file mode 100644 index 0000000..d1abdc0 --- /dev/null +++ b/charts/aptly/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aptly.fullname" . }} + labels: + {{- include "aptly.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "aptly.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/charts/aptly/templates/servicemonitor.yaml b/charts/aptly/templates/servicemonitor.yaml new file mode 100644 index 0000000..501a28b --- /dev/null +++ b/charts/aptly/templates/servicemonitor.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.metrics.serviceMonitor.enabled .Values.metrics.service.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "aptly.fullname" . }} + labels: + {{- include "aptly.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "aptly.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + path: /api/metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + {{- with .Values.metrics.serviceMonitor.relabelings }} + relabelings: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/aptly/templates/statefulset.yaml b/charts/aptly/templates/statefulset.yaml new file mode 100644 index 0000000..d085f24 --- /dev/null +++ b/charts/aptly/templates/statefulset.yaml @@ -0,0 +1,298 @@ +{{- $fullname := include "aptly.fullname" . -}} +{{- $hasGpgSecret := or .Values.aptly.gpg.signingKey.existingSecret (and (not .Values.aptly.gpg.signingKey.existingSecret) .Values.aptly.gpg.signingKey.privateKey) -}} +{{- $gpgSecretName := .Values.aptly.gpg.signingKey.existingSecret | default (printf "%s-gpg" $fullname) -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $fullname }} + labels: + {{- include "aptly.labels" . | nindent 4 }} + {{- with .Values.workload.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + serviceName: {{ $fullname }} + # LevelDB (aptly's database) takes an exclusive OS-level file lock — two + # writers would corrupt it. A StatefulSet with replicas=1 always terminates + # the old pod before creating its replacement, so RollingUpdate is safe + # here in a way it would not be for a Deployment on a ReadWriteOnce PVC + # (which would deadlock on a Multi-Attach error instead). + replicas: 1 + podManagementPolicy: {{ .Values.workload.podManagementPolicy }} + revisionHistoryLimit: {{ .Values.workload.revisionHistoryLimit }} + updateStrategy: + type: {{ .Values.workload.updateStrategy.type }} + selector: + matchLabels: + {{- include "aptly.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "aptly.selectorLabels" . | nindent 8 }} + {{- with .Values.workload.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + checksum/config: {{ include "aptly.config" . | sha256sum }} + checksum/nginx: {{ include "aptly.nginxConf" . | sha256sum }} + {{- with .Values.workload.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- include "aptly.imagePullSecrets" . | nindent 6 }} + terminationGracePeriodSeconds: {{ .Values.workload.terminationGracePeriodSeconds }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + initContainers: + - name: config-init + image: {{ include "aptly.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/usr/local/bin/aptly-init"] + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + env: + - name: APTLY_ROOT_DIR + value: /var/lib/aptly + - name: APTLY_CONFIG_SRC + value: /etc/aptly-src/aptly.yaml + - name: APTLY_CONFIG_DST + value: /run/aptly/aptly.yaml + - name: APTLY_GPG_ENABLED + value: {{ .Values.aptly.gpg.enabled | quote }} + {{- if not .Values.security.auth.existingSecret }} + - name: APTLY_USERS_FILE + value: /etc/aptly-secrets/users + {{- else }} + - name: APTLY_HTPASSWD_SRC + value: /etc/aptly-secrets-existing/htpasswd + {{- end }} + {{- if .Values.security.auth.internalUser.enabled }} + - name: APTLY_INTERNAL_USER + value: {{ .Values.security.auth.internalUser.username | quote }} + - name: APTLY_INTERNAL_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $fullname }}-credentials + key: internal-password + {{- end }} + {{- if $hasGpgSecret }} + - name: APTLY_GPG_PRIVATE_KEY_FILE + value: /etc/aptly-gpg/privateKey + - name: APTLY_GPG_SECRET_KEYRING_FILE + value: /etc/aptly-gpg/secretKeyring + - name: APTLY_GPG_PASSPHRASE_FILE + value: /etc/aptly-gpg/passphrase + {{- end }} + {{- if .Values.aptly.gpgKeys }} + - name: APTLY_GPG_KEYS_DIR + value: /etc/aptly-gpg-keys + {{- end }} + volumeMounts: + - name: config-src + mountPath: /etc/aptly-src + readOnly: true + - name: run + mountPath: /run/aptly + - name: data + mountPath: /var/lib/aptly + {{- if not .Values.security.auth.existingSecret }} + - name: credentials + mountPath: /etc/aptly-secrets + readOnly: true + {{- else }} + - name: credentials-existing + mountPath: /etc/aptly-secrets-existing + readOnly: true + {{- end }} + {{- if $hasGpgSecret }} + - name: gpg-secret + mountPath: /etc/aptly-gpg + readOnly: true + {{- end }} + {{- if .Values.aptly.gpgKeys }} + - name: gpg-keys + mountPath: /etc/aptly-gpg-keys + readOnly: true + {{- end }} + {{- with .Values.extraInitContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: aptly + image: {{ include "aptly.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.containerSecurityContext | nindent 12 }} + env: + - name: APTLY_API_LISTEN + value: {{ include "aptly.apiListen" . }} + - name: APTLY_CONFIG + value: /run/aptly/aptly.yaml + - name: GNUPGHOME + value: /run/aptly/gnupg + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if or .Values.aptly.existingSecretEnv .Values.extraEnvFrom }} + envFrom: + {{- range .Values.aptly.existingSecretEnv }} + - secretRef: + name: {{ . }} + {{- end }} + {{- with .Values.extraEnvFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + ports: + - name: aptly + containerPort: 8080 + volumeMounts: + - name: data + mountPath: /var/lib/aptly + - name: run + mountPath: /run/aptly + - name: tmp + mountPath: /tmp + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + startupProbe: + httpGet: { path: /api/ready, port: aptly } + periodSeconds: {{ .Values.probes.startup.periodSeconds }} + failureThreshold: {{ .Values.probes.startup.failureThreshold }} + readinessProbe: + httpGet: { path: /api/ready, port: aptly } + periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.readiness.failureThreshold }} + livenessProbe: + httpGet: { path: /api/healthy, port: aptly } + periodSeconds: {{ .Values.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.probes.liveness.failureThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + - name: nginx + image: {{ include "aptly.nginxImage" . }} + imagePullPolicy: {{ .Values.nginx.image.pullPolicy }} + securityContext: + {{- toYaml .Values.nginx.securityContext | nindent 12 }} + ports: + - name: http + containerPort: 8080 + {{- if .Values.metrics.service.enabled }} + - name: metrics + containerPort: 9090 + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/aptly/public + subPath: public + readOnly: true + - name: run + mountPath: /run/aptly + readOnly: true + - name: nginx-config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + readOnly: true + - name: tmp + mountPath: /tmp + - name: nginx-cache + mountPath: /var/cache/nginx + readinessProbe: + httpGet: { path: /healthz, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /healthz, port: http } + periodSeconds: 30 + resources: + {{- toYaml .Values.nginx.resources | nindent 12 }} + {{- with .Values.extraContainers }} + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config-src + configMap: + name: {{ $fullname }}-config + - name: nginx-config + configMap: + name: {{ $fullname }}-nginx + - name: run + emptyDir: + medium: Memory + sizeLimit: 16Mi + - name: tmp + emptyDir: {} + - name: nginx-cache + emptyDir: {} + {{- if not .Values.security.auth.existingSecret }} + - name: credentials + secret: + secretName: {{ $fullname }}-credentials + optional: true + {{- else }} + - name: credentials-existing + secret: + secretName: {{ .Values.security.auth.existingSecret }} + {{- end }} + {{- if $hasGpgSecret }} + - name: gpg-secret + secret: + secretName: {{ $gpgSecretName }} + optional: true + {{- end }} + {{- if .Values.aptly.gpgKeys }} + - name: gpg-keys + configMap: + name: {{ $fullname }}-gpg-keys + {{- end }} + {{- if not .Values.persistence.enabled }} + - name: data + emptyDir: {} + {{- else if .Values.persistence.existingClaim }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} + volumeClaimTemplates: + - metadata: + name: data + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 10 }} + {{- end }} + spec: + accessModes: [{{ .Values.persistence.accessMode }}] + {{- $sc := .Values.persistence.storageClass | default .Values.global.defaultStorageClass }} + {{- if $sc }} + storageClassName: {{ $sc }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- end }} diff --git a/charts/aptly/values.schema.json b/charts/aptly/values.schema.json new file mode 100644 index 0000000..62d574a --- /dev/null +++ b/charts/aptly/values.schema.json @@ -0,0 +1,421 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "aptly", + "type": "object", + "additionalProperties": false, + "properties": { + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + "image": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, + "pullSecrets": { "type": "array" } + } + }, + "nginx": { + "type": "object", + "additionalProperties": false, + "properties": { + "image": { + "type": "object", + "additionalProperties": false, + "properties": { + "repository": { "type": "string" }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "resources": { "type": "object" }, + "securityContext": { "type": "object" } + } + }, + "aptly": { + "type": "object", + "additionalProperties": false, + "properties": { + "architectures": { "type": "array", "items": { "type": "string" } }, + "logLevel": { "type": "string", "enum": ["debug", "info", "warn", "error"] }, + "logFormat": { "type": "string", "enum": ["default", "json"] }, + "download": { + "type": "object", + "additionalProperties": false, + "properties": { + "concurrency": { "type": "integer", "minimum": 1 }, + "limit": { "type": "integer", "minimum": 0 }, + "retries": { "type": "integer", "minimum": 0 }, + "sourcePackages": { "type": "boolean" } + } + }, + "publishing": { + "type": "object", + "additionalProperties": false, + "properties": { + "skipContents": { "type": "boolean" }, + "skipBz2": { "type": "boolean" } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { "enabled": { "type": "boolean" } } + }, + "swagger": { + "type": "object", + "additionalProperties": false, + "properties": { "enabled": { "type": "boolean" } } + }, + "gpg": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "verify": { "type": "boolean" }, + "provider": { "type": "string", "enum": ["gpg", "internal"] }, + "signingKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "existingSecret": { "type": "string" }, + "privateKey": { "type": "string" }, + "passphrase": { "type": "string" } + } + }, + "publishPublicKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" } + } + } + } + }, + "gpgKeys": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "armored"], + "properties": { + "name": { "type": "string" }, + "armored": { "type": "string" } + } + } + }, + "localRepos": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "comment": { "type": "string" }, + "defaultDistribution": { "type": "string" }, + "defaultComponent": { "type": "string" } + } + } + }, + "mirrors": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "archiveURL", "distribution"], + "properties": { + "name": { "type": "string" }, + "archiveURL": { "type": "string" }, + "distribution": { "type": "string" }, + "components": { "type": "array", "items": { "type": "string" } }, + "architectures": { "type": "array", "items": { "type": "string" } }, + "filter": { "type": "string" }, + "filterWithDeps": { "type": "boolean" }, + "downloadSources": { "type": "boolean" }, + "downloadUdebs": { "type": "boolean" }, + "downloadInstaller": { "type": "boolean" }, + "downloadAppStream": { "type": "boolean" }, + "ignoreSignatures": { "type": "boolean" }, + "keyrings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "publish": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "distribution"], + "properties": { + "name": { "type": "string" }, + "prefix": { "type": "string" }, + "distribution": { "type": "string" }, + "sourceKind": { "type": "string", "enum": ["local", "snapshot"] }, + "sources": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "component"], + "properties": { + "name": { "type": "string" }, + "component": { "type": "string" } + } + } + }, + "architectures": { "type": "array", "items": { "type": "string" } }, + "acquireByHash": { "type": "boolean" }, + "skipContents": { "type": "boolean" }, + "skipBz2": { "type": "boolean" } + } + } + }, + "configOverrides": { "type": "object", "additionalProperties": true }, + "existingSecretEnv": { "type": "array", "items": { "type": "string" } } + } + }, + "security": { + "type": "object", + "additionalProperties": false, + "properties": { + "preset": { "type": "string", "enum": ["open", "publicRead", "authenticated", "readOnly"] }, + "auth": { + "type": "object", + "additionalProperties": false, + "properties": { + "users": { "type": "object", "additionalProperties": { "type": "string" } }, + "existingSecret": { "type": "string" }, + "internalUser": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "username": { "type": "string" } + } + } + } + }, + "trustedProxies": { "type": "array", "items": { "type": "string" } }, + "read": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "requireAuth": { "type": ["boolean", "null"] }, + "allowCIDRs": { "type": "array", "items": { "type": "string" } } + } + }, + "write": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "requireAuth": { "type": ["boolean", "null"] }, + "allowCIDRs": { "type": "array", "items": { "type": "string" } }, + "inClusterOnly": { "type": "boolean" } + } + } + } + }, + "proxy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "compatPaths": { "type": "boolean" }, + "maxUploadSize": { "type": "string" }, + "readTimeout": { "type": "string" }, + "publishEndpointName": { "type": "string" } + } + }, + "persistence": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "existingClaim": { "type": "string" }, + "storageClass": { "type": "string" }, + "accessMode": { "type": "string", "enum": ["ReadWriteOnce", "ReadWriteOncePod"] }, + "size": { "type": "string" }, + "annotations": { "type": "object" } + } + }, + "workload": { + "type": "object", + "additionalProperties": false, + "properties": { + "updateStrategy": { + "type": "object", + "additionalProperties": false, + "properties": { "type": { "type": "string", "enum": ["RollingUpdate", "OnDelete"] } } + }, + "podManagementPolicy": { "type": "string", "enum": ["OrderedReady", "Parallel"] }, + "revisionHistoryLimit": { "type": "integer", "minimum": 0 }, + "terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 }, + "annotations": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "podLabels": { "type": "object" } + } + }, + "podSecurityContext": { "type": "object" }, + "containerSecurityContext": { "type": "object" }, + "resources": { "type": "object" }, + "probes": { + "type": "object", + "additionalProperties": false, + "properties": { + "startup": { "type": "object" }, + "readiness": { "type": "object" }, + "liveness": { "type": "object" } + } + }, + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer" }, + "annotations": { "type": "object" } + } + }, + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "mode": { "type": "string", "enum": ["single", "split"] }, + "className": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": true }, + "repo": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { "type": "string" }, + "path": { "type": "string" }, + "pathType": { "type": "string", "enum": ["Prefix", "Exact", "ImplementationSpecific"] }, + "tls": { "type": "array" } + } + }, + "api": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "host": { "type": "string" }, + "className": { "type": "string" }, + "annotations": { "type": "object", "additionalProperties": true }, + "tls": { "type": "array" } + } + } + } + }, + "gateway": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "apiVersion": { "type": "string" }, + "mode": { "type": "string", "enum": ["single", "split"] }, + "parentRefs": { "type": "array" }, + "repo": { + "type": "object", + "additionalProperties": false, + "properties": { + "hostnames": { "type": "array", "items": { "type": "string" } }, + "path": { "type": "string" }, + "pathType": { "type": "string", "enum": ["PathPrefix", "Exact", "RegularExpression"] } + } + }, + "api": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "hostnames": { "type": "array", "items": { "type": "string" } }, + "parentRefs": { "type": "array" } + } + } + } + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "port": { "type": "integer" }, + "annotations": { "type": "object" } + } + }, + "serviceMonitor": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "interval": { "type": "string" }, + "labels": { "type": "object" }, + "relabelings": { "type": "array" } + } + } + } + }, + "reconcile": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "mode": { "type": "string", "enum": ["hook", "job", "manual"] }, + "failOnError": { "type": "boolean" }, + "timeoutSeconds": { "type": "integer", "minimum": 1 }, + "image": { "type": "object" }, + "resources": { "type": "object" } + } + }, + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "maxUnavailable": { "type": "integer", "minimum": 0 } + } + }, + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "allowedNamespaces": { "type": "array", "items": { "type": "string" } }, + "extraIngress": { "type": "array" }, + "egress": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowAll": { "type": "boolean" }, + "extra": { "type": "array" } + } + } + } + }, + "extraEnv": { "type": "array" }, + "extraEnvFrom": { "type": "array" }, + "extraVolumes": { "type": "array" }, + "extraVolumeMounts": { "type": "array" }, + "extraInitContainers": { "type": "array" }, + "extraContainers": { "type": "array" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "affinity": { "type": "object" }, + "topologySpreadConstraints": { "type": "array" }, + "priorityClassName": { "type": "string" }, + "global": { + "type": "object", + "additionalProperties": false, + "properties": { + "imageRegistry": { "type": "string" }, + "imagePullSecrets": { "type": "array" }, + "defaultStorageClass": { "type": "string" } + } + } + } +} diff --git a/charts/aptly/values.yaml b/charts/aptly/values.yaml new file mode 100644 index 0000000..194b0eb --- /dev/null +++ b/charts/aptly/values.yaml @@ -0,0 +1,322 @@ +nameOverride: "" +fullnameOverride: "" + +# -- Container image for the aptly server itself (also used for the +# initContainer and the reconcile Job — all three run the same image). +image: + repository: git.morlana.online/f.weber/aptly + tag: "" # "" -> .Chart.AppVersion, i.e. the last released image. Never "latest". + pullPolicy: IfNotPresent + pullSecrets: [] + +# -- The read/auth sidecar. A plain upstream image — this chart owns none of +# its code, only its rendered config (see `security` below). +nginx: + image: + repository: nginxinc/nginx-unprivileged + tag: "1-alpine" + pullPolicy: IfNotPresent + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + +# ============================================================================= +# aptly configuration. Two layers, always merged in this order: +# 1. the curated keys below (omitted from the rendered config when unset, +# so a default install matches aptly's own upstream defaults exactly) +# 2. aptly.configOverrides — raw aptly YAML, deep-merged last, always wins. +# Every current and future aptly config key is reachable here without a +# chart change. See rootfs/usr/local/bin/aptly-init and +# https://github.com/aptly-dev/aptly/blob/master/utils/config.go for the +# full field list (snake_case yaml tags). +# ============================================================================= +aptly: + architectures: [] + logLevel: info + logFormat: json + download: + concurrency: 4 + limit: 0 + retries: 0 + sourcePackages: false + publishing: + skipContents: false + skipBz2: false + + metrics: + enabled: false + swagger: + enabled: false + + gpg: + # false -> gpg_disable_sign: true AND Signing.Skip: true on every publish + # call the reconcile Job makes (both are required — aptly's publish API + # does not consult gpg_disable_sign on its own, see docs/security.md). + enabled: true + verify: true + # gpg (default): the real gnupg binary, in the image already — handles + # armored keys, subkeys and passphrases the way upstream aptly expects. + # internal: pure-Go openpgp, no gnupg binary needed, smaller attack + # surface — verify your key type works with it before switching. + provider: gpg + signingKey: + # Secret with key "privateKey" (armored .asc) or "secretKeyring" + # (binary secring.gpg), optionally "passphrase". Never put a real key + # inline in values.yaml — this is the production path. + existingSecret: "" + # Discouraged escape hatch for quick tests only. + privateKey: "" + passphrase: "" + publishPublicKey: + enabled: true + path: /signing-key.asc + + # Trusted keys imported into GNUPGHOME on every start, for mirror + # signature verification. The keyring is therefore a pure function of + # values.yaml — restart the pod to pick up an edit here. + # Inline ASCII-armored public keys only (Helm has no network access at + # render time to support a `url:`/`keyserver:` form the way a plain script + # could — fetch the key yourself once and paste it here). + gpgKeys: [] + # - name: debian-archive + # armored: | + # -----BEGIN PGP PUBLIC KEY BLOCK----- + # ... + + # Declarative desired state, reconciled by the post-install/post-upgrade + # Job against the REST API (never the CLI — see reconcile.mode below and + # rootfs/usr/local/bin/aptly-reconcile for the exact field reference and + # the documented limitation on editing mirrors[].components after creation). + localRepos: [] + # - name: stable + # comment: "Production package repository" + # defaultDistribution: stable + # defaultComponent: main + + mirrors: [] + # - name: debian-security + # archiveURL: http://security.debian.org/debian-security + # distribution: trixie-security + # components: [main] + # architectures: [amd64, arm64] + + publish: [] + # - name: stable-root + # prefix: "" # "" = repo root + # distribution: stable + # sourceKind: local # local | snapshot + # sources: [{ name: stable, component: main }] + # architectures: [amd64, arm64] + # acquireByHash: true + + # Raw passthrough, deep-merged over the generated config last. See header. + configOverrides: {} + + # Existing Secrets to envFrom into the aptly container, so configOverrides + # can reference ${VAR} placeholders (e.g. S3 credentials) that resolve from + # Secrets you already manage, without ever putting them in values.yaml. + existingSecretEnv: [] + +# ============================================================================= +# Security matrix — one preset switch, escape hatches for every axis. See +# docs/security.md for the full decision table. +# ============================================================================= +security: + # open: read+write, no auth, no exceptions — the explicit "unabgesichert" + # mode. publicRead (default): read is open, write needs Basic Auth. + # authenticated: both need Basic Auth. readOnly: write returns 404. + preset: publicRead + auth: + # name: plaintext password. Hashed into htpasswd by the initContainer at + # pod start (never a bcrypt/apr1 hash here — see rootfs/.../aptly-init + # for why: a template-side hash would change, hence restart-loop, on + # every single helm upgrade). + users: {} + # Secret key "htpasswd" (pre-hashed) — the recommended production path, + # e.g. via ExternalSecrets/SealedSecrets. Wins over `users` when set. + existingSecret: "" + internalUser: + # Always appended to htpasswd: the reconcile Job talks to nginx (not + # directly to aptly, which is loopback-only), so it needs credentials + # in every preset, including existingSecret + authenticated. + enabled: true + username: aptly-internal + # CIDRs matched against $remote_addr. Behind an Ingress controller that is + # the CONTROLLER's pod IP, not the real client — set trustedProxies to the + # controller's CIDR (via X-Forwarded-For) or use networkPolicy instead. The + # chart renders a warning comment into nginx.conf when allowCIDRs is set + # without trustedProxies. + trustedProxies: [] + read: + enabled: true + requireAuth: null # null = take the preset's value; true/false overrides it + allowCIDRs: [] + write: + enabled: true + requireAuth: null + allowCIDRs: [] + # true: do not render the API Ingress at all (regardless of ingress.api.*) + # and rely on networkPolicy for isolation — an honest implementation, not + # an nginx trick. + inClusterOnly: false + +# proxy.enabled=false hands aptly's unauthenticated write API directly to +# whatever can reach the Service — the chart refuses to render an Ingress in +# that combination unless security.preset is explicitly "open" (see +# templates/NOTES.txt / the `fail` guard in templates/_helpers.tpl). +proxy: + enabled: true + # nginx additionally serves the same tree under /repos//, matching + # aptly's own serve_in_api_mode URL shape, so toggling this flag never + # breaks an already-deployed sources.list. + compatPaths: true + maxUploadSize: "0" # nginx client_max_body_size; "0" = unlimited + readTimeout: "3600s" + publishEndpointName: public + +persistence: + enabled: true + existingClaim: "" # set this in production — see docs/operations.md + storageClass: "" + accessMode: ReadWriteOnce + size: 20Gi + annotations: {} + +workload: + updateStrategy: + type: RollingUpdate # safe here: a StatefulSet with replicas=1 always + # terminates the old pod before creating the new one + podManagementPolicy: OrderedReady + revisionHistoryLimit: 3 + terminationGracePeriodSeconds: 60 + annotations: {} + podAnnotations: {} + podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: { type: RuntimeDefault } + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + +resources: {} + +probes: + startup: { periodSeconds: 5, failureThreshold: 60 } + readiness: { periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } + liveness: { periodSeconds: 30, timeoutSeconds: 5, failureThreshold: 6 } + +service: + type: ClusterIP + port: 8080 + annotations: {} + +ingress: + enabled: false + mode: single # single | split — see docs/security.md + className: "" + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + repo: + host: "" + path: / + pathType: Prefix + tls: [] + # - hosts: [apt.example.com] + # secretName: apt-tls + api: + enabled: false + host: "" + className: "" + annotations: {} + tls: [] + +# Gateway API HTTPRoute support — fully independent of `ingress.*` above, and +# safe to enable at the same time as it (e.g. mid-migration between the two: +# both can point at the same Service simultaneously, see docs/security.md). +# This chart never creates a Gateway itself, only HTTPRoutes attaching to one +# your cluster admin already manages — TLS is that Gateway listener's job, not +# something set here. +gateway: + enabled: false + # Core Gateway API resources are apiVersion gateway.networking.k8s.io/v1 (GA + # since v1.0) — override only if your cluster's CRDs are still pre-GA. + apiVersion: gateway.networking.k8s.io/v1 + mode: single # single | split — same meaning as ingress.mode, see docs/security.md + # Referenced Gateway(s). Required when gateway.enabled is true. + parentRefs: [] + # - name: my-gateway + # namespace: gateway-infra # optional, defaults to this release's namespace + # sectionName: https # optional, binds to one named listener + repo: + hostnames: [] # e.g. [apt.example.com] — omit to match the Gateway listener's own hostname(s) + path: / + pathType: PathPrefix # PathPrefix | Exact | RegularExpression — Gateway API's own enum, distinct from ingress.repo.pathType's + api: + enabled: false + hostnames: [] + parentRefs: [] # override for the API route only — falls back to gateway.parentRefs when empty + +metrics: + service: + enabled: false + port: 9090 + annotations: {} + serviceMonitor: + enabled: false + interval: 30s + labels: {} + relabelings: [] + +reconcile: + enabled: true + # hook (default): post-install,post-upgrade Helm hook Job. + # job: a plain Job named with a hash of the desired state, for GitOps + # controllers (ArgoCD/Flux) that dislike Helm hooks. + # manual: render the state ConfigMap only. + mode: hook + failOnError: false + timeoutSeconds: 600 + image: {} # override repository/tag/pullPolicy; defaults to the main `image` + resources: {} + +podDisruptionBudget: + enabled: false + maxUnavailable: 1 + +networkPolicy: + enabled: false + allowedNamespaces: [] + extraIngress: [] + egress: + # A default-deny egress policy silently breaks every mirror — this stays + # true until you have a specific reason to lock it down. + allowAll: true + extra: [] + +extraEnv: [] +extraEnvFrom: [] +extraVolumes: [] +extraVolumeMounts: [] +extraInitContainers: [] +extraContainers: [] +nodeSelector: {} +tolerations: [] +affinity: {} +topologySpreadConstraints: [] +priorityClassName: "" + +global: + imageRegistry: "" + imagePullSecrets: [] + defaultStorageClass: "" diff --git a/compose/.env.example b/compose/.env.example new file mode 100644 index 0000000..51dd762 --- /dev/null +++ b/compose/.env.example @@ -0,0 +1,38 @@ +# Copy to .env and edit. See docs/quickstart-compose.md for the full walkthrough. + +# Image tag to run — set this to a released image/vX.Y.Z-N tag once you have +# one (see docs/versioning.md); "latest" is fine to try things out with. +APTLY_IMAGE_TAG=latest + +# Host port nginx (reads + the auth-gated /api/) is published on. +APTLY_PUBLISH_PORT=8080 + +# --- Auth (security.preset "publicRead" equivalent) --- +# Password for the internal user aptly-reconcile/aptly-mirror-refresh use to +# talk through nginx. Required — has no default, compose refuses to start +# without it. Any non-trivial value; nothing external ever needs to know it. +APTLY_INTERNAL_PASSWORD=changeme-generate-a-real-secret + +# Also edit config/users (copy from config/users.example) with the +# username:password pairs that external CI/uploaders should use. + +# --- GPG signing --- +# true (default): publishing requires a signing key at config/gpg/private.asc +# (+ config/gpg/passphrase if it's passphrase-protected). Missing key -> +# aptly-init logs a WARN and publishes unsigned instead of failing to start +# — check `docker compose logs aptly-init` after first boot. +# false: explicitly unsigned, no key needed. See docs/security.md. +APTLY_GPG_ENABLED=true + +# Directory containing private.asc (and optionally passphrase). Defaults to +# ./config/gpg, gitignored. +#APTLY_GPG_DIR=./config/gpg + +# --- Reconcile --- +# false (default): an unreachable mirror or malformed state.yaml entry only +# warns — `docker compose up` still succeeds. Set true in CI to catch +# mistakes in state.yaml. +APTLY_RECONCILE_FAIL_ON_ERROR=false + +# --- Backup (docker compose --profile backup run --rm backup) --- +#APTLY_BACKUP_DIR=./backup diff --git a/compose/backup/.gitkeep b/compose/backup/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/compose/config/aptly.yaml b/compose/config/aptly.yaml new file mode 100644 index 0000000..6c07a86 --- /dev/null +++ b/compose/config/aptly.yaml @@ -0,0 +1,16 @@ +# Source aptly config, rendered by aptly-init into /run/aptly/aptly.yaml. +# ${VAR}-style placeholders are resolved against the aptly container's +# environment at startup — see .env.example for what to set. +# +# This one file is shared by both docker-compose.test.yaml and +# docker-compose.yaml (production); the security posture (auth, network +# exposure) is entirely an nginx concern (see nginx.test.conf / nginx.prod.conf), +# not an aptly config concern — aptly itself never changes between the two. +root_dir: /var/lib/aptly +log_level: info +gpg_provider: internal +serve_in_api_mode: false +filesystem_publish_endpoints: + public: + root_dir: /var/lib/aptly/public + link_method: hardlink diff --git a/compose/config/gpg/.gitkeep b/compose/config/gpg/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/compose/config/nginx.prod.conf b/compose/config/nginx.prod.conf new file mode 100644 index 0000000..4c620f7 --- /dev/null +++ b/compose/config/nginx.prod.conf @@ -0,0 +1,48 @@ +# Production topology: security.preset "publicRead" equivalent — reading the +# repo (apt clients) needs no credentials, writing (the API under /api/) does. +# Health-check paths stay exempt so container/orchestrator probes never need +# credentials in any mode. +# +# Mounted at /etc/nginx/conf.d/default.conf, which the base image's own +# nginx.conf already `include`s from inside its own http{} block — so this +# file must contain ONLY a server{} block (or other http-context directives), +# never its own http{}/events{}/worker_processes wrapper. +server { + listen 8080; + server_name _; + client_max_body_size 0; + absolute_redirect off; + + location = /healthz { access_log off; return 200 "ok\n"; } + + location = /api/ready { access_log off; proxy_pass http://aptly:8080; } + location = /api/healthy { access_log off; proxy_pass http://aptly:8080; } + + location /api/ { + auth_basic "aptly"; + auth_basic_user_file /run/aptly/htpasswd; + + proxy_pass http://aptly:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location = /signing-key.asc { + alias /run/aptly/pub/signing-key.asc; + default_type text/plain; + } + + location / { + root /var/lib/aptly/public; + autoindex on; + autoindex_exact_size off; + + location ~* /(InRelease|Release|Release\.gpg|Packages(\.[a-z0-9]+)?|Sources(\.[a-z0-9]+)?)$ { + root /var/lib/aptly/public; + add_header Cache-Control "no-cache" always; + } + } +} diff --git a/compose/config/nginx.test.conf b/compose/config/nginx.test.conf new file mode 100644 index 0000000..15bf2c8 --- /dev/null +++ b/compose/config/nginx.test.conf @@ -0,0 +1,39 @@ +# Test topology: everything open (security.preset "open" equivalent) — no +# auth on read OR write. This is what "komplett unabgesichert" looks like. +# Do not reuse this file for docker-compose.yaml (production). +# +# Mounted at /etc/nginx/conf.d/default.conf, which the base image's own +# nginx.conf already `include`s from inside its own http{} block — so this +# file must contain ONLY a server{} block (or other http-context directives), +# never its own http{}/events{}/worker_processes wrapper. +server { + listen 8080; + server_name _; + client_max_body_size 0; + absolute_redirect off; + + location = /healthz { access_log off; return 200 "ok\n"; } + + location = /api/ready { access_log off; proxy_pass http://aptly:8080; } + location = /api/healthy { access_log off; proxy_pass http://aptly:8080; } + + location /api/ { + proxy_pass http://aptly:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location = /signing-key.asc { + alias /run/aptly/pub/signing-key.asc; + default_type text/plain; + } + + location / { + root /var/lib/aptly/public; + autoindex on; + autoindex_exact_size off; + } +} diff --git a/compose/config/state.test.yaml b/compose/config/state.test.yaml new file mode 100644 index 0000000..7e0a07f --- /dev/null +++ b/compose/config/state.test.yaml @@ -0,0 +1,18 @@ +# Demo state for docker-compose.test.yaml: one local repo, published at the +# repo root, completely open and unsigned. Not meant for production — see +# compose/config/state.yaml for the starter template used there. +localRepos: + - name: demo + comment: "docker-compose.test.yaml demo repo" + defaultDistribution: stable + defaultComponent: main + +publish: + - name: demo-root + prefix: "" + distribution: stable + sourceKind: local + sources: + - { name: demo, component: main } + architectures: [amd64, arm64] + acquireByHash: true diff --git a/compose/config/state.yaml b/compose/config/state.yaml new file mode 100644 index 0000000..51b5841 --- /dev/null +++ b/compose/config/state.yaml @@ -0,0 +1,31 @@ +# Declarative aptly state for docker-compose.yaml (production). Consumed by +# aptly-reconcile on every start of the `reconcile` service — safe to edit and +# restart that service as often as you like, it only converges towards +# whatever is described here (see docs/packaging.md). +# +# Uncomment and adjust the example below to get your first repo + publish +# target, or add your own. See rootfs/usr/local/bin/aptly-reconcile for the +# full field reference. + +localRepos: [] + # - name: stable + # comment: "Production package repository" + # defaultDistribution: stable + # defaultComponent: main + +mirrors: [] + # - name: debian-security + # archiveURL: http://security.debian.org/debian-security + # distribution: trixie-security + # components: [main] + # architectures: [amd64, arm64] + +publish: [] + # - name: stable-root + # prefix: "" + # distribution: stable + # sourceKind: local + # sources: + # - { name: stable, component: main } + # architectures: [amd64, arm64] + # acquireByHash: true diff --git a/compose/config/users.example b/compose/config/users.example new file mode 100644 index 0000000..9349b61 --- /dev/null +++ b/compose/config/users.example @@ -0,0 +1,7 @@ +# Plaintext "user:password" lines, one per line — hashed into htpasswd by +# aptly-init at container start (never store a bcrypt/apr1 hash here yourself; +# see rootfs/usr/local/bin/aptly-init for why). Copy this file to +# compose/config/users (gitignored) and edit it before starting +# docker-compose.yaml (production). Blank lines and anything without a colon +# are ignored. +ci:change-me-please diff --git a/compose/docker-compose.test.yaml b/compose/docker-compose.test.yaml new file mode 100644 index 0000000..31ba528 --- /dev/null +++ b/compose/docker-compose.test.yaml @@ -0,0 +1,75 @@ +# Test stack: fully open (no auth on read OR write), unsigned, ephemeral +# volumes, ready to use with a single `docker compose -f compose/docker-compose.test.yaml up`. +# This is also what tests/smoke-test.sh drives — see docs/quickstart-compose.md. +# +# Topology mirrors production (aptly-init -> aptly -> nginx, aptly never +# reachable from outside the compose network) so that what you test here is +# what you'd actually run, just with the security matrix dialed to "open". +services: + aptly-init: + build: + context: .. + dockerfile: images/aptly-server/Dockerfile + entrypoint: ["/usr/local/bin/aptly-init"] + environment: + APTLY_GPG_ENABLED: "false" + volumes: + - ./config/aptly.yaml:/etc/aptly-src/aptly.yaml:ro + - aptly_run:/run/aptly + restart: "no" + + aptly: + build: + context: .. + dockerfile: images/aptly-server/Dockerfile + depends_on: + aptly-init: + condition: service_completed_successfully + environment: + APTLY_API_LISTEN: "0.0.0.0:8080" + volumes: + - aptly_data:/var/lib/aptly + - aptly_run:/run/aptly + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/healthy"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + + nginx: + image: nginxinc/nginx-unprivileged:1-alpine + depends_on: + aptly: + condition: service_healthy + volumes: + - aptly_data:/var/lib/aptly:ro + - aptly_run:/run/aptly:ro + - ./config/nginx.test.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 5s + timeout: 3s + retries: 20 + + reconcile: + build: + context: .. + dockerfile: images/aptly-server/Dockerfile + depends_on: + aptly: + condition: service_healthy + entrypoint: ["/usr/local/bin/aptly-reconcile"] + environment: + APTLY_URL: "http://aptly:8080" + APTLY_STATE_FILE: "/state.yaml" + APTLY_FAIL_ON_ERROR: "true" + volumes: + - ./config/state.test.yaml:/state.yaml:ro + restart: "no" + +volumes: + aptly_data: + aptly_run: diff --git a/compose/docker-compose.yaml b/compose/docker-compose.yaml new file mode 100644 index 0000000..3b93520 --- /dev/null +++ b/compose/docker-compose.yaml @@ -0,0 +1,134 @@ +# Production stack: security.preset "publicRead" equivalent — reads are +# open (apt clients need no credentials), the mutating API is behind Basic +# Auth. aptly itself is never published to the host; only nginx is. See +# docs/security.md for how to move to the other three presets (fully open, +# fully authenticated, or read-only), and .env.example for every variable +# used below. +# +# First run: +# cp .env.example .env && edit it +# cp config/users.example config/users && edit it (at least change the password) +# docker compose up -d +# docker compose logs -f aptly-init # check for GPG warnings +services: + aptly-init: + image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest} + entrypoint: ["/usr/local/bin/aptly-init"] + environment: + APTLY_GPG_ENABLED: "${APTLY_GPG_ENABLED:-true}" + # Fixed in-container paths — put your key material at the host paths + # below (an empty/missing directory is fine: aptly-init then warns and + # publishes unsigned instead of failing to start). + APTLY_GPG_PRIVATE_KEY_FILE: "/etc/aptly-secrets/gpg/private.asc" + APTLY_GPG_PASSPHRASE_FILE: "/etc/aptly-secrets/gpg/passphrase" + APTLY_USERS_FILE: "/etc/aptly-secrets/users" + APTLY_INTERNAL_USER: "aptly-internal" + APTLY_INTERNAL_PASSWORD: "${APTLY_INTERNAL_PASSWORD:?set APTLY_INTERNAL_PASSWORD in .env}" + volumes: + - ./config/aptly.yaml:/etc/aptly-src/aptly.yaml:ro + - ./config/users:/etc/aptly-secrets/users:ro + - ${APTLY_GPG_DIR:-./config/gpg}:/etc/aptly-secrets/gpg:ro + - aptly_run:/run/aptly + restart: "no" + + aptly: + image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest} + depends_on: + aptly-init: + condition: service_completed_successfully + environment: + APTLY_API_LISTEN: "0.0.0.0:8080" + volumes: + - aptly_data:/var/lib/aptly + - aptly_run:/run/aptly + read_only: true + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/healthy"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 10s + restart: unless-stopped + deploy: + resources: + limits: + memory: 1g + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + nginx: + image: nginxinc/nginx-unprivileged:1-alpine + depends_on: + aptly: + condition: service_healthy + volumes: + - aptly_data:/var/lib/aptly:ro + - aptly_run:/run/aptly:ro + - ./config/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "${APTLY_PUBLISH_PORT:-8080}:8080" + read_only: true + tmpfs: + - /tmp + - /var/cache/nginx + - /run + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 5s + retries: 20 + restart: unless-stopped + deploy: + resources: + limits: + memory: 256m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # --- TLS via an external reverse proxy (recommended) --- + # Put Traefik/Caddy/whatever you already run in front of this service + # instead of terminating TLS here. Traefik label example: + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.aptly.rule=Host(`apt.example.com`)" + # - "traefik.http.routers.aptly.tls.certresolver=letsencrypt" + + reconcile: + image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest} + depends_on: + aptly: + condition: service_healthy + entrypoint: ["/usr/local/bin/aptly-reconcile"] + environment: + APTLY_URL: "http://aptly:8080" + APTLY_STATE_FILE: "/state.yaml" + APTLY_FAIL_ON_ERROR: "${APTLY_RECONCILE_FAIL_ON_ERROR:-false}" + volumes: + - ./config/state.yaml:/state.yaml:ro + restart: "no" + + # Run on demand: docker compose --profile backup run --rm backup + backup: + image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest} + profiles: ["backup"] + entrypoint: ["/bin/sh", "-c"] + command: + - > + set -eu; + ts=$$(date -u +%Y%m%dT%H%M%SZ); + tar -C /var/lib/aptly --exclude=.gnupg -c . | zstd -q -o "/backup/aptly-$${ts}.tar.zst"; + echo "wrote /backup/aptly-$${ts}.tar.zst" + volumes: + - aptly_data:/var/lib/aptly:ro + - ${APTLY_BACKUP_DIR:-./backup}:/backup + +volumes: + aptly_data: + aptly_run: diff --git a/images/aptly-deb-builder/Dockerfile b/images/aptly-deb-builder/Dockerfile new file mode 100644 index 0000000..5ba8dd9 --- /dev/null +++ b/images/aptly-deb-builder/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 +# +# aptly-deb-builder — turns a source tree into a signed-and-pushable .deb in +# one command (aptly-release). Ships both packaging paths: +# - nfpm, for projects with no debian/ directory (a plain YAML descriptor) +# - the real Debian toolchain, for projects that already have a debian/ dir +# +# renovate: datasource=github-releases depName=goreleaser/nfpm +ARG NFPM_VERSION=2.47.0 +ARG NFPM_SHA256_AMD64=3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97 +ARG NFPM_SHA256_ARM64=27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97 +ARG RUNTIME_IMAGE=debian:trixie-slim + +FROM ${RUNTIME_IMAGE} +ARG TARGETARCH +ARG NFPM_VERSION +ARG NFPM_SHA256_AMD64 +ARG NFPM_SHA256_ARM64 + +LABEL org.opencontainers.image.title="aptly-deb-builder" \ + org.opencontainers.image.description="Package, build and push .deb packages into an aptly repository in one step" \ + org.opencontainers.image.source="https://git.morlana.online/f.weber/aptly-containerized" \ + org.opencontainers.image.licenses="MIT" + +# hadolint ignore=DL3008 +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl jq gnupg \ + build-essential debhelper devscripts dpkg-dev fakeroot equivs \ + && rm -rf /var/lib/apt/lists/* + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) sha256="${NFPM_SHA256_AMD64}" ;; \ + arm64) sha256="${NFPM_SHA256_ARM64}" ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/nfpm.deb "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${TARGETARCH}.deb"; \ + echo "${sha256} /tmp/nfpm.deb" | sha256sum -c -; \ + dpkg -i /tmp/nfpm.deb; \ + rm -f /tmp/nfpm.deb + +COPY rootfs/usr/local/bin/aptly-pack rootfs/usr/local/bin/aptly-push rootfs/usr/local/bin/aptly-release /usr/local/bin/ +COPY rootfs/usr/local/bin/lib/ /usr/local/bin/lib/ + +WORKDIR /work +ENTRYPOINT [] +CMD ["bash"] diff --git a/images/aptly-server/Dockerfile b/images/aptly-server/Dockerfile new file mode 100644 index 0000000..5061407 --- /dev/null +++ b/images/aptly-server/Dockerfile @@ -0,0 +1,91 @@ +# syntax=docker/dockerfile:1 +# +# aptly-server — aptly built from source, cross-compiled, plus the tools its +# own entrypoint/reconcile scripts need (jq, yq, envsubst, gpg). Doubles as +# the CLI/debug image: `docker run --rm aptly-server aptly version`. +# +# Built from source rather than the upstream .deb or release zip because: +# - the aptly release assets ship no checksums file, so a zip download can +# only ever be "trust the network", never "verify the bytes"; +# - cross-compiling is native on an arm64 build host (the expensive part +# needs no QEMU — only the tiny runtime stage below does), which matters +# because there is no amd64 runner in this project's CI; +# - it removes any dependency on repo.aptly.info having published a build +# for this exact Debian release/arch combination in time for a rebuild. +# +# renovate: datasource=github-releases depName=aptly-dev/aptly +ARG APTLY_VERSION=1.6.3 +ARG GO_IMAGE=golang:1.25-trixie +ARG RUNTIME_IMAGE=debian:trixie-slim + +# renovate: datasource=github-releases depName=mikefarah/yq +ARG YQ_VERSION=4.53.3 +ARG YQ_SHA256_AMD64=fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 +ARG YQ_SHA256_ARM64=578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build +ARG APTLY_VERSION +ARG TARGETARCH +WORKDIR /src +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + git clone --depth 1 --branch "v${APTLY_VERSION}" https://github.com/aptly-dev/aptly . \ + && printf '%s' "${APTLY_VERSION}" > VERSION \ + && CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" \ + go build -trimpath -ldflags="-s -w" -o /out/aptly . + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS build-tools +# Runs once for BUILDPLATFORM, not per target arch — cheap even on an +# emulated runtime stage below, since we just copy the right binary in. +ARG YQ_VERSION +ARG YQ_SHA256_AMD64 +ARG YQ_SHA256_ARM64 +WORKDIR /out +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN set -eux; \ + curl -fsSL -o yq_linux_amd64 "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64"; \ + curl -fsSL -o yq_linux_arm64 "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_arm64"; \ + echo "${YQ_SHA256_AMD64} yq_linux_amd64" | sha256sum -c -; \ + echo "${YQ_SHA256_ARM64} yq_linux_arm64" | sha256sum -c -; \ + chmod +x yq_linux_amd64 yq_linux_arm64 + +FROM ${RUNTIME_IMAGE} +ARG TARGETARCH +ARG APTLY_VERSION +ARG APTLY_REVISION=1 + +LABEL org.opencontainers.image.title="aptly-server" \ + org.opencontainers.image.description="aptly (Debian repository management tool), containerized: REST API + non-root runtime" \ + org.opencontainers.image.source="https://git.morlana.online/f.weber/aptly-containerized" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${APTLY_VERSION}-${APTLY_REVISION}" + +# hadolint ignore=DL3008 +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates gnupg bzip2 xz-utils curl jq gettext-base openssl \ + && rm -rf /var/lib/apt/lists/* \ + && addgroup --system --gid 10001 aptly \ + && adduser --system --uid 10001 --ingroup aptly --home /var/lib/aptly --disabled-password aptly \ + && mkdir -p /var/lib/aptly/public /run/aptly /etc/aptly-src \ + && chown -R aptly:aptly /var/lib/aptly /run/aptly + +COPY --from=build /out/aptly /usr/local/bin/aptly +COPY --from=build-tools /out/yq_linux_${TARGETARCH} /usr/local/bin/yq +COPY rootfs/ / + +ENV APTLY_ROOT_DIR=/var/lib/aptly \ + APTLY_RUN_DIR=/run/aptly \ + APTLY_CONFIG=/run/aptly/aptly.yaml \ + APTLY_CONFIG_SRC=/etc/aptly-src/aptly.yaml \ + APTLY_CONFIG_DST=/run/aptly/aptly.yaml \ + APTLY_API_LISTEN=127.0.0.1:8080 + +USER 10001:10001 +WORKDIR /var/lib/aptly + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["curl", "-fsS", "http://127.0.0.1:8080/api/healthy"] + +ENTRYPOINT ["/usr/local/bin/aptly-entrypoint"] diff --git a/pubkeys/README.md b/pubkeys/README.md new file mode 100644 index 0000000..4be4a30 --- /dev/null +++ b/pubkeys/README.md @@ -0,0 +1,62 @@ +# Public keys + +## Chart signing key (needed, not yet set up) + +`.gitea/workflows/release-chart.yaml` runs `helm package --sign` to produce a +`.tgz.prov` file for every chart release, and `charts/aptly/Chart.yaml` is meant to +carry an `artifacthub.io/signKey` annotation pointing at the public half of that +key (both are currently commented out / referencing a placeholder — see below). + +**This key must NOT be Ed25519/EdDSA.** Helm's chart signing is built on the +deprecated `golang.org/x/crypto/openpgp` library, which cannot read Ed25519 keys at +all — signing fails with `Error: private key not found` (or, depending on gpg +version, `openpgp: unsupported feature: public key type: 22`). This is a +long-standing, unresolved upstream limitation (helm/helm#11634, #31180, #31181), not +a configuration mistake — confirmed by reproducing it locally against a throwaway +Ed25519 test key before writing this note. Use **RSA** (4096-bit, no expiry is +fine for a CI signing key) or a classic ECC curve helm's openpgp fork supports; +RSA is the safest choice since it's unambiguously supported. + +The org's existing "Morlana CI Signing Key" (used by e.g. `bookstack-chart`) is +Ed25519 and was tried here first — it does not work for this purpose. It may still +be perfectly valid for other things (signing an actual apt repository via +`aptly.gpg.signingKey`, which is a completely different code path that does support +Ed25519 — see [docs/packaging.md](../docs/packaging.md#gpg) — just not for +`helm package --sign`. This repo therefore needs its own, separate, RSA key +dedicated to chart-package signing. + +### Generating it + +Run this yourself (locally, not in CI) so the private key material never has to +pass through anything but your own machine and the Gitea secrets store: + +```bash +gpg --full-generate-key +# RSA and RSA (default) +# 4096 bit +# key does not expire (or a long expiry — a CI signing key you'd have to rotate +# on a schedule is more operational overhead than it's worth here) +# Name: Aptly Chart Signing Key +# Email: something you control, e.g. contact+development@morlana.net + +gpg --list-secret-keys --with-colons | awk -F: '$1=="sec"{print $5}' # -> the key ID +gpg --armor --export > pubkeys/chart-signing.asc +gpg --armor --export-secret-keys # -> paste as GPG_PRIVATE_KEY +``` + +Then, in the repo's Gitea settings: +- Secret **`GPG_PRIVATE_KEY`** — the armored output of the last command above +- Secret **`GPG_PASSPHRASE`** — whatever passphrase you set (empty string if none) +- Secret **`GPG_KEY_ID`** — the key ID or fingerprint from `gpg --list-secret-keys` + +Commit `pubkeys/chart-signing.asc`, then uncomment the `artifacthub.io/signKey` +block in `charts/aptly/Chart.yaml` with the real fingerprint, and uncomment +`pubkeys/chart-signing.asc` in `release-chart.yaml`'s release-assets step. + +### Verifying a downloaded chart (once the key exists) + +```bash +gpg --import pubkeys/chart-signing.asc +gpg --export > /tmp/pubring.gpg # legacy binary format — helm can't read pubring.kbx +helm verify aptly-.tgz --keyring /tmp/pubring.gpg +``` diff --git a/pubkeys/chart-signing.asc b/pubkeys/chart-signing.asc new file mode 100644 index 0000000..fced330 --- /dev/null +++ b/pubkeys/chart-signing.asc @@ -0,0 +1,53 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGp8Ox8BEACpuaxGZoPiwCBeSBigfVFrT6Bo9v/XhJxaJGDD4HLmA0Dy8aPI +KVcINstQdrGFcoP7MS+oSLkFUbslZkTdKOwv6OX2R5PeW05HfzuCkEPawVNrfzzz +fWp1c8eOvw2siUO3xKg5IhkivoDmZcLlvRkmu2h4kb8+YN2QycOx1I/VsG6I+FGC +Kmz6pMqYbPmKpRYQ4X6IaovvcVGTMMRL4ARat82rS/QL0ZwW2qyj7NsXKgZEc8AN +wngSiR7SKB2CXKPnC3EB/G1c2i93bkwmz96u+TulQaumQ/dwEscT0L7xP5zsjE7u +dxYessuSsYX1WmOygISxQDh2DZcPw/YfSoPusEq9AVO2rlC1ivYL9Wc5U8UV5A6g +9EjR/M9bWvr8CO9jw9xsmYE4sgbc4E4HPpeFkg+r7cmbHIxBWlwsAcTFaA1ls5I0 +hzxPx0KX89w8BjHcvq91O3HOVk5wpZf1kOg59lsBqwa/ytFoX49tlwK9rJ6vm+Nt +WMfKQZqUn1hO2KQqlBhQO+CMD8qbkMkCLoii+JMsIf+9Kr+lS650AcWt6jGy8so5 +KWgvgqH1aX/964I1kujNsJxky2+XF9h7MofvLxpoXIfjKFNkW2q0Fex8xy05fChE +Kevsw5/++zhqE8zGoXpknAa7mud46kkgDMmDnYK8CyFfJpoQ48c/MbwmDwARAQAB +tG1Nb3JsYW5hIEhlbG0gQ2hhcnRzIChUaGlzIGtleSBpcyBvbmx5IGJlIHVzZWQg +dG8gc2lnbiBNb3JsYW5hcyBIZWxtIENoYXJ0cy4pIDxjb250YWN0K2hlbG1jaGFy +dHNAbW9ybGFuYS5uZXQ+iQJRBBMBCgA7FiEE/DXA+qJmBcTCHHu/v0OIQUXlqpQF +Amp8Ox8CGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQv0OIQUXlqpQ/ +fBAAlJF9RxUVN/Tj+byuy24k/JyVcSbJH5QhF6sqAR8mDNnCr02S4CIICgAhagBl +VUXkH50k92iEh8QC3yXY1RakibBd5XtOF1Pw07/Hg73/wYa4+WgZqODxt2OUhCg1 +VVTLh1dNSniD37bCOFfqYtUv/UNEhqzMQSHecvDY9DfVFWVxvP9vxSCCYsQesCQD +DqzdYU3xW3EeT0KB8JuMoSgxai6pL1d0irKw8NJ06u9uLoSmcc5M9vPAlnLlO3WO +9ZoGKkwfbStN/VfAi7YF+stO1c+2RGeR4XilNRgbcflaInKwOdYne2yJaUf5pccw +INbJuDyaJJ0/Q+xs+pjCnZeDhrGCjWxSD6FZYsDOHnut031v/XDNz3jjlrOwYZXh +rVAz5aqkydDmO/MG+u474YH+L7uK2ZnR+/ktEaKaSaGOrLAh8oR058q2Aisbq5V+ +1cenBwpQGnlMaFUArubDbgDNlfdf3l0ZYatmVy3H+Hvz2geTQ3J0tmbzHMSfOqvh +5le1LzUpGSMItNxcRP4Ba8PUtgiuwCpthKal7sXLWHj/CzXQcoeRn9kkz/MU1mAF +jaQCrnL5eX2tk6aa4egKHPI2Pn9i9ctCxRBYP5yI+DQ5U0ih0fl+DAMYRydrrnGG +a+DVSTpgXGivET6CjLVOkhk0F1V9JvnaMKBsMVVC61Wa/1q5Ag0Eanw7HwEQAONI +/dmsHwynIr/9j8PVutVUwXRU/fSbU98vN4h7Tal4lxnOpQDFhbSUnDuVjR7BMQDX +Zs6q8GRj7tcCtsb1B3C6eLQ12YMdndHnxmkUNcZtG1DiJ80WpVU+m4H0rRJvhFh9 +jov3c6/I1Bov68xQOxBfFModRui27Ro0s1VrEQRCDCIVbXJ1guoeZmzuliKtjMfv +2wTleSnDBLGL/MFFd8BxHwBbsrKq1Ab7542/87s9js+xEE+bE9ImcUOAT4Ry9yyE +cUzOyeJi/z/kwRxbcAm+f8CcmdvK5okV3xUN4DKz5imubLfL9/r/jWGPxPcNFUIN +g2AIPWcujvtGEwU4+/fvGjSUL4B12cVUPUoKT9LsiJbRi4WMx4YmMOZhOKPohV+K +wRjLsCh89oKkR7n5c2ztjgA/fzRACnSN7mZyitishUJ4E9UarpJzkCavQp5cuwTh +9sz27pEP18rLTw7CDvardd/hsfuCRCFWyO4f7oYYo//Kn5FEUVbgSlkLaCcS+9PK +cJnTzqk5rSNtjyzGWETO2BVtPLlD4j8FTPp69vmBinUhFilQzSZkU3i1dULfU6mE +AoIgnfmwyQLgUzeK3gpvpLc2uAAnn6/uz8ygZ/16Iom3zbrbeQmVk7bsSEh4z5px +6bPRKcKSNut/luIJzgU9aqU8uUmGFPU7iu11ebqLABEBAAGJAjYEGAEKACAWIQT8 +NcD6omYFxMIce7+/Q4hBReWqlAUCanw7HwIbDAAKCRC/Q4hBReWqlOg0D/4vF6y7 +VSpADc28EXZlfamaEwrIE8rKKWeuYKlN4imW5HPi0QyJv2b+6MBVu24AkrdCqWi6 +SiiKSAlbOS7a8NODpjQVNgbKdeASA9syibidM7dO6lxn3SyuCLOMNS8qnBiYkr4i +SSk6GADuPnYVZwo7Pjx6wAndpAVrJqSVM8U5lIE1+v/JvF+JaKxMzK0TM6Q2AORV +I/zBc1Uz0gHYKECsbmrOdgJgjLO8luMldPIb2CTAgpgn74lY3oCMyxweaPb30UGE +sZj3+BrxhrDMKOrK7ZKHAdsSK8NeQQGlDMYUrvamxcX+FyQbUCaZXrLpX3GFxtcy +AOhhkXLxhWKN4MW0Kl/B5VqXTz1Ro1/4exlZJBydLEYxDRJgcJ/DnUlfaxTQ1SCR +SFM6lr5vArXjt/xA4evAJZsCFuDa6Zev/GXQsYLXi9YdFf2ERufUMd8fF5m3FJsB +ndRfGAjrSWO6PeV8XrmkBtknVAAnT16qKUtlqTIG9rfoVWqR152U1GXHNSRDpgiN +W+3AOWbDSKKFu/7mKmglOnUdxhOjoX+Bs4Mb+hU8Jxrm8IxWx1xSffCgTZnrrXqn +eYf7FxGcCUhmn6WCS1gy1u/9nc5ekO0OTvNe2TBiyVFzwtr/ZHtQEJOlv/Lcd1jF +sA2BeDVj62NOnzRGG9lMVzVtoXtM5IKVc4Erkg== +=zwls +-----END PGP PUBLIC KEY BLOCK----- diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..549e07f --- /dev/null +++ b/renovate.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "skipInstalls": true, + "ignoreScripts": true, + "ignorePaths": ["**/.github/**", "**/.gitea/**"], + "osvVulnerabilityAlerts": true, + "reviewers": ["f.weber"], + "customManagers": [ + { + "customType": "regex", + "description": "aptly itself — bump ARG APTLY_VERSION in the server image", + "managerFilePatterns": ["/^images/aptly-server/Dockerfile$/"], + "matchStrings": ["ARG APTLY_VERSION=(?\\S+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "aptly-dev/aptly", + "extractVersionTemplate": "^v(?.*)$" + }, + { + "customType": "regex", + "description": "yq (config rendering helper baked into the aptly-server image)", + "managerFilePatterns": ["/^images/aptly-server/Dockerfile$/"], + "matchStrings": ["ARG YQ_VERSION=(?\\S+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "mikefarah/yq" + }, + { + "customType": "regex", + "description": "nfpm (deb-builder image)", + "managerFilePatterns": ["/^images/aptly-deb-builder/Dockerfile$/"], + "matchStrings": ["ARG NFPM_VERSION=(?\\S+)"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "goreleaser/nfpm" + }, + { + "customType": "regex", + "description": "pinned Helm CLI version used across Gitea workflows", + "managerFilePatterns": ["/^\\.gitea/workflows/.*\\.yaml$/"], + "matchStrings": ["HELM_VERSION \\|\\| '(?[^']+)'"], + "datasourceTemplate": "github-releases", + "depNameTemplate": "helm/helm", + "extractVersionTemplate": "^v(?.*)$" + } + ], + "packageRules": [ + { + "description": "the yq/nfpm sha256 pins in the Dockerfiles are updated by hand alongside the version bump (see the ARG lines directly above each), not by Renovate — flag PRs so a human re-pins them", + "matchDepNames": ["mikefarah/yq", "goreleaser/nfpm"], + "commitMessageSuffix": "(re-pin the matching SHA256_AMD64/ARM64 ARG lines by hand — see images/*/Dockerfile)" + } + ] +} diff --git a/rootfs/usr/local/bin/aptly-entrypoint b/rootfs/usr/local/bin/aptly-entrypoint new file mode 100755 index 0000000..92698a2 --- /dev/null +++ b/rootfs/usr/local/bin/aptly-entrypoint @@ -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 "$@" diff --git a/rootfs/usr/local/bin/aptly-init b/rootfs/usr/local/bin/aptly-init new file mode 100755 index 0000000..44fc67d --- /dev/null +++ b/rootfs/usr/local/bin/aptly-init @@ -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" diff --git a/rootfs/usr/local/bin/aptly-mirror-refresh b/rootfs/usr/local/bin/aptly-mirror-refresh new file mode 100755 index 0000000..50480c9 --- /dev/null +++ b/rootfs/usr/local/bin/aptly-mirror-refresh @@ -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 '${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 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" diff --git a/rootfs/usr/local/bin/aptly-pack b/rootfs/usr/local/bin/aptly-pack new file mode 100755 index 0000000..ba30639 --- /dev/null +++ b/rootfs/usr/local/bin/aptly-pack @@ -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 diff --git a/rootfs/usr/local/bin/aptly-push b/rootfs/usr/local/bin/aptly-push new file mode 100755 index 0000000..ec60b2f --- /dev/null +++ b/rootfs/usr/local/bin/aptly-push @@ -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:-}' 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:-}' 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" diff --git a/rootfs/usr/local/bin/aptly-reconcile b/rootfs/usr/local/bin/aptly-reconcile new file mode 100755 index 0000000..7e52137 --- /dev/null +++ b/rootfs/usr/local/bin/aptly-reconcile @@ -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/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 diff --git a/rootfs/usr/local/bin/aptly-release b/rootfs/usr/local/bin/aptly-release new file mode 100755 index 0000000..7529f0d --- /dev/null +++ b/rootfs/usr/local/bin/aptly-release @@ -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[@]}" diff --git a/rootfs/usr/local/bin/lib/common.sh b/rootfs/usr/local/bin/lib/common.sh new file mode 100755 index 0000000..16a6bf8 --- /dev/null +++ b/rootfs/usr/local/bin/lib/common.sh @@ -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 +# 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 "HTTPSTATUSBODY". 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 +} diff --git a/tests/fixtures/demo-package/nfpm.yaml b/tests/fixtures/demo-package/nfpm.yaml new file mode 100644 index 0000000..e7c21cc --- /dev/null +++ b/tests/fixtures/demo-package/nfpm.yaml @@ -0,0 +1,12 @@ +name: demo-package +arch: amd64 +platform: linux +version: "1.0.0" +section: default +priority: optional +maintainer: "aptly-containerized CI " +description: Demo package used by tests/smoke-test.sh — proves the full + build -> push -> publish -> apt-get chain end to end. +contents: + - src: ./payload/hello.txt + dst: /usr/share/demo-package/hello.txt diff --git a/tests/fixtures/demo-package/payload/hello.txt b/tests/fixtures/demo-package/payload/hello.txt new file mode 100644 index 0000000..3e96df9 --- /dev/null +++ b/tests/fixtures/demo-package/payload/hello.txt @@ -0,0 +1 @@ +hello from aptly-containerized diff --git a/tests/smoke-test.sh b/tests/smoke-test.sh new file mode 100755 index 0000000..0ab9750 --- /dev/null +++ b/tests/smoke-test.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# tests/smoke-test.sh — the actual merge gate for this repo. Proves the +# whole promise of the project end to end, not just that things compile: +# +# pack -> push -> publish -> `apt-get update && apt-get install` -> content +# +# against the real docker-compose.test.yaml stack (aptly-init -> aptly -> +# nginx sidecar), using the same rootfs scripts and images CI ships. Nothing +# here is mocked. Run from the repo root: ./tests/smoke-test.sh +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +log() { printf '\033[1;34m[smoke]\033[0m %s\n' "$*"; } +die() { printf '\033[1;31m[smoke] ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +COMPOSE_FILE="compose/docker-compose.test.yaml" +PROJECT="aptly-smoke-$$" +FIXTURE_DIR="tests/fixtures/demo-package" +WORK_DIR="$(mktemp -d)" + +cleanup() { + log "cleaning up (project: ${PROJECT})" + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +log "building images and starting the test stack" +# Not --wait: it treats the `reconcile` one-shot service's expected exit(0) +# as a failure and aborts. Poll nginx's own healthcheck instead. +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --build + +network="${PROJECT}_default" + +log "waiting for nginx to report healthy" +deadline=$(( $(date +%s) + 120 )) +until docker compose -p "$PROJECT" -f "$COMPOSE_FILE" ps nginx --format '{{.Health}}' 2>/dev/null | grep -q healthy; do + (( $(date +%s) < deadline )) || die "nginx did not become healthy within 120s" + sleep 2 +done + +log "building the deb-builder image" +docker build -q -f images/aptly-deb-builder/Dockerfile -t "aptly-deb-builder:${PROJECT}" . >/dev/null + +log "packaging the demo .deb with aptly-pack (nfpm path)" +mkdir -p "${WORK_DIR}/dist" +docker run --rm \ + -v "${ROOT_DIR}/${FIXTURE_DIR}:/work:ro" \ + -v "${WORK_DIR}/dist:/work-out" \ + -w /tmp/pkg \ + --entrypoint /bin/sh \ + "aptly-deb-builder:${PROJECT}" \ + -c "cp -r /work/. /tmp/pkg && aptly-pack --output-dir /work-out" + +deb_file="$(find "${WORK_DIR}/dist" -name '*.deb' | head -1)" +[[ -n "$deb_file" ]] || die "aptly-pack produced no .deb" +log "built: $(basename "$deb_file")" + +log "pushing the package into the (unsigned, open) test repo via aptly-push" +# Reuses the already-built `reconcile` service's image/network via `compose +# run` instead of guessing compose's image-naming convention. +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" run --rm --no-deps \ + -e "APTLY_URL=http://aptly:8080" \ + -v "${WORK_DIR}/dist:/dist:ro" \ + --entrypoint /usr/local/bin/aptly-push \ + reconcile \ + --repo demo --distribution stable --no-sign "/dist/$(basename "$deb_file")" + +log "verifying via a real apt-get against the published repo (through nginx)" +docker run --rm --network "$network" debian:trixie-slim bash -euxc " + echo 'deb [trusted=yes] http://nginx:8080/ stable main' > /etc/apt/sources.list.d/smoke.list + apt-get update + apt-get install -y --no-install-recommends demo-package + test \"\$(cat /usr/share/demo-package/hello.txt)\" = 'hello from aptly-containerized' +" + +log "reconcile idempotency: re-running against unchanged state must not fail" +docker compose -p "$PROJECT" -f "$COMPOSE_FILE" run --rm reconcile + +log "PASS — build -> push -> publish -> apt-get chain verified end to end"