Public Access
- Updated Chart.yaml to version 0.2.0 and added annotations for changes. - Modified release-chart.yaml to trigger releases via Gitea and handle pre-releases. - Introduced ServiceAccount configuration in values.yaml and related templates. - Adjusted internal container ports to prevent conflicts between nginx and aptly. - Updated README.md and NOTES.txt to reflect new configurations and usage instructions.
408 lines
19 KiB
Markdown
408 lines
19 KiB
Markdown
# 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/charts/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/charts/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` | `{}` | |
|
|
|
|
### ServiceAccount
|
|
|
|
| Key | Default | Description |
|
|
|---|---|---|
|
|
| `serviceAccount.create` | `true` | creates a dedicated `ServiceAccount` for this release |
|
|
| `serviceAccount.name` | `""` | `create: true` → defaults to the release's fullname; `create: false` → set this to bind an existing `ServiceAccount`, or leave `""` to use the namespace's `default` one |
|
|
| `serviceAccount.annotations` | `{}` | e.g. for IRSA/Workload Identity |
|
|
| `serviceAccount.automountServiceAccountToken` | `true` | |
|
|
|
|
### 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` | external port — unaffected by `ports.*` below, which are internal-only |
|
|
| `service.annotations` | `{}` | |
|
|
| `ports.aptly` | `8080` | container port aptly itself listens on (loopback-only unless `proxy.enabled: false`) |
|
|
| `ports.nginx` | `8081` | container port nginx listens on for repo + API traffic; must differ from `ports.aptly` — they're two containers sharing one pod network namespace, and a clash makes nginx fail to start with "address already in use" |
|
|
| `ports.metrics` | `9090` | container port nginx listens on for the `/api/metrics` passthrough, when `metrics.service.enabled` |
|
|
| `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.
|