Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines
CI / lint (push) Failing after 24s
CI / smoke-test (push) Failing after 2m4s
Release image / release (push) Successful in 23m18s
Release chart / release (push) Successful in 7s

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

- images/: aptly-server (aptly built from source, cross-compiled) and
  aptly-deb-builder (nfpm + dpkg-buildpackage) container images
- rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack
  scripts consumed identically by Compose and the Helm chart, driven
  by one declarative state.yaml contract
- compose/: test (ephemeral, open) and production docker-compose
  stacks with an nginx read/auth sidecar
- charts/aptly/: aptly-native Helm chart covering every security
  posture from fully open to authenticated read+write, Ingress and
  Gateway API support (usable in parallel for migration scenarios),
  metrics, and declarative repo/mirror/publish reconciliation via a
  Helm hook
- .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test)
  plus separately tagged image (image/v*) and chart (chart/v*)
  releases, weekly rebuilds, and a preflight workflow validating the
  runner's Docker/Helm-OCI capabilities
- pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's
  signKey annotation (Helm can't verify Ed25519 keys)
- docs/, README.md, charts/aptly/README.md: usage, security, and
  versioning documentation
This commit is contained in:
2026-08-12 12:21:08 +02:00
commit 103ad311b7
71 changed files with 4843 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $sec := include "aptly.security" . | fromJson -}}
aptly ({{ .Chart.AppVersion }}, chart {{ .Chart.Version }}) is deploying as {{ $fullname }}-0 in {{ .Release.Namespace }}.
{{- if eq .Values.security.preset "open" }}
*** security.preset: open ***
Both reading AND writing (the mutating /api/ path) are reachable with NO
authentication from anything that can reach the Service — this is exactly
the "komplett unabgesichert" mode, working as configured. Nothing further
to set up; just make sure this is really what you want before exposing it
beyond your own network.
{{- end }}
--- Check it's up -------------------------------------------------------
kubectl exec -n {{ .Release.Namespace }} {{ $fullname }}-0 -c aptly -- \
curl -fsS http://127.0.0.1:8080/api/ready
kubectl logs -n {{ .Release.Namespace }} job/{{ $fullname }}-reconcile
# (only present right after install/upgrade in `hook` mode)
--- Reach it -------------------------------------------------------------
{{- if .Values.ingress.enabled }}
{{- with .Values.ingress.repo.host }}
https://{{ . }}/ (Ingress)
{{- end }}
{{- end }}
{{- if .Values.gateway.enabled }}
{{- range .Values.gateway.repo.hostnames }}
https://{{ . }}/ (Gateway API)
{{- end }}
{{- end }}
{{- if not (or .Values.ingress.enabled .Values.gateway.enabled) }}
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ $fullname }} 8080:{{ .Values.service.port }}
# then use http://127.0.0.1:8080/ below
{{- end }}
--- Configure apt on a client ---------------------------------------------
{{- $host := "apt.example.com" }}
{{- if and .Values.ingress.enabled .Values.ingress.repo.host }}
{{- $host = .Values.ingress.repo.host }}
{{- else if and .Values.gateway.enabled .Values.gateway.repo.hostnames }}
{{- $host = first .Values.gateway.repo.hostnames }}
{{- end }}
{{- if .Values.aptly.gpg.enabled }}
{{- if .Values.aptly.gpg.publishPublicKey.enabled }}
curl -fsSL https://{{ $host }}{{ .Values.aptly.gpg.publishPublicKey.path }} \
| gpg --dearmor | sudo tee /usr/share/keyrings/{{ include "aptly.name" . }}.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/{{ include "aptly.name" . }}.gpg] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- else }}
aptly.gpg.enabled=true but aptly.gpg.publishPublicKey.enabled=false — the
signing key is not being served; distribute it to clients yourself.
echo 'deb [signed-by=/path/to/your-key.gpg] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- end }}
{{- else }}
echo 'deb [trusted=yes] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- end }}
{{- if $sec.ra }}
Reads require credentials in this preset ({{ .Values.security.preset }}):
echo 'machine {{ $host }} login <user> password <password>' \
| sudo tee -a /etc/apt/auth.conf.d/{{ include "aptly.name" . }}.conf
{{- end }}
--- Resizing storage later -------------------------------------------------
persistence.size on an already-installed StatefulSet is IMMUTABLE via
`helm upgrade` (Kubernetes forbids changing volumeClaimTemplates in place).
For production, set persistence.existingClaim to a PVC you manage yourself
— resizing that is a plain PVC edit. See docs/operations.md for the
recovery procedure if you need to resize a chart-managed PVC anyway.
{{- if and .Values.podDisruptionBudget.enabled (le (int .Values.podDisruptionBudget.maxUnavailable) 0) }}
*** podDisruptionBudget.maxUnavailable is 0 with replicas=1 — this blocks
every voluntary node drain forever. ***
{{- end }}
+263
View File
@@ -0,0 +1,263 @@
{{/*
Standard name/label helpers, bookstack-chart style.
*/}}
{{- define "aptly.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "aptly.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "aptly.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "aptly.labels" -}}
helm.sh/chart: {{ include "aptly.chart" . }}
{{ include "aptly.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "aptly.selectorLabels" -}}
app.kubernetes.io/name: {{ include "aptly.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{/*
Image helpers global.imageRegistry prefixes the repository when set,
matching the bookstack-chart convention.
*/}}
{{- define "aptly.image" -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $repo := .Values.image.repository -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion -}}
{{- if $registry -}}
{{- printf "%s/%s:%s" $registry $repo $tag -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag -}}
{{- end -}}
{{- end -}}
{{- define "aptly.nginxImage" -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $repo := .Values.nginx.image.repository -}}
{{- $tag := .Values.nginx.image.tag -}}
{{- if $registry -}}
{{- printf "%s/%s:%s" $registry $repo $tag -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag -}}
{{- end -}}
{{- end -}}
{{/*
Shared by templates/ingress.yaml and templates/httproute.yaml: proxy.enabled=false
means aptly's unauthenticated write API sits directly behind whatever routes to
it, on a single listener with no path-based auth split possible — refuse to wire
that up to an Ingress OR a Gateway API HTTPRoute unless security.preset=open
confirms it's intended.
*/}}
{{- define "aptly.exposureGuard" -}}
{{- if and (not .Values.proxy.enabled) (or .Values.ingress.enabled .Values.gateway.enabled) (ne .Values.security.preset "open") -}}
{{- fail "proxy.enabled=false publishes aptly's unauthenticated write API through the Ingress/HTTPRoute (no path-based auth split is possible on a single listener). Set security.preset=open to confirm this is intended, or keep proxy.enabled=true." -}}
{{- end -}}
{{- end -}}
{{- define "aptly.imagePullSecrets" -}}
{{- $secrets := concat (.Values.global.imagePullSecrets | default list) (.Values.image.pullSecrets | default list) -}}
{{- if $secrets }}
imagePullSecrets:
{{- range $secrets }}
- name: {{ if kindIs "map" . }}{{ .name }}{{ else }}{{ . }}{{ end }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
Whether aptly serves the published tree itself (no nginx proxy in front).
Only sane in combination with security.preset=open enforced in
templates/statefulset.yaml (the aptly config) and templates/ingress.yaml
(the hard `fail` guard).
*/}}
{{- define "aptly.serveInApiMode" -}}
{{- if .Values.proxy.enabled -}}false{{- else -}}true{{- end -}}
{{- end -}}
{{- define "aptly.apiListen" -}}
{{- if .Values.proxy.enabled -}}127.0.0.1:8080{{- else -}}0.0.0.0:8080{{- end -}}
{{- end -}}
{{/*
Resolve security.preset + explicit read/write overrides into a plain dict
{r, ra, w, wa} (read-enabled, read-auth, write-enabled, write-auth). Both
nginx.conf.tpl and templates/ingress.yaml key off this so preset expansion
lives in exactly one place.
*/}}
{{- define "aptly.security" -}}
{{- $p := .Values.security.preset -}}
{{- $presets := dict
"open" (dict "r" true "ra" false "w" true "wa" false)
"publicRead" (dict "r" true "ra" false "w" true "wa" true)
"authenticated" (dict "r" true "ra" true "w" true "wa" true)
"readOnly" (dict "r" true "ra" false "w" false "wa" false)
-}}
{{- $base := index $presets $p -}}
{{- if not $base -}}
{{- fail (printf "security.preset: unknown value %q (must be one of open, publicRead, authenticated, readOnly)" $p) -}}
{{- end -}}
{{- $d := deepCopy $base -}}
{{- if kindIs "bool" .Values.security.read.enabled }}{{- $_ := set $d "r" .Values.security.read.enabled -}}{{- end -}}
{{- if kindIs "bool" .Values.security.read.requireAuth }}{{- $_ := set $d "ra" .Values.security.read.requireAuth -}}{{- end -}}
{{- if kindIs "bool" .Values.security.write.enabled }}{{- $_ := set $d "w" .Values.security.write.enabled -}}{{- end -}}
{{- if kindIs "bool" .Values.security.write.requireAuth }}{{- $_ := set $d "wa" .Values.security.write.requireAuth -}}{{- end -}}
{{- $d | toJson -}}
{{- end -}}
{{/*
Render the aptly config (YAML) from the curated values.aptly.* keys, then
deep-merge aptly.configOverrides on top so every current/future aptly config
key stays reachable without a chart change. Curated keys are OMITTED when
unset, so a default install matches aptly's own upstream defaults exactly
(see utils/config.go in aptly-dev/aptly for the canonical defaults).
*/}}
{{- define "aptly.config" -}}
{{- $v := .Values.aptly -}}
{{- $c := dict "root_dir" "/var/lib/aptly" -}}
{{- with $v.logLevel }}{{- $_ := set $c "log_level" . -}}{{- end -}}
{{- with $v.logFormat }}{{- $_ := set $c "log_format" . -}}{{- end -}}
{{- if $v.architectures }}{{- $_ := set $c "architectures" $v.architectures -}}{{- end -}}
{{- with $v.download.concurrency }}{{- $_ := set $c "download_concurrency" . -}}{{- end -}}
{{- with $v.download.limit }}{{- $_ := set $c "download_limit" . -}}{{- end -}}
{{- with $v.download.retries }}{{- $_ := set $c "download_retries" . -}}{{- end -}}
{{- if kindIs "bool" $v.download.sourcePackages }}{{- $_ := set $c "download_sourcepackages" $v.download.sourcePackages -}}{{- end -}}
{{- $_ := set $c "gpg_provider" ($v.gpg.provider | default "gpg") -}}
{{- $_ := set $c "gpg_disable_sign" (not $v.gpg.enabled) -}}
{{- $_ := set $c "gpg_disable_verify" (not $v.gpg.verify) -}}
{{- if kindIs "bool" $v.publishing.skipContents }}{{- $_ := set $c "skip_contents_publishing" $v.publishing.skipContents -}}{{- end -}}
{{- if kindIs "bool" $v.publishing.skipBz2 }}{{- $_ := set $c "skip_bz2_publishing" $v.publishing.skipBz2 -}}{{- end -}}
{{- $_ := set $c "enable_metrics_endpoint" ($v.metrics.enabled | default false) -}}
{{- $_ := set $c "enable_swagger_endpoint" ($v.swagger.enabled | default false) -}}
{{- $_ := set $c "serve_in_api_mode" (eq (include "aptly.serveInApiMode" .) "true") -}}
{{- $_ := set $c "filesystem_publish_endpoints" (dict $.Values.proxy.publishEndpointName (dict "root_dir" "/var/lib/aptly/public" "link_method" "hardlink")) -}}
{{- $merged := mergeOverwrite $c (deepCopy ($v.configOverrides | default dict)) -}}
{{- toYaml $merged -}}
{{- end -}}
{{/*
Render nginx's server{} block (mounted at /etc/nginx/conf.d/default.conf,
which the base image's own nginx.conf already includes from inside its own
http{} block — this template must therefore emit ONLY a server{} block, see
compose/config/nginx.*.conf for the same constraint hit empirically).
*/}}
{{- define "aptly.nginxConf" -}}
{{- $sec := include "aptly.security" . | fromJson -}}
{{- $p := .Values.proxy -}}
server {
listen 8080;
server_name _;
client_max_body_size {{ $p.maxUploadSize }};
absolute_redirect off;
{{- range .Values.security.trustedProxies }}
set_real_ip_from {{ . }};
{{- end }}
{{- if .Values.security.trustedProxies }}
real_ip_header X-Forwarded-For;
real_ip_recursive on;
{{- end }}
location = /healthz { access_log off; return 200 "ok\n"; }
location = /api/ready { access_log off; proxy_pass http://127.0.0.1:8080; }
location = /api/healthy { access_log off; proxy_pass http://127.0.0.1:8080; }
{{- if $sec.w }}
location /api/ {
{{- if .Values.security.write.allowCIDRs }}
{{- if and .Values.security.write.allowCIDRs (not .Values.security.trustedProxies) }}
# WARNING: write.allowCIDRs is set without security.trustedProxies. Behind
# an Ingress controller, $remote_addr is the CONTROLLER's pod IP, not the
# real client this will match every client on earth. Set
# trustedProxies to the controller's CIDR, or use networkPolicy instead.
{{- end }}
{{- range .Values.security.write.allowCIDRs }}
allow {{ . }};
{{- end }}
deny all;
satisfy {{ if $sec.wa }}any{{ else }}all{{ end }};
{{- end }}
{{- if $sec.wa }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- else }}
auth_basic off;
{{- end }}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_request_buffering off;
proxy_read_timeout {{ .Values.proxy.readTimeout }};
proxy_send_timeout {{ .Values.proxy.readTimeout }};
}
{{- else }}
location /api/ { return 404; }
{{- end }}
location = /signing-key.asc {
alias /run/aptly/pub/signing-key.asc;
default_type text/plain;
}
{{- if $sec.r }}
location / {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
root /var/lib/aptly/public;
autoindex on;
autoindex_exact_size off;
location ~* /(InRelease|Release|Release\.gpg|Packages(\.[a-z0-9]+)?|Sources(\.[a-z0-9]+)?)$ {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
root /var/lib/aptly/public;
add_header Cache-Control "no-cache" always;
}
}
{{- if .Values.proxy.compatPaths }}
location /repos/{{ .Values.proxy.publishEndpointName }}/ {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
alias /var/lib/aptly/public/;
autoindex on;
}
{{- end }}
{{- else }}
location / { return 404; }
{{- end }}
}
{{- if .Values.metrics.service.enabled }}
# Separate, unauthenticated listener so scraping never needs the write-path
# credentials and a ServiceMonitor never needs a basicAuth secret.
server {
listen 9090;
server_name _;
location = /api/metrics { proxy_pass http://127.0.0.1:8080; }
location / { return 404; }
}
{{- end }}
{{- end -}}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-config
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
aptly.yaml: |
{{- include "aptly.config" . | nindent 4 }}
@@ -0,0 +1,13 @@
{{- if .Values.aptly.gpgKeys }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-gpg-keys
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
{{- range .Values.aptly.gpgKeys }}
{{ .name }}.asc: |
{{- .armored | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-nginx
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
default.conf: |
{{- include "aptly.nginxConf" . | nindent 4 }}
@@ -0,0 +1,11 @@
{{- if .Values.reconcile.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-state
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
state.yaml: |
{{- dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | nindent 4 }}
{{- end }}
+65
View File
@@ -0,0 +1,65 @@
{{- include "aptly.exposureGuard" . -}}
{{- if .Values.gateway.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $svcName := $fullname -}}
{{- if not .Values.gateway.parentRefs }}
{{- fail "gateway.enabled=true requires gateway.parentRefs to reference at least one existing Gateway — this chart never creates a Gateway itself." -}}
{{- end }}
---
# Repo HTTPRoute — apt clients. In `single` mode this is also where /api/
# traffic arrives; nginx does the read/write split internally (see
# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this
# is a route-level split only: nginx does not itself reject /api/ requests
# that arrive via this hostname, so combine `split` mode with
# security.write.inClusterOnly or a NetworkPolicy if you need that enforced.
# TLS is configured on the referenced Gateway's listener, not here.
apiVersion: {{ .Values.gateway.apiVersion }}
kind: HTTPRoute
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
parentRefs:
{{- toYaml .Values.gateway.parentRefs | nindent 4 }}
{{- with .Values.gateway.repo.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- matches:
- path:
type: {{ .Values.gateway.repo.pathType }}
value: {{ .Values.gateway.repo.path }}
backendRefs:
- name: {{ $svcName }}
port: {{ .Values.service.port }}
{{- if and (eq .Values.gateway.mode "split") .Values.gateway.api.enabled (not .Values.security.write.inClusterOnly) }}
---
# API HTTPRoute (split mode) — a separate hostname/Gateway so you can put a
# different Gateway, mTLS, or WAF policy in front of the mutating API than
# the public read path gets.
apiVersion: {{ .Values.gateway.apiVersion }}
kind: HTTPRoute
metadata:
name: {{ $fullname }}-api
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
{{- $apiParentRefs := .Values.gateway.api.parentRefs | default .Values.gateway.parentRefs }}
parentRefs:
{{- toYaml $apiParentRefs | nindent 4 }}
{{- with .Values.gateway.api.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $svcName }}
port: {{ .Values.service.port }}
{{- end }}
{{- end }}
+77
View File
@@ -0,0 +1,77 @@
{{- include "aptly.exposureGuard" . -}}
{{- if .Values.ingress.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $svcName := $fullname -}}
---
# Repo Ingress — apt clients. In `single` mode this is also where /api/
# traffic arrives; nginx does the read/write split internally (see
# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this
# is a DNS/Ingress-level split only: nginx does not itself reject /api/
# requests that arrive via this host, so combine `split` mode with
# security.write.inClusterOnly or a NetworkPolicy if you need that enforced.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.repo.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- {{- with .Values.ingress.repo.host }}
host: {{ . | quote }}
{{- end }}
http:
paths:
- path: {{ .Values.ingress.repo.path }}
pathType: {{ .Values.ingress.repo.pathType }}
backend:
service:
name: {{ $svcName }}
port:
name: http
{{- if and (eq .Values.ingress.mode "split") .Values.ingress.api.enabled (not .Values.security.write.inClusterOnly) }}
---
# API Ingress (split mode) — a separate host so you can put a different
# ingressClass, mTLS, or WAF policy in front of the mutating API than the
# public read path gets.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullname }}-api
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
{{- toYaml (merge .Values.ingress.api.annotations .Values.ingress.annotations) | nindent 4 }}
spec:
{{- $apiClass := .Values.ingress.api.className | default .Values.ingress.className }}
{{- with $apiClass }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.api.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- {{- with .Values.ingress.api.host }}
host: {{ . | quote }}
{{- end }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $svcName }}
port:
name: http
{{- end }}
{{- end }}
+80
View File
@@ -0,0 +1,80 @@
{{- if .Values.reconcile.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $img := .Values.reconcile.image -}}
{{- $repo := $img.repository | default .Values.image.repository -}}
{{- $tag := $img.tag | default .Values.image.tag | default .Chart.AppVersion -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $image := ternary (printf "%s/%s:%s" $registry $repo $tag) (printf "%s:%s" $repo $tag) (ne $registry "") -}}
apiVersion: batch/v1
kind: Job
metadata:
{{- if eq .Values.reconcile.mode "hook" }}
name: {{ $fullname }}-reconcile
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "5"
# Deliberately no hook-failed: a failed Job stays around for `kubectl
# logs`/`kubectl describe job` instead of vanishing before anyone can
# read why reconciliation didn't converge.
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
{{- else }}
# A plain (non-hook) Job named after the desired state's hash, for GitOps
# controllers (ArgoCD/Flux) that reconcile hooks poorly: it only re-runs
# when aptly.{gpgKeys,localRepos,mirrors,publish} actually change.
name: {{ $fullname }}-reconcile-{{ dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | sha256sum | trunc 8 }}
{{- end }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
backoffLimit: 3
activeDeadlineSeconds: {{ mul .Values.reconcile.timeoutSeconds 2 }}
{{- if ne .Values.reconcile.mode "hook" }}
ttlSecondsAfterFinished: 86400
{{- end }}
template:
metadata:
labels:
{{- include "aptly.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: reconcile
spec:
restartPolicy: Never
{{- include "aptly.imagePullSecrets" . | nindent 6 }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: reconcile
image: {{ $image }}
imagePullPolicy: {{ $img.pullPolicy | default .Values.image.pullPolicy }}
command: ["/usr/local/bin/aptly-reconcile"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_URL
value: "http://{{ $fullname }}:{{ .Values.service.port }}"
- name: APTLY_STATE_FILE
value: /state.yaml
- name: APTLY_FAIL_ON_ERROR
value: {{ .Values.reconcile.failOnError | quote }}
- name: APTLY_WAIT_TIMEOUT
value: {{ .Values.reconcile.timeoutSeconds | quote }}
{{- if .Values.security.auth.internalUser.enabled }}
- name: APTLY_USER
value: {{ .Values.security.auth.internalUser.username | quote }}
- name: APTLY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ $fullname }}-credentials
key: internal-password
{{- end }}
volumeMounts:
- name: state
mountPath: /state.yaml
subPath: state.yaml
readOnly: true
resources:
{{- toYaml .Values.reconcile.resources | nindent 12 }}
volumes:
- name: state
configMap:
name: {{ $fullname }}-state
{{- end }}
+51
View File
@@ -0,0 +1,51 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
{{- if .Values.networkPolicy.allowedNamespaces }}
# Restricted to these namespaces (plus this one). NOTE: this applies to
# the whole nginx:8080 endpoint — read and write share one port, so this
# cannot itself express "reads are public, writes are cluster-only" any
# more precisely than security.write.allowCIDRs can (see the warning
# rendered into nginx.conf for that). Use it to fence the Service off
# from unrelated namespaces, not as a read/write split.
- from:
- podSelector: {}
{{- range .Values.networkPolicy.allowedNamespaces }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . }}
{{- end }}
{{- else }}
- {}
{{- end }}
{{- with .Values.networkPolicy.extraIngress }}
{{- toYaml . | nindent 4 }}
{{- end }}
egress:
{{- if .Values.networkPolicy.egress.allowAll }}
- {}
{{- else }}
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with .Values.networkPolicy.egress.extra }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,16 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
# With replicas fixed at 1 (see templates/statefulset.yaml), a
# minAvailable:1 budget would block every voluntary node drain forever —
# maxUnavailable is the only sane knob here.
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,34 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $secretName := printf "%s-credentials" $fullname -}}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}}
{{- $internalPassword := "" -}}
{{- if and $existing (hasKey $existing.data "internal-password") -}}
{{- $internalPassword = index $existing.data "internal-password" | b64dec -}}
{{- else -}}
{{- $internalPassword = randAlphaNum 32 -}}
{{- end -}}
{{- $userLines := list -}}
{{- range $name, $pass := .Values.security.auth.users -}}
{{- $userLines = append $userLines (printf "%s:%s" $name $pass) -}}
{{- end -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
# Reused across `helm upgrade` (via `lookup` above) and preserved across
# `helm uninstall` so aptly-reconcile keeps working without a manual step
# after a reinstall. Never regenerate this key from a template — see
# rootfs/usr/local/bin/aptly-init for why hashing must not happen here.
helm.sh/resource-policy: keep
type: Opaque
stringData:
internal-password: {{ $internalPassword | quote }}
{{- if $userLines }}
users: |
{{- range $userLines }}
{{ . }}
{{- end }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- if and .Values.aptly.gpg.enabled (not .Values.aptly.gpg.signingKey.existingSecret) (.Values.aptly.gpg.signingKey.privateKey) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "aptly.fullname" . }}-gpg
labels:
{{- include "aptly.labels" . | nindent 4 }}
type: Opaque
stringData:
privateKey: {{ .Values.aptly.gpg.signingKey.privateKey | quote }}
{{- if .Values.aptly.gpg.signingKey.passphrase }}
passphrase: {{ .Values.aptly.gpg.signingKey.passphrase | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.metrics.service.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "aptly.fullname" . }}-metrics
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.metrics.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: ClusterIP
selector:
{{- include "aptly.selectorLabels" . | nindent 4 }}
ports:
- name: metrics
port: {{ .Values.metrics.service.port }}
targetPort: metrics
protocol: TCP
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "aptly.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
@@ -0,0 +1,23 @@
{{- if and .Values.metrics.serviceMonitor.enabled .Values.metrics.service.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.metrics.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
path: /api/metrics
interval: {{ .Values.metrics.serviceMonitor.interval }}
{{- with .Values.metrics.serviceMonitor.relabelings }}
relabelings:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
+298
View File
@@ -0,0 +1,298 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $hasGpgSecret := or .Values.aptly.gpg.signingKey.existingSecret (and (not .Values.aptly.gpg.signingKey.existingSecret) .Values.aptly.gpg.signingKey.privateKey) -}}
{{- $gpgSecretName := .Values.aptly.gpg.signingKey.existingSecret | default (printf "%s-gpg" $fullname) -}}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.workload.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
serviceName: {{ $fullname }}
# LevelDB (aptly's database) takes an exclusive OS-level file lock — two
# writers would corrupt it. A StatefulSet with replicas=1 always terminates
# the old pod before creating its replacement, so RollingUpdate is safe
# here in a way it would not be for a Deployment on a ReadWriteOnce PVC
# (which would deadlock on a Multi-Attach error instead).
replicas: 1
podManagementPolicy: {{ .Values.workload.podManagementPolicy }}
revisionHistoryLimit: {{ .Values.workload.revisionHistoryLimit }}
updateStrategy:
type: {{ .Values.workload.updateStrategy.type }}
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "aptly.selectorLabels" . | nindent 8 }}
{{- with .Values.workload.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations:
checksum/config: {{ include "aptly.config" . | sha256sum }}
checksum/nginx: {{ include "aptly.nginxConf" . | sha256sum }}
{{- with .Values.workload.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- include "aptly.imagePullSecrets" . | nindent 6 }}
terminationGracePeriodSeconds: {{ .Values.workload.terminationGracePeriodSeconds }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
initContainers:
- name: config-init
image: {{ include "aptly.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/usr/local/bin/aptly-init"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_ROOT_DIR
value: /var/lib/aptly
- name: APTLY_CONFIG_SRC
value: /etc/aptly-src/aptly.yaml
- name: APTLY_CONFIG_DST
value: /run/aptly/aptly.yaml
- name: APTLY_GPG_ENABLED
value: {{ .Values.aptly.gpg.enabled | quote }}
{{- if not .Values.security.auth.existingSecret }}
- name: APTLY_USERS_FILE
value: /etc/aptly-secrets/users
{{- else }}
- name: APTLY_HTPASSWD_SRC
value: /etc/aptly-secrets-existing/htpasswd
{{- end }}
{{- if .Values.security.auth.internalUser.enabled }}
- name: APTLY_INTERNAL_USER
value: {{ .Values.security.auth.internalUser.username | quote }}
- name: APTLY_INTERNAL_PASSWORD
valueFrom:
secretKeyRef:
name: {{ $fullname }}-credentials
key: internal-password
{{- end }}
{{- if $hasGpgSecret }}
- name: APTLY_GPG_PRIVATE_KEY_FILE
value: /etc/aptly-gpg/privateKey
- name: APTLY_GPG_SECRET_KEYRING_FILE
value: /etc/aptly-gpg/secretKeyring
- name: APTLY_GPG_PASSPHRASE_FILE
value: /etc/aptly-gpg/passphrase
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: APTLY_GPG_KEYS_DIR
value: /etc/aptly-gpg-keys
{{- end }}
volumeMounts:
- name: config-src
mountPath: /etc/aptly-src
readOnly: true
- name: run
mountPath: /run/aptly
- name: data
mountPath: /var/lib/aptly
{{- if not .Values.security.auth.existingSecret }}
- name: credentials
mountPath: /etc/aptly-secrets
readOnly: true
{{- else }}
- name: credentials-existing
mountPath: /etc/aptly-secrets-existing
readOnly: true
{{- end }}
{{- if $hasGpgSecret }}
- name: gpg-secret
mountPath: /etc/aptly-gpg
readOnly: true
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: gpg-keys
mountPath: /etc/aptly-gpg-keys
readOnly: true
{{- end }}
{{- with .Values.extraInitContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: aptly
image: {{ include "aptly.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_API_LISTEN
value: {{ include "aptly.apiListen" . }}
- name: APTLY_CONFIG
value: /run/aptly/aptly.yaml
- name: GNUPGHOME
value: /run/aptly/gnupg
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if or .Values.aptly.existingSecretEnv .Values.extraEnvFrom }}
envFrom:
{{- range .Values.aptly.existingSecretEnv }}
- secretRef:
name: {{ . }}
{{- end }}
{{- with .Values.extraEnvFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
ports:
- name: aptly
containerPort: 8080
volumeMounts:
- name: data
mountPath: /var/lib/aptly
- name: run
mountPath: /run/aptly
- name: tmp
mountPath: /tmp
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
startupProbe:
httpGet: { path: /api/ready, port: aptly }
periodSeconds: {{ .Values.probes.startup.periodSeconds }}
failureThreshold: {{ .Values.probes.startup.failureThreshold }}
readinessProbe:
httpGet: { path: /api/ready, port: aptly }
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
livenessProbe:
httpGet: { path: /api/healthy, port: aptly }
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
- name: nginx
image: {{ include "aptly.nginxImage" . }}
imagePullPolicy: {{ .Values.nginx.image.pullPolicy }}
securityContext:
{{- toYaml .Values.nginx.securityContext | nindent 12 }}
ports:
- name: http
containerPort: 8080
{{- if .Values.metrics.service.enabled }}
- name: metrics
containerPort: 9090
{{- end }}
volumeMounts:
- name: data
mountPath: /var/lib/aptly/public
subPath: public
readOnly: true
- name: run
mountPath: /run/aptly
readOnly: true
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
- name: tmp
mountPath: /tmp
- name: nginx-cache
mountPath: /var/cache/nginx
readinessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 30
resources:
{{- toYaml .Values.nginx.resources | nindent 12 }}
{{- with .Values.extraContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: config-src
configMap:
name: {{ $fullname }}-config
- name: nginx-config
configMap:
name: {{ $fullname }}-nginx
- name: run
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: tmp
emptyDir: {}
- name: nginx-cache
emptyDir: {}
{{- if not .Values.security.auth.existingSecret }}
- name: credentials
secret:
secretName: {{ $fullname }}-credentials
optional: true
{{- else }}
- name: credentials-existing
secret:
secretName: {{ .Values.security.auth.existingSecret }}
{{- end }}
{{- if $hasGpgSecret }}
- name: gpg-secret
secret:
secretName: {{ $gpgSecretName }}
optional: true
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: gpg-keys
configMap:
name: {{ $fullname }}-gpg-keys
{{- end }}
{{- if not .Values.persistence.enabled }}
- name: data
emptyDir: {}
{{- else if .Values.persistence.existingClaim }}
- name: data
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim }}
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
volumeClaimTemplates:
- metadata:
name: data
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 10 }}
{{- end }}
spec:
accessModes: [{{ .Values.persistence.accessMode }}]
{{- $sc := .Values.persistence.storageClass | default .Values.global.defaultStorageClass }}
{{- if $sc }}
storageClassName: {{ $sc }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}