Files
aptly-containerized/charts/aptly/templates/_helpers.tpl
T
f.weber 103ad311b7
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
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
2026-08-12 12:21:08 +02:00

264 lines
10 KiB
Smarty

{{/*
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 -}}