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
+33
View File
@@ -0,0 +1,33 @@
apiVersion: v2
name: aptly
description: >-
aptly (Debian repository management tool) on Kubernetes — a StatefulSet with
an nginx read/auth sidecar, a fully aptly-native values API, and declarative
repo/mirror/publish state reconciled via a Helm hook.
type: application
version: 0.1.0
appVersion: "1.6.3-1"
home: https://git.morlana.online/f.weber/aptly-containerized
sources:
- https://git.morlana.online/f.weber/aptly-containerized
- https://github.com/aptly-dev/aptly
keywords:
- aptly
- apt
- debian
- package-repository
- artifact-repository
maintainers:
- name: Florian Weber
email: f.weber@flweber.me
icon: https://www.aptly.info/img/aptly_medium.png
annotations:
artifacthub.io/license: MIT
artifacthub.io/links: |
- name: aptly upstream
url: https://www.aptly.info/
- name: Source
url: https://git.morlana.online/f.weber/aptly-containerized
artifacthub.io/signKey: |
fingerprint: FC35C0FAA26605C4C21C7BBFBF43884145E5AA94
url: https://git.morlana.online/f.weber/aptly-containerized/raw/branch/main/pubkeys/chart-signing.asc
+395
View File
@@ -0,0 +1,395 @@
# aptly
[aptly](https://www.aptly.info/) (Debian repository management tool) on Kubernetes:
a single-pod StatefulSet (aptly + an nginx read/auth sidecar), a fully aptly-native
values API, and declarative repo/mirror/publish state reconciled by a Helm hook —
no library-chart dependency, no concepts to learn beyond aptly's and Kubernetes' own.
## TL;DR
```bash
helm install my-aptly oci://git.morlana.online/f.weber/aptly --version <version>
```
## Introduction
This chart deploys [aptly](https://www.aptly.info/) as:
- a `StatefulSet` running two containers in one pod — `aptly` itself (bound to
`127.0.0.1`, never reachable off-pod) and an `nginx` sidecar that is the only
thing actually exposed, handling reads, auth, and the write-API proxy;
- a `Service` in front of `nginx`, and optional `Ingress` and/or Gateway API
`HTTPRoute` resources for apt clients and the API — both can be enabled at the
same time, e.g. mid-migration between the two (see [Gateway API](#gateway-api));
- a post-install/post-upgrade `Job` that reconciles the local repos, mirrors, and
publish targets declared in `values.yaml` against the running instance's REST API.
Three things this chart is built around:
1. **A single security switch that actually reaches every mode**, including
completely open (no auth on read *or* write) if that's what you want — see
[Security modes](#security-modes) below.
2. **An aptly-native config surface**: curated `aptly.*` keys for the common cases,
plus `aptly.configOverrides` as a raw passthrough so any current or future aptly
config key is reachable without waiting on a chart update.
3. **Declarative state**: local repos, mirrors, and publish targets live in
`values.yaml` and are converged towards on every `helm install`/`helm upgrade`,
the same way the rest of the cluster is managed.
## Prerequisites
- Kubernetes 1.24+
- Helm 3.8+ (for OCI registry support) — Helm 4 also works
- A `StorageClass` supporting `ReadWriteOnce`, unless `persistence.enabled: false`
## Installing the chart
```bash
helm install my-aptly oci://git.morlana.online/f.weber/aptly --version <version> \
--set ingress.enabled=true \
--set ingress.repo.host=apt.example.com
```
Without `ingress.enabled`, the post-install NOTES print a `kubectl port-forward`
command instead — nothing has to be configured up front to try the chart out (e.g.
in kind/k3d).
## Uninstalling the chart
```bash
helm uninstall my-aptly
```
The `PersistentVolumeClaim` created by this chart's `volumeClaimTemplate` (i.e.
when `persistence.existingClaim` is unset) is **not** deleted — StatefulSet-owned
PVCs never are, by Kubernetes' own design, regardless of any Helm annotation.
Remove it yourself if you're done with the data — for a release named `my-aptly`,
that PVC is `data-my-aptly-0` (`data-<statefulset-name>-<ordinal>`; find the exact
name with `kubectl get pvc -l app.kubernetes.io/instance=<release>`):
`kubectl delete pvc data-my-aptly-0`.
## Security modes
aptly itself ships with **no authentication at all**; this chart's `security.preset`
switch controls what the nginx sidecar in front of it requires:
| `security.preset` | Read (apt clients) | Read auth | Write (`/api/`) | Write auth |
|---|---|---|---|---|
| `open` | on | **no** | on | **no** |
| `publicRead` (**default**) | on | no | on | yes |
| `authenticated` | on | yes | on | yes |
| `readOnly` | on | no | **off (404)** | — |
```yaml
security:
preset: open # the fully-unsecured mode — one line, no confirmation gate
```
`security.read.*` and `security.write.*` override the preset explicitly, so every
individual cell of the matrix is reachable (e.g. `authenticated` but with anonymous
reads, or `open` restricted to a CIDR on the write path). Health probes
(`/api/ready`, `/api/healthy`) never require credentials, in any mode.
Credentials come from `security.auth.users` (plaintext, hashed into an
`htpasswd` file by the initContainer at pod start — never store a pre-hashed
password here, see the comment in `values.yaml`) or from
`security.auth.existingSecret` (a pre-built `htpasswd` Secret key — the recommended
production path, e.g. via ExternalSecrets/SealedSecrets).
Full write-up of the Ingress `split` mode, the `trustedProxies`/`allowCIDRs`
pitfall behind an Ingress controller, and `write.inClusterOnly`:
[docs/security.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/docs/security.md)
in the repository.
## Gateway API
`gateway.*` is a complete, independent alternative to `ingress.*` — enable either
one, or **both at once**. Nothing about this chart forces a choice, on purpose:
if you're partway through migrating a cluster from Ingress to Gateway API, both
resource sets can point at the same `Service` for as long as that takes.
```yaml
gateway:
enabled: true
parentRefs:
- name: my-gateway # a Gateway your cluster admin already manages —
namespace: gateway-infra # this chart never creates one itself
repo:
hostnames: [apt.example.com]
```
This mirrors `ingress.*` concept for concept:
- `gateway.mode: single` (default) creates one `HTTPRoute` that carries both apt
reads and `/api/` writes — nginx does the split internally, exactly as in
Ingress `single` mode.
- `gateway.mode: split` creates a second `HTTPRoute` for `/api/` under
`gateway.api.hostnames`, optionally attached to a **different** Gateway via
`gateway.api.parentRefs` (falls back to `gateway.parentRefs` when unset) — e.g.
an internal-only Gateway for the write path. Same caveat as Ingress `split`
mode applies: this is a route-level split, not something nginx itself enforces,
so combine it with `security.write.inClusterOnly` or a `NetworkPolicy` if a
request arriving on the wrong hostname must actually be rejected at the network
level.
- `security.write.inClusterOnly: true` omits the API `HTTPRoute` entirely in
`split` mode, the same way it omits the API `Ingress`.
- The shared `proxy.enabled=false` guard applies here too: a `HTTPRoute` (or
`Ingress`) in front of aptly's unauthenticated write API is refused unless
`security.preset: open` confirms it's intended.
One real difference from Ingress: **TLS is not configured here.** Gateway API
deliberately separates infrastructure (the `Gateway` and its listeners, owned by
a cluster admin) from routing (the `HTTPRoute`, owned by this chart) — so TLS
termination is the referenced `Gateway`'s job, not a `gateway.repo.tls`-style
field this chart would need to expose.
Core Gateway API resources have been GA (`gateway.networking.k8s.io/v1`) since
v1.0; `gateway.apiVersion` exists as an escape hatch only if your cluster's CRDs
still predate that.
## Configuring aptly itself
Two layers, always merged in this order — nothing in aptly's own configuration is
ever unreachable through this chart:
1. curated `aptly.*` keys (omitted from the rendered config when unset, so a
default install matches aptly's own upstream defaults exactly);
2. `aptly.configOverrides` — raw aptly YAML (snake_case keys, same as aptly's own
config file), deep-merged over the generated config last, always wins.
```yaml
aptly:
architectures: [amd64, arm64]
metrics:
enabled: true
configOverrides:
s3_publish_endpoints:
cdn:
region: eu-central-1
bucket: apt-example
```
## Declarative state
Local repos, mirrors, and publish targets declared under `aptly.*` are converged
towards by a Helm hook Job on every install/upgrade — talking only to aptly's REST
API, never the CLI (the API server holds aptly's database lock).
```yaml
aptly:
localRepos:
- name: stable
defaultDistribution: stable
defaultComponent: main
publish:
- name: stable-root
prefix: ""
distribution: stable
sourceKind: local
sources: [{ name: stable, component: main }]
architectures: [amd64, arm64]
```
Known limitation: `mirrors[].components` cannot be changed after a mirror is
created (aptly's API has no endpoint for that) — changing it means deleting and
recreating the mirror. Everything else is kept in sync on every run.
## GPG signing
```yaml
aptly:
gpg:
enabled: true # false = gpg_disable_sign + Signing.Skip on every publish call
provider: gpg # gpg (default) | internal (pure-Go, no gnupg binary)
signingKey:
existingSecret: my-signing-key # keys: privateKey (or secretKeyring), passphrase
```
The public key is served at `aptly.gpg.publishPublicKey.path` (default
`/signing-key.asc`) so clients can fetch it directly:
```bash
curl -fsSL https://apt.example.com/signing-key.asc | gpg --dearmor \
| sudo tee /usr/share/keyrings/example.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/example.gpg] https://apt.example.com/ stable main' \
| sudo tee /etc/apt/sources.list.d/example.list
```
Never put a real private key inline in `values.yaml``aptly.gpg.signingKey.privateKey`
exists only as a quick-test escape hatch.
## Storage
`persistence.size`/`persistence.storageClass` are **immutable** once installed —
Kubernetes forbids changing a StatefulSet's `volumeClaimTemplates` in place. Set
`persistence.existingClaim` from the start in production: then no
`volumeClaimTemplate` exists at all, and resizing the referenced PVC directly is a
plain, supported operation.
## Parameters
### Image
| Key | Default | Description |
|---|---|---|
| `image.repository` | `git.morlana.online/f.weber/aptly` | aptly-server image |
| `image.tag` | `""` | falls back to `.Chart.AppVersion` — the last released image, never `latest` |
| `image.pullPolicy` | `IfNotPresent` | |
| `image.pullSecrets` | `[]` | |
| `nginx.image.repository` | `nginxinc/nginx-unprivileged` | upstream image, unmodified |
| `nginx.image.tag` | `1-alpine` | |
| `nginx.image.pullPolicy` | `IfNotPresent` | |
| `nginx.resources` | `{}` | |
| `nginx.securityContext` | non-root, all caps dropped | |
### aptly configuration
| Key | Default | Description |
|---|---|---|
| `aptly.architectures` | `[]` | empty = all available |
| `aptly.logLevel` | `info` | |
| `aptly.logFormat` | `json` | |
| `aptly.download.concurrency` | `4` | |
| `aptly.download.limit` | `0` | KB/s, `0` = unlimited |
| `aptly.download.retries` | `0` | |
| `aptly.download.sourcePackages` | `false` | |
| `aptly.publishing.skipContents` | `false` | |
| `aptly.publishing.skipBz2` | `false` | |
| `aptly.metrics.enabled` | `false` | exposes `/api/metrics` on a separate, unauthenticated port — see [Metrics](#metrics) |
| `aptly.swagger.enabled` | `false` | exposes `/docs.html` |
| `aptly.gpg.enabled` | `true` | `false` disables signing entirely — see [GPG signing](#gpg-signing) |
| `aptly.gpg.verify` | `true` | mirror signature verification |
| `aptly.gpg.provider` | `gpg` | `gpg` \| `internal` |
| `aptly.gpg.signingKey.existingSecret` | `""` | Secret key `privateKey` or `secretKeyring`, optional `passphrase` |
| `aptly.gpg.signingKey.privateKey` | `""` | inline armored key — quick tests only, never for production |
| `aptly.gpg.signingKey.passphrase` | `""` | only used with the inline key above |
| `aptly.gpg.publishPublicKey.enabled` | `true` | serve the public key over HTTP |
| `aptly.gpg.publishPublicKey.path` | `/signing-key.asc` | |
| `aptly.gpgKeys` | `[]` | trusted keys imported for mirror verification — `[{name, armored}]` |
| `aptly.localRepos` | `[]` | see [Declarative state](#declarative-state) |
| `aptly.mirrors` | `[]` | see [Declarative state](#declarative-state) |
| `aptly.publish` | `[]` | see [Declarative state](#declarative-state) |
| `aptly.configOverrides` | `{}` | raw aptly config, deep-merged last — see [Configuring aptly itself](#configuring-aptly-itself) |
| `aptly.existingSecretEnv` | `[]` | Secret names to `envFrom`, for `${VAR}` placeholders inside `configOverrides` |
### Security
| Key | Default | Description |
|---|---|---|
| `security.preset` | `publicRead` | `open` \| `publicRead` \| `authenticated` \| `readOnly` — see [Security modes](#security-modes) |
| `security.auth.users` | `{}` | `name: plaintext-password` map, hashed at pod start |
| `security.auth.existingSecret` | `""` | pre-built `htpasswd` Secret — wins over `users` |
| `security.auth.internalUser.enabled` | `true` | credentials the reconcile Job authenticates through nginx with, in every preset |
| `security.auth.internalUser.username` | `aptly-internal` | |
| `security.trustedProxies` | `[]` | CIDRs to trust `X-Forwarded-For` from — required for `allowCIDRs` to be meaningful behind an Ingress |
| `security.read.enabled` | `true` | overrides the preset |
| `security.read.requireAuth` | `null` | `null` = preset's value |
| `security.read.allowCIDRs` | `[]` | |
| `security.write.enabled` | `true` | overrides the preset |
| `security.write.requireAuth` | `null` | `null` = preset's value |
| `security.write.allowCIDRs` | `[]` | |
| `security.write.inClusterOnly` | `false` | in `ingress.mode: split`, omits the API Ingress entirely |
### Proxy
| Key | Default | Description |
|---|---|---|
| `proxy.enabled` | `true` | `false` = aptly serves reads itself (`serve_in_api_mode`), no nginx split — only sane with `security.preset: open` |
| `proxy.compatPaths` | `true` | also serve the tree under `/repos/<name>/`, aptly's own URL shape |
| `proxy.maxUploadSize` | `"0"` | nginx `client_max_body_size`, `"0"` = unlimited |
| `proxy.readTimeout` | `3600s` | |
| `proxy.publishEndpointName` | `public` | the `filesystem_publish_endpoints` key aptly publishes under |
### Persistence
| Key | Default | Description |
|---|---|---|
| `persistence.enabled` | `true` | `false` = `emptyDir` |
| `persistence.existingClaim` | `""` | **set this in production** — see [Storage](#storage) |
| `persistence.storageClass` | `""` | falls back to `global.defaultStorageClass` |
| `persistence.accessMode` | `ReadWriteOnce` | |
| `persistence.size` | `20Gi` | immutable once installed unless using `existingClaim` |
| `persistence.annotations` | `{}` | |
### Workload
| Key | Default | Description |
|---|---|---|
| `workload.updateStrategy.type` | `RollingUpdate` | safe with `replicas: 1` on a StatefulSet |
| `workload.podManagementPolicy` | `OrderedReady` | |
| `workload.revisionHistoryLimit` | `3` | |
| `workload.terminationGracePeriodSeconds` | `60` | |
| `workload.annotations` / `podAnnotations` / `podLabels` | `{}` | |
| `podSecurityContext` | non-root, uid/gid 10001 | |
| `containerSecurityContext` | all caps dropped, read-only root fs | |
| `resources` | `{}` | the `aptly` container |
| `probes.startup` / `.readiness` / `.liveness` | see `values.yaml` | tuned for LevelDB recovery time on unclean shutdown |
### Networking
| Key | Default | Description |
|---|---|---|
| `service.type` | `ClusterIP` | |
| `service.port` | `8080` | |
| `service.annotations` | `{}` | |
| `ingress.enabled` | `false` | |
| `ingress.mode` | `single` | `single` \| `split` — see [docs/security.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/docs/security.md) |
| `ingress.className` | `""` | |
| `ingress.annotations` | body-size/read-timeout defaults for nginx-ingress | |
| `ingress.repo.host` / `.path` / `.pathType` / `.tls` | | apt-client-facing Ingress |
| `ingress.api.enabled` / `.host` / `.className` / `.annotations` / `.tls` | | `split` mode only |
| `gateway.enabled` | `false` | independent of `ingress.enabled` — both may be `true` at once, see [Gateway API](#gateway-api) |
| `gateway.apiVersion` | `gateway.networking.k8s.io/v1` | override only for pre-GA clusters |
| `gateway.mode` | `single` | `single` \| `split` — same meaning as `ingress.mode` |
| `gateway.parentRefs` | `[]` | required when `gateway.enabled: true``[{name, namespace, sectionName}]` |
| `gateway.repo.hostnames` / `.path` / `.pathType` | | apt-client-facing `HTTPRoute`; `pathType` is Gateway API's own enum (`PathPrefix`/`Exact`/`RegularExpression`), distinct from `ingress.repo.pathType`'s |
| `gateway.api.enabled` / `.hostnames` / `.parentRefs` | | `split` mode only; `.parentRefs` falls back to `gateway.parentRefs` when unset |
| `networkPolicy.enabled` | `false` | |
| `networkPolicy.allowedNamespaces` | `[]` | empty = no ingress restriction |
| `networkPolicy.extraIngress` | `[]` | |
| `networkPolicy.egress.allowAll` | `true` | disabling this breaks mirrors unless you add `egress.extra` rules yourself |
### Metrics
| Key | Default | Description |
|---|---|---|
| `metrics.service.enabled` | `false` | separate, unauthenticated port |
| `metrics.service.port` | `9090` | |
| `metrics.serviceMonitor.enabled` | `false` | requires the Prometheus Operator CRDs |
| `metrics.serviceMonitor.interval` | `30s` | |
### Reconcile
| Key | Default | Description |
|---|---|---|
| `reconcile.enabled` | `true` | |
| `reconcile.mode` | `hook` | `hook` \| `job` (GitOps-friendly, hashed name) \| `manual` |
| `reconcile.failOnError` | `false` | `true` makes an unreachable mirror fail the release |
| `reconcile.timeoutSeconds` | `600` | |
| `reconcile.image` | `{}` | overrides `repository`/`tag`/`pullPolicy`; defaults to the main `image` |
| `reconcile.resources` | `{}` | |
### Pod disruption & scheduling
| Key | Default | Description |
|---|---|---|
| `podDisruptionBudget.enabled` | `false` | |
| `podDisruptionBudget.maxUnavailable` | `1` | never set `minAvailable` here — with `replicas: 1` it blocks every node drain forever |
| `nodeSelector` / `tolerations` / `affinity` / `topologySpreadConstraints` / `priorityClassName` | | standard scheduling escape hatches |
### Escape hatches
| Key | Default | Description |
|---|---|---|
| `extraEnv` / `extraEnvFrom` | `[]` | on the `aptly` container |
| `extraVolumes` / `extraVolumeMounts` | `[]` | |
| `extraInitContainers` / `extraContainers` | `[]` | |
| `global.imageRegistry` | `""` | prefixes both `image.repository` and `nginx.image.repository` |
| `global.imagePullSecrets` | `[]` | |
| `global.defaultStorageClass` | `""` | |
## License
MIT. See [LICENSE](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/LICENSE)
and [NOTICE.md](https://git.morlana.online/f.weber/aptly-containerized/src/branch/main/NOTICE.md)
in the repository.
+5
View File
@@ -0,0 +1,5 @@
security:
preset: authenticated
auth:
users:
ci: "changeme"
+82
View File
@@ -0,0 +1,82 @@
# Exercises: existingClaim, existingSecret (auth + gpg), metrics + ServiceMonitor,
# networkPolicy, PDB, declarative state (gpgKeys/localRepos/mirrors/publish),
# configOverrides passthrough, extra* escape hatches.
persistence:
existingClaim: my-existing-pvc
security:
preset: authenticated
auth:
existingSecret: my-htpasswd-secret
trustedProxies: ["10.0.0.0/8"]
write:
allowCIDRs: ["10.42.0.0/16"]
aptly:
architectures: [amd64, arm64]
metrics:
enabled: true
gpg:
signingKey:
existingSecret: my-signing-key-secret
gpgKeys:
- name: debian-archive
armored: |
-----BEGIN PGP PUBLIC KEY BLOCK-----
dGVzdA==
-----END PGP PUBLIC KEY BLOCK-----
localRepos:
- name: stable
comment: "Production repo"
defaultDistribution: stable
defaultComponent: main
mirrors:
- name: debian-security
archiveURL: http://security.debian.org/debian-security
distribution: trixie-security
components: [main]
architectures: [amd64, arm64]
publish:
- name: stable-root
prefix: ""
distribution: stable
sourceKind: local
sources: [{ name: stable, component: main }]
architectures: [amd64, arm64]
acquireByHash: true
configOverrides:
download_concurrency: 8
existingSecretEnv: ["some-other-secret"]
metrics:
service:
enabled: true
serviceMonitor:
enabled: true
networkPolicy:
enabled: true
allowedNamespaces: ["ci", "monitoring"]
podDisruptionBudget:
enabled: true
maxUnavailable: 1
reconcile:
mode: job
extraEnv:
- name: FOO
value: bar
extraVolumes:
- name: extra
emptyDir: {}
extraVolumeMounts:
- name: extra
mountPath: /extra
nodeSelector:
kubernetes.io/os: linux
tolerations:
- key: "example"
operator: "Exists"
priorityClassName: "high-priority"
+7
View File
@@ -0,0 +1,7 @@
gateway:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-infra
repo:
hostnames: [apt.example.com]
+20
View File
@@ -0,0 +1,20 @@
gateway:
enabled: true
mode: split
parentRefs:
- name: my-gateway
namespace: gateway-infra
repo:
hostnames: [apt.example.com]
api:
enabled: true
hostnames: [aptly-api.example.com]
parentRefs:
- name: internal-gateway
namespace: gateway-infra
security:
preset: authenticated
auth:
users:
ci: "changeme"
+14
View File
@@ -0,0 +1,14 @@
# Mid-migration scenario: Ingress and Gateway API enabled at the same time,
# both pointing at the same Service. Proves the two never conflict.
ingress:
enabled: true
repo:
host: apt.example.com
gateway:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-infra
repo:
hostnames: [apt.example.com]
+5
View File
@@ -0,0 +1,5 @@
ingress:
enabled: true
mode: single
repo:
host: apt.example.com
+13
View File
@@ -0,0 +1,13 @@
ingress:
enabled: true
mode: split
repo:
host: apt.example.com
api:
enabled: true
host: aptly-api.example.com
security:
preset: authenticated
auth:
users:
ci: "changeme"
+7
View File
@@ -0,0 +1,7 @@
security:
preset: open
proxy:
enabled: false
aptly:
gpg:
enabled: false
+5
View File
@@ -0,0 +1,5 @@
security:
preset: open
aptly:
gpg:
enabled: false
+2
View File
@@ -0,0 +1,2 @@
security:
preset: readOnly
+89
View File
@@ -0,0 +1,89 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $sec := include "aptly.security" . | fromJson -}}
aptly ({{ .Chart.AppVersion }}, chart {{ .Chart.Version }}) is deploying as {{ $fullname }}-0 in {{ .Release.Namespace }}.
{{- if eq .Values.security.preset "open" }}
*** security.preset: open ***
Both reading AND writing (the mutating /api/ path) are reachable with NO
authentication from anything that can reach the Service — this is exactly
the "komplett unabgesichert" mode, working as configured. Nothing further
to set up; just make sure this is really what you want before exposing it
beyond your own network.
{{- end }}
--- Check it's up -------------------------------------------------------
kubectl exec -n {{ .Release.Namespace }} {{ $fullname }}-0 -c aptly -- \
curl -fsS http://127.0.0.1:8080/api/ready
kubectl logs -n {{ .Release.Namespace }} job/{{ $fullname }}-reconcile
# (only present right after install/upgrade in `hook` mode)
--- Reach it -------------------------------------------------------------
{{- if .Values.ingress.enabled }}
{{- with .Values.ingress.repo.host }}
https://{{ . }}/ (Ingress)
{{- end }}
{{- end }}
{{- if .Values.gateway.enabled }}
{{- range .Values.gateway.repo.hostnames }}
https://{{ . }}/ (Gateway API)
{{- end }}
{{- end }}
{{- if not (or .Values.ingress.enabled .Values.gateway.enabled) }}
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ $fullname }} 8080:{{ .Values.service.port }}
# then use http://127.0.0.1:8080/ below
{{- end }}
--- Configure apt on a client ---------------------------------------------
{{- $host := "apt.example.com" }}
{{- if and .Values.ingress.enabled .Values.ingress.repo.host }}
{{- $host = .Values.ingress.repo.host }}
{{- else if and .Values.gateway.enabled .Values.gateway.repo.hostnames }}
{{- $host = first .Values.gateway.repo.hostnames }}
{{- end }}
{{- if .Values.aptly.gpg.enabled }}
{{- if .Values.aptly.gpg.publishPublicKey.enabled }}
curl -fsSL https://{{ $host }}{{ .Values.aptly.gpg.publishPublicKey.path }} \
| gpg --dearmor | sudo tee /usr/share/keyrings/{{ include "aptly.name" . }}.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/{{ include "aptly.name" . }}.gpg] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- else }}
aptly.gpg.enabled=true but aptly.gpg.publishPublicKey.enabled=false — the
signing key is not being served; distribute it to clients yourself.
echo 'deb [signed-by=/path/to/your-key.gpg] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- end }}
{{- else }}
echo 'deb [trusted=yes] https://{{ $host }}/ <dist> <component>' \
| sudo tee /etc/apt/sources.list.d/{{ include "aptly.name" . }}.list
{{- end }}
{{- if $sec.ra }}
Reads require credentials in this preset ({{ .Values.security.preset }}):
echo 'machine {{ $host }} login <user> password <password>' \
| sudo tee -a /etc/apt/auth.conf.d/{{ include "aptly.name" . }}.conf
{{- end }}
--- Resizing storage later -------------------------------------------------
persistence.size on an already-installed StatefulSet is IMMUTABLE via
`helm upgrade` (Kubernetes forbids changing volumeClaimTemplates in place).
For production, set persistence.existingClaim to a PVC you manage yourself
— resizing that is a plain PVC edit. See docs/operations.md for the
recovery procedure if you need to resize a chart-managed PVC anyway.
{{- if and .Values.podDisruptionBudget.enabled (le (int .Values.podDisruptionBudget.maxUnavailable) 0) }}
*** podDisruptionBudget.maxUnavailable is 0 with replicas=1 — this blocks
every voluntary node drain forever. ***
{{- end }}
+263
View File
@@ -0,0 +1,263 @@
{{/*
Standard name/label helpers, bookstack-chart style.
*/}}
{{- define "aptly.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "aptly.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "aptly.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "aptly.labels" -}}
helm.sh/chart: {{ include "aptly.chart" . }}
{{ include "aptly.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "aptly.selectorLabels" -}}
app.kubernetes.io/name: {{ include "aptly.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{/*
Image helpers global.imageRegistry prefixes the repository when set,
matching the bookstack-chart convention.
*/}}
{{- define "aptly.image" -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $repo := .Values.image.repository -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion -}}
{{- if $registry -}}
{{- printf "%s/%s:%s" $registry $repo $tag -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag -}}
{{- end -}}
{{- end -}}
{{- define "aptly.nginxImage" -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $repo := .Values.nginx.image.repository -}}
{{- $tag := .Values.nginx.image.tag -}}
{{- if $registry -}}
{{- printf "%s/%s:%s" $registry $repo $tag -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag -}}
{{- end -}}
{{- end -}}
{{/*
Shared by templates/ingress.yaml and templates/httproute.yaml: proxy.enabled=false
means aptly's unauthenticated write API sits directly behind whatever routes to
it, on a single listener with no path-based auth split possible — refuse to wire
that up to an Ingress OR a Gateway API HTTPRoute unless security.preset=open
confirms it's intended.
*/}}
{{- define "aptly.exposureGuard" -}}
{{- if and (not .Values.proxy.enabled) (or .Values.ingress.enabled .Values.gateway.enabled) (ne .Values.security.preset "open") -}}
{{- fail "proxy.enabled=false publishes aptly's unauthenticated write API through the Ingress/HTTPRoute (no path-based auth split is possible on a single listener). Set security.preset=open to confirm this is intended, or keep proxy.enabled=true." -}}
{{- end -}}
{{- end -}}
{{- define "aptly.imagePullSecrets" -}}
{{- $secrets := concat (.Values.global.imagePullSecrets | default list) (.Values.image.pullSecrets | default list) -}}
{{- if $secrets }}
imagePullSecrets:
{{- range $secrets }}
- name: {{ if kindIs "map" . }}{{ .name }}{{ else }}{{ . }}{{ end }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
Whether aptly serves the published tree itself (no nginx proxy in front).
Only sane in combination with security.preset=open enforced in
templates/statefulset.yaml (the aptly config) and templates/ingress.yaml
(the hard `fail` guard).
*/}}
{{- define "aptly.serveInApiMode" -}}
{{- if .Values.proxy.enabled -}}false{{- else -}}true{{- end -}}
{{- end -}}
{{- define "aptly.apiListen" -}}
{{- if .Values.proxy.enabled -}}127.0.0.1:8080{{- else -}}0.0.0.0:8080{{- end -}}
{{- end -}}
{{/*
Resolve security.preset + explicit read/write overrides into a plain dict
{r, ra, w, wa} (read-enabled, read-auth, write-enabled, write-auth). Both
nginx.conf.tpl and templates/ingress.yaml key off this so preset expansion
lives in exactly one place.
*/}}
{{- define "aptly.security" -}}
{{- $p := .Values.security.preset -}}
{{- $presets := dict
"open" (dict "r" true "ra" false "w" true "wa" false)
"publicRead" (dict "r" true "ra" false "w" true "wa" true)
"authenticated" (dict "r" true "ra" true "w" true "wa" true)
"readOnly" (dict "r" true "ra" false "w" false "wa" false)
-}}
{{- $base := index $presets $p -}}
{{- if not $base -}}
{{- fail (printf "security.preset: unknown value %q (must be one of open, publicRead, authenticated, readOnly)" $p) -}}
{{- end -}}
{{- $d := deepCopy $base -}}
{{- if kindIs "bool" .Values.security.read.enabled }}{{- $_ := set $d "r" .Values.security.read.enabled -}}{{- end -}}
{{- if kindIs "bool" .Values.security.read.requireAuth }}{{- $_ := set $d "ra" .Values.security.read.requireAuth -}}{{- end -}}
{{- if kindIs "bool" .Values.security.write.enabled }}{{- $_ := set $d "w" .Values.security.write.enabled -}}{{- end -}}
{{- if kindIs "bool" .Values.security.write.requireAuth }}{{- $_ := set $d "wa" .Values.security.write.requireAuth -}}{{- end -}}
{{- $d | toJson -}}
{{- end -}}
{{/*
Render the aptly config (YAML) from the curated values.aptly.* keys, then
deep-merge aptly.configOverrides on top so every current/future aptly config
key stays reachable without a chart change. Curated keys are OMITTED when
unset, so a default install matches aptly's own upstream defaults exactly
(see utils/config.go in aptly-dev/aptly for the canonical defaults).
*/}}
{{- define "aptly.config" -}}
{{- $v := .Values.aptly -}}
{{- $c := dict "root_dir" "/var/lib/aptly" -}}
{{- with $v.logLevel }}{{- $_ := set $c "log_level" . -}}{{- end -}}
{{- with $v.logFormat }}{{- $_ := set $c "log_format" . -}}{{- end -}}
{{- if $v.architectures }}{{- $_ := set $c "architectures" $v.architectures -}}{{- end -}}
{{- with $v.download.concurrency }}{{- $_ := set $c "download_concurrency" . -}}{{- end -}}
{{- with $v.download.limit }}{{- $_ := set $c "download_limit" . -}}{{- end -}}
{{- with $v.download.retries }}{{- $_ := set $c "download_retries" . -}}{{- end -}}
{{- if kindIs "bool" $v.download.sourcePackages }}{{- $_ := set $c "download_sourcepackages" $v.download.sourcePackages -}}{{- end -}}
{{- $_ := set $c "gpg_provider" ($v.gpg.provider | default "gpg") -}}
{{- $_ := set $c "gpg_disable_sign" (not $v.gpg.enabled) -}}
{{- $_ := set $c "gpg_disable_verify" (not $v.gpg.verify) -}}
{{- if kindIs "bool" $v.publishing.skipContents }}{{- $_ := set $c "skip_contents_publishing" $v.publishing.skipContents -}}{{- end -}}
{{- if kindIs "bool" $v.publishing.skipBz2 }}{{- $_ := set $c "skip_bz2_publishing" $v.publishing.skipBz2 -}}{{- end -}}
{{- $_ := set $c "enable_metrics_endpoint" ($v.metrics.enabled | default false) -}}
{{- $_ := set $c "enable_swagger_endpoint" ($v.swagger.enabled | default false) -}}
{{- $_ := set $c "serve_in_api_mode" (eq (include "aptly.serveInApiMode" .) "true") -}}
{{- $_ := set $c "filesystem_publish_endpoints" (dict $.Values.proxy.publishEndpointName (dict "root_dir" "/var/lib/aptly/public" "link_method" "hardlink")) -}}
{{- $merged := mergeOverwrite $c (deepCopy ($v.configOverrides | default dict)) -}}
{{- toYaml $merged -}}
{{- end -}}
{{/*
Render nginx's server{} block (mounted at /etc/nginx/conf.d/default.conf,
which the base image's own nginx.conf already includes from inside its own
http{} block — this template must therefore emit ONLY a server{} block, see
compose/config/nginx.*.conf for the same constraint hit empirically).
*/}}
{{- define "aptly.nginxConf" -}}
{{- $sec := include "aptly.security" . | fromJson -}}
{{- $p := .Values.proxy -}}
server {
listen 8080;
server_name _;
client_max_body_size {{ $p.maxUploadSize }};
absolute_redirect off;
{{- range .Values.security.trustedProxies }}
set_real_ip_from {{ . }};
{{- end }}
{{- if .Values.security.trustedProxies }}
real_ip_header X-Forwarded-For;
real_ip_recursive on;
{{- end }}
location = /healthz { access_log off; return 200 "ok\n"; }
location = /api/ready { access_log off; proxy_pass http://127.0.0.1:8080; }
location = /api/healthy { access_log off; proxy_pass http://127.0.0.1:8080; }
{{- if $sec.w }}
location /api/ {
{{- if .Values.security.write.allowCIDRs }}
{{- if and .Values.security.write.allowCIDRs (not .Values.security.trustedProxies) }}
# WARNING: write.allowCIDRs is set without security.trustedProxies. Behind
# an Ingress controller, $remote_addr is the CONTROLLER's pod IP, not the
# real client this will match every client on earth. Set
# trustedProxies to the controller's CIDR, or use networkPolicy instead.
{{- end }}
{{- range .Values.security.write.allowCIDRs }}
allow {{ . }};
{{- end }}
deny all;
satisfy {{ if $sec.wa }}any{{ else }}all{{ end }};
{{- end }}
{{- if $sec.wa }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- else }}
auth_basic off;
{{- end }}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_request_buffering off;
proxy_read_timeout {{ .Values.proxy.readTimeout }};
proxy_send_timeout {{ .Values.proxy.readTimeout }};
}
{{- else }}
location /api/ { return 404; }
{{- end }}
location = /signing-key.asc {
alias /run/aptly/pub/signing-key.asc;
default_type text/plain;
}
{{- if $sec.r }}
location / {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
root /var/lib/aptly/public;
autoindex on;
autoindex_exact_size off;
location ~* /(InRelease|Release|Release\.gpg|Packages(\.[a-z0-9]+)?|Sources(\.[a-z0-9]+)?)$ {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
root /var/lib/aptly/public;
add_header Cache-Control "no-cache" always;
}
}
{{- if .Values.proxy.compatPaths }}
location /repos/{{ .Values.proxy.publishEndpointName }}/ {
{{- if $sec.ra }}
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
{{- end }}
alias /var/lib/aptly/public/;
autoindex on;
}
{{- end }}
{{- else }}
location / { return 404; }
{{- end }}
}
{{- if .Values.metrics.service.enabled }}
# Separate, unauthenticated listener so scraping never needs the write-path
# credentials and a ServiceMonitor never needs a basicAuth secret.
server {
listen 9090;
server_name _;
location = /api/metrics { proxy_pass http://127.0.0.1:8080; }
location / { return 404; }
}
{{- end }}
{{- end -}}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-config
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
aptly.yaml: |
{{- include "aptly.config" . | nindent 4 }}
@@ -0,0 +1,13 @@
{{- if .Values.aptly.gpgKeys }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-gpg-keys
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
{{- range .Values.aptly.gpgKeys }}
{{ .name }}.asc: |
{{- .armored | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,9 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-nginx
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
default.conf: |
{{- include "aptly.nginxConf" . | nindent 4 }}
@@ -0,0 +1,11 @@
{{- if .Values.reconcile.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "aptly.fullname" . }}-state
labels:
{{- include "aptly.labels" . | nindent 4 }}
data:
state.yaml: |
{{- dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | nindent 4 }}
{{- end }}
+65
View File
@@ -0,0 +1,65 @@
{{- include "aptly.exposureGuard" . -}}
{{- if .Values.gateway.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $svcName := $fullname -}}
{{- if not .Values.gateway.parentRefs }}
{{- fail "gateway.enabled=true requires gateway.parentRefs to reference at least one existing Gateway — this chart never creates a Gateway itself." -}}
{{- end }}
---
# Repo HTTPRoute — apt clients. In `single` mode this is also where /api/
# traffic arrives; nginx does the read/write split internally (see
# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this
# is a route-level split only: nginx does not itself reject /api/ requests
# that arrive via this hostname, so combine `split` mode with
# security.write.inClusterOnly or a NetworkPolicy if you need that enforced.
# TLS is configured on the referenced Gateway's listener, not here.
apiVersion: {{ .Values.gateway.apiVersion }}
kind: HTTPRoute
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
parentRefs:
{{- toYaml .Values.gateway.parentRefs | nindent 4 }}
{{- with .Values.gateway.repo.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- matches:
- path:
type: {{ .Values.gateway.repo.pathType }}
value: {{ .Values.gateway.repo.path }}
backendRefs:
- name: {{ $svcName }}
port: {{ .Values.service.port }}
{{- if and (eq .Values.gateway.mode "split") .Values.gateway.api.enabled (not .Values.security.write.inClusterOnly) }}
---
# API HTTPRoute (split mode) — a separate hostname/Gateway so you can put a
# different Gateway, mTLS, or WAF policy in front of the mutating API than
# the public read path gets.
apiVersion: {{ .Values.gateway.apiVersion }}
kind: HTTPRoute
metadata:
name: {{ $fullname }}-api
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
{{- $apiParentRefs := .Values.gateway.api.parentRefs | default .Values.gateway.parentRefs }}
parentRefs:
{{- toYaml $apiParentRefs | nindent 4 }}
{{- with .Values.gateway.api.hostnames }}
hostnames:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $svcName }}
port: {{ .Values.service.port }}
{{- end }}
{{- end }}
+77
View File
@@ -0,0 +1,77 @@
{{- include "aptly.exposureGuard" . -}}
{{- if .Values.ingress.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $svcName := $fullname -}}
---
# Repo Ingress — apt clients. In `single` mode this is also where /api/
# traffic arrives; nginx does the read/write split internally (see
# templates/_helpers.tpl's aptly.nginxConf). In `split` mode, note that this
# is a DNS/Ingress-level split only: nginx does not itself reject /api/
# requests that arrive via this host, so combine `split` mode with
# security.write.inClusterOnly or a NetworkPolicy if you need that enforced.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.repo.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- {{- with .Values.ingress.repo.host }}
host: {{ . | quote }}
{{- end }}
http:
paths:
- path: {{ .Values.ingress.repo.path }}
pathType: {{ .Values.ingress.repo.pathType }}
backend:
service:
name: {{ $svcName }}
port:
name: http
{{- if and (eq .Values.ingress.mode "split") .Values.ingress.api.enabled (not .Values.security.write.inClusterOnly) }}
---
# API Ingress (split mode) — a separate host so you can put a different
# ingressClass, mTLS, or WAF policy in front of the mutating API than the
# public read path gets.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullname }}-api
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
{{- toYaml (merge .Values.ingress.api.annotations .Values.ingress.annotations) | nindent 4 }}
spec:
{{- $apiClass := .Values.ingress.api.className | default .Values.ingress.className }}
{{- with $apiClass }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.api.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- {{- with .Values.ingress.api.host }}
host: {{ . | quote }}
{{- end }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $svcName }}
port:
name: http
{{- end }}
{{- end }}
+80
View File
@@ -0,0 +1,80 @@
{{- if .Values.reconcile.enabled }}
{{- $fullname := include "aptly.fullname" . -}}
{{- $img := .Values.reconcile.image -}}
{{- $repo := $img.repository | default .Values.image.repository -}}
{{- $tag := $img.tag | default .Values.image.tag | default .Chart.AppVersion -}}
{{- $registry := .Values.global.imageRegistry -}}
{{- $image := ternary (printf "%s/%s:%s" $registry $repo $tag) (printf "%s:%s" $repo $tag) (ne $registry "") -}}
apiVersion: batch/v1
kind: Job
metadata:
{{- if eq .Values.reconcile.mode "hook" }}
name: {{ $fullname }}-reconcile
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "5"
# Deliberately no hook-failed: a failed Job stays around for `kubectl
# logs`/`kubectl describe job` instead of vanishing before anyone can
# read why reconciliation didn't converge.
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
{{- else }}
# A plain (non-hook) Job named after the desired state's hash, for GitOps
# controllers (ArgoCD/Flux) that reconcile hooks poorly: it only re-runs
# when aptly.{gpgKeys,localRepos,mirrors,publish} actually change.
name: {{ $fullname }}-reconcile-{{ dict "localRepos" .Values.aptly.localRepos "mirrors" .Values.aptly.mirrors "publish" .Values.aptly.publish | toYaml | sha256sum | trunc 8 }}
{{- end }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
backoffLimit: 3
activeDeadlineSeconds: {{ mul .Values.reconcile.timeoutSeconds 2 }}
{{- if ne .Values.reconcile.mode "hook" }}
ttlSecondsAfterFinished: 86400
{{- end }}
template:
metadata:
labels:
{{- include "aptly.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: reconcile
spec:
restartPolicy: Never
{{- include "aptly.imagePullSecrets" . | nindent 6 }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: reconcile
image: {{ $image }}
imagePullPolicy: {{ $img.pullPolicy | default .Values.image.pullPolicy }}
command: ["/usr/local/bin/aptly-reconcile"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_URL
value: "http://{{ $fullname }}:{{ .Values.service.port }}"
- name: APTLY_STATE_FILE
value: /state.yaml
- name: APTLY_FAIL_ON_ERROR
value: {{ .Values.reconcile.failOnError | quote }}
- name: APTLY_WAIT_TIMEOUT
value: {{ .Values.reconcile.timeoutSeconds | quote }}
{{- if .Values.security.auth.internalUser.enabled }}
- name: APTLY_USER
value: {{ .Values.security.auth.internalUser.username | quote }}
- name: APTLY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ $fullname }}-credentials
key: internal-password
{{- end }}
volumeMounts:
- name: state
mountPath: /state.yaml
subPath: state.yaml
readOnly: true
resources:
{{- toYaml .Values.reconcile.resources | nindent 12 }}
volumes:
- name: state
configMap:
name: {{ $fullname }}-state
{{- end }}
+51
View File
@@ -0,0 +1,51 @@
{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
{{- if .Values.networkPolicy.allowedNamespaces }}
# Restricted to these namespaces (plus this one). NOTE: this applies to
# the whole nginx:8080 endpoint — read and write share one port, so this
# cannot itself express "reads are public, writes are cluster-only" any
# more precisely than security.write.allowCIDRs can (see the warning
# rendered into nginx.conf for that). Use it to fence the Service off
# from unrelated namespaces, not as a read/write split.
- from:
- podSelector: {}
{{- range .Values.networkPolicy.allowedNamespaces }}
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . }}
{{- end }}
{{- else }}
- {}
{{- end }}
{{- with .Values.networkPolicy.extraIngress }}
{{- toYaml . | nindent 4 }}
{{- end }}
egress:
{{- if .Values.networkPolicy.egress.allowAll }}
- {}
{{- else }}
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
{{- with .Values.networkPolicy.egress.extra }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,16 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
spec:
# With replicas fixed at 1 (see templates/statefulset.yaml), a
# minAvailable:1 budget would block every voluntary node drain forever —
# maxUnavailable is the only sane knob here.
maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }}
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,34 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $secretName := printf "%s-credentials" $fullname -}}
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName -}}
{{- $internalPassword := "" -}}
{{- if and $existing (hasKey $existing.data "internal-password") -}}
{{- $internalPassword = index $existing.data "internal-password" | b64dec -}}
{{- else -}}
{{- $internalPassword = randAlphaNum 32 -}}
{{- end -}}
{{- $userLines := list -}}
{{- range $name, $pass := .Values.security.auth.users -}}
{{- $userLines = append $userLines (printf "%s:%s" $name $pass) -}}
{{- end -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
annotations:
# Reused across `helm upgrade` (via `lookup` above) and preserved across
# `helm uninstall` so aptly-reconcile keeps working without a manual step
# after a reinstall. Never regenerate this key from a template — see
# rootfs/usr/local/bin/aptly-init for why hashing must not happen here.
helm.sh/resource-policy: keep
type: Opaque
stringData:
internal-password: {{ $internalPassword | quote }}
{{- if $userLines }}
users: |
{{- range $userLines }}
{{ . }}
{{- end }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- if and .Values.aptly.gpg.enabled (not .Values.aptly.gpg.signingKey.existingSecret) (.Values.aptly.gpg.signingKey.privateKey) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "aptly.fullname" . }}-gpg
labels:
{{- include "aptly.labels" . | nindent 4 }}
type: Opaque
stringData:
privateKey: {{ .Values.aptly.gpg.signingKey.privateKey | quote }}
{{- if .Values.aptly.gpg.signingKey.passphrase }}
passphrase: {{ .Values.aptly.gpg.signingKey.passphrase | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.metrics.service.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "aptly.fullname" . }}-metrics
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.metrics.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: ClusterIP
selector:
{{- include "aptly.selectorLabels" . | nindent 4 }}
ports:
- name: metrics
port: {{ .Values.metrics.service.port }}
targetPort: metrics
protocol: TCP
{{- end }}
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "aptly.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
@@ -0,0 +1,23 @@
{{- if and .Values.metrics.serviceMonitor.enabled .Values.metrics.service.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "aptly.fullname" . }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.metrics.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
path: /api/metrics
interval: {{ .Values.metrics.serviceMonitor.interval }}
{{- with .Values.metrics.serviceMonitor.relabelings }}
relabelings:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
+298
View File
@@ -0,0 +1,298 @@
{{- $fullname := include "aptly.fullname" . -}}
{{- $hasGpgSecret := or .Values.aptly.gpg.signingKey.existingSecret (and (not .Values.aptly.gpg.signingKey.existingSecret) .Values.aptly.gpg.signingKey.privateKey) -}}
{{- $gpgSecretName := .Values.aptly.gpg.signingKey.existingSecret | default (printf "%s-gpg" $fullname) -}}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $fullname }}
labels:
{{- include "aptly.labels" . | nindent 4 }}
{{- with .Values.workload.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
serviceName: {{ $fullname }}
# LevelDB (aptly's database) takes an exclusive OS-level file lock — two
# writers would corrupt it. A StatefulSet with replicas=1 always terminates
# the old pod before creating its replacement, so RollingUpdate is safe
# here in a way it would not be for a Deployment on a ReadWriteOnce PVC
# (which would deadlock on a Multi-Attach error instead).
replicas: 1
podManagementPolicy: {{ .Values.workload.podManagementPolicy }}
revisionHistoryLimit: {{ .Values.workload.revisionHistoryLimit }}
updateStrategy:
type: {{ .Values.workload.updateStrategy.type }}
selector:
matchLabels:
{{- include "aptly.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "aptly.selectorLabels" . | nindent 8 }}
{{- with .Values.workload.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations:
checksum/config: {{ include "aptly.config" . | sha256sum }}
checksum/nginx: {{ include "aptly.nginxConf" . | sha256sum }}
{{- with .Values.workload.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- include "aptly.imagePullSecrets" . | nindent 6 }}
terminationGracePeriodSeconds: {{ .Values.workload.terminationGracePeriodSeconds }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.priorityClassName }}
priorityClassName: {{ . }}
{{- end }}
initContainers:
- name: config-init
image: {{ include "aptly.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/usr/local/bin/aptly-init"]
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_ROOT_DIR
value: /var/lib/aptly
- name: APTLY_CONFIG_SRC
value: /etc/aptly-src/aptly.yaml
- name: APTLY_CONFIG_DST
value: /run/aptly/aptly.yaml
- name: APTLY_GPG_ENABLED
value: {{ .Values.aptly.gpg.enabled | quote }}
{{- if not .Values.security.auth.existingSecret }}
- name: APTLY_USERS_FILE
value: /etc/aptly-secrets/users
{{- else }}
- name: APTLY_HTPASSWD_SRC
value: /etc/aptly-secrets-existing/htpasswd
{{- end }}
{{- if .Values.security.auth.internalUser.enabled }}
- name: APTLY_INTERNAL_USER
value: {{ .Values.security.auth.internalUser.username | quote }}
- name: APTLY_INTERNAL_PASSWORD
valueFrom:
secretKeyRef:
name: {{ $fullname }}-credentials
key: internal-password
{{- end }}
{{- if $hasGpgSecret }}
- name: APTLY_GPG_PRIVATE_KEY_FILE
value: /etc/aptly-gpg/privateKey
- name: APTLY_GPG_SECRET_KEYRING_FILE
value: /etc/aptly-gpg/secretKeyring
- name: APTLY_GPG_PASSPHRASE_FILE
value: /etc/aptly-gpg/passphrase
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: APTLY_GPG_KEYS_DIR
value: /etc/aptly-gpg-keys
{{- end }}
volumeMounts:
- name: config-src
mountPath: /etc/aptly-src
readOnly: true
- name: run
mountPath: /run/aptly
- name: data
mountPath: /var/lib/aptly
{{- if not .Values.security.auth.existingSecret }}
- name: credentials
mountPath: /etc/aptly-secrets
readOnly: true
{{- else }}
- name: credentials-existing
mountPath: /etc/aptly-secrets-existing
readOnly: true
{{- end }}
{{- if $hasGpgSecret }}
- name: gpg-secret
mountPath: /etc/aptly-gpg
readOnly: true
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: gpg-keys
mountPath: /etc/aptly-gpg-keys
readOnly: true
{{- end }}
{{- with .Values.extraInitContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: aptly
image: {{ include "aptly.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
env:
- name: APTLY_API_LISTEN
value: {{ include "aptly.apiListen" . }}
- name: APTLY_CONFIG
value: /run/aptly/aptly.yaml
- name: GNUPGHOME
value: /run/aptly/gnupg
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if or .Values.aptly.existingSecretEnv .Values.extraEnvFrom }}
envFrom:
{{- range .Values.aptly.existingSecretEnv }}
- secretRef:
name: {{ . }}
{{- end }}
{{- with .Values.extraEnvFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
ports:
- name: aptly
containerPort: 8080
volumeMounts:
- name: data
mountPath: /var/lib/aptly
- name: run
mountPath: /run/aptly
- name: tmp
mountPath: /tmp
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
startupProbe:
httpGet: { path: /api/ready, port: aptly }
periodSeconds: {{ .Values.probes.startup.periodSeconds }}
failureThreshold: {{ .Values.probes.startup.failureThreshold }}
readinessProbe:
httpGet: { path: /api/ready, port: aptly }
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
livenessProbe:
httpGet: { path: /api/healthy, port: aptly }
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
- name: nginx
image: {{ include "aptly.nginxImage" . }}
imagePullPolicy: {{ .Values.nginx.image.pullPolicy }}
securityContext:
{{- toYaml .Values.nginx.securityContext | nindent 12 }}
ports:
- name: http
containerPort: 8080
{{- if .Values.metrics.service.enabled }}
- name: metrics
containerPort: 9090
{{- end }}
volumeMounts:
- name: data
mountPath: /var/lib/aptly/public
subPath: public
readOnly: true
- name: run
mountPath: /run/aptly
readOnly: true
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
- name: tmp
mountPath: /tmp
- name: nginx-cache
mountPath: /var/cache/nginx
readinessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 30
resources:
{{- toYaml .Values.nginx.resources | nindent 12 }}
{{- with .Values.extraContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: config-src
configMap:
name: {{ $fullname }}-config
- name: nginx-config
configMap:
name: {{ $fullname }}-nginx
- name: run
emptyDir:
medium: Memory
sizeLimit: 16Mi
- name: tmp
emptyDir: {}
- name: nginx-cache
emptyDir: {}
{{- if not .Values.security.auth.existingSecret }}
- name: credentials
secret:
secretName: {{ $fullname }}-credentials
optional: true
{{- else }}
- name: credentials-existing
secret:
secretName: {{ .Values.security.auth.existingSecret }}
{{- end }}
{{- if $hasGpgSecret }}
- name: gpg-secret
secret:
secretName: {{ $gpgSecretName }}
optional: true
{{- end }}
{{- if .Values.aptly.gpgKeys }}
- name: gpg-keys
configMap:
name: {{ $fullname }}-gpg-keys
{{- end }}
{{- if not .Values.persistence.enabled }}
- name: data
emptyDir: {}
{{- else if .Values.persistence.existingClaim }}
- name: data
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim }}
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
volumeClaimTemplates:
- metadata:
name: data
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 10 }}
{{- end }}
spec:
accessModes: [{{ .Values.persistence.accessMode }}]
{{- $sc := .Values.persistence.storageClass | default .Values.global.defaultStorageClass }}
{{- if $sc }}
storageClassName: {{ $sc }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
+421
View File
@@ -0,0 +1,421 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "aptly",
"type": "object",
"additionalProperties": false,
"properties": {
"nameOverride": { "type": "string" },
"fullnameOverride": { "type": "string" },
"image": {
"type": "object",
"additionalProperties": false,
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] },
"pullSecrets": { "type": "array" }
}
},
"nginx": {
"type": "object",
"additionalProperties": false,
"properties": {
"image": {
"type": "object",
"additionalProperties": false,
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
}
},
"resources": { "type": "object" },
"securityContext": { "type": "object" }
}
},
"aptly": {
"type": "object",
"additionalProperties": false,
"properties": {
"architectures": { "type": "array", "items": { "type": "string" } },
"logLevel": { "type": "string", "enum": ["debug", "info", "warn", "error"] },
"logFormat": { "type": "string", "enum": ["default", "json"] },
"download": {
"type": "object",
"additionalProperties": false,
"properties": {
"concurrency": { "type": "integer", "minimum": 1 },
"limit": { "type": "integer", "minimum": 0 },
"retries": { "type": "integer", "minimum": 0 },
"sourcePackages": { "type": "boolean" }
}
},
"publishing": {
"type": "object",
"additionalProperties": false,
"properties": {
"skipContents": { "type": "boolean" },
"skipBz2": { "type": "boolean" }
}
},
"metrics": {
"type": "object",
"additionalProperties": false,
"properties": { "enabled": { "type": "boolean" } }
},
"swagger": {
"type": "object",
"additionalProperties": false,
"properties": { "enabled": { "type": "boolean" } }
},
"gpg": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"verify": { "type": "boolean" },
"provider": { "type": "string", "enum": ["gpg", "internal"] },
"signingKey": {
"type": "object",
"additionalProperties": false,
"properties": {
"existingSecret": { "type": "string" },
"privateKey": { "type": "string" },
"passphrase": { "type": "string" }
}
},
"publishPublicKey": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"path": { "type": "string" }
}
}
}
},
"gpgKeys": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "armored"],
"properties": {
"name": { "type": "string" },
"armored": { "type": "string" }
}
}
},
"localRepos": {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" },
"comment": { "type": "string" },
"defaultDistribution": { "type": "string" },
"defaultComponent": { "type": "string" }
}
}
},
"mirrors": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "archiveURL", "distribution"],
"properties": {
"name": { "type": "string" },
"archiveURL": { "type": "string" },
"distribution": { "type": "string" },
"components": { "type": "array", "items": { "type": "string" } },
"architectures": { "type": "array", "items": { "type": "string" } },
"filter": { "type": "string" },
"filterWithDeps": { "type": "boolean" },
"downloadSources": { "type": "boolean" },
"downloadUdebs": { "type": "boolean" },
"downloadInstaller": { "type": "boolean" },
"downloadAppStream": { "type": "boolean" },
"ignoreSignatures": { "type": "boolean" },
"keyrings": { "type": "array", "items": { "type": "string" } }
}
}
},
"publish": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "distribution"],
"properties": {
"name": { "type": "string" },
"prefix": { "type": "string" },
"distribution": { "type": "string" },
"sourceKind": { "type": "string", "enum": ["local", "snapshot"] },
"sources": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "component"],
"properties": {
"name": { "type": "string" },
"component": { "type": "string" }
}
}
},
"architectures": { "type": "array", "items": { "type": "string" } },
"acquireByHash": { "type": "boolean" },
"skipContents": { "type": "boolean" },
"skipBz2": { "type": "boolean" }
}
}
},
"configOverrides": { "type": "object", "additionalProperties": true },
"existingSecretEnv": { "type": "array", "items": { "type": "string" } }
}
},
"security": {
"type": "object",
"additionalProperties": false,
"properties": {
"preset": { "type": "string", "enum": ["open", "publicRead", "authenticated", "readOnly"] },
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"users": { "type": "object", "additionalProperties": { "type": "string" } },
"existingSecret": { "type": "string" },
"internalUser": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"username": { "type": "string" }
}
}
}
},
"trustedProxies": { "type": "array", "items": { "type": "string" } },
"read": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"requireAuth": { "type": ["boolean", "null"] },
"allowCIDRs": { "type": "array", "items": { "type": "string" } }
}
},
"write": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"requireAuth": { "type": ["boolean", "null"] },
"allowCIDRs": { "type": "array", "items": { "type": "string" } },
"inClusterOnly": { "type": "boolean" }
}
}
}
},
"proxy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"compatPaths": { "type": "boolean" },
"maxUploadSize": { "type": "string" },
"readTimeout": { "type": "string" },
"publishEndpointName": { "type": "string" }
}
},
"persistence": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"existingClaim": { "type": "string" },
"storageClass": { "type": "string" },
"accessMode": { "type": "string", "enum": ["ReadWriteOnce", "ReadWriteOncePod"] },
"size": { "type": "string" },
"annotations": { "type": "object" }
}
},
"workload": {
"type": "object",
"additionalProperties": false,
"properties": {
"updateStrategy": {
"type": "object",
"additionalProperties": false,
"properties": { "type": { "type": "string", "enum": ["RollingUpdate", "OnDelete"] } }
},
"podManagementPolicy": { "type": "string", "enum": ["OrderedReady", "Parallel"] },
"revisionHistoryLimit": { "type": "integer", "minimum": 0 },
"terminationGracePeriodSeconds": { "type": "integer", "minimum": 0 },
"annotations": { "type": "object" },
"podAnnotations": { "type": "object" },
"podLabels": { "type": "object" }
}
},
"podSecurityContext": { "type": "object" },
"containerSecurityContext": { "type": "object" },
"resources": { "type": "object" },
"probes": {
"type": "object",
"additionalProperties": false,
"properties": {
"startup": { "type": "object" },
"readiness": { "type": "object" },
"liveness": { "type": "object" }
}
},
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer" },
"annotations": { "type": "object" }
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"mode": { "type": "string", "enum": ["single", "split"] },
"className": { "type": "string" },
"annotations": { "type": "object", "additionalProperties": true },
"repo": {
"type": "object",
"additionalProperties": false,
"properties": {
"host": { "type": "string" },
"path": { "type": "string" },
"pathType": { "type": "string", "enum": ["Prefix", "Exact", "ImplementationSpecific"] },
"tls": { "type": "array" }
}
},
"api": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"host": { "type": "string" },
"className": { "type": "string" },
"annotations": { "type": "object", "additionalProperties": true },
"tls": { "type": "array" }
}
}
}
},
"gateway": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"apiVersion": { "type": "string" },
"mode": { "type": "string", "enum": ["single", "split"] },
"parentRefs": { "type": "array" },
"repo": {
"type": "object",
"additionalProperties": false,
"properties": {
"hostnames": { "type": "array", "items": { "type": "string" } },
"path": { "type": "string" },
"pathType": { "type": "string", "enum": ["PathPrefix", "Exact", "RegularExpression"] }
}
},
"api": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"hostnames": { "type": "array", "items": { "type": "string" } },
"parentRefs": { "type": "array" }
}
}
}
},
"metrics": {
"type": "object",
"additionalProperties": false,
"properties": {
"service": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"port": { "type": "integer" },
"annotations": { "type": "object" }
}
},
"serviceMonitor": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"interval": { "type": "string" },
"labels": { "type": "object" },
"relabelings": { "type": "array" }
}
}
}
},
"reconcile": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"mode": { "type": "string", "enum": ["hook", "job", "manual"] },
"failOnError": { "type": "boolean" },
"timeoutSeconds": { "type": "integer", "minimum": 1 },
"image": { "type": "object" },
"resources": { "type": "object" }
}
},
"podDisruptionBudget": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxUnavailable": { "type": "integer", "minimum": 0 }
}
},
"networkPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"allowedNamespaces": { "type": "array", "items": { "type": "string" } },
"extraIngress": { "type": "array" },
"egress": {
"type": "object",
"additionalProperties": false,
"properties": {
"allowAll": { "type": "boolean" },
"extra": { "type": "array" }
}
}
}
},
"extraEnv": { "type": "array" },
"extraEnvFrom": { "type": "array" },
"extraVolumes": { "type": "array" },
"extraVolumeMounts": { "type": "array" },
"extraInitContainers": { "type": "array" },
"extraContainers": { "type": "array" },
"nodeSelector": { "type": "object" },
"tolerations": { "type": "array" },
"affinity": { "type": "object" },
"topologySpreadConstraints": { "type": "array" },
"priorityClassName": { "type": "string" },
"global": {
"type": "object",
"additionalProperties": false,
"properties": {
"imageRegistry": { "type": "string" },
"imagePullSecrets": { "type": "array" },
"defaultStorageClass": { "type": "string" }
}
}
}
}
+322
View File
@@ -0,0 +1,322 @@
nameOverride: ""
fullnameOverride: ""
# -- Container image for the aptly server itself (also used for the
# initContainer and the reconcile Job — all three run the same image).
image:
repository: git.morlana.online/f.weber/aptly
tag: "" # "" -> .Chart.AppVersion, i.e. the last released image. Never "latest".
pullPolicy: IfNotPresent
pullSecrets: []
# -- The read/auth sidecar. A plain upstream image — this chart owns none of
# its code, only its rendered config (see `security` below).
nginx:
image:
repository: nginxinc/nginx-unprivileged
tag: "1-alpine"
pullPolicy: IfNotPresent
resources: {}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
# =============================================================================
# aptly configuration. Two layers, always merged in this order:
# 1. the curated keys below (omitted from the rendered config when unset,
# so a default install matches aptly's own upstream defaults exactly)
# 2. aptly.configOverrides — raw aptly YAML, deep-merged last, always wins.
# Every current and future aptly config key is reachable here without a
# chart change. See rootfs/usr/local/bin/aptly-init and
# https://github.com/aptly-dev/aptly/blob/master/utils/config.go for the
# full field list (snake_case yaml tags).
# =============================================================================
aptly:
architectures: []
logLevel: info
logFormat: json
download:
concurrency: 4
limit: 0
retries: 0
sourcePackages: false
publishing:
skipContents: false
skipBz2: false
metrics:
enabled: false
swagger:
enabled: false
gpg:
# false -> gpg_disable_sign: true AND Signing.Skip: true on every publish
# call the reconcile Job makes (both are required — aptly's publish API
# does not consult gpg_disable_sign on its own, see docs/security.md).
enabled: true
verify: true
# gpg (default): the real gnupg binary, in the image already — handles
# armored keys, subkeys and passphrases the way upstream aptly expects.
# internal: pure-Go openpgp, no gnupg binary needed, smaller attack
# surface — verify your key type works with it before switching.
provider: gpg
signingKey:
# Secret with key "privateKey" (armored .asc) or "secretKeyring"
# (binary secring.gpg), optionally "passphrase". Never put a real key
# inline in values.yaml — this is the production path.
existingSecret: ""
# Discouraged escape hatch for quick tests only.
privateKey: ""
passphrase: ""
publishPublicKey:
enabled: true
path: /signing-key.asc
# Trusted keys imported into GNUPGHOME on every start, for mirror
# signature verification. The keyring is therefore a pure function of
# values.yaml — restart the pod to pick up an edit here.
# Inline ASCII-armored public keys only (Helm has no network access at
# render time to support a `url:`/`keyserver:` form the way a plain script
# could — fetch the key yourself once and paste it here).
gpgKeys: []
# - name: debian-archive
# armored: |
# -----BEGIN PGP PUBLIC KEY BLOCK-----
# ...
# Declarative desired state, reconciled by the post-install/post-upgrade
# Job against the REST API (never the CLI — see reconcile.mode below and
# rootfs/usr/local/bin/aptly-reconcile for the exact field reference and
# the documented limitation on editing mirrors[].components after creation).
localRepos: []
# - name: stable
# comment: "Production package repository"
# defaultDistribution: stable
# defaultComponent: main
mirrors: []
# - name: debian-security
# archiveURL: http://security.debian.org/debian-security
# distribution: trixie-security
# components: [main]
# architectures: [amd64, arm64]
publish: []
# - name: stable-root
# prefix: "" # "" = repo root
# distribution: stable
# sourceKind: local # local | snapshot
# sources: [{ name: stable, component: main }]
# architectures: [amd64, arm64]
# acquireByHash: true
# Raw passthrough, deep-merged over the generated config last. See header.
configOverrides: {}
# Existing Secrets to envFrom into the aptly container, so configOverrides
# can reference ${VAR} placeholders (e.g. S3 credentials) that resolve from
# Secrets you already manage, without ever putting them in values.yaml.
existingSecretEnv: []
# =============================================================================
# Security matrix — one preset switch, escape hatches for every axis. See
# docs/security.md for the full decision table.
# =============================================================================
security:
# open: read+write, no auth, no exceptions — the explicit "unabgesichert"
# mode. publicRead (default): read is open, write needs Basic Auth.
# authenticated: both need Basic Auth. readOnly: write returns 404.
preset: publicRead
auth:
# name: plaintext password. Hashed into htpasswd by the initContainer at
# pod start (never a bcrypt/apr1 hash here — see rootfs/.../aptly-init
# for why: a template-side hash would change, hence restart-loop, on
# every single helm upgrade).
users: {}
# Secret key "htpasswd" (pre-hashed) — the recommended production path,
# e.g. via ExternalSecrets/SealedSecrets. Wins over `users` when set.
existingSecret: ""
internalUser:
# Always appended to htpasswd: the reconcile Job talks to nginx (not
# directly to aptly, which is loopback-only), so it needs credentials
# in every preset, including existingSecret + authenticated.
enabled: true
username: aptly-internal
# CIDRs matched against $remote_addr. Behind an Ingress controller that is
# the CONTROLLER's pod IP, not the real client — set trustedProxies to the
# controller's CIDR (via X-Forwarded-For) or use networkPolicy instead. The
# chart renders a warning comment into nginx.conf when allowCIDRs is set
# without trustedProxies.
trustedProxies: []
read:
enabled: true
requireAuth: null # null = take the preset's value; true/false overrides it
allowCIDRs: []
write:
enabled: true
requireAuth: null
allowCIDRs: []
# true: do not render the API Ingress at all (regardless of ingress.api.*)
# and rely on networkPolicy for isolation — an honest implementation, not
# an nginx trick.
inClusterOnly: false
# proxy.enabled=false hands aptly's unauthenticated write API directly to
# whatever can reach the Service — the chart refuses to render an Ingress in
# that combination unless security.preset is explicitly "open" (see
# templates/NOTES.txt / the `fail` guard in templates/_helpers.tpl).
proxy:
enabled: true
# nginx additionally serves the same tree under /repos/<name>/, matching
# aptly's own serve_in_api_mode URL shape, so toggling this flag never
# breaks an already-deployed sources.list.
compatPaths: true
maxUploadSize: "0" # nginx client_max_body_size; "0" = unlimited
readTimeout: "3600s"
publishEndpointName: public
persistence:
enabled: true
existingClaim: "" # set this in production — see docs/operations.md
storageClass: ""
accessMode: ReadWriteOnce
size: 20Gi
annotations: {}
workload:
updateStrategy:
type: RollingUpdate # safe here: a StatefulSet with replicas=1 always
# terminates the old pod before creating the new one
podManagementPolicy: OrderedReady
revisionHistoryLimit: 3
terminationGracePeriodSeconds: 60
annotations: {}
podAnnotations: {}
podLabels: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
seccompProfile: { type: RuntimeDefault }
containerSecurityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
resources: {}
probes:
startup: { periodSeconds: 5, failureThreshold: 60 }
readiness: { periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 }
liveness: { periodSeconds: 30, timeoutSeconds: 5, failureThreshold: 6 }
service:
type: ClusterIP
port: 8080
annotations: {}
ingress:
enabled: false
mode: single # single | split — see docs/security.md
className: ""
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
repo:
host: ""
path: /
pathType: Prefix
tls: []
# - hosts: [apt.example.com]
# secretName: apt-tls
api:
enabled: false
host: ""
className: ""
annotations: {}
tls: []
# Gateway API HTTPRoute support — fully independent of `ingress.*` above, and
# safe to enable at the same time as it (e.g. mid-migration between the two:
# both can point at the same Service simultaneously, see docs/security.md).
# This chart never creates a Gateway itself, only HTTPRoutes attaching to one
# your cluster admin already manages — TLS is that Gateway listener's job, not
# something set here.
gateway:
enabled: false
# Core Gateway API resources are apiVersion gateway.networking.k8s.io/v1 (GA
# since v1.0) — override only if your cluster's CRDs are still pre-GA.
apiVersion: gateway.networking.k8s.io/v1
mode: single # single | split — same meaning as ingress.mode, see docs/security.md
# Referenced Gateway(s). Required when gateway.enabled is true.
parentRefs: []
# - name: my-gateway
# namespace: gateway-infra # optional, defaults to this release's namespace
# sectionName: https # optional, binds to one named listener
repo:
hostnames: [] # e.g. [apt.example.com] — omit to match the Gateway listener's own hostname(s)
path: /
pathType: PathPrefix # PathPrefix | Exact | RegularExpression — Gateway API's own enum, distinct from ingress.repo.pathType's
api:
enabled: false
hostnames: []
parentRefs: [] # override for the API route only — falls back to gateway.parentRefs when empty
metrics:
service:
enabled: false
port: 9090
annotations: {}
serviceMonitor:
enabled: false
interval: 30s
labels: {}
relabelings: []
reconcile:
enabled: true
# hook (default): post-install,post-upgrade Helm hook Job.
# job: a plain Job named with a hash of the desired state, for GitOps
# controllers (ArgoCD/Flux) that dislike Helm hooks.
# manual: render the state ConfigMap only.
mode: hook
failOnError: false
timeoutSeconds: 600
image: {} # override repository/tag/pullPolicy; defaults to the main `image`
resources: {}
podDisruptionBudget:
enabled: false
maxUnavailable: 1
networkPolicy:
enabled: false
allowedNamespaces: []
extraIngress: []
egress:
# A default-deny egress policy silently breaks every mirror — this stays
# true until you have a specific reason to lock it down.
allowAll: true
extra: []
extraEnv: []
extraEnvFrom: []
extraVolumes: []
extraVolumeMounts: []
extraInitContainers: []
extraContainers: []
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
priorityClassName: ""
global:
imageRegistry: ""
imagePullSecrets: []
defaultStorageClass: ""