Public Access
Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines
Provides a self-contained, containerized aptly (Debian repo manager) stack with independently releasable image and Helm chart versions. - images/: aptly-server (aptly built from source, cross-compiled) and aptly-deb-builder (nfpm + dpkg-buildpackage) container images - rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack scripts consumed identically by Compose and the Helm chart, driven by one declarative state.yaml contract - compose/: test (ephemeral, open) and production docker-compose stacks with an nginx read/auth sidecar - charts/aptly/: aptly-native Helm chart covering every security posture from fully open to authenticated read+write, Ingress and Gateway API support (usable in parallel for migration scenarios), metrics, and declarative repo/mirror/publish reconciliation via a Helm hook - .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test) plus separately tagged image (image/v*) and chart (chart/v*) releases, weekly rebuilds, and a preflight workflow validating the runner's Docker/Helm-OCI capabilities - pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's signKey annotation (Helm can't verify Ed25519 keys) - docs/, README.md, charts/aptly/README.md: usage, security, and versioning documentation
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
charts
|
||||||
|
compose
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
*.md
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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\"}"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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[@]}"
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
security:
|
||||||
|
preset: authenticated
|
||||||
|
auth:
|
||||||
|
users:
|
||||||
|
ci: "changeme"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
gateway:
|
||||||
|
enabled: true
|
||||||
|
parentRefs:
|
||||||
|
- name: my-gateway
|
||||||
|
namespace: gateway-infra
|
||||||
|
repo:
|
||||||
|
hostnames: [apt.example.com]
|
||||||
@@ -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"
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
mode: single
|
||||||
|
repo:
|
||||||
|
host: apt.example.com
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
security:
|
||||||
|
preset: open
|
||||||
|
proxy:
|
||||||
|
enabled: false
|
||||||
|
aptly:
|
||||||
|
gpg:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
security:
|
||||||
|
preset: open
|
||||||
|
aptly:
|
||||||
|
gpg:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
security:
|
||||||
|
preset: readOnly
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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-----
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Entrypoint for the aptly-server image. Everything derived (rendered config,
|
||||||
|
# GPG keyring, htpasswd) is expected to already exist under APTLY_RUN_DIR,
|
||||||
|
# written by aptly-init in a preceding initContainer / compose service.
|
||||||
|
#
|
||||||
|
# Any argument other than "aptly api serve" (the default) is exec'd verbatim,
|
||||||
|
# so the same image is also usable as a one-off CLI/debug container:
|
||||||
|
# docker run --rm -it aptly-server bash
|
||||||
|
# docker run --rm aptly-server aptly version
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APTLY_ROOT_DIR="${APTLY_ROOT_DIR:-/var/lib/aptly}"
|
||||||
|
APTLY_RUN_DIR="${APTLY_RUN_DIR:-/run/aptly}"
|
||||||
|
APTLY_CONFIG="${APTLY_CONFIG:-${APTLY_RUN_DIR}/aptly.yaml}"
|
||||||
|
APTLY_API_LISTEN="${APTLY_API_LISTEN:-127.0.0.1:8080}"
|
||||||
|
|
||||||
|
mkdir -p "${APTLY_ROOT_DIR}"
|
||||||
|
|
||||||
|
if [[ "$#" -eq 0 ]]; then
|
||||||
|
set -- aptly api serve "-listen=${APTLY_API_LISTEN}" "-config=${APTLY_CONFIG}"
|
||||||
|
elif [[ "$1" == "aptly" && "$#" -eq 1 ]]; then
|
||||||
|
set -- aptly api serve "-listen=${APTLY_API_LISTEN}" "-config=${APTLY_CONFIG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
Executable
+180
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-init — runs once as an initContainer (Helm) or a one-shot service (Compose).
|
||||||
|
# Produces everything the aptly and nginx containers consume, so neither of them
|
||||||
|
# has to guess about start-order or generate secrets themselves:
|
||||||
|
#
|
||||||
|
# /run/aptly/aptly.yaml rendered aptly config (env placeholders resolved)
|
||||||
|
# /run/aptly/htpasswd nginx basic-auth file (hashed here, never in a template)
|
||||||
|
# /run/aptly/gnupg/ GNUPGHOME, imported signing key + trusted keys
|
||||||
|
# /run/aptly/signing.json the one source of truth for "how do I sign a publish"
|
||||||
|
# /run/aptly/pub/signing-key.asc armored public key, served by nginx
|
||||||
|
#
|
||||||
|
# Inputs (all optional unless noted):
|
||||||
|
# APTLY_CONFIG_SRC path to the un-interpolated aptly config (required)
|
||||||
|
# APTLY_CONFIG_DST path to write the rendered config (required)
|
||||||
|
# APTLY_USERS_FILE plaintext "user:pass" lines, one per line, to hash into htpasswd
|
||||||
|
# APTLY_HTPASSWD_SRC pre-hashed htpasswd file to use as-is (wins over APTLY_USERS_FILE)
|
||||||
|
# APTLY_INTERNAL_USER username always appended to htpasswd (used by the reconcile job)
|
||||||
|
# APTLY_INTERNAL_PASSWORD password for APTLY_INTERNAL_USER
|
||||||
|
# APTLY_GPG_PRIVATE_KEY_FILE armored private key
|
||||||
|
# APTLY_GPG_SECRET_KEYRING_FILE binary secring.gpg (alternative to the above)
|
||||||
|
# APTLY_GPG_PASSPHRASE_FILE file containing the passphrase, if any
|
||||||
|
# APTLY_GPG_PUBLIC_KEY_FILE armored public key (derived from the private key if absent)
|
||||||
|
# APTLY_GPG_KEYS_DIR directory of extra *.asc files to import as trusted (mirror) keys
|
||||||
|
# APTLY_GPG_ENABLED "true"/"false" — false disables signing entirely (see below)
|
||||||
|
# APTLY_RUN_DIR defaults to /run/aptly
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Deliberately does NOT source lib/common.sh: that helper requires APTLY_URL
|
||||||
|
# (it's built for the REST-API scripts), and aptly-init makes no API calls —
|
||||||
|
# coupling it to that requirement would make aptly-init fail outside a
|
||||||
|
# context that sets APTLY_URL, which is wrong for an initContainer.
|
||||||
|
log() { printf '[aptly-init] %s\n' "$*" >&2; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
warn() { log "WARN: $*"; }
|
||||||
|
|
||||||
|
RUN_DIR="${APTLY_RUN_DIR:-/run/aptly}"
|
||||||
|
GNUPGHOME="${RUN_DIR}/gnupg"
|
||||||
|
mkdir -p "${RUN_DIR}" "${RUN_DIR}/pub"
|
||||||
|
install -d -m 0700 "${GNUPGHOME}"
|
||||||
|
export GNUPGHOME
|
||||||
|
|
||||||
|
# Ensure the publish directory exists before nginx ever tries to mount it.
|
||||||
|
# On Kubernetes, nginx's subPath mount of data/public happens at container
|
||||||
|
# creation, after this initContainer completes but with no other guarantee
|
||||||
|
# the directory has been created yet on a fresh, empty volume — aptly itself
|
||||||
|
# only creates it lazily on first publish.
|
||||||
|
if [[ -n "${APTLY_ROOT_DIR:-}" ]]; then
|
||||||
|
mkdir -p "${APTLY_ROOT_DIR}/public"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Render the aptly config: resolve ${VAR} placeholders against the process
|
||||||
|
# environment. Bare `envsubst` (no argument) blanks every variable it does
|
||||||
|
# not know about, which would silently wreck a GPG passphrase containing a
|
||||||
|
# literal "$". We restrict substitution to a computed SHELL-FORMAT list of
|
||||||
|
# currently-exported vars instead.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: "${APTLY_CONFIG_SRC:?APTLY_CONFIG_SRC must point at the source aptly config}"
|
||||||
|
: "${APTLY_CONFIG_DST:?APTLY_CONFIG_DST must point at the rendered output path}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname -- "$APTLY_CONFIG_DST")"
|
||||||
|
shell_format="$(env | cut -d= -f1 | grep -E '^[A-Za-z_][A-Za-z0-9_]*$' | sed 's/^/${/;s/$/}/' | tr '\n' ' ')"
|
||||||
|
envsubst "${shell_format}" < "$APTLY_CONFIG_SRC" > "$APTLY_CONFIG_DST"
|
||||||
|
chmod 0640 "$APTLY_CONFIG_DST"
|
||||||
|
|
||||||
|
# Strip comment lines first: a source config documenting its own ${VAR}
|
||||||
|
# syntax in a comment would otherwise trip this check on itself.
|
||||||
|
# shellcheck disable=SC2016 # single quotes are deliberate: this is a regex, not shell expansion
|
||||||
|
if unresolved="$(grep -v '^[[:space:]]*#' "$APTLY_CONFIG_DST" \
|
||||||
|
| grep -o '\${[A-Za-z_][A-Za-z0-9_]*}' | sort -u)"; then
|
||||||
|
while IFS= read -r v; do
|
||||||
|
[[ -n "$v" ]] && log "WARN: unresolved placeholder ${v} left in rendered config"
|
||||||
|
done <<< "$unresolved"
|
||||||
|
fi
|
||||||
|
log "rendered config -> ${APTLY_CONFIG_DST}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. htpasswd. Hashing happens here, not in a Helm template: a template using
|
||||||
|
# sprig's htpasswd would pick a new random bcrypt salt on every render,
|
||||||
|
# changing the Secret on every `helm upgrade` and restart-looping the pod
|
||||||
|
# via the checksum/secret annotation. The Secret therefore carries
|
||||||
|
# plaintext "user:pass" lines (or a ready-made htpasswd, for the
|
||||||
|
# ExternalSecrets path) and this script does the one-time hashing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
HTPASSWD_OUT="${RUN_DIR}/htpasswd"
|
||||||
|
: > "$HTPASSWD_OUT"
|
||||||
|
|
||||||
|
if [[ -n "${APTLY_HTPASSWD_SRC:-}" && -s "${APTLY_HTPASSWD_SRC}" ]]; then
|
||||||
|
cat "${APTLY_HTPASSWD_SRC}" >> "$HTPASSWD_OUT"
|
||||||
|
log "using pre-hashed htpasswd from ${APTLY_HTPASSWD_SRC}"
|
||||||
|
elif [[ -n "${APTLY_USERS_FILE:-}" && -s "${APTLY_USERS_FILE}" ]]; then
|
||||||
|
while IFS=: read -r user pass; do
|
||||||
|
[[ -z "$user" || "$user" == \#* || -z "${pass:-}" ]] && continue
|
||||||
|
printf '%s:%s\n' "$user" "$(openssl passwd -apr1 -- "$pass")" >> "$HTPASSWD_OUT"
|
||||||
|
done < "${APTLY_USERS_FILE}"
|
||||||
|
log "hashed $(wc -l < "$HTPASSWD_OUT") user(s) from ${APTLY_USERS_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${APTLY_INTERNAL_USER:-}" && -n "${APTLY_INTERNAL_PASSWORD:-}" ]]; then
|
||||||
|
printf '%s:%s\n' "$APTLY_INTERNAL_USER" "$(openssl passwd -apr1 -- "$APTLY_INTERNAL_PASSWORD")" >> "$HTPASSWD_OUT"
|
||||||
|
log "appended internal user '${APTLY_INTERNAL_USER}' (used by the reconcile job / cron)"
|
||||||
|
fi
|
||||||
|
# World-readable, not 0640: the nginx container reads this file as its own
|
||||||
|
# UID (101 upstream, or whatever securityContext.runAsUser is set to in the
|
||||||
|
# Helm chart), which has no relation to the aptly UID that wrote it. In
|
||||||
|
# Kubernetes, pod-level fsGroup would put both UIDs in a shared supplementary
|
||||||
|
# group instead — Compose has no equivalent, so this file has to be
|
||||||
|
# world-readable. It contains only apr1-hashed passwords, not plaintext.
|
||||||
|
chmod 0644 "$HTPASSWD_OUT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. GPG. GNUPGHOME lives on an in-memory emptyDir: the private key never
|
||||||
|
# touches a PersistentVolume, and the keyring is re-derived from Secrets
|
||||||
|
# on every start, so key rotation is just "restart the pod".
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
gpg_enabled="${APTLY_GPG_ENABLED:-true}"
|
||||||
|
signing_json="${RUN_DIR}/signing.json"
|
||||||
|
|
||||||
|
if [[ "$gpg_enabled" != "true" ]]; then
|
||||||
|
printf '{"skip": true}\n' > "$signing_json"
|
||||||
|
log "signing disabled (APTLY_GPG_ENABLED=false) -> ${signing_json}"
|
||||||
|
else
|
||||||
|
imported_any=false
|
||||||
|
|
||||||
|
if [[ -n "${APTLY_GPG_PRIVATE_KEY_FILE:-}" && -s "${APTLY_GPG_PRIVATE_KEY_FILE}" ]]; then
|
||||||
|
gpg --batch --import "${APTLY_GPG_PRIVATE_KEY_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
|
||||||
|
imported_any=true
|
||||||
|
fi
|
||||||
|
if [[ -n "${APTLY_GPG_SECRET_KEYRING_FILE:-}" && -s "${APTLY_GPG_SECRET_KEYRING_FILE}" ]]; then
|
||||||
|
gpg --batch --import "${APTLY_GPG_SECRET_KEYRING_FILE}" 2>&1 | while read -r l; do log "gpg: $l"; done || true
|
||||||
|
imported_any=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$imported_any" != "true" ]]; then
|
||||||
|
warn_msg="APTLY_GPG_ENABLED=true but no private key was provided"
|
||||||
|
log "WARN: ${warn_msg} — publishing will fail signature checks unless aptly.gpg.enabled is also false"
|
||||||
|
printf '{"skip": true}\n' > "$signing_json"
|
||||||
|
else
|
||||||
|
# Auto-detect the key id so nothing downstream has to guess it.
|
||||||
|
key_id="$(gpg --batch --list-secret-keys --with-colons 2>/dev/null | awk -F: '$1=="sec"{print $5; exit}')"
|
||||||
|
[[ -z "$key_id" ]] && die "GPG key(s) imported but no secret key id could be detected"
|
||||||
|
|
||||||
|
passphrase_file=""
|
||||||
|
if [[ -n "${APTLY_GPG_PASSPHRASE_FILE:-}" && -s "${APTLY_GPG_PASSPHRASE_FILE}" ]]; then
|
||||||
|
passphrase_file="${APTLY_GPG_PASSPHRASE_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n --arg keyId "$key_id" --arg keyring "" --arg secretKeyring "" \
|
||||||
|
--arg passphraseFile "$passphrase_file" \
|
||||||
|
'{skip: false, batch: true, gpgKey: $keyId} + (if $passphraseFile != "" then {passphraseFile: $passphraseFile} else {} end)' \
|
||||||
|
> "$signing_json"
|
||||||
|
log "signing key detected: ${key_id} -> ${signing_json}"
|
||||||
|
|
||||||
|
# Export the public key so nginx can serve it, and clients can `signed-by=` it.
|
||||||
|
pub_out="${RUN_DIR}/pub/signing-key.asc"
|
||||||
|
if [[ -n "${APTLY_GPG_PUBLIC_KEY_FILE:-}" && -s "${APTLY_GPG_PUBLIC_KEY_FILE}" ]]; then
|
||||||
|
cp "${APTLY_GPG_PUBLIC_KEY_FILE}" "$pub_out"
|
||||||
|
else
|
||||||
|
gpg --batch --armor --export "$key_id" > "$pub_out"
|
||||||
|
fi
|
||||||
|
log "public key exported -> ${pub_out}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trusted keys for mirror verification (aptly.gpgKeys[] in values / mirrors.gpgKeys in state.yaml)
|
||||||
|
if [[ -n "${APTLY_GPG_KEYS_DIR:-}" && -d "${APTLY_GPG_KEYS_DIR}" ]]; then
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "${APTLY_GPG_KEYS_DIR}"/*.asc "${APTLY_GPG_KEYS_DIR}"/*.gpg; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
if gpg --batch --import "$f" 2>&1 | while read -r l; do log "gpg: $l"; done; then
|
||||||
|
log "imported trusted key from ${f}"
|
||||||
|
else
|
||||||
|
warn "failed to import trusted key ${f} (continuing — mirrors referencing it will fail signature checks)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod -R go-rwx "${GNUPGHOME}" 2>/dev/null || true
|
||||||
|
log "done"
|
||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-mirror-refresh — periodic content refresh for mirrors declared in
|
||||||
|
# state.yaml: fetch the mirror, snapshot it, switch every publish target that
|
||||||
|
# references it, then prune old snapshots. Intended to be run by an external
|
||||||
|
# scheduler (cron, a Compose --profile, a k8s CronJob you wire up yourself —
|
||||||
|
# this chart does not ship one, see docs/versioning.md "Nicht im Scope").
|
||||||
|
#
|
||||||
|
# Only touches mirrors listed in --mirrors (or all mirrors in state.yaml if
|
||||||
|
# omitted). Safe to run concurrently with aptly-reconcile against the same
|
||||||
|
# instance (both go through the REST API); NOT safe to run two instances of
|
||||||
|
# this script concurrently against the same mirror, since both would try to
|
||||||
|
# update+snapshot it at once — use your scheduler's concurrency policy
|
||||||
|
# (Gitea/k8s CronJob: concurrencyPolicy=Forbid) to prevent that.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# APTLY_STATE_FILE path to state.yaml (required)
|
||||||
|
# APTLY_MIRRORS comma-separated mirror names to refresh (default: all)
|
||||||
|
# APTLY_KEEP_SNAPSHOTS how many historical snapshots to retain per mirror (default: 5)
|
||||||
|
# APTLY_SNAPSHOT_PREFIX snapshot name template prefix (default: mirror name)
|
||||||
|
# plus lib/common.sh's APTLY_URL / auth vars
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
require_cmd curl jq yq date
|
||||||
|
|
||||||
|
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
|
||||||
|
KEEP="${APTLY_KEEP_SNAPSHOTS:-5}"
|
||||||
|
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
|
||||||
|
wanted_mirrors=()
|
||||||
|
if [[ -n "${APTLY_MIRRORS:-}" ]]; then
|
||||||
|
IFS=',' read -ra wanted_mirrors <<< "$APTLY_MIRRORS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wants() {
|
||||||
|
local name="$1"
|
||||||
|
[[ "${#wanted_mirrors[@]}" -eq 0 ]] && return 0
|
||||||
|
for m in "${wanted_mirrors[@]}"; do [[ "$m" == "$name" ]] && return 0; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
api_ready
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
|
||||||
|
for ((i=0; i<mirror_count; i++)); do
|
||||||
|
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
|
||||||
|
name="$(jq -r '.name' <<<"$m")"
|
||||||
|
wants "$name" || continue
|
||||||
|
|
||||||
|
log "updating mirror '${name}'"
|
||||||
|
resp="$(api PUT "/api/mirrors/${name}" -H 'Content-Type: application/json' -d '{"ForceUpdate": true}')" \
|
||||||
|
|| { warn "mirror update failed for ${name}"; failures=$((failures+1)); continue; }
|
||||||
|
task_id="$(jq -r '.ID // empty' <<<"$resp")"
|
||||||
|
if ! wait_task "$task_id"; then
|
||||||
|
warn "mirror update task failed for ${name}, skipping snapshot/publish"
|
||||||
|
failures=$((failures+1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
snap_name="${name}-${STAMP}"
|
||||||
|
log "snapshotting '${name}' -> '${snap_name}'"
|
||||||
|
resp="$(api POST "/api/mirrors/${name}/snapshots" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg n "$snap_name" '{Name: $n}')")" \
|
||||||
|
|| { warn "snapshot failed for ${name}"; failures=$((failures+1)); continue; }
|
||||||
|
|
||||||
|
# Switch every publish target in state.yaml whose sources reference this mirror's snapshots.
|
||||||
|
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
|
||||||
|
for ((j=0; j<publish_count; j++)); do
|
||||||
|
p="$(jq ".publish[$j]" <<<"$STATE_JSON")"
|
||||||
|
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
|
||||||
|
[[ "$kind" == "snapshot" ]] || continue
|
||||||
|
refs_mirror="$(jq --arg m "$name" '[.sources[] | select(.name | startswith($m + "-"))] | length > 0' <<<"$p")"
|
||||||
|
[[ "$refs_mirror" == "true" ]] || continue
|
||||||
|
|
||||||
|
prefix="$(jq -r '.prefix // ""' <<<"$p")"
|
||||||
|
dist="$(jq -r '.distribution' <<<"$p")"
|
||||||
|
escaped_prefix="$(api_prefix "$prefix")"
|
||||||
|
component="$(jq -r '.sources[0].component // "main"' <<<"$p")"
|
||||||
|
|
||||||
|
log "switching publish prefix='${prefix}' distribution='${dist}' -> snapshot '${snap_name}'"
|
||||||
|
resp="$(api PUT "/api/publish/${escaped_prefix}/${dist}" -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg n "$snap_name" --arg c "$component" '{Snapshots: [{Name: $n, Component: $c}]}')")" \
|
||||||
|
|| { warn "publish switch failed for ${prefix}/${dist}"; failures=$((failures+1)); continue; }
|
||||||
|
task_id="$(jq -r '.ID // empty' <<<"$resp")"
|
||||||
|
wait_task "$task_id" || { warn "publish switch task failed for ${prefix}/${dist}"; failures=$((failures+1)); }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Prune old snapshots for this mirror beyond APTLY_KEEP_SNAPSHOTS, oldest first.
|
||||||
|
all_snaps="$(api GET "/api/snapshots" | jq -r --arg m "$name" \
|
||||||
|
'[.[] | select(.Name | startswith($m + "-"))] | sort_by(.CreatedAt) | .[].Name')"
|
||||||
|
total="$(wc -l <<<"$all_snaps" | tr -d ' ')"
|
||||||
|
to_prune=$(( total > KEEP ? total - KEEP : 0 ))
|
||||||
|
if [[ "$to_prune" -gt 0 ]]; then
|
||||||
|
log "pruning ${to_prune} old snapshot(s) for '${name}' (keeping ${KEEP})"
|
||||||
|
head -n "$to_prune" <<<"$all_snaps" | while IFS= read -r s; do
|
||||||
|
[[ -z "$s" ]] && continue
|
||||||
|
api DELETE "/api/snapshots/${s}" >/dev/null 2>&1 \
|
||||||
|
|| warn "could not drop snapshot ${s} (likely still referenced by a publish — will retry next run)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$failures" -gt 0 ]]; then
|
||||||
|
die "${failures} mirror(s) failed to refresh — see warnings above"
|
||||||
|
fi
|
||||||
|
log "mirror refresh complete"
|
||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-pack — build a .deb without requiring Debian packaging knowledge.
|
||||||
|
#
|
||||||
|
# Two modes, auto-detected:
|
||||||
|
# 1. A debian/ directory exists in --source-dir -> delegate to the real
|
||||||
|
# Debian toolchain: `dpkg-buildpackage -us -uc -b` (binary-only, unsigned;
|
||||||
|
# signing is aptly's job at publish time, not the package's).
|
||||||
|
# 2. Otherwise -> nfpm (https://nfpm.goreleaser.com/), driven by a plain
|
||||||
|
# nfpm.yaml describing name/version/files — no debian/ dir needed.
|
||||||
|
#
|
||||||
|
# Only runs inside the aptly-deb-builder image, which has both toolchains.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# aptly-pack [--source-dir .] [--config nfpm.yaml] [--output-dir dist]
|
||||||
|
#
|
||||||
|
# For the nfpm path, target architecture is whatever `arch:` says in the
|
||||||
|
# nfpm.yaml itself (nfpm has no --arch flag — a config describes one arch;
|
||||||
|
# for multi-arch packages, run aptly-pack once per arch-specific config).
|
||||||
|
#
|
||||||
|
# Prints the path(s) of the produced .deb file(s) on stdout, one per line —
|
||||||
|
# meant to be fed straight into aptly-push:
|
||||||
|
# aptly-push --repo stable $(aptly-pack)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { printf '[aptly-pack] %s\n' "$*" >&2; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
|
||||||
|
SOURCE_DIR="."
|
||||||
|
CONFIG="nfpm.yaml"
|
||||||
|
OUTPUT_DIR="dist"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
|
||||||
|
--config) CONFIG="$2"; shift 2 ;;
|
||||||
|
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||||
|
*) die "unknown argument: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
OUTPUT_DIR="$(cd -- "$OUTPUT_DIR" && pwd)"
|
||||||
|
|
||||||
|
if [[ -d "${SOURCE_DIR}/debian" ]]; then
|
||||||
|
log "found ${SOURCE_DIR}/debian -> building with dpkg-buildpackage"
|
||||||
|
command -v dpkg-buildpackage >/dev/null 2>&1 || die "dpkg-buildpackage not found (wrong image?)"
|
||||||
|
(
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
dpkg-buildpackage -us -uc -b
|
||||||
|
)
|
||||||
|
# dpkg-buildpackage drops artifacts one level above the source tree.
|
||||||
|
parent_dir="$(cd -- "${SOURCE_DIR}/.." && pwd)"
|
||||||
|
found=0
|
||||||
|
for f in "${parent_dir}"/*.deb; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
mv -- "$f" "$OUTPUT_DIR/"
|
||||||
|
printf '%s\n' "${OUTPUT_DIR}/$(basename -- "$f")"
|
||||||
|
found=1
|
||||||
|
done
|
||||||
|
[[ "$found" -eq 1 ]] || die "dpkg-buildpackage produced no .deb file"
|
||||||
|
else
|
||||||
|
cfg="${SOURCE_DIR}/${CONFIG}"
|
||||||
|
[[ -f "$cfg" ]] || die "no ${SOURCE_DIR}/debian and no nfpm config at ${cfg} — nothing to build"
|
||||||
|
command -v nfpm >/dev/null 2>&1 || die "nfpm not found (wrong image?)"
|
||||||
|
log "packaging with nfpm (config: ${cfg})"
|
||||||
|
(
|
||||||
|
cd "$SOURCE_DIR"
|
||||||
|
nfpm package --config "$(basename -- "$cfg")" --target "$OUTPUT_DIR" --packager deb
|
||||||
|
)
|
||||||
|
found=0
|
||||||
|
for f in "${OUTPUT_DIR}"/*.deb; do
|
||||||
|
[[ -e "$f" ]] || continue
|
||||||
|
printf '%s\n' "$f"
|
||||||
|
found=1
|
||||||
|
done
|
||||||
|
[[ "$found" -eq 1 ]] || die "nfpm produced no .deb file"
|
||||||
|
fi
|
||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-push — upload one or more .deb/.dsc/.changes files into an aptly local
|
||||||
|
# repo and (by default) refresh the publish that serves it. Works unchanged
|
||||||
|
# against every security.preset in the Helm chart's matrix: pass credentials
|
||||||
|
# via APTLY_USER/APTLY_PASSWORD, APTLY_TOKEN, or neither for an open repo.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# aptly-push --repo stable [--prefix ""] [--distribution stable] \
|
||||||
|
# [--no-publish] [--no-force-replace] [--no-sign] FILE.deb [FILE2.deb ...]
|
||||||
|
#
|
||||||
|
# Signing note: aptly-push runs as an external actor (CI, a developer's
|
||||||
|
# laptop) and generally has no access to the aptly-server pod's GNUPGHOME or
|
||||||
|
# /run/aptly/signing.json — only aptly-init and aptly-reconcile, which run
|
||||||
|
# inside that pod, do. So by default aptly-push sends NO Signing field at all
|
||||||
|
# on the publish-refresh call, letting the server use its own configured
|
||||||
|
# default signer (this is the correct behaviour for a signed repo). Pass
|
||||||
|
# --no-sign (or APTLY_GPG_SIGN=false) only when you know the target repo is
|
||||||
|
# genuinely unsigned (aptly.gpg.enabled: false) — otherwise the update call
|
||||||
|
# will fail with a clear "no GPG key" error rather than silently publishing
|
||||||
|
# unsigned.
|
||||||
|
#
|
||||||
|
# Env (all overridable by the matching flag):
|
||||||
|
# APTLY_URL (required, from lib/common.sh) APTLY_REPO APTLY_PREFIX APTLY_DISTRIBUTION APTLY_GPG_SIGN
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
require_cmd curl jq
|
||||||
|
|
||||||
|
REPO="${APTLY_REPO:-}"
|
||||||
|
PREFIX="${APTLY_PREFIX:-}"
|
||||||
|
DISTRIBUTION="${APTLY_DISTRIBUTION:-}"
|
||||||
|
DO_PUBLISH=true
|
||||||
|
FORCE_REPLACE=true
|
||||||
|
SIGN="${APTLY_GPG_SIGN:-true}"
|
||||||
|
FILES=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) REPO="$2"; shift 2 ;;
|
||||||
|
--prefix) PREFIX="$2"; shift 2 ;;
|
||||||
|
--distribution) DISTRIBUTION="$2"; shift 2 ;;
|
||||||
|
--no-publish) DO_PUBLISH=false; shift ;;
|
||||||
|
--no-force-replace) FORCE_REPLACE=false; shift ;;
|
||||||
|
--no-sign) SIGN=false; shift ;;
|
||||||
|
--) shift; FILES+=("$@"); break ;;
|
||||||
|
-*) die "unknown flag: $1" ;;
|
||||||
|
*) FILES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$REPO" ]] || die "--repo (or APTLY_REPO) is required"
|
||||||
|
[[ "${#FILES[@]}" -gt 0 ]] || die "no files given"
|
||||||
|
for f in "${FILES[@]}"; do [[ -f "$f" ]] || die "file not found: $f"; done
|
||||||
|
|
||||||
|
UPLOAD_DIR="push-$(date +%s)-$$"
|
||||||
|
log "uploading ${#FILES[@]} file(s) into upload directory '${UPLOAD_DIR}'"
|
||||||
|
|
||||||
|
cleanup() { api DELETE "/api/files/${UPLOAD_DIR}" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
base="$(basename -- "$f")"
|
||||||
|
log " -> ${base}"
|
||||||
|
api POST "/api/files/${UPLOAD_DIR}" -F "file=@${f}" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
log "including uploaded files into repo '${REPO}'"
|
||||||
|
qs="forceReplace=$([[ "$FORCE_REPLACE" == "true" ]] && echo 1 || echo 0)"
|
||||||
|
resp="$(api POST "/api/repos/${REPO}/file/${UPLOAD_DIR}?${qs}")"
|
||||||
|
|
||||||
|
failed="$(jq -r '(.FailedFiles // []) | length' <<<"$resp")"
|
||||||
|
if [[ "$failed" -gt 0 ]]; then
|
||||||
|
jq -r '.FailedFiles[]' <<<"$resp" | while IFS= read -r ff; do log "FAILED: ${ff}"; done
|
||||||
|
jq -r '(.Report.Warnings // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r w; do log "warning: ${w}"; done
|
||||||
|
die "${failed} file(s) were rejected by the repo — see above"
|
||||||
|
fi
|
||||||
|
jq -r '(.Report.AddedLines // [])[]' <<<"$resp" 2>/dev/null | while IFS= read -r l; do log "added: ${l}"; done
|
||||||
|
log "included successfully into '${REPO}'"
|
||||||
|
|
||||||
|
if [[ "$DO_PUBLISH" != "true" ]]; then
|
||||||
|
log "skipping publish refresh (--no-publish)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ -n "$DISTRIBUTION" ]] || { warn "no --distribution/APTLY_DISTRIBUTION given, skipping publish refresh"; exit 0; }
|
||||||
|
|
||||||
|
escaped_prefix="$(api_prefix "$PREFIX")"
|
||||||
|
log "refreshing publish prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}'"
|
||||||
|
|
||||||
|
update_body="{}"
|
||||||
|
[[ "$SIGN" == "false" ]] && update_body='{"Signing":{"Skip":true}}'
|
||||||
|
|
||||||
|
code_body="$(api_status POST "/api/publish/${escaped_prefix}/${DISTRIBUTION}/update" \
|
||||||
|
-H 'Content-Type: application/json' -d "$update_body")"
|
||||||
|
code="$(head -1 <<<"$code_body")"
|
||||||
|
body="$(tail -n +2 <<<"$code_body")"
|
||||||
|
|
||||||
|
if [[ "$code" == "404" ]]; then
|
||||||
|
warn "no publish exists at prefix='${PREFIX:-<root>}' distribution='${DISTRIBUTION}' yet."
|
||||||
|
warn "the package is in the repo but not yet reachable by clients — run aptly-reconcile" \
|
||||||
|
"(or 'helm upgrade') to create the publish target once, then re-run this push."
|
||||||
|
exit 0
|
||||||
|
elif [[ "$code" != 2* ]]; then
|
||||||
|
warn "publish refresh failed (HTTP ${code}): ${body}"
|
||||||
|
warn "the package IS in the repo '${REPO}' — only the publish step failed." \
|
||||||
|
"If this repo is signed, check that the server has a usable signing key" \
|
||||||
|
"(see docs/security.md); if it is meant to be unsigned, re-run with --no-sign."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
task_id="$(jq -r '.ID // empty' <<<"$body")"
|
||||||
|
if ! wait_task "$task_id"; then
|
||||||
|
warn "publish refresh task failed — package is in the repo but not yet published, see task output above"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "published successfully"
|
||||||
Executable
+218
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-reconcile — converges a running aptly instance towards a declarative
|
||||||
|
# state.yaml document. Talks ONLY to the REST API, never to the aptly CLI:
|
||||||
|
# `aptly api serve` holds the LevelDB single-writer lock, and a CLI invocation
|
||||||
|
# against the same rootDir would either fail to acquire it or (with -no-lock)
|
||||||
|
# corrupt the database. Safe to run repeatedly (Helm post-install/post-upgrade
|
||||||
|
# hook, a plain Job, or by hand).
|
||||||
|
#
|
||||||
|
# Contract (state.yaml):
|
||||||
|
#
|
||||||
|
# localRepos:
|
||||||
|
# - name: internal-stable
|
||||||
|
# comment: ""
|
||||||
|
# defaultDistribution: stable
|
||||||
|
# defaultComponent: main
|
||||||
|
#
|
||||||
|
# mirrors:
|
||||||
|
# - name: debian-trixie
|
||||||
|
# archiveURL: http://deb.debian.org/debian
|
||||||
|
# distribution: trixie
|
||||||
|
# components: [main, contrib]
|
||||||
|
# architectures: [amd64, arm64]
|
||||||
|
# filter: ""
|
||||||
|
# filterWithDeps: false
|
||||||
|
# downloadSources: false
|
||||||
|
# downloadUdebs: false
|
||||||
|
# downloadInstaller: false
|
||||||
|
# ignoreSignatures: false
|
||||||
|
# keyrings: [] # usually empty: verification uses GNUPGHOME's
|
||||||
|
# # default keyring, already populated by aptly-init
|
||||||
|
# # from aptly.gpgKeys / state.gpgKeys.
|
||||||
|
#
|
||||||
|
# publish:
|
||||||
|
# - name: stable-root # reconcile-local handle, never sent to the API
|
||||||
|
# prefix: "" # "" = repo root
|
||||||
|
# distribution: stable
|
||||||
|
# sourceKind: local # local | snapshot
|
||||||
|
# sources: [{name: internal-stable, component: main}]
|
||||||
|
# architectures: [amd64, arm64]
|
||||||
|
# acquireByHash: true
|
||||||
|
# skipContents: false
|
||||||
|
# skipBz2: false
|
||||||
|
#
|
||||||
|
# Known, deliberate limitation: components[] on an EXISTING mirror cannot be
|
||||||
|
# changed via the API (aptly has no such endpoint) — changing them requires
|
||||||
|
# dropping and recreating the mirror by hand. Everything else in a mirror
|
||||||
|
# definition, and all of a local repo's metadata, IS kept in sync on every run.
|
||||||
|
#
|
||||||
|
# Env:
|
||||||
|
# APTLY_STATE_FILE path to state.yaml (required)
|
||||||
|
# APTLY_FAIL_ON_ERROR "true"/"false" (default: false) — false logs and
|
||||||
|
# continues past a failed item instead of aborting the
|
||||||
|
# whole run (an unreachable upstream mirror must not
|
||||||
|
# break `helm upgrade`)
|
||||||
|
# APTLY_DRY_RUN "true"/"false" (default: false) — print the planned
|
||||||
|
# calls without making them
|
||||||
|
# plus everything lib/common.sh's api()/wait_task() need (APTLY_URL, ...)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
|
||||||
|
source "${SCRIPT_DIR}/lib/common.sh"
|
||||||
|
require_cmd curl jq yq
|
||||||
|
|
||||||
|
: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
|
||||||
|
FAIL_ON_ERROR="${APTLY_FAIL_ON_ERROR:-false}"
|
||||||
|
DRY_RUN="${APTLY_DRY_RUN:-false}"
|
||||||
|
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"
|
||||||
|
|
||||||
|
item_failed=0
|
||||||
|
on_error() {
|
||||||
|
local what="$1"
|
||||||
|
item_failed=1
|
||||||
|
if [[ "$FAIL_ON_ERROR" == "true" ]]; then
|
||||||
|
die "reconcile failed on: ${what}"
|
||||||
|
else
|
||||||
|
warn "reconcile step failed, continuing (APTLY_FAIL_ON_ERROR=false): ${what}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_api() {
|
||||||
|
# do_api METHOD PATH JSON_BODY -> prints body, returns curl's exit status
|
||||||
|
local method="$1" path="$2" body="$3"
|
||||||
|
if [[ "$DRY_RUN" == "true" ]]; then
|
||||||
|
log "[dry-run] ${method} ${path} ${body}"
|
||||||
|
printf '{}'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
api "$method" "$path" -H 'Content-Type: application/json' -d "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
api_ready
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local repos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
repo_count="$(jq '(.localRepos // []) | length' <<<"$STATE_JSON")"
|
||||||
|
for ((i=0; i<repo_count; i++)); do
|
||||||
|
repo="$(jq ".localRepos[$i]" <<<"$STATE_JSON")"
|
||||||
|
name="$(jq -r '.name' <<<"$repo")"
|
||||||
|
body="$(jq '{Name: .name, Comment: (.comment // ""), DefaultDistribution: (.defaultDistribution // ""), DefaultComponent: (.defaultComponent // "")}' <<<"$repo")"
|
||||||
|
|
||||||
|
code_body="$(api_status GET "/api/repos/${name}")"
|
||||||
|
code="$(head -1 <<<"$code_body")"
|
||||||
|
if [[ "$code" == "404" ]]; then
|
||||||
|
log "creating local repo '${name}'"
|
||||||
|
do_api POST "/api/repos" "$body" >/dev/null || on_error "create local repo ${name}"
|
||||||
|
else
|
||||||
|
log "syncing local repo '${name}' metadata"
|
||||||
|
do_api PUT "/api/repos/${name}" "$body" >/dev/null || on_error "edit local repo ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mirrors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
|
||||||
|
for ((i=0; i<mirror_count; i++)); do
|
||||||
|
m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
|
||||||
|
name="$(jq -r '.name' <<<"$m")"
|
||||||
|
|
||||||
|
code_body="$(api_status GET "/api/mirrors/${name}")"
|
||||||
|
code="$(head -1 <<<"$code_body")"
|
||||||
|
if [[ "$code" == "404" ]]; then
|
||||||
|
body="$(jq '{
|
||||||
|
Name: .name, ArchiveURL: .archiveURL, Distribution: .distribution,
|
||||||
|
Filter: (.filter // ""), Components: (.components // []),
|
||||||
|
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
|
||||||
|
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
|
||||||
|
DownloadInstaller: (.downloadInstaller // false), DownloadAppStream: (.downloadAppStream // false),
|
||||||
|
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
|
||||||
|
}' <<<"$m")"
|
||||||
|
log "creating mirror '${name}'"
|
||||||
|
do_api POST "/api/mirrors" "$body" >/dev/null || on_error "create mirror ${name}"
|
||||||
|
else
|
||||||
|
body="$(jq '{
|
||||||
|
ArchiveURL: .archiveURL, Filter: (.filter // ""),
|
||||||
|
Architectures: (.architectures // []), Keyrings: (.keyrings // []),
|
||||||
|
DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
|
||||||
|
DownloadInstaller: (.downloadInstaller // false),
|
||||||
|
FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
|
||||||
|
}' <<<"$m")"
|
||||||
|
log "syncing mirror '${name}' definition (note: components[] cannot be changed post-creation)"
|
||||||
|
do_api POST "/api/mirrors/${name}" "$body" >/dev/null || on_error "edit mirror ${name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Publish targets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
existing_publishes="$(api GET "/api/publish")"
|
||||||
|
|
||||||
|
publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
|
||||||
|
for ((i=0; i<publish_count; i++)); do
|
||||||
|
p="$(jq ".publish[$i]" <<<"$STATE_JSON")"
|
||||||
|
handle="$(jq -r '.name' <<<"$p")"
|
||||||
|
prefix="$(jq -r '.prefix // ""' <<<"$p")"
|
||||||
|
dist="$(jq -r '.distribution' <<<"$p")"
|
||||||
|
escaped_prefix="$(api_prefix "$prefix")"
|
||||||
|
|
||||||
|
# GET /api/publish returns the root prefix as the literal string "." (not
|
||||||
|
# ""), matching aptly's own storage convention — confirmed empirically, see
|
||||||
|
# docs/packaging.md. Nested prefixes are returned with real slashes, not
|
||||||
|
# underscore-escaped, so only the root case needs translating here.
|
||||||
|
prefix_for_compare="$prefix"
|
||||||
|
[[ -z "$prefix_for_compare" ]] && prefix_for_compare="."
|
||||||
|
|
||||||
|
found="$(jq --arg prefix "$prefix_for_compare" --arg dist "$dist" \
|
||||||
|
'[.[] | select(.Prefix == $prefix and .Distribution == $dist)] | length > 0' \
|
||||||
|
<<<"$existing_publishes")"
|
||||||
|
|
||||||
|
sources="$(jq '[.sources[] | {Component: .component, Name: .name}]' <<<"$p")"
|
||||||
|
signing="$(jq -n --slurpfile sj <(cat "${APTLY_SIGNING_JSON:-/run/aptly/signing.json}" 2>/dev/null || echo '{"skip":true}') '
|
||||||
|
($sj[0] // {skip:true}) as $s
|
||||||
|
| if $s.skip then {Skip: true}
|
||||||
|
else {Skip: false, GpgKey: ($s.gpgKey // ""), PassphraseFile: ($s.passphraseFile // "")} end')"
|
||||||
|
|
||||||
|
if [[ "$found" != "true" ]]; then
|
||||||
|
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
|
||||||
|
{
|
||||||
|
SourceKind: ($p.sourceKind // "local"),
|
||||||
|
Sources: $sources,
|
||||||
|
Distribution: $p.distribution,
|
||||||
|
Architectures: ($p.architectures // []),
|
||||||
|
AcquireByHash: ($p.acquireByHash // false),
|
||||||
|
SkipContents: ($p.skipContents // false),
|
||||||
|
SkipBz2: ($p.skipBz2 // false),
|
||||||
|
Signing: $signing
|
||||||
|
}')"
|
||||||
|
log "creating publish '${handle}' at prefix='${prefix}' distribution='${dist}'"
|
||||||
|
do_api POST "/api/publish/${escaped_prefix}" "$body" >/dev/null || on_error "create publish ${handle}"
|
||||||
|
else
|
||||||
|
kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
|
||||||
|
if [[ "$kind" == "local" ]]; then
|
||||||
|
body="$(jq -n --argjson signing "$signing" --argjson p "$p" '
|
||||||
|
{ Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
|
||||||
|
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
|
||||||
|
log "refreshing publish '${handle}' (local repo -> re-publish pending changes)"
|
||||||
|
resp="$(do_api POST "/api/publish/${escaped_prefix}/${dist}/update" "$body")" || { on_error "update publish ${handle}"; continue; }
|
||||||
|
else
|
||||||
|
body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
|
||||||
|
{ Snapshots: $sources, Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
|
||||||
|
SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
|
||||||
|
log "switching publish '${handle}' to configured snapshot(s)"
|
||||||
|
resp="$(do_api PUT "/api/publish/${escaped_prefix}/${dist}" "$body")" || { on_error "switch publish ${handle}"; continue; }
|
||||||
|
fi
|
||||||
|
if [[ "$DRY_RUN" != "true" ]]; then
|
||||||
|
task_id="$(jq -r '.ID // empty' <<<"$resp")"
|
||||||
|
[[ -n "$task_id" ]] && { wait_task "$task_id" || on_error "task for publish ${handle}"; }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$item_failed" -eq 1 ]]; then
|
||||||
|
log "completed with warnings (APTLY_FAIL_ON_ERROR=false) — see above"
|
||||||
|
else
|
||||||
|
log "reconcile completed successfully"
|
||||||
|
fi
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# aptly-release — aptly-pack + aptly-push in one call. The whole point of the
|
||||||
|
# deb-builder image: one command turns a source tree into a package on the
|
||||||
|
# repo, whichever of the four security presets is in effect.
|
||||||
|
#
|
||||||
|
# Usage: aptly-release [aptly-pack flags] -- [aptly-push flags]
|
||||||
|
# Anything before "--" goes to aptly-pack; anything after goes to aptly-push
|
||||||
|
# (files are appended automatically, do not pass them yourself).
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# aptly-release --config nfpm.yaml -- --repo stable --distribution stable
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
log() { printf '[aptly-release] %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
pack_args=()
|
||||||
|
push_args=()
|
||||||
|
target=pack_args
|
||||||
|
for a in "$@"; do
|
||||||
|
if [[ "$a" == "--" && "$target" == "pack_args" ]]; then
|
||||||
|
target=push_args
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$target" == "pack_args" ]]; then pack_args+=("$a"); else push_args+=("$a"); fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "packaging..."
|
||||||
|
mapfile -t debs < <("${SCRIPT_DIR}/aptly-pack" "${pack_args[@]}")
|
||||||
|
[[ "${#debs[@]}" -gt 0 ]] || { log "aptly-pack produced no output"; exit 1; }
|
||||||
|
for d in "${debs[@]}"; do log " built: ${d}"; done
|
||||||
|
|
||||||
|
log "pushing..."
|
||||||
|
exec "${SCRIPT_DIR}/aptly-push" "${push_args[@]}" "${debs[@]}"
|
||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for aptly-init, aptly-reconcile, aptly-mirror-refresh, aptly-push.
|
||||||
|
# Sourced, not executed: no shebang execution, no `set` calls here (callers set their own).
|
||||||
|
|
||||||
|
log() { printf '[%s] %s\n' "$(basename "${0:-common.sh}")" "$*" >&2; }
|
||||||
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
warn() { log "WARN: $*"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aptly REST API client
|
||||||
|
#
|
||||||
|
# Auth resolution order (first match wins):
|
||||||
|
# APTLY_TOKEN -> Authorization: Bearer <token>
|
||||||
|
# APTLY_USER + APTLY_PASSWORD -> HTTP Basic
|
||||||
|
# neither -> no Authorization header (open / read-only repo)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
: "${APTLY_URL:?APTLY_URL must be set, e.g. http://aptly:8080 or https://apt.example.com}"
|
||||||
|
APTLY_URL="${APTLY_URL%/}"
|
||||||
|
APTLY_CURL_RETRIES="${APTLY_CURL_RETRIES:-5}"
|
||||||
|
APTLY_CURL_RETRY_DELAY="${APTLY_CURL_RETRY_DELAY:-3}"
|
||||||
|
|
||||||
|
_auth_args=()
|
||||||
|
if [[ -n "${APTLY_TOKEN:-}" ]]; then
|
||||||
|
_auth_args=(-H "Authorization: Bearer ${APTLY_TOKEN}")
|
||||||
|
elif [[ -n "${APTLY_USER:-}" && -n "${APTLY_PASSWORD:-}" ]]; then
|
||||||
|
_auth_args=(-u "${APTLY_USER}:${APTLY_PASSWORD}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# api METHOD PATH [curl-data-args...]
|
||||||
|
# PATH must start with /api/... Prints the response body on stdout.
|
||||||
|
# Non-2xx is a hard failure (die) — callers that need to inspect the status
|
||||||
|
# themselves should use api_status instead.
|
||||||
|
#
|
||||||
|
# --path-as-is is not optional: aptly's root-publish-prefix convention is the
|
||||||
|
# literal path segment ".", and curl's URL parser applies RFC 3986 dot-segment
|
||||||
|
# removal by default — silently rewriting ".../publish/." to ".../publish/"
|
||||||
|
# and ".../publish/./stable" to ".../publish/stable" before the request is
|
||||||
|
# even sent, which then 404s or hits the wrong (prefix-less) route.
|
||||||
|
api() {
|
||||||
|
local method="$1" path="$2"; shift 2
|
||||||
|
local out
|
||||||
|
# Deliberately no --retry-all-errors: that would also retry a deterministic
|
||||||
|
# 401 (wrong credentials) or 404 several times before giving up, turning an
|
||||||
|
# instant "wrong password" into a ~15s hang. Plain --retry already covers
|
||||||
|
# the transient cases that matter (connection errors, 429, 5xx).
|
||||||
|
out="$(curl -fsS --path-as-is --retry "$APTLY_CURL_RETRIES" --retry-delay "$APTLY_CURL_RETRY_DELAY" \
|
||||||
|
-X "$method" "${_auth_args[@]}" "${APTLY_URL}${path}" "$@")" \
|
||||||
|
|| die "API call failed: ${method} ${path}"
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# api_status METHOD PATH [curl-data-args...]
|
||||||
|
# Prints "HTTPSTATUS<newline>BODY". Never dies on non-2xx — caller decides.
|
||||||
|
api_status() {
|
||||||
|
local method="$1" path="$2"; shift 2
|
||||||
|
curl -sS --path-as-is -o /tmp/api_status.body -w '%{http_code}' -X "$method" \
|
||||||
|
"${_auth_args[@]}" "${APTLY_URL}${path}" "$@" > /tmp/api_status.code \
|
||||||
|
|| die "API call errored (connection): ${method} ${path}"
|
||||||
|
printf '%s\n' "$(cat /tmp/api_status.code)"
|
||||||
|
cat /tmp/api_status.body
|
||||||
|
}
|
||||||
|
|
||||||
|
# api_ready: block until /api/ready answers 200, or die after timeout.
|
||||||
|
api_ready() {
|
||||||
|
local timeout="${APTLY_WAIT_TIMEOUT:-600}" waited=0
|
||||||
|
log "waiting for ${APTLY_URL}/api/ready (timeout ${timeout}s)"
|
||||||
|
until curl -fsS -o /dev/null "${APTLY_URL}/api/ready"; do
|
||||||
|
(( waited >= timeout )) && die "aptly not ready after ${timeout}s"
|
||||||
|
sleep 3
|
||||||
|
waited=$(( waited + 3 ))
|
||||||
|
done
|
||||||
|
log "aptly is ready (waited ${waited}s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_task TASK_ID: poll /api/tasks/:id until State is 2 (succeeded) or 3 (failed).
|
||||||
|
# State enum per aptly's task package: 0 init, 1 running, 2 succeeded, 3 failed.
|
||||||
|
wait_task() {
|
||||||
|
local id="$1" timeout="${APTLY_TASK_TIMEOUT:-3600}" waited=0 state
|
||||||
|
[[ -z "$id" || "$id" == "null" ]] && return 0
|
||||||
|
while true; do
|
||||||
|
state="$(api GET "/api/tasks/${id}" | jq -r '.State')"
|
||||||
|
case "$state" in
|
||||||
|
2) return 0 ;;
|
||||||
|
3)
|
||||||
|
log "task ${id} failed, output:"
|
||||||
|
api GET "/api/tasks/${id}/output" >&2 || true
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
(( waited >= timeout )) && die "task ${id} did not finish within ${timeout}s (last state: ${state})"
|
||||||
|
sleep 2
|
||||||
|
waited=$(( waited + 2 ))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# api_prefix PREFIX: map a human-readable publish prefix to aptly's URL-escaped form.
|
||||||
|
# "" -> "." "debian/stable" -> "debian_stable"
|
||||||
|
# See aptly's api/publish.go: ParsePrefix / EscapePrefix semantics.
|
||||||
|
api_prefix() {
|
||||||
|
local p="$1"
|
||||||
|
if [[ -z "$p" ]]; then
|
||||||
|
printf '.'
|
||||||
|
else
|
||||||
|
printf '%s' "${p//\//_}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
for c in "$@"; do
|
||||||
|
command -v "$c" >/dev/null 2>&1 || die "required command not found: $c"
|
||||||
|
done
|
||||||
|
}
|
||||||
+12
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hello from aptly-containerized
|
||||||
Executable
+82
@@ -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"
|
||||||
Reference in New Issue
Block a user