Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines

Provides a self-contained, containerized aptly (Debian repo manager)
stack with independently releasable image and Helm chart versions.

- images/: aptly-server (aptly built from source, cross-compiled) and
  aptly-deb-builder (nfpm + dpkg-buildpackage) container images
- rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack
  scripts consumed identically by Compose and the Helm chart, driven
  by one declarative state.yaml contract
- compose/: test (ephemeral, open) and production docker-compose
  stacks with an nginx read/auth sidecar
- charts/aptly/: aptly-native Helm chart covering every security
  posture from fully open to authenticated read+write, Ingress and
  Gateway API support (usable in parallel for migration scenarios),
  metrics, and declarative repo/mirror/publish reconciliation via a
  Helm hook
- .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test)
  plus separately tagged image (image/v*) and chart (chart/v*)
  releases, weekly rebuilds, and a preflight workflow validating the
  runner's Docker/Helm-OCI capabilities
- pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's
  signKey annotation (Helm can't verify Ed25519 keys)
- docs/, README.md, charts/aptly/README.md: usage, security, and
  versioning documentation
This commit is contained in:
2026-08-12 12:21:08 +02:00
commit 34c0f91f4b
71 changed files with 4843 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
charts
compose
docs
tests
*.md
+15
View File
@@ -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
+77
View File
@@ -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
+105
View File
@@ -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}"
+71
View File
@@ -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\"}"
+99
View File
@@ -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<aptly>` 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
+113
View File
@@ -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
+32
View File
@@ -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/
+21
View File
@@ -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.
+49
View File
@@ -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/
+13
View File
@@ -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.
+63
View File
@@ -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 <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).
+72
View File
@@ -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[@]}"
+33
View File
@@ -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
+395
View File
@@ -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 <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 <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-<statefulset-name>-<ordinal>`; find the exact
name with `kubectl get pvc -l app.kubernetes.io/instance=<release>`):
`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/<name>/`, 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.
+5
View File
@@ -0,0 +1,5 @@
security:
preset: authenticated
auth:
users:
ci: "changeme"
+82
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
gateway:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-infra
repo:
hostnames: [apt.example.com]
+20
View File
@@ -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"
+14
View File
@@ -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]
+5
View File
@@ -0,0 +1,5 @@
ingress:
enabled: true
mode: single
repo:
host: apt.example.com
+13
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
security:
preset: open
proxy:
enabled: false
aptly:
gpg:
enabled: false
+5
View File
@@ -0,0 +1,5 @@
security:
preset: open
aptly:
gpg:
enabled: false
+2
View File
@@ -0,0 +1,2 @@
security:
preset: readOnly
+89
View File
@@ -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 }}/ <dist> <component>' \
| 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 }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- end }}
{{- else }}
echo 'deb [trusted=yes] https://{{ $host }}/ <dist> <component>' \
| 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 <user> password <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 }}
+263
View File
@@ -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 -}}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
+65
View File
@@ -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 }}
+77
View File
@@ -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 }}
+80
View File
@@ -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 }}
+51
View File
@@ -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 }}
@@ -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 }}
@@ -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 }}
+14
View File
@@ -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 }}
@@ -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 }}
+19
View File
@@ -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
@@ -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 }}
+298
View File
@@ -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 }}
+421
View File
@@ -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" }
}
}
}
}
+322
View File
@@ -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/<name>/, 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: ""
+38
View File
@@ -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
View File
+16
View File
@@ -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
View File
+48
View File
@@ -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;
}
}
}
+39
View File
@@ -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;
}
}
+18
View File
@@ -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
+31
View File
@@ -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
+7
View File
@@ -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
+75
View File
@@ -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:
+134
View File
@@ -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:
+50
View File
@@ -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"]
+91
View File
@@ -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"]
+62
View File
@@ -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 <key-id> > pubkeys/chart-signing.asc
gpg --armor --export-secret-keys <key-id> # -> 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-<version>.tgz --keyring /tmp/pubring.gpg
```
+53
View File
@@ -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-----
+52
View File
@@ -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=(?<currentValue>\\S+)"],
"datasourceTemplate": "github-releases",
"depNameTemplate": "aptly-dev/aptly",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"description": "yq (config rendering helper baked into the aptly-server image)",
"managerFilePatterns": ["/^images/aptly-server/Dockerfile$/"],
"matchStrings": ["ARG YQ_VERSION=(?<currentValue>\\S+)"],
"datasourceTemplate": "github-releases",
"depNameTemplate": "mikefarah/yq"
},
{
"customType": "regex",
"description": "nfpm (deb-builder image)",
"managerFilePatterns": ["/^images/aptly-deb-builder/Dockerfile$/"],
"matchStrings": ["ARG NFPM_VERSION=(?<currentValue>\\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 \\|\\| '(?<currentValue>[^']+)'"],
"datasourceTemplate": "github-releases",
"depNameTemplate": "helm/helm",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
"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)"
}
]
}
+25
View File
@@ -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 "$@"
+180
View File
@@ -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"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# aptly-mirror-refresh — periodic content refresh for mirrors declared in
# state.yaml: fetch the mirror, snapshot it, switch every publish target that
# references it, then prune old snapshots. Intended to be run by an external
# scheduler (cron, a Compose --profile, a k8s CronJob you wire up yourself —
# this chart does not ship one, see docs/versioning.md "Nicht im Scope").
#
# Only touches mirrors listed in --mirrors (or all mirrors in state.yaml if
# omitted). Safe to run concurrently with aptly-reconcile against the same
# instance (both go through the REST API); NOT safe to run two instances of
# this script concurrently against the same mirror, since both would try to
# update+snapshot it at once — use your scheduler's concurrency policy
# (Gitea/k8s CronJob: concurrencyPolicy=Forbid) to prevent that.
#
# Env:
# APTLY_STATE_FILE path to state.yaml (required)
# APTLY_MIRRORS comma-separated mirror names to refresh (default: all)
# APTLY_KEEP_SNAPSHOTS how many historical snapshots to retain per mirror (default: 5)
# APTLY_SNAPSHOT_PREFIX snapshot name template prefix (default: mirror name)
# plus lib/common.sh's APTLY_URL / auth vars
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq yq date
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
KEEP="${APTLY_KEEP_SNAPSHOTS:-5}"
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
wanted_mirrors=()
if [[ -n "${APTLY_MIRRORS:-}" ]]; then
IFS=',' read -ra wanted_mirrors <<< "$APTLY_MIRRORS"
fi
wants() {
local name="$1"
[[ "${#wanted_mirrors[@]}" -eq 0 ]] && return 0
for m in "${wanted_mirrors[@]}"; do [[ "$m" == "$name" ]] && return 0; done
return 1
}
api_ready
failures=0
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<mirror_count; i++)); do
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$m")"
wants "$name" || continue
log "updating mirror '${name}'"
resp="$(api PUT "/api/mirrors/${name}" -H 'Content-Type: application/json' -d '{"ForceUpdate": true}')" \
|| { warn "mirror update failed for ${name}"; failures=$((failures+1)); continue; }
task_id="$(jq -r '.ID // empty' <<<"$resp")"
if ! wait_task "$task_id"; then
warn "mirror update task failed for ${name}, skipping snapshot/publish"
failures=$((failures+1))
continue
fi
snap_name="${name}-${STAMP}"
log "snapshotting '${name}' -> '${snap_name}'"
resp="$(api POST "/api/mirrors/${name}/snapshots" -H 'Content-Type: application/json' \
-d "$(jq -n --arg n "$snap_name" '{Name: $n}')")" \
|| { warn "snapshot failed for ${name}"; failures=$((failures+1)); continue; }
# Switch every publish target in state.yaml whose sources reference this mirror's snapshots.
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
for ((j=0; j<publish_count; j++)); do
p="$(jq ".publish[$j]" <<<"$STATE_JSON")"
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
[[ "$kind" == "snapshot" ]] || continue
refs_mirror="$(jq --arg m "$name" '[.sources[] | select(.name | startswith($m + "-"))] | length > 0' <<<"$p")"
[[ "$refs_mirror" == "true" ]] || continue
prefix="$(jq -r '.prefix // ""' <<<"$p")"
dist="$(jq -r '.distribution' <<<"$p")"
escaped_prefix="$(api_prefix "$prefix")"
component="$(jq -r '.sources[0].component // "main"' <<<"$p")"
log "switching publish prefix='${prefix}' distribution='${dist}' -> snapshot '${snap_name}'"
resp="$(api PUT "/api/publish/${escaped_prefix}/${dist}" -H 'Content-Type: application/json' \
-d "$(jq -n --arg n "$snap_name" --arg c "$component" '{Snapshots: [{Name: $n, Component: $c}]}')")" \
|| { warn "publish switch failed for ${prefix}/${dist}"; failures=$((failures+1)); continue; }
task_id="$(jq -r '.ID // empty' <<<"$resp")"
wait_task "$task_id" || { warn "publish switch task failed for ${prefix}/${dist}"; failures=$((failures+1)); }
done
# Prune old snapshots for this mirror beyond APTLY_KEEP_SNAPSHOTS, oldest first.
all_snaps="$(api GET "/api/snapshots" | jq -r --arg m "$name" \
'[.[] | select(.Name | startswith($m + "-"))] | sort_by(.CreatedAt) | .[].Name')"
total="$(wc -l <<<"$all_snaps" | tr -d ' ')"
to_prune=$(( total > KEEP ? total - KEEP : 0 ))
if [[ "$to_prune" -gt 0 ]]; then
log "pruning ${to_prune} old snapshot(s) for '${name}' (keeping ${KEEP})"
head -n "$to_prune" <<<"$all_snaps" | while IFS= read -r s; do
[[ -z "$s" ]] && continue
api DELETE "/api/snapshots/${s}" >/dev/null 2>&1 \
|| warn "could not drop snapshot ${s} (likely still referenced by a publish — will retry next run)"
done
fi
done
if [[ "$failures" -gt 0 ]]; then
die "${failures} mirror(s) failed to refresh — see warnings above"
fi
log "mirror refresh complete"
+77
View File
@@ -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
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# aptly-push — upload one or more .deb/.dsc/.changes files into an aptly local
# repo and (by default) refresh the publish that serves it. Works unchanged
# against every security.preset in the Helm chart's matrix: pass credentials
# via APTLY_USER/APTLY_PASSWORD, APTLY_TOKEN, or neither for an open repo.
#
# Usage:
# aptly-push --repo stable [--prefix ""] [--distribution stable] \
# [--no-publish] [--no-force-replace] [--no-sign] FILE.deb [FILE2.deb ...]
#
# Signing note: aptly-push runs as an external actor (CI, a developer's
# laptop) and generally has no access to the aptly-server pod's GNUPGHOME or
# /run/aptly/signing.json — only aptly-init and aptly-reconcile, which run
# inside that pod, do. So by default aptly-push sends NO Signing field at all
# on the publish-refresh call, letting the server use its own configured
# default signer (this is the correct behaviour for a signed repo). Pass
# --no-sign (or APTLY_GPG_SIGN=false) only when you know the target repo is
# genuinely unsigned (aptly.gpg.enabled: false) — otherwise the update call
# will fail with a clear "no GPG key" error rather than silently publishing
# unsigned.
#
# Env (all overridable by the matching flag):
# APTLY_URL (required, from lib/common.sh) APTLY_REPO APTLY_PREFIX APTLY_DISTRIBUTION APTLY_GPG_SIGN
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq
REPO="${APTLY_REPO:-}"
PREFIX="${APTLY_PREFIX:-}"
DISTRIBUTION="${APTLY_DISTRIBUTION:-}"
DO_PUBLISH=true
FORCE_REPLACE=true
SIGN="${APTLY_GPG_SIGN:-true}"
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--distribution) DISTRIBUTION="$2"; shift 2 ;;
--no-publish) DO_PUBLISH=false; shift ;;
--no-force-replace) FORCE_REPLACE=false; shift ;;
--no-sign) SIGN=false; shift ;;
--) shift; FILES+=("$@"); break ;;
-*) die "unknown flag: $1" ;;
*) FILES+=("$1"); shift ;;
esac
done
[[ -n "$REPO" ]] || die "--repo (or APTLY_REPO) is required"
[[ "${#FILES[@]}" -gt 0 ]] || die "no files given"
for f in "${FILES[@]}"; do [[ -f "$f" ]] || die "file not found: $f"; done
UPLOAD_DIR="push-$(date +%s)-$$"
log "uploading ${#FILES[@]} file(s) into upload directory '${UPLOAD_DIR}'"
cleanup() { api DELETE "/api/files/${UPLOAD_DIR}" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for f in "${FILES[@]}"; do
base="$(basename -- "$f")"
log " -> ${base}"
api POST "/api/files/${UPLOAD_DIR}" -F "file=@${f}" >/dev/null
done
log "including uploaded files into repo '${REPO}'"
qs="forceReplace=$([[ "$FORCE_REPLACE" == "true" ]] && echo 1 || echo 0)"
resp="$(api POST "/api/repos/${REPO}/file/${UPLOAD_DIR}?${qs}")"
failed="$(jq -r '(.FailedFiles // []) | length' <<<"$resp")"
if [[ "$failed" -gt 0 ]]; then
jq -r '.FailedFiles[]' <<<"$resp" | while IFS= read -r ff; do log "FAILED: ${ff}"; done
jq -r '(.Report.Warnings // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r w; do log "warning: ${w}"; done
die "${failed} file(s) were rejected by the repo — see above"
fi
jq -r '(.Report.AddedLines // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r l; do log "added: ${l}"; done
log "included successfully into '${REPO}'"
if [[ "$DO_PUBLISH" != "true" ]]; then
log "skipping publish refresh (--no-publish)"
exit 0
fi
[[ -n "$DISTRIBUTION" ]] || { warn "no --distribution/APTLY_DISTRIBUTION given, skipping publish refresh"; exit 0; }
escaped_prefix="$(api_prefix "$PREFIX")"
log "refreshing publish prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}'"
update_body="{}"
[[ "$SIGN" == "false" ]] && update_body='{"Signing":{"Skip":true}}'
code_body="$(api_status POST "/api/publish/${escaped_prefix}/${DISTRIBUTION}/update" \
-H 'Content-Type: application/json' -d "$update_body")"
code="$(head -1 <<<"$code_body")"
body="$(tail -n +2 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
warn "no publish exists at prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}' yet."
warn "the package is in the repo but not yet reachable by clients — run aptly-reconcile" \
"(or 'helm upgrade') to create the publish target once, then re-run this push."
exit 0
elif [[ "$code" != 2* ]]; then
warn "publish refresh failed (HTTP ${code}): ${body}"
warn "the package IS in the repo '${REPO}' — only the publish step failed." \
"If this repo is signed, check that the server has a usable signing key" \
"(see docs/security.md); if it is meant to be unsigned, re-run with --no-sign."
exit 0
fi
task_id="$(jq -r '.ID // empty' <<<"$body")"
if ! wait_task "$task_id"; then
warn "publish refresh task failed — package is in the repo but not yet published, see task output above"
exit 0
fi
log "published successfully"
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# aptly-reconcile — converges a running aptly instance towards a declarative
# state.yaml document. Talks ONLY to the REST API, never to the aptly CLI:
# `aptly api serve` holds the LevelDB single-writer lock, and a CLI invocation
# against the same rootDir would either fail to acquire it or (with -no-lock)
# corrupt the database. Safe to run repeatedly (Helm post-install/post-upgrade
# hook, a plain Job, or by hand).
#
# Contract (state.yaml):
#
# localRepos:
# - name: internal-stable
# comment: ""
# defaultDistribution: stable
# defaultComponent: main
#
# mirrors:
# - name: debian-trixie
# archiveURL: http://deb.debian.org/debian
# distribution: trixie
# components: [main, contrib]
# architectures: [amd64, arm64]
# filter: ""
# filterWithDeps: false
# downloadSources: false
# downloadUdebs: false
# downloadInstaller: false
# ignoreSignatures: false
# keyrings: [] # usually empty: verification uses GNUPGHOME's
# # default keyring, already populated by aptly-init
# # from aptly.gpgKeys / state.gpgKeys.
#
# publish:
# - name: stable-root # reconcile-local handle, never sent to the API
# prefix: "" # "" = repo root
# distribution: stable
# sourceKind: local # local | snapshot
# sources: [{name: internal-stable, component: main}]
# architectures: [amd64, arm64]
# acquireByHash: true
# skipContents: false
# skipBz2: false
#
# Known, deliberate limitation: components[] on an EXISTING mirror cannot be
# changed via the API (aptly has no such endpoint) — changing them requires
# dropping and recreating the mirror by hand. Everything else in a mirror
# definition, and all of a local repo's metadata, IS kept in sync on every run.
#
# Env:
# APTLY_STATE_FILE path to state.yaml (required)
# APTLY_FAIL_ON_ERROR "true"/"false" (default: false) — false logs and
# continues past a failed item instead of aborting the
# whole run (an unreachable upstream mirror must not
# break `helm upgrade`)
# APTLY_DRY_RUN "true"/"false" (default: false) — print the planned
# calls without making them
# plus everything lib/common.sh's api()/wait_task() need (APTLY_URL, ...)
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq yq
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
FAIL_ON_ERROR="${APTLY_FAIL_ON_ERROR:-false}"
DRY_RUN="${APTLY_DRY_RUN:-false}"
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
item_failed=0
on_error() {
local what="$1"
item_failed=1
if [[ "$FAIL_ON_ERROR" == "true" ]]; then
die "reconcile failed on: ${what}"
else
warn "reconcile step failed, continuing (APTLY_FAIL_ON_ERROR=false): ${what}"
fi
}
do_api() {
# do_api METHOD PATH JSON_BODY -> prints body, returns curl's exit status
local method="$1" path="$2" body="$3"
if [[ "$DRY_RUN" == "true" ]]; then
log "[dry-run] ${method} ${path} ${body}"
printf '{}'
return 0
fi
api "$method" "$path" -H 'Content-Type: application/json' -d "$body"
}
api_ready
# ---------------------------------------------------------------------------
# Local repos
# ---------------------------------------------------------------------------
repo_count="$(jq '(.localRepos // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<repo_count; i++)); do
repo="$(jq ".localRepos[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$repo")"
body="$(jq '{Name: .name, Comment: (.comment // ""), DefaultDistribution: (.defaultDistribution // ""), DefaultComponent: (.defaultComponent // "")}' <<<"$repo")"
code_body="$(api_status GET "/api/repos/${name}")"
code="$(head -1 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
log "creating local repo '${name}'"
do_api POST "/api/repos" "$body" >/dev/null || on_error "create local repo ${name}"
else
log "syncing local repo '${name}' metadata"
do_api PUT "/api/repos/${name}" "$body" >/dev/null || on_error "edit local repo ${name}"
fi
done
# ---------------------------------------------------------------------------
# Mirrors
# ---------------------------------------------------------------------------
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<mirror_count; i++)); do
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
name="$(jq -r '.name' <<<"$m")"
code_body="$(api_status GET "/api/mirrors/${name}")"
code="$(head -1 <<<"$code_body")"
if [[ "$code" == "404" ]]; then
body="$(jq '{
Name: .name, ArchiveURL: .archiveURL, Distribution: .distribution,
Filter: (.filter // ""), Components: (.components // []),
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
DownloadInstaller: (.downloadInstaller // false), DownloadAppStream: (.downloadAppStream // false),
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
}' <<<"$m")"
log "creating mirror '${name}'"
do_api POST "/api/mirrors" "$body" >/dev/null || on_error "create mirror ${name}"
else
body="$(jq '{
ArchiveURL: .archiveURL, Filter: (.filter // ""),
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
DownloadInstaller: (.downloadInstaller // false),
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
}' <<<"$m")"
log "syncing mirror '${name}' definition (note: components[] cannot be changed post-creation)"
do_api POST "/api/mirrors/${name}" "$body" >/dev/null || on_error "edit mirror ${name}"
fi
done
# ---------------------------------------------------------------------------
# Publish targets
# ---------------------------------------------------------------------------
existing_publishes="$(api GET "/api/publish")"
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<publish_count; i++)); do
p="$(jq ".publish[$i]" <<<"$STATE_JSON")"
handle="$(jq -r '.name' <<<"$p")"
prefix="$(jq -r '.prefix // ""' <<<"$p")"
dist="$(jq -r '.distribution' <<<"$p")"
escaped_prefix="$(api_prefix "$prefix")"
# GET /api/publish returns the root prefix as the literal string "." (not
# ""), matching aptly's own storage convention — confirmed empirically, see
# docs/packaging.md. Nested prefixes are returned with real slashes, not
# underscore-escaped, so only the root case needs translating here.
prefix_for_compare="$prefix"
[[ -z "$prefix_for_compare" ]] && prefix_for_compare="."
found="$(jq --arg prefix "$prefix_for_compare" --arg dist "$dist" \
'[.[] | select(.Prefix == $prefix and .Distribution == $dist)] | length > 0' \
<<<"$existing_publishes")"
sources="$(jq '[.sources[] | {Component: .component, Name: .name}]' <<<"$p")"
signing="$(jq -n --slurpfile sj <(cat "${APTLY_SIGNING_JSON:-/run/aptly/signing.json}" 2>/dev/null || echo '{"skip":true}') '
($sj[0] // {skip:true}) as $s
| if $s.skip then {Skip: true}
else {Skip: false, GpgKey: ($s.gpgKey // ""), PassphraseFile: ($s.passphraseFile // "")} end')"
if [[ "$found" != "true" ]]; then
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
{
SourceKind: ($p.sourceKind // "local"),
Sources: $sources,
Distribution: $p.distribution,
Architectures: ($p.architectures // []),
AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false),
SkipBz2: ($p.skipBz2 // false),
Signing: $signing
}')"
log "creating publish '${handle}' at prefix='${prefix}' distribution='${dist}'"
do_api POST "/api/publish/${escaped_prefix}" "$body" >/dev/null || on_error "create publish ${handle}"
else
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
if [[ "$kind" == "local" ]]; then
body="$(jq -n --argjson signing "$signing" --argjson p "$p" '
{ Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
log "refreshing publish '${handle}' (local repo -> re-publish pending changes)"
resp="$(do_api POST "/api/publish/${escaped_prefix}/${dist}/update" "$body")" || { on_error "update publish ${handle}"; continue; }
else
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
{ Snapshots: $sources, Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
log "switching publish '${handle}' to configured snapshot(s)"
resp="$(do_api PUT "/api/publish/${escaped_prefix}/${dist}" "$body")" || { on_error "switch publish ${handle}"; continue; }
fi
if [[ "$DRY_RUN" != "true" ]]; then
task_id="$(jq -r '.ID // empty' <<<"$resp")"
[[ -n "$task_id" ]] && { wait_task "$task_id" || on_error "task for publish ${handle}"; }
fi
fi
done
if [[ "$item_failed" -eq 1 ]]; then
log "completed with warnings (APTLY_FAIL_ON_ERROR=false) — see above"
else
log "reconcile completed successfully"
fi
+34
View File
@@ -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[@]}"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Shared helpers for aptly-init, aptly-reconcile, aptly-mirror-refresh, aptly-push.
# Sourced, not executed: no shebang execution, no `set` calls here (callers set their own).
log() { printf '[%s] %s\n' "$(basename "${0:-common.sh}")" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
warn() { log "WARN: $*"; }
# ---------------------------------------------------------------------------
# aptly REST API client
#
# Auth resolution order (first match wins):
# APTLY_TOKEN -> Authorization: Bearer <token>
# APTLY_USER + APTLY_PASSWORD -> HTTP Basic
# neither -> no Authorization header (open / read-only repo)
# ---------------------------------------------------------------------------
: "${APTLY_URL:?APTLY_URL must be set, e.g. http://aptly:8080 or https://apt.example.com}"
APTLY_URL="${APTLY_URL%/}"
APTLY_CURL_RETRIES="${APTLY_CURL_RETRIES:-5}"
APTLY_CURL_RETRY_DELAY="${APTLY_CURL_RETRY_DELAY:-3}"
_auth_args=()
if [[ -n "${APTLY_TOKEN:-}" ]]; then
_auth_args=(-H "Authorization: Bearer ${APTLY_TOKEN}")
elif [[ -n "${APTLY_USER:-}" && -n "${APTLY_PASSWORD:-}" ]]; then
_auth_args=(-u "${APTLY_USER}:${APTLY_PASSWORD}")
fi
# api METHOD PATH [curl-data-args...]
# PATH must start with /api/... Prints the response body on stdout.
# Non-2xx is a hard failure (die) — callers that need to inspect the status
# themselves should use api_status instead.
#
# --path-as-is is not optional: aptly's root-publish-prefix convention is the
# literal path segment ".", and curl's URL parser applies RFC 3986 dot-segment
# removal by default — silently rewriting ".../publish/." to ".../publish/"
# and ".../publish/./stable" to ".../publish/stable" before the request is
# even sent, which then 404s or hits the wrong (prefix-less) route.
api() {
local method="$1" path="$2"; shift 2
local out
# Deliberately no --retry-all-errors: that would also retry a deterministic
# 401 (wrong credentials) or 404 several times before giving up, turning an
# instant "wrong password" into a ~15s hang. Plain --retry already covers
# the transient cases that matter (connection errors, 429, 5xx).
out="$(curl -fsS --path-as-is --retry "$APTLY_CURL_RETRIES" --retry-delay "$APTLY_CURL_RETRY_DELAY" \
-X "$method" "${_auth_args[@]}" "${APTLY_URL}${path}" "$@")" \
|| die "API call failed: ${method} ${path}"
printf '%s' "$out"
}
# api_status METHOD PATH [curl-data-args...]
# Prints "HTTPSTATUS<newline>BODY". Never dies on non-2xx — caller decides.
api_status() {
local method="$1" path="$2"; shift 2
curl -sS --path-as-is -o /tmp/api_status.body -w '%{http_code}' -X "$method" \
"${_auth_args[@]}" "${APTLY_URL}${path}" "$@" > /tmp/api_status.code \
|| die "API call errored (connection): ${method} ${path}"
printf '%s\n' "$(cat /tmp/api_status.code)"
cat /tmp/api_status.body
}
# api_ready: block until /api/ready answers 200, or die after timeout.
api_ready() {
local timeout="${APTLY_WAIT_TIMEOUT:-600}" waited=0
log "waiting for ${APTLY_URL}/api/ready (timeout ${timeout}s)"
until curl -fsS -o /dev/null "${APTLY_URL}/api/ready"; do
(( waited >= timeout )) && die "aptly not ready after ${timeout}s"
sleep 3
waited=$(( waited + 3 ))
done
log "aptly is ready (waited ${waited}s)"
}
# wait_task TASK_ID: poll /api/tasks/:id until State is 2 (succeeded) or 3 (failed).
# State enum per aptly's task package: 0 init, 1 running, 2 succeeded, 3 failed.
wait_task() {
local id="$1" timeout="${APTLY_TASK_TIMEOUT:-3600}" waited=0 state
[[ -z "$id" || "$id" == "null" ]] && return 0
while true; do
state="$(api GET "/api/tasks/${id}" | jq -r '.State')"
case "$state" in
2) return 0 ;;
3)
log "task ${id} failed, output:"
api GET "/api/tasks/${id}/output" >&2 || true
return 1
;;
esac
(( waited >= timeout )) && die "task ${id} did not finish within ${timeout}s (last state: ${state})"
sleep 2
waited=$(( waited + 2 ))
done
}
# api_prefix PREFIX: map a human-readable publish prefix to aptly's URL-escaped form.
# "" -> "." "debian/stable" -> "debian_stable"
# See aptly's api/publish.go: ParsePrefix / EscapePrefix semantics.
api_prefix() {
local p="$1"
if [[ -z "$p" ]]; then
printf '.'
else
printf '%s' "${p//\//_}"
fi
}
require_cmd() {
for c in "$@"; do
command -v "$c" >/dev/null 2>&1 || die "required command not found: $c"
done
}
+12
View File
@@ -0,0 +1,12 @@
name: demo-package
arch: amd64
platform: linux
version: "1.0.0"
section: default
priority: optional
maintainer: "aptly-containerized CI <ci@example.com>"
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
+1
View File
@@ -0,0 +1 @@
hello from aptly-containerized
+82
View File
@@ -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"