Compare commits

..

31 Commits

Author SHA1 Message Date
unkin-agent 5a23146513 logging: set clickhouse readonly profile to readonly=2 (#409)
ci/woodpecker/pr/vector-test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful
## Why

The `readonly` profile used by `logreader` sets `readonly=1`, which rejects any per-query settings change. HTTP clients (logviewer, chlog) send settings params like `max_execution_time` with their queries, so every query fails with `Code: 164 DB::Exception: Cannot modify 'max_execution_time' setting in readonly mode` — logviewer healthz returns 503 and CrashLoops, chlog queries fail. `readonly=2` keeps data access read-only while permitting settings changes, which is what HTTP clients need.

## Changes

- Changes the `readonly` profile in `apps/base/logging/clickhouseinstallation.yaml` from `readonly: "1"` to `readonly: "2"`

Reviewed-on: #409
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 22:36:52 +10:00
unkin-agent 03c56c0ff0 arrstack: raise arrstack-postgres resources after OOMKills (#408)
## Why

Both `arrstack-postgres` replicas were OOMKilled at the 1Gi memory limit on 2026-08-23 07:19, causing a failover to timeline 14, leaving replica-3 unrecoverable via pg_rewind, and blocking the argocd sync wave on Cluster Ready=False.

## Changes

- Raises the CNPG Cluster resources to requests 1Gi memory / 250m CPU and limits 2Gi memory / 1 CPU

**NOTE:** applying this restarts the postgres instances (CNPG rolling restart).

Reviewed-on: #408
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 22:01:25 +10:00
unkin-agent 61b5a7f695 Add Traefik Grafana dashboard (#407)
## Why

Traefik metrics (traefik_entrypoint_requests_total, traefik_service_*, etc.) are now flowing into VictoriaMetrics with the relabeled `traefik_instance` label, but there is no dashboard to view them.

## Changes

- Add `apps/base/grafana/dashboards/traefik.yaml`: GrafanaDashboard CR mirroring the redis.yaml sibling (gzipJson embed, `instanceSelector dashboards: grafana`, `resyncPeriod: 5m`, `allowCrossNamespaceImport: false`)
- Embed the official Traefik Standalone dashboard (grafana.com ID 17346, revision 9), adapted:
  - strip `__inputs`/`__requires`/`__elements` and the `DS_PROMETHEUS` datasource variable
  - rewrite all prometheus datasource uids to the estate VictoriaMetrics uid `det2y55dac4jkc`
  - add an `instance` template variable over `traefik_instance` and filter every query with `traefik_instance=~"$instance"` so traefik-internal / traefik-external are switchable
  - chain the entrypoint/service variable queries through the instance filter
- Register the dashboard in `apps/base/grafana/kustomization.yaml`

Validated: gzipJson decode roundtrip, `kustomize build`, kubeconform.
Reviewed-on: #407
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 21:56:45 +10:00
unkin-agent 8da505ceb4 arrproxy: bump image versions to v0.3.0 (#406)
Ship icon grid + jellyfin tile features by upgrading arrproxy UI and API to v0.3.0.

- Bump arrproxy-api image from v0.2.0 to v0.3.0
- Bump arrproxy-ui image from v0.2.0 to v0.3.0
- Verified images built and pushed to artifactapi

Reviewed-on: #406
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 21:38:43 +10:00
unkin-agent f6c297dcb5 Add logviewer.unkin.net internal DNS record (#405)
## Why
logviewer.unkin.net does not resolve, so the log viewer UI is unreachable even though its Gateway and certificate already exist (from #404).

## Changes
- Adds DNSRecord `logviewer-dns-internal` (A, ttl 600 → 198.18.200.4, the internal traefik gateway VIP) to bind-internal authoritative records, mirroring the existing ghp/identity records

Reviewed-on: #405
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 21:38:27 +10:00
unkin-agent e608dadae7 observability: fix dropped kubelet/cadvisor metrics, add kube-state-metrics + VMAlert k8s.rules (#399)
## Why

Kubernetes container metrics are entirely missing from VictoriaMetrics, leaving the CNPG dashboard CPU panels empty. The `kubernetes-nodes`/`kubernetes-nodes-cadvisor` scrape jobs use a blanket `labelmap` on `__meta_kubernetes_node_label_(.+)`, copying ~50+ node-feature-discovery labels onto every series; every kubelet/cadvisor series then exceeds vminsert's 40-label default and 100% are silently dropped (`vm_rows_ignored_total{reason="too_many_labels"}` ~38B). On top of that, kube-state-metrics, VMAlert and the kube-prometheus `k8s.rules` recording rules were never deployed, so `node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate` (and companions) have no producer.

## Changes

- replaces the blanket node-label `labelmap` in both node scrape jobs with a single `__meta_kubernetes_node_name -> node` relabel
- renames the node jobs so series carry `job="kubelet"` plus `metrics_path` (`/metrics` and `/metrics/cadvisor`), matching kube-prometheus selectors; the cadvisor job keeps a distinct `job_name` (uniqueness requirement) and forces `job=kubelet` via relabel
- adds kube-state-metrics v2.20.0 (Deployment, standard ClusterRole/Binding, headless Service, VMServiceScrape with `jobLabel` yielding `job="kube-state-metrics"`), image routed through the artifactapi `k8s-registry` remote
- adds a VMAlert CR (datasource vmselect-main, remoteWrite/remoteRead vminsert/vmselect-main, `notifier.blackhole` since it only evaluates recording rules)
- adds a VMRule with the kube-prometheus `k8s.rules` recording group (cpu sum_irate, memory working_set/rss/cache/swap, namespace requests/limits sums, `kube_pod_owner` workload relabel rules)

## Prerequisite

The artifactapi `k8s-registry` remote exists but its `patterns` allowlist (`^autoscaling/vpa-`, `^external-dns/`, `^sig-storage/`) does not cover `kube-state-metrics/` — terraform-artifactapi needs `^kube-state-metrics/` added to `config/remote_docker/k8s-registry.yaml` before/with this merge, or the KSM pod will ImagePullBackOff.

## Notes

- validated with `kustomize build` (base + au-syd1 overlay) and kubeconform (VM CRD schemas skipped)
- the grafana CNPG dashboard also has stale datasource uids — separate follow-up, deliberately not touched here

Reviewed-on: #399
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 21:38:06 +10:00
unkin-agent 13a04a6ebc Export and scrape metrics from all redis/valkey instances (#398)
## Why

No redis/valkey instance managed in this repo exports metrics today — the six caches (artifactapi, authentik, litellm, gitea, netbox, jellyfin) are blind spots in VictoriaMetrics.

## What

- Enables the valkey-operator exporter sidecar on the jellyfin ValkeyCluster (`exporter.enabled: true`); overrides the operator's default bare-dockerhub image (`oliver006/redis_exporter:v1.80.0`) with the artifactapi-proxied pin. The operator manages a dedicated `_exporter` ACL user for it.
- Adds a `metrics-exporter` sidecar (`artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0`, :9121, port name `metrics`, req 32Mi/25m lim 64Mi/100m) to the five raw Deployments: artifactapi/authentik/litellm redis and gitea/netbox valkey. gitea/netbox sidecars get the same restrictive securityContext as their valkey container.
- Adds the `monitoring.unkin.net/redis-exporter: "true"` opt-in pod label to all five pod templates.
- Adds `apps/base/observability/vmpodscrape-redis.yaml` with two VMPodScrapes (any namespace, port `metrics`): `redis-exporters` selecting the opt-in label, and `valkey-operator-clusters` selecting `app.kubernetes.io/managed-by: valkey-operator` + `app.kubernetes.io/component: valkey-node` — the ValkeyCluster CR has no pod-label passthrough (verified against the v0.5.0 CRD/source), so operator pods cannot carry the opt-in label.

## Notes

- No instance runs with `requirepass`/auth, so no REDIS_PASSWORD wiring is needed on the raw Deployments; the jellyfin exporter auth is operator-managed.
- Validated: `kustomize build` on every touched base + au-syd1 overlay (helm overlays with `--enable-helm`) and `kubeconform -strict -ignore-missing-schemas` (68 valid, 0 invalid).

Reviewed-on: #398
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 19:19:16 +10:00
unkin-agent 08a3493097 logging: deploy logviewer v0.1.0 behind oauth2-proxy (#404)
## Why

The logviewer UI gives browser access to the ClickHouse log store (`logreader` user). It must only be reachable by authorized users, so it sits behind an Authentik-gated oauth2-proxy front at https://logviewer.unkin.net, mirroring the arrstack front-door and mediamover/pdbmux gateway patterns.

## Changes

- Adds the logviewer Deployment (1 replica, `docker-internal/logviewer:v0.1.0`) reading ClickHouse at `clickhouse-logs` via the existing `clickhouse-logreader` Secret, `/healthz` probes, nonroot securityContext
- Adds an oauth2-proxy (quay.io/oauth2-proxy/oauth2-proxy:v7.15.3) front with issuer `https://identity.unkin.net/application/o/logviewer/`, `ak_groups` claim, allowed group `akP-logviewer-admin`, internal-CA trust via the combine-certs initContainer
- Syncs kv `kubernetes/namespace/logging/default/oauth-credentials` into the `logviewer-oauth-credentials` Secret via the existing `default` VaultAuth (kv path already seeded; no terraform-vault change needed)
- Adds ClusterIP Services for the app and the oauth2-proxy front door
- Adds internal Gateway + HTTPRoutes for `logviewer.unkin.net` (vault-issuer cert, external-dns -> 198.18.200.4, http->https redirect)
- Registers `logviewer/` in the logging base kustomization

## Dependency

Login requires the Authentik `logviewer` provider/application from terraform-authentik PR #21. This PR is safe to merge first: the VSO secret sync and the deployment come up regardless; OIDC login only works once #21 is applied.

Validated with `kustomize build` (base + au-syd1 overlay) and kubeconform (49/49 valid).

Reviewed-on: #404
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 18:51:15 +10:00
unkin-agent 4e3c8288e2 Bump radarr to v6.4.2-unkin7 (#403)
Ship Commands-index migration (PR #13) which adds partial indexes on Commands for queue claim polling.

- Bump radarr image to v6.4.2-unkin7 in apps/base/arrstack/radarr/deployment.yaml

Reviewed-on: #403
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 17:15:54 +10:00
unkin-agent c649cc7ee3 Bump prowlarr to v2.6.2-unkin6 (#402)
Ship Commands-index migration.

- Update prowlarr image tag to v2.6.2-unkin6 in apps/base/arrstack/prowlarr/deployment.yaml

Reviewed-on: #402
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 17:13:54 +10:00
unkin-agent cd51078d79 logging: add read-only clickhouse logreader user + Vault-synced secret (#400)
## Why
CLI tools and the logviewer UI need ClickHouse access without write or DDL rights. The only existing app user (`vector`) has `access_management` and write access; this adds a properly scoped read-only login using the same secret-sourcing mechanism.

## Changes
- Adds a `logreader` user to the `logs` ClickHouseInstallation: password hash via `secretKeyRef` to the Vault-synced `clickhouse-logreader` Secret (same mechanism as `vector`), networks `::/0`, `readonly` profile, scoped to the `logs` database only
- Adds a `readonly` profile (`readonly: 1`, same memory/time limits as default)
- Adds a `clickhouse-logreader` VaultStaticSecret mirroring the existing `clickhouse-credentials` object (kv-v2, 5m refresh, VSO-created destination Secret)

## Vault kv
Seeded at `kv/kubernetes/namespace/logging/default/clickhouse-logreader` (version 1) with keys: `username`, `password`, `password_sha256_hex`. No secret material lands in git; the CHI only ever sees the sha256 hash via the synced Secret.

Reviewed-on: #400
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 17:00:07 +10:00
unkin-agent b5acb19ac0 Bump arrstack image pins: radarr v6.4.2-unkin6, sonarr v5.0.0-unkin6 (#401)
Ship ffprobe exec bit fix (radarr) and Commands-index migration (sonarr).

- Update radarr image tag v6.4.2-unkin5 → v6.4.2-unkin6 (ffprobe fix)
- Update sonarr image tag v5.0.0-unkin5 → v5.0.0-unkin6 (Commands-index)

Reviewed-on: #401
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 16:57:27 +10:00
unkin-agent ba71bd1a35 Scrape traefik gateway metrics into VictoriaMetrics (#397)
## Why

Both traefik instances (traefik-internal/traefik-external, chart 40.0.0, image v3.7.0) already expose prometheus metrics on container port `metrics` (9100), but nothing scrapes them — VictoriaMetrics has no traefik series (verified empty). The observability VMAgent runs with selectAllByDefault and consumes VM*Scrape CRs only, so a VMPodScrape is all that is needed.

## Changes

- Adds `apps/base/observability/vmpodscrape-traefik.yaml`: scrapes port `metrics` /metrics on `app.kubernetes.io/name: traefik` pods in traefik-system, relabeling `app.kubernetes.io/instance` to a stable `traefik_instance` label to split internal vs external series
- Registers the new CR in the observability base kustomization
- Enables `metrics.prometheus.addEntryPointsLabels` and `addRoutersLabels` in both au-syd1 traefik values files for per-entrypoint/per-route dashboards (pods restart on rollout — acceptable)

Validated with `kustomize build` (observability base + overlay, traefik-system overlay with --enable-helm) and kubeconform (unknown CRDs skipped); rendered deployments carry the new metric flags and the VMPodScrape renders in the overlay.

Reviewed-on: #397
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 12:41:58 +10:00
unkin-agent 8fd1d83b1b Add Redis/Valkey Grafana dashboard (#396)
## Why

The grafana-operator estate has no dashboard for Redis/Valkey; redis_exporter instances are being added in a parallel PR and need visibility.

## Changes

- Adds `apps/base/grafana/dashboards/redis.yaml`, a GrafanaDashboard CR embedding grafana.com dashboard 763 ("Redis Dashboard for Prometheus Redis Exporter 1.x") as gzipJson like the sibling dashboards, with namespace/instance template variables over `redis_up` for multi-instance use and all datasource refs pinned to the VictoriaMetrics datasource uid (`det2y55dac4jkc`), registered in the grafana kustomization

Reviewed-on: #396
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 12:38:55 +10:00
unkin-agent 19f7afac92 Add shared arrstack Valkey and wire arr apps' Redis features (#394)
Activates the arr forks (sonarr/radarr/prowlarr) #14 Redis features — SignalR backplane, cross-replica cache-invalidation bus, and distributed rate limiter — which ship deployed but inert because no Valkey exists in arrstack and nothing is wired to it. Ben wants ONE Valkey shared by all three apps.

## Changes
- Adds a single shared `ValkeyCluster` (`arrstack-valkey`) under `apps/base/arrstack/valkey/`, modeled on `jellyfin-valkey`: `shards:1`/`replicas:2` HA (one primary + two replicas, automatic failover), image via artifactapi (`artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0`), operator-default passwordless `default` user, node spread across hosts, cluster-aware PDB, ephemeral `/data`.
- Registers the `valkey` component in the arrstack base kustomization.
- Wires `<App>__Redis__Host` + `<App>__Redis__Port` into the `sonarr-env` / `radarr-env` / `prowlarr-env` ConfigMaps, all pointing at the shared service `valkey-arrstack-valkey.arrstack.svc.cluster.local:6379`.

## Notes
- Setting `Host` is the activation switch: the fork's `RedisOptions.IsConfigured` gates purely on a non-empty `Host`, so there is no separate Enabled flag.
- Passwordless (jellyfin parity — the operator leaves the `default` user without auth), so no `Password`/`Ssl` is wired and there is no secret to reference.
- One cluster is safe for all three: each fork namespaces its keys and pub/sub channels by a per-app prefix (`sonarr:ratelimit:` / `radarr:ratelimit:` / `prowlarr:ratelimit:`), so their state never collides.
- App `image:` (-unkin5), the waitfordb initContainer, and S3 buckets are untouched.
- Validated: `kubectl kustomize` renders clean for both `apps/base/arrstack` and `apps/overlays/au-syd1/arrstack`.

Follow-up: after merge, Valkey must come up and the three apps must roll (pick up the new env) before the #14 features can be validated live.
---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #394
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-23 12:37:42 +10:00
unkin-agent b3d202b8fa arrstack: add exportarr metrics sidecars to sonarr and radarr (#395)
Sonarr and radarr currently expose no Prometheus metrics, so queue depth, indexer health, and library stats are invisible to the observability stack. This adds the standard exportarr exporter as a sidecar to both Deployments.

- Adds an exportarr sidecar (ghcr.io/onedr0p/exportarr v2.3.0, pulled through the artifactapi `ghcr` remote, which already whitelists `^onedr0p/`) to the sonarr and radarr Deployments
- Points each exporter at its local replica via env (`URL=http://localhost:8989/sonarr` / `:7878/radarr`, matching the apps' UrlBase) and reuses the existing VSO-synced API-key Secrets (`sonarr-apikey`/`radarr-apikey`, key `apitoken`) via `APIKEY` secretKeyRef — no key in argv
- Serves metrics on 9707 (sonarr) / 9708 (radarr) with `/healthz` liveness+readiness probes and small resources (25m/32Mi requests, 100m/128Mi limits)
- Adds per-app VMPodScrape objects (picked up by the observability VMAgent, selectAllByDefault); pod-level rather than VMServiceScrape because the app Services don't expose the metrics port

Validated with `kustomize build apps/base/arrstack` + kubeconform (0 invalid).

Note: with 3 replicas per app, each pod's exporter reports the same shared-Postgres-backed stats, so series are duplicated across the `pod` label; dashboards should aggregate with `max` or filter to one pod.
Reviewed-on: #395
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 12:35:56 +10:00
unkin-agent 32611bfd16 Deploy mediamover v0.1.0 into arrstack (#393)
Deploys the released mediamover v0.1.0 into the arrstack namespace: a single-replica server (API + UI on :8080) that browses the mediafs source, queues per-file copy/move operations into the media PVCs, and spawns one worker Job per file using its own image — its ServiceAccount/RBAC grants the Job and Pod access that requires. All PVs/PVCs it mounts are already live.

- Adds `apps/base/arrstack/mediamover/` with SA, Role/RoleBinding (batch jobs create/get/list/watch/delete; pods get/list/watch; pods/log get/list), Deployment, Service, Gateway, HTTPRoute
- Runs `mediamover server` with `--src-root /srv/src --src-pvc mediafs`, `--dst-roots movies=/srv/dst/movies,tv=/srv/dst/tv`, `--dst-pvc movies=media-movies,tv=media-tv`, `--namespace arrstack`, `--image ...mediamover:v0.1.0`
- Mounts mediafs RW at /srv/src (move deletes the source) plus media-movies and media-tv destinations; keeps 1 replica with Recreate strategy since the queue is in-memory
- Names the Service `mediamover` on 8080 to match the worker callback default `http://mediamover.arrstack.svc:8080`
- Exposes the UI at https://mediamover.k8s.syd1.au.unkin.net via a dedicated internal Gateway (pdbmux pattern); the existing arrproxy Gateway is external and hostname-locked to arrstack.unkin.net so it cannot carry this route
- Probes hit `GET /api/limit` (the server has no dedicated health endpoint); registers `mediamover` in the arrstack base kustomization

Reviewed-on: #393
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 00:09:40 +10:00
unkin-agent 7c49a9f3ad Deploy arrstack S3 TLS-trust fix (arrstack images -unkin4 -> -unkin5) (#392)
## Why

Ben merged the S3 TLS-trust fix into each Servarr fork's `main` (sonarr#10, radarr#11, prowlarr#10, branch `benvin/s3-tls-trust-fix`). The fix corrects TLS trust for the leaf-only certificate presentation of the Ceph RGW endpoint, so backups and MediaCover uploads to S3 succeed across all replicas instead of failing the TLS handshake.

The `-unkin5` images carrying the fix are built and pushed to `artifactapi.k8s.syd1.au.unkin.net/docker-internal/<app>`. This PR rolls them out.

## Changes

- Bump `apps/base/arrstack/sonarr/deployment.yaml` app image `v5.0.0-unkin4` -> `v5.0.0-unkin5`
- Bump `apps/base/arrstack/radarr/deployment.yaml` app image `v6.4.2-unkin4` -> `v6.4.2-unkin5`
- Bump `apps/base/arrstack/prowlarr/deployment.yaml` app image `v2.6.2-unkin4` -> `v2.6.2-unkin5`

Only the application container `image:` line changes in each file; the `waitfordb` initContainer and bucket YAML are untouched.

Reviewed-on: #392
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 23:15:11 +10:00
unkin-agent 671c075a08 Mount mediafs CephFS into arrstack (#391)
Exposes the legacy mediafs CephFS filesystem to the arrstack namespace so the upcoming mediamover tool can migrate media and run bandwidth tests against the old filesystem; the ceph-mediafs client secret is already seeded in Vault. The mover deployment itself lands in a follow-up PR.

- Adds a `ceph-mediafs-secret` VaultStaticSecret in csi-cephfs syncing `kv/service/kubernetes/au/syd1/csi/ceph-mediafs-secret`
- Adds a 10Ti static RWX PV `arrstack-mediafs` (fsName `mediafs`, rootPath `/`, staged with the mediafs secret) pinned by claimRef
- Adds the matching `mediafs` PVC in arrstack (storageClassName "", volumeName-bound, backups excluded)
- Registers all three in their base kustomizations

Reviewed-on: #391
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 23:05:43 +10:00
unkin-agent 06fe261661 arrstack: deploy -unkin4 (Redis SignalR backplane + S3 storage) (#390)
## Why

The cluster runs the arrstack forks at `-unkin3`. Ben merged two workstreams into each fork's `main`:

- **Redis SignalR backplane + cross-replica cache invalidation** — SignalR messages and cache-invalidation events fan out across replicas via Redis, so any replica serves consistent live UI/state.
- **MediaCover -> S3 + backups -> S3** — MediaCover (sonarr/radarr) and backups (all three) use an optional S3 store, so any stateless replica can serve covers and write/restore backups.

Fresh `-unkin4` tags were cut on each fork's `main` HEAD, building `docker-internal/<app>:<base>-unkin4` images. This bumps the deployment images so ArgoCD rolls the merged work into the cluster.

## Changes

- Bump sonarr app image `v5.0.0-unkin3` -> `v5.0.0-unkin4`
- Bump radarr app image `v6.4.2-unkin3` -> `v6.4.2-unkin4`
- Bump prowlarr app image `v2.6.2-unkin3` -> `v2.6.2-unkin4`

The waitfordb initContainer and bucket resources are unchanged; only the app-container `image:` tags move.

Reviewed-on: #390
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 20:46:48 +10:00
unkin-agent bdf6114794 arrstack: S3 buckets + wiring for app backups and sonarr MediaCover (#389)
## Why

Servarr backups (periodic config+DB zips) and sonarr MediaCover posters still live on each replica's local `/config` emptyDir: backups are leader-local and lost on reschedule, and sonarr covers render inconsistently across the 3 active-active replicas. #387 moved radarr covers to a shared Ceph RGW bucket; this extends that pattern to backups for all three apps and to sonarr's covers.

## Changes

- Add an `arrstack-backups` ObjectStoreUser + Bucket (cephrgw-operator); the operator mints the `arrstack-backups-s3` credential Secret in-namespace (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / S3_ENDPOINT), so no Vault KV seeding is required. Backups get their own bucket (not `arrstack-media`) because they carry secrets (Config.xml API keys, the DB) and warrant separate credentials/lifecycle from poster art; the three apps share it, isolated by a per-app key prefix.
- Wire sonarr to the shared `arrstack-media` bucket via `Sonarr__MediaCoverS3__*` (prefix `sonarr`, partitioned from radarr's `radarr` prefix to avoid movie/series id collisions), plus the estate CA mount at `/etc/ssl/vault-ca/ca.crt` for RGW TLS validation.
- Wire radarr/sonarr/prowlarr backups via `<App>__BackupS3__*` to `arrstack-backups` (per-app prefix); add the CA mount to sonarr and prowlarr.

## Notes

- Credentials come only from the operator-minted Secret via env; no secrets in manifests.
- The env keys are consumed by the -unkin3+ images (separate app PRs) and ignored by older images, so this is safe to apply ahead of the image bump.
- `kubectl kustomize apps/base/arrstack` builds clean.

Reviewed-on: #389
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 18:39:40 +10:00
unkin-agent eb37beb7e4 Deploy arrstack -unkin3 (active-active code) (#388)
Deploy the merged active-active work now on each Servarr fork's main. The cluster still runs the -unkin2 images, so the merged code (K8s process lifecycle, Redis-backed distributed rate limiting, and radarr MediaCover->S3) is not yet deployed. The -unkin3 tag pipelines have built and pushed new images to docker-internal.

Changes:
- Bump sonarr app image v5.0.0-unkin2 -> v5.0.0-unkin3
- Bump radarr app image v6.4.2-unkin2 -> v6.4.2-unkin3
- Bump prowlarr app image v2.6.2-unkin2 -> v2.6.2-unkin3

Touches only the app container image line in each apps/base/arrstack/<app>/deployment.yaml; the waitfordb initContainer is unchanged.

Reviewed-on: #388
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 18:28:14 +10:00
unkin-agent 9cdcdf62bc arrstack: provision shared Ceph RGW bucket for media assets (#387)
## Why

Servarr MediaCover assets (movie/series posters and fanart) and app backups currently live on each replica's local `/config`, which is an `emptyDir`. A poster the leader downloads is invisible to the other replicas behind the Service, so covers render inconsistently and backups are leader-local. This provisions a shared Ceph RGW (S3) bucket to hold those assets instead.

## Changes

- Add an `arrstack-media` `ObjectStoreUser` + `Bucket` (cephrgw-operator), mirroring the CNPG backup pattern. The operator mints the `arrstack-media-s3` credential Secret in-namespace (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`), so **no Vault KV seeding is required**.
- Wire the radarr Deployment to the bucket via `Radarr__MediaCover__S3__*` env (creds from the operator Secret; estate CA mounted at `/etc/ssl/vault-ca/ca.crt` for RGW TLS validation).

## Notes

- The env keys are unknown to the current `-unkin2` image and are ignored by it; they activate once the `-unkin3+` image (radarr MediaCover-S3 support, separate radarr PR) ships. sonarr/prowlarr wiring and backups-to-S3 follow in later PRs.
- `kubectl kustomize apps/base/arrstack` builds clean.

Reviewed-on: #387
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 16:17:23 +10:00
unkin-agent 5155cee5e7 arrstack: use waitfordb initContainer instead of inline psql loop (#386)
## Why

The sonarr/radarr/prowlarr deployments gated app startup on their Postgres
database being reachable using a hand-written `psql`-in-a-shell loop running
the `postgres:17-alpine` image. Now that `unkin/waitfordb` is released
(v0.1.0), replace that inline probe with the purpose-built tool so the wait
logic lives in one tested place instead of being copy-pasted per workload.

`waitfordb` reads the same `PG*` env as a libpq fallback and exits `0` once
`SELECT 1` succeeds under those credentials/database, so the retrofit is a
drop-in — the password still never lands in argv.

## Changes

- Swap the `wait-for-db` initContainer image from `dockerhub/library/postgres:17-alpine`
  to `docker-internal/waitfordb:v0.1.0` (pinned, not `latest`) in all three
  arrstack deployments.
- Drop the `sh -c` psql loop `command` — `waitfordb` is the image entrypoint.
- Keep the existing `PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD` env, the
  `wait-for-db` name, and the resource requests/limits unchanged.
- Add `WAITFORDB_TIMEOUT=5m` so a stuck database fails the pod fast, and
  `WAITFORDB_SSLMODE=disable` to match the in-cluster Postgres.

Validated with `kubectl kustomize apps/overlays/au-syd1/arrstack` (builds clean;
3 waitfordb refs, no remaining postgres init image).

Reviewed-on: #386
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 16:15:45 +10:00
unkin-agent 8446c31414 arrstack: fix sync-wave deadlock + config multi-attach (#385)
## Why
The active-active arrstack deploy (#383) is stuck in-cluster: the app Deployments (sync-wave 0) can never become Healthy without their Postgres DB, so ArgoCD never advances to create the shared `arrstack-postgres` cluster (wave 1). Separately, `/config` is an immutable RWO cephrbd PVC, so 3 replicas fail with Multi-Attach.

## How
- Order DB infra before the apps: `<app>-db` VaultStaticSecrets + backup ObjectStoreUser/Bucket at sync-wave -3, CNPG `Cluster` -2, `Database` CRDs -1; app Deployments stay at default 0.
- Switch each app's `/config` to an emptyDir per-pod volume (library DB is in Postgres, config is env-driven; MediaCover regenerates). Old RWO config PVCs orphan.

Validated with kustomize build (62 resources). arrproxy/nzbget untouched.

Reviewed-on: #385
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-22 12:47:38 +10:00
unkin-agent e0d47295f2 Deploy arrproxy v0.2.0 machine-mint admin route (#384)
## Why

The future Vault engine needs to machine-mint arrproxy tokens without an interactive Authentik session. arrproxy v0.2.0 adds an admin route (`POST /api/admin/...`) protected by its own bearer token; OpenBao running on the VMs calls it through the arrstack ingress. This deploys that route.

## Changes

- Add an `arrproxy-admin-token` VaultStaticSecret (mirrors the `arrproxy-pepper` VSO: same `default` VaultAuth, k8s-auth default-SA pattern) syncing `kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token` into the `arrproxy-admin-token` Secret. The token is seeded in Vault KV as the shared source of truth for the future Vault engine.
- Set `ARRPROXY_ADMIN_TOKEN` on the arrproxy-api Deployment via `secretKeyRef` (key `token`), and add `arrproxy-admin-token` to the Reloader `secret.reloader.stakater.com/reload` annotation so the pod rolls on rotation.
- Skip-auth the `/api/admin/` route in oauth2-proxy: `OAUTH2_PROXY_SKIP_AUTH_REGEX` becomes `^/[^/]+/api,^/api/admin/`. The admin route is intentionally oauth-skipped because it is protected by arrproxy's OWN bearer token so OpenBao can reach it machine-to-machine. `/api/tokens` and `/api/me` are NOT matched and stay oauth-gated. `/api/admin/*` already routes to the arrproxy-api upstream via the existing catch-all `/api/` upstream, so no upstream change is needed.
- Bump arrproxy-api and arrproxy-ui images to `v0.2.0` (kept in lockstep).

## Validation

- `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` succeeds.
- pre-commit passes, including the plain-Secret guard (only the VSO CRD is used; no plain Secret objects added).

Reviewed-on: #384
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-18 22:37:06 +10:00
unkin-agent 187fabb72b arrstack: run sonarr/radarr/prowlarr active-active on shared Postgres (#383)
## Why

The arrstack `sonarr`/`radarr`/`prowlarr` apps currently run as single-replica upstream LinuxServer images on SQLite (RWO config PVC, `Recreate` strategy) — no HA, no horizontal scale, and a hard one-pod ceiling because SQLite can't be shared. Our `-unkin2` forks move all app state into PostgreSQL and use Postgres advisory locks for cross-replica coordination (migrations, scheduled tasks, command execution), which makes an **active-active multi-replica** deployment safe.

This is a fresh Postgres backend — **no SQLite data migration**. The current single-replica pods are throwaway baselines; the new pods start on an empty per-app database and the fork's provider migrates the schema on first start. `arrproxy` (its own `arrproxy-db`) and `nzbget` are untouched.

## What

New `apps/base/arrstack/postgres/`:
- `cnpg_cluster.yaml` — CNPG `Cluster` `arrstack-postgres` (3 instances, `cephrbd-fast-delete`), throwaway `app` initdb owner, and 3 `managed.roles` (`sonarr`/`radarr`/`prowlarr`, login non-superuser) each with `passwordSecret: <app>-db`.
- `database-{sonarr,radarr,prowlarr}.yaml` — CNPG `Database` `<app>-main` owned by `<app>`, `databaseReclaimPolicy: retain`.
- `cnpg_backup.yaml` — cephrgw `ObjectStoreUser` + `Bucket cnpg-arrstack` + nightly `ScheduledBackup` (mirrors jellyfin).
- `vaultstaticsecret.yaml` — 3 VSO `VaultStaticSecret`s syncing `<app>-db` (keys `username`/`password`) from `kv/kubernetes/namespace/arrstack/default/<app>-db` (already seeded); these are both the CNPG role `passwordSecret` and the Deployment's Postgres creds.
- sync-waves: `<app>-db` Secrets (0) → Cluster (1) → Databases (2).
- added `postgres` to `apps/base/arrstack/kustomization.yaml`.

Per app (`sonarr`/`radarr`/`prowlarr`):
- `deployment.yaml`: image → `docker-internal/<app>:v<base>-unkin2` (sonarr `v5.0.0`, radarr `v6.4.2`, prowlarr `v2.6.2`); `replicas: 3`; `RollingUpdate`. Removed the `apikey-init` initContainer and `PUID`/`PGID`/`TZ`. Runs the binary directly (`command: [/app/<Bin>]`, `args: [-nobrowser, -data=/config, -nosingleinstancecheck]`). Env from the new ConfigMap + `secretKeyRef`: `<App>__Postgres__User`/`__Password` (Secret `<app>-db`) and `<App>__Auth__ApiKey` (existing `<app>-apikey`, key `apitoken`). `/<app>/ping` probes kept. `securityContext` runAsUser/runAsGroup/fsGroup 1000 for CephFS group-write.
- new `configmap.yaml`: non-secret `<App>__…` env (Postgres Host/Port/MainDb, `Log__DbEnabled=false`, `Auth__Method=External`, `Auth__Required=DisabledForLocalAddresses`, `App__InstanceName`, `Server__Port`, `Server__UrlBase=/<app>`, `Update__Mechanism=External`).
- `pvc-config.yaml` → RWX `cephfs-raid5-retain` (shared `/config` for `config.xml`/MediaCover).
- unchanged: `service.yaml`, `gateway.yaml`, `httproute.yaml`, apikey `vaultstaticsecret.yaml` — arrproxy still addresses the Service VIP, so N replicas are transparent.

## Validation

`kustomize build apps/overlays/au-syd1/arrstack` clean; `kubeconform` **64/64 valid**; `yamllint` and the repo pre-commit (incl. no-plain-secrets) pass. Runtime validation (pods Ready, single migrate under advisory lock, scheduled-task/command single-owner, UI via `https://arrstack.unkin.net/<app>/`, 3→1→3 rescale) is post-merge once ArgoCD syncs.

## Decisions / unknowns

- **Fork image UID/fsGroup**: the `-unkin2` images have no `USER` (run as root); pinned `runAsUser`/`runAsGroup`/`fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`. Verify group-write on the shared CephFS `/config` on first deploy.
- **Config storage**: chose RWX `cephfs-raid5-retain` shared `/config` (per spec preference) over per-pod `emptyDir`.
- **Postgres storage/version/resources**: mirrored jellyfin/arrproxy CNPG conventions — `cephrbd-fast-delete` 10Gi, PG17, 3 instances.

Reviewed-on: #383
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-18 21:29:59 +10:00
unkin-agent 108d1cb213 arrstack: set *arr AuthenticationMethod=External (proxy-trusted) (#382)
## Why

The *arr UIs reached through **arrproxy** were prompting **"Authentication Required"**. Sonarr v4 (and Radarr/Prowlarr on the same Servarr auth code) refuses remote access when `AuthenticationMethod=None`, so the web UI kept demanding a login even though **arrproxy + oauth2-proxy already authenticate every user at the front door**. That is a double prompt with no purpose.

## Change

Extend the existing `apikey-init` container for **sonarr / radarr / prowlarr** to also idempotently enforce, in `/config/config.xml`:

- `<AuthenticationMethod>External</AuthenticationMethod>`
- `<AuthenticationRequired>Enabled</AuthenticationRequired>`

Create-or-replace both elements (same idempotent sed/printf pattern already used for `<ApiKey>`/`<UrlBase>`), without disturbing ApiKey or UrlBase. Pods use `Recreate`, so they roll and re-run the init container on apply.

## Why this is the correct, header-less fix

In the Servarr v4 source, `AddExternal()` registers the **identical `NoAuthenticationHandler` as `AddNone()`** — `External` requires **no** username header (no `X-Forwarded-User` / `Remote-User`). It differs from `None` only in that it is **exempt from the None remote-access block**, so the UI stops prompting while remote access is permitted.

This matters because arrproxy's `trustBoundary` deliberately **strips all inbound identity headers** and forwards only the real `X-Api-Key` to the upstream *arr — it forwards no username header. Because `External` needs none, that stripping is irrelevant and **no arrproxy change is required**. The API path is unaffected (arrproxy injects the real key; *arr API auth is key-based regardless of AuthenticationMethod).

## Validation

- `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` → OK (rendered init carries the auth logic for all 3 apps)
- pre-commit (yamllint + all hooks) → Passed

Versions in scope: sonarr 4.0.19, radarr 6.3.0, prowlarr 2.5.2 (all share the Servarr v4 auth handler).

---------

Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Reviewed-on: #382
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-18 20:03:18 +10:00
unkin-agent c22fbe435a arrproxy: allow unverified email in oauth2-proxy (Authentik sets email_verified=false) (#381)
Authentik hardcodes email_verified=false in the id_token it issues for the arrstack application. oauth2-proxy rejects such sessions by default ("email ... isnt verified"), so the /oauth2/callback returns a 500 and login fails.

Setting OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true tells oauth2-proxy to accept the id_token and complete the callback. This is safe: authorization is not derived from email verification here -- access is enforced downstream via the ak_groups claim (X-Forwarded-Groups), so accepting the unverified email only lets the OIDC session establish.

Restart mechanism: the arrproxy-oauth2 Deployment already carries configmap.reloader.stakater.com/auto=true and Reloader is deployed/running, so the ConfigMap change rolls the pods automatically after ArgoCD syncs.

---------

Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Reviewed-on: #381
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-17 22:25:09 +10:00
unkin-agent 5466562bc1 arrproxy: use real quay.io oauth2-proxy address (rke2 mirror) (#380)
Repoints the arrproxy `oauth2-proxy` sidecar image to the real upstream address `quay.io/oauth2-proxy/oauth2-proxy:v7.15.3`, replacing the one-off artifactapi copy (`artifactapi.k8s.syd1.au.unkin.net/ghcr/oauth2-proxy/oauth2-proxy:v7.15.3`).

## Why
Pull the image from its canonical upstream location instead of a hand-copied artifactapi path. containerd on rke2 transparently mirrors `quay.io` through artifactapi, so the real address resolves without hardcoding the proxy host.

## Change
- `apps/base/arrstack/arrproxy/oauth2-proxy-deployment.yaml`: image only -> `quay.io/oauth2-proxy/oauth2-proxy:v7.15.3`.

Validated with `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` and pre-commit.

## Dependencies (must be in place before merge)
1. **rke2 `registries.yaml` mirrors quay.io** — already configured in puppet (`hieradata/roles/infra/k8s.yaml`, `rke2::registries` rewrites `quay.io` -> `artifactapi.k8s.syd1.au.unkin.net/quay/$1`). No change needed.
2. **artifactapi quay remote allowlists oauth2-proxy** — terraform-artifactapi PR unkin/terraform-artifactapi#35 adds `^oauth2-proxy/` to the quay remote patterns. **This PR must NOT be merged until #35 is applied**, otherwise the caching proxy rejects `oauth2-proxy/*` and the sidecar fails to pull.

---------

Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Reviewed-on: #380
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-17 21:31:29 +10:00
unkin-agent 76da30d2f7 arrstack: set *arr UrlBase=/<app> for path-based proxy hosting (#378)
## Why
arrproxy forwards `arrstack.unkin.net/<app>/...` to each *arr Service **preserving** the `/<app>` prefix. Without a matching `<UrlBase>` the apps 307-redirect to `/`, breaking path-based hosting. Prerequisite for arrproxy path routing (PR #377... arrproxy service account added in #376).

## Change
Extend the existing idempotent `apikey-init` init container in sonarr/radarr/prowlarr to also enforce `<UrlBase>/<app></UrlBase>` in `/config/config.xml` (sonarr=/sonarr, radarr=/radarr, prowlarr=/prowlarr):

- Set/replace `<UrlBase>` if present, insert after `<Config>` if absent, or write both `<ApiKey>` + `<UrlBase>` when creating a fresh config. `<ApiKey>` handling is unchanged.
- Because Servarr moves all routes (including `/ping`) under the URL base, update liveness/readiness probes from `/ping` to `/<app>/ping` so they don't 404 once UrlBase is set.

nzbget is not fronted by arrproxy and is left untouched.

## Validation
- `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` succeeds
- `pre-commit` (yamllint etc.) passes

---------

Co-authored-by: BenVincent <benvin@main.unkin.net>
Co-authored-by: unkin-agent <unkin-agent@git.unkin.net>
Reviewed-on: #378
Co-authored-by: Unkin Agent <unkin-agent@unkin.net>
Co-committed-by: Unkin Agent <unkin-agent@unkin.net>
2026-08-17 07:59:48 +10:00
83 changed files with 2563 additions and 191 deletions
@@ -7,7 +7,7 @@ metadata:
annotations:
# Wave 2: serve only after the wave-1 migrate Job completes.
argocd.argoproj.io/sync-wave: "2"
secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-db-app,sonarr-apikey,radarr-apikey,prowlarr-apikey"
secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-apikey,radarr-apikey,prowlarr-apikey"
spec:
replicas: 2
selector:
@@ -33,7 +33,7 @@ spec:
type: RuntimeDefault
containers:
- name: api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.1.0
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -59,6 +59,15 @@ spec:
secretKeyRef:
name: arrproxy-pepper
key: pepper
# Machine-mint admin bearer, synced from Vault KV via the
# arrproxy-admin-token VSO. Gates the /api/admin/ route that
# oauth2-proxy intentionally skip-auths so OpenBao on the VMs can
# mint tokens against arrproxy's own bearer.
- name: ARRPROXY_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: arrproxy-admin-token
key: token
# DSN assembled from the CNPG-generated arrproxy-db-app Secret;
# $(VAR) expansion resolves the two env entries defined above it.
- name: ARRPROXY_DB_USER
@@ -33,10 +33,20 @@ data:
# set-xauthrequest is intentionally NOT set -- it only populates auth_request
# *response* headers, which never reach an --upstreams-proxied backend.
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
# Bypass auth ONLY for the *arr proxy API (/<app>/api...): this matches
# /sonarr/api but NOT /api/tokens or /api/me (which stay authenticated).
OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/api"
# Bypass auth for the *arr proxy API (/<app>/api...) and the machine-mint
# admin route (/api/admin/...). The first matches /sonarr/api; the second
# matches /api/admin/ only -- both routed to the arrproxy-api upstream by the
# catch-all /api/ prefix above. /api/admin/ is protected by arrproxy's OWN
# ARRPROXY_ADMIN_TOKEN bearer (OpenBao on the VMs reaches it via the ingress),
# so it is intentionally oauth-skipped. /api/tokens and /api/me are NOT
# matched and stay oauth-authenticated.
OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/api,^/api/admin/"
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; without this
# oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on
# /oauth2/callback. Authorization is enforced downstream via ak_groups, so
# accepting the unverified email here is safe.
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true"
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_COOKIE_DOMAINS: "arrstack.unkin.net"
OAUTH2_PROXY_WHITELIST_DOMAINS: "arrstack.unkin.net"
@@ -62,7 +62,7 @@ spec:
memory: 64Mi
containers:
- name: oauth2-proxy
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/oauth2-proxy:v7.15.3
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3
imagePullPolicy: IfNotPresent
ports:
- containerPort: 4180
@@ -31,7 +31,7 @@ spec:
type: RuntimeDefault
containers:
- name: ui
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.1.0
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
@@ -25,6 +25,33 @@ spec:
type: kv-v2
vaultAuthRef: default
---
# Machine-mint admin bearer token. Seeded (openssl rand) at
# kv/kubernetes/namespace/arrstack/default/arrproxy-admin-token (key: token) and
# shared as the source of truth with the future Vault engine. The default k8s
# role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs it into the arrproxy-admin-token Secret consumed by arrproxy-api as
# ARRPROXY_ADMIN_TOKEN to gate the bearer-protected /api/admin/ route.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: arrproxy-admin-token
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: arrproxy-admin-token
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/arrproxy-admin-token
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Authentik OIDC client for the arrstack front door (client_id, client_secret,
# cookie_secret), created by terraform-authentik at
# kv/kubernetes/namespace/arrstack/default/oauth-credentials. VSO syncs it into
+42
View File
@@ -0,0 +1,42 @@
---
# Shared Ceph RGW (S3) bucket for arrstack application backups (the periodic
# radarr/sonarr/prowlarr config+database zip backups), provisioned by the
# in-estate cephrgw-operator. Backups otherwise land on each replica's local
# /config (an emptyDir) and are lost when that pod is rescheduled; routing them
# to S3 lets any replica write and restore them.
#
# Backups are kept in their own bucket (not the arrstack-media one) because they
# carry secrets (Config.xml holds API keys, the DB has all app state) and thus
# warrant separate credentials, lifecycle and retention from the public-ish
# poster art. The three apps share this one bucket, isolated by a per-app key
# prefix (radarr/, sonarr/, prowlarr/) set via <App>__BackupS3__Prefix.
#
# The operator mints the S3 credential Secret (arrstack-backups-s3) in this
# namespace with keys AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and S3_ENDPOINT,
# so no Vault KV seeding is required.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: arrstack-backups
namespace: arrstack
spec:
displayName: "arrstack backups owner"
uid: arrstack-backups
maxBuckets: 5
secretName: arrstack-backups-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: arrstack-backups
namespace: arrstack
spec:
placementTarget: ec
bucketName: arrstack-backups
ownerRef: arrstack-backups
versioning: false
tags:
app: arrstack
purpose: backups
retainOnDelete: true
+7
View File
@@ -7,10 +7,17 @@ resources:
- vaultauth.yaml
- pv-media-tv.yaml
- pv-media-movies.yaml
- pv-mediafs.yaml
- pvc-media-tv.yaml
- pvc-media-movies.yaml
- pvc-mediafs.yaml
- media-bucket.yaml
- backups-bucket.yaml
- postgres
- valkey
- sonarr
- radarr
- prowlarr
- nzbget
- arrproxy
- mediamover
+34
View File
@@ -0,0 +1,34 @@
---
# Shared Ceph RGW (S3) bucket for arrstack media assets (Servarr MediaCover
# posters/fanart and, later, application backups), provisioned by the in-estate
# cephrgw-operator. Moving these off each replica's local /config (an emptyDir)
# lets any radarr/sonarr/prowlarr replica serve covers and store backups without
# leader-local state. The operator mints the S3 credential Secret
# (arrstack-media-s3) in this namespace with keys AWS_ACCESS_KEY_ID,
# AWS_SECRET_ACCESS_KEY and S3_ENDPOINT, so no Vault KV seeding is required.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: arrstack-media
namespace: arrstack
spec:
displayName: "arrstack media assets owner"
uid: arrstack-media
maxBuckets: 5
secretName: arrstack-media-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: arrstack-media
namespace: arrstack
spec:
placementTarget: ec
bucketName: arrstack-media
ownerRef: arrstack-media
versioning: false
tags:
app: arrstack
purpose: media-assets
retainOnDelete: true
@@ -0,0 +1,95 @@
---
# mediamover server: REST API + UI on :8080. Spawns one worker Job per queued
# file using this same image with `worker` args (Job spec lives in code; workers
# run as the default ServiceAccount and mount the same PVCs). The queue is
# in-memory, so keep a single replica; a restart just loses queued entries.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mediamover
namespace: arrstack
spec:
replicas: 1
selector:
matchLabels:
app: mediamover
strategy:
type: Recreate
template:
metadata:
labels:
app: mediamover
spec:
serviceAccountName: mediamover
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: server
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamover:v0.1.0
imagePullPolicy: IfNotPresent
args:
- server
- --src-root=/srv/src
- --src-pvc=mediafs
- --dst-roots=movies=/srv/dst/movies,tv=/srv/dst/tv
- --dst-pvc=movies=media-movies,tv=media-tv
- --namespace=arrstack
- --image=artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamover:v0.1.0
ports:
- containerPort: 8080
name: http
protocol: TCP
volumeMounts:
# RW: move mode deletes the source file after a successful copy.
- name: src
mountPath: /srv/src
- name: dst-movies
mountPath: /srv/dst/movies
- name: dst-tv
mountPath: /srv/dst/tv
livenessProbe:
httpGet:
path: /api/limit
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/limit
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: src
persistentVolumeClaim:
claimName: mediafs
- name: dst-movies
persistentVolumeClaim:
claimName: media-movies
- name: dst-tv
persistentVolumeClaim:
claimName: media-tv
restartPolicy: Always
@@ -0,0 +1,40 @@
---
# Internal front for mediamover. The existing arrstack (arrproxy) Gateway is
# external and hostname-locked to arrstack.unkin.net, so this tool gets its own
# internal Gateway following the cluster convention (cf. pdbmux).
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: mediamover.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: mediamover.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: mediamover
namespace: arrstack
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: mediamover.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: mediamover.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: mediamover-tls
mode: Terminate
@@ -0,0 +1,49 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mediamover-http-redirect
namespace: arrstack
spec:
hostnames:
- mediamover.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mediamover
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mediamover
namespace: arrstack
spec:
hostnames:
- mediamover.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mediamover
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: mediamover
port: 8080
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -0,0 +1,11 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- serviceaccount.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
+48
View File
@@ -0,0 +1,48 @@
---
# The server creates one worker Job per queued file and polls Job/Pod state to
# track progress and clean up.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mediamover
namespace: arrstack
rules:
- apiGroups:
- batch
resources:
- jobs
verbs:
- create
- get
- list
- watch
- delete
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mediamover
namespace: arrstack
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mediamover
subjects:
- kind: ServiceAccount
name: mediamover
namespace: arrstack
@@ -0,0 +1,19 @@
---
# Must stay named "mediamover" on port 8080: worker Jobs default their progress
# callback URL to http://mediamover.<namespace>.svc:8080.
apiVersion: v1
kind: Service
metadata:
name: mediamover
namespace: arrstack
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
app: mediamover
sessionAffinity: None
type: ClusterIP
@@ -0,0 +1,6 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: mediamover
namespace: arrstack
@@ -0,0 +1,45 @@
---
# Ceph RGW (S3) backup target for the shared arrstack CNPG cluster, provisioned
# by the in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG
# reads the S3 credential Secret (cnpg-arrstack-backup-s3) from this namespace.
apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser
metadata:
name: cnpg-arrstack-backup
namespace: arrstack
spec:
displayName: "CNPG backup owner (arrstack)"
uid: cnpg-arrstack-backup
maxBuckets: 5
secretName: cnpg-arrstack-backup-s3
retainOnDelete: true
---
apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket
metadata:
name: cnpg-arrstack
namespace: arrstack
spec:
placementTarget: ec
bucketName: cnpg-arrstack
ownerRef: cnpg-arrstack-backup
versioning: false
tags:
app: arrstack
purpose: cnpg-backup
retainOnDelete: true
---
# Nightly base backup on top of always-on WAL archiving. Staggered from the
# other CNPG clusters (6-field cron, seconds first).
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: cnpg-arrstack-nightly
namespace: arrstack
spec:
schedule: "0 45 3 * * *"
immediate: false
backupOwnerReference: self
method: barmanObjectStore
cluster:
name: arrstack-postgres
@@ -0,0 +1,161 @@
---
# Shared PostgreSQL backend for the -unkin2 fork sonarr/radarr/prowlarr, whose
# Npgsql/EF Core provider moves each *arr off SQLite into Postgres and makes the
# shared-nothing, active-active multi-replica deployment possible. One cluster,
# one throwaway initdb owner ("app"), and three managed login roles — one per
# app — each with its own per-app database (see database-*.yaml). Role passwords
# come from the VSO-synced <app>-db Secrets (vaultstaticsecret.yaml), so no
# credential is rendered into git.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: arrstack-postgres
namespace: arrstack
spec:
inheritedMetadata:
annotations:
k8up.io/backup: "false"
affinity:
podAntiAffinityType: preferred
backup:
retentionPolicy: 30d
barmanObjectStore:
destinationPath: s3://cnpg-arrstack
endpointURL: https://s3.ceph.unkin.net
endpointCA:
name: vault-ca-cert
key: ca.crt
s3Credentials:
accessKeyId:
name: cnpg-arrstack-backup-s3
key: AWS_ACCESS_KEY_ID
secretAccessKey:
name: cnpg-arrstack-backup-s3
key: AWS_SECRET_ACCESS_KEY
serverName: arrstack
data:
compression: bzip2
jobs: 2
wal:
compression: zstd
maxParallel: 2
bootstrap:
initdb:
# Throwaway owner + database: the real per-app databases are provisioned by
# the CNPG Database CRDs, owned by the managed roles below.
database: app
encoding: UTF8
localeCType: C
localeCollate: C
owner: app
managed:
roles:
- name: sonarr
ensure: present
comment: Sonarr application role (owns sonarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-db
- name: radarr
ensure: present
comment: Radarr application role (owns radarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-db
- name: prowlarr
ensure: present
comment: Prowlarr application role (owns prowlarr-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: prowlarr-db
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
imageName: ghcr.io/cloudnative-pg/postgresql:17-system-trixie
instances: 3
logLevel: info
maxSyncReplicas: 0
minSyncReplicas: 0
monitoring:
customQueriesConfigMap:
- key: queries
name: cnpg-default-monitoring
disableDefaultQueries: false
enablePodMonitor: false
postgresql:
parameters:
archive_mode: "on"
archive_timeout: 5min
dynamic_shared_memory_type: posix
effective_cache_size: 256MB
full_page_writes: "on"
log_destination: csvlog
log_directory: /controller/log
log_filename: postgres
log_rotation_age: "0"
log_rotation_size: "0"
log_truncate_on_rotation: "false"
logging_collector: "on"
max_connections: "200"
max_parallel_workers: "16"
max_replication_slots: "16"
max_worker_processes: "16"
shared_buffers: 128MB
shared_memory_type: mmap
ssl_max_protocol_version: TLSv1.3
ssl_min_protocol_version: TLSv1.3
wal_keep_size: 256MB
wal_level: logical
wal_log_hints: "on"
wal_receiver_timeout: 5s
wal_sender_timeout: 5s
syncReplicaElectionConstraint:
enabled: false
primaryUpdateMethod: restart
primaryUpdateStrategy: unsupervised
probes:
liveness:
isolationCheck:
connectionTimeout: 1000
enabled: true
requestTimeout: 1000
replicationSlots:
highAvailability:
enabled: true
slotPrefix: _cnpg_
synchronizeReplicas:
enabled: true
updateInterval: 30
resources:
limits:
cpu: "1"
memory: 2Gi
requests:
cpu: 250m
memory: 1Gi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 10Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
@@ -0,0 +1,15 @@
---
# Per-app database owned by the prowlarr managed role. The fork's provider runs
# its own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: prowlarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: prowlarr-main
owner: prowlarr
databaseReclaimPolicy: retain
@@ -0,0 +1,15 @@
---
# Per-app database owned by the radarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: radarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-main
owner: radarr
databaseReclaimPolicy: retain
@@ -0,0 +1,15 @@
---
# Per-app database owned by the sonarr managed role. The fork's provider runs its
# own schema migrations on first start (advisory-locked, so only one replica
# migrates). retain: the database survives a Database CRD delete.
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: sonarr-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-main
owner: sonarr
databaseReclaimPolicy: retain
@@ -0,0 +1,11 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- cnpg_cluster.yaml
- cnpg_backup.yaml
- database-sonarr.yaml
- database-radarr.yaml
- database-prowlarr.yaml
@@ -0,0 +1,60 @@
---
# Per-app Postgres role credentials. Each is seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/<app>-db (keys: username, password);
# the default k8s role's templated policy already grants read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/* for the
# arrstack/default ServiceAccount, so no terraform-vault change is needed. VSO
# syncs each into the <app>-db Secret, which is both the CNPG managed role's
# passwordSecret (cnpg_cluster.yaml) and the source of the app Deployment's
# <App>__Postgres__User/__Password env. Wave 0: must exist before the Cluster
# (wave 1) reconciles the roles.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: prowlarr-db
namespace: arrstack
spec:
destination:
create: true
name: prowlarr-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/prowlarr-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,33 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Prowlarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres
# wiring points every replica at the same shared DB (arrstack-postgres-rw /
# prowlarr-main); Auth__Method=External defers UI auth to arrproxy/oauth2-proxy;
# Server__UrlBase keeps the /prowlarr prefix so arrproxy path-routing works;
# App__InstanceName is identical across replicas (shared session-cookie name).
# User/Password/ApiKey come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: prowlarr-env
namespace: arrstack
data:
Prowlarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Prowlarr__Postgres__Port: "5432"
Prowlarr__Postgres__MainDb: prowlarr-main
Prowlarr__Log__DbEnabled: "false"
Prowlarr__Auth__Method: External
Prowlarr__Auth__Required: DisabledForLocalAddresses
Prowlarr__App__InstanceName: Prowlarr
Prowlarr__Server__Port: "9696"
Prowlarr__Server__UrlBase: /prowlarr
Prowlarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's prowlarr:ratelimit: prefix, so the
# one cluster is safe to share with sonarr/radarr.
Prowlarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Prowlarr__Redis__Port: "6379"
+113 -54
View File
@@ -4,11 +4,21 @@ kind: Deployment
metadata:
name: prowlarr
namespace: arrstack
annotations:
# prowlarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
replicas: 1
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the prowlarr Service. RollingUpdate is safe
# — no SQLite, no RWO lock.
replicas: 3
strategy:
# RWO config PVC + single stateful SQLite DB: never run two pods at once.
type: Recreate
type: RollingUpdate
selector:
matchLabels:
app: prowlarr
@@ -18,74 +28,114 @@ spec:
app: prowlarr
spec:
securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config. OnRootMismatch
# avoids a recursive chown of the whole volume.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Enforce the Vault-sourced API key in /config/config.xml before the app
# starts. Vault is source of truth (override bootstrap): the key is minted
# in Vault, synced by VSO into the prowlarr-apikey Secret, and written here.
# Runs as root to fix ownership; touches only the <ApiKey> element.
- name: apikey-init
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/busybox:1.37.0
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: API_KEY
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: prowlarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: prowlarr-apikey
key: apitoken
command:
- sh
- -c
- |
set -eu
case "$API_KEY" in
"" | *[!0-9a-fA-F]*)
echo "apikey-init: API_KEY missing or not hex; refusing" >&2
exit 1
;;
esac
CFG=/config/config.xml
if [ ! -f "$CFG" ]; then
printf '<Config>\n <ApiKey>%s</ApiKey>\n</Config>\n' "$API_KEY" > "$CFG"
elif grep -q '<ApiKey>' "$CFG"; then
sed -i "s|<ApiKey>[^<]*</ApiKey>|<ApiKey>${API_KEY}</ApiKey>|" "$CFG"
else
sed -i "s|<Config>|<Config>\n <ApiKey>${API_KEY}</ApiKey>|" "$CFG"
fi
chown 1000:1000 "$CFG"
chmod 600 "$CFG"
echo "apikey-init: <ApiKey> enforced from Vault"
name: prowlarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: prowlarr-db
key: password
resources:
requests:
cpu: 50m
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
cpu: 100m
memory: 64Mi
volumeMounts:
- name: config
mountPath: /config
containers:
- name: prowlarr
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/prowlarr:2.5.2
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/prowlarr:v2.6.2-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Prowlarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 9696
protocol: TCP
envFrom:
- configMapRef:
name: prowlarr-env
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
- name: Prowlarr__Postgres__User
valueFrom:
secretKeyRef:
name: prowlarr-db
key: username
- name: Prowlarr__Postgres__Password
valueFrom:
secretKeyRef:
name: prowlarr-db
key: password
- name: Prowlarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: prowlarr-apikey
key: apitoken
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
# Consumed by the -unkin3+ image; older images ignore these unknown
# config keys. Creds Secret minted by cephrgw-operator.
- name: Prowlarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Prowlarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Prowlarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Prowlarr__BackupS3__Bucket
value: arrstack-backups
- name: Prowlarr__BackupS3__Prefix
value: prowlarr
- name: Prowlarr__BackupS3__ForcePathStyle
value: "true"
- name: Prowlarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /ping
path: /prowlarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
@@ -93,7 +143,7 @@ spec:
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
path: /prowlarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
@@ -105,11 +155,20 @@ spec:
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
volumes:
- name: config
persistentVolumeClaim:
claimName: prowlarr-config
emptyDir: {}
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
@@ -3,8 +3,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
+5 -4
View File
@@ -1,6 +1,7 @@
---
# Prowlarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite
# does not tolerate CephFS locking. Retain: this is state.
# Prowlarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork
# keeps the database in Postgres; /config now holds only config.xml + assets,
# which tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -8,9 +9,9 @@ metadata:
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-retain
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
+31
View File
@@ -0,0 +1,31 @@
---
# Static PV exposing the legacy mediafs CephFS filesystem root for the upcoming
# mediamover tool. clusterID only selects the monitor set from the csi config;
# fsName selects the actual filesystem, so the ssd_ec clusterID still reaches
# mediafs on the same cluster. Staged with the dedicated ceph-mediafs client.
apiVersion: v1
kind: PersistentVolume
metadata:
name: arrstack-mediafs
spec:
capacity:
storage: 10Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: arrstack
name: mediafs
csi:
driver: cephfs.csi.ceph.com
volumeHandle: arrstack-mediafs-static
nodeStageSecretRef:
name: ceph-mediafs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: mediafs
rootPath: /
+20
View File
@@ -0,0 +1,20 @@
---
# Legacy mediafs filesystem root, statically bound to the arrstack-mediafs PV
# for the upcoming mediamover tool. storageClassName "" + volumeName disables
# dynamic provisioning and binds the pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mediafs
namespace: arrstack
annotations:
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Ti
storageClassName: ""
volumeName: arrstack-mediafs
volumeMode: Filesystem
+33
View File
@@ -0,0 +1,33 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Radarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / radarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /radarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /radarr
Radarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's radarr:ratelimit: prefix, so the
# one cluster is safe to share with sonarr/prowlarr.
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
+182 -53
View File
@@ -4,11 +4,21 @@ kind: Deployment
metadata:
name: radarr
namespace: arrstack
annotations:
# radarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
replicas: 1
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr Service. RollingUpdate is safe —
# no SQLite, no RWO lock.
replicas: 3
strategy:
# RWO config PVC + single stateful SQLite DB: never run two pods at once.
type: Recreate
type: RollingUpdate
selector:
matchLabels:
app: radarr
@@ -18,74 +28,139 @@ spec:
app: radarr
spec:
securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Enforce the Vault-sourced API key in /config/config.xml before the app
# starts. Vault is source of truth (override bootstrap): the key is minted
# in Vault, synced by VSO into the radarr-apikey Secret, and written here.
# Runs as root to fix ownership; touches only the <ApiKey> element.
- name: apikey-init
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/busybox:1.37.0
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: API_KEY
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: radarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-apikey
key: apitoken
command:
- sh
- -c
- |
set -eu
case "$API_KEY" in
"" | *[!0-9a-fA-F]*)
echo "apikey-init: API_KEY missing or not hex; refusing" >&2
exit 1
;;
esac
CFG=/config/config.xml
if [ ! -f "$CFG" ]; then
printf '<Config>\n <ApiKey>%s</ApiKey>\n</Config>\n' "$API_KEY" > "$CFG"
elif grep -q '<ApiKey>' "$CFG"; then
sed -i "s|<ApiKey>[^<]*</ApiKey>|<ApiKey>${API_KEY}</ApiKey>|" "$CFG"
else
sed -i "s|<Config>|<Config>\n <ApiKey>${API_KEY}</ApiKey>|" "$CFG"
fi
chown 1000:1000 "$CFG"
chmod 600 "$CFG"
echo "apikey-init: <ApiKey> enforced from Vault"
name: radarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-db
key: password
resources:
requests:
cpu: 50m
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
cpu: 100m
memory: 64Mi
volumeMounts:
- name: config
mountPath: /config
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/radarr:6.3.0
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-env
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-apikey
key: apitoken
# MediaCover object store (shared Ceph RGW bucket). Serves posters/fanart
# from S3 so any replica can render them instead of the leader-local
# emptyDir /config. Consumed by the -unkin3+ image; older images ignore
# these unknown config keys. Creds Secret is minted by cephrgw-operator.
- name: Radarr__MediaCover__S3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Radarr__MediaCover__S3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__MediaCover__S3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__MediaCover__S3__Bucket
value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix
value: radarr
- name: Radarr__MediaCover__S3__ForcePathStyle
value: "true"
- name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Radarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Radarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Radarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Radarr__BackupS3__Bucket
value: arrstack-backups
- name: Radarr__BackupS3__Prefix
value: radarr
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /ping
path: /radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
@@ -93,7 +168,7 @@ spec:
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
path: /radarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
@@ -111,10 +186,64 @@ spec:
mountPath: /config
- name: media-movies
mountPath: /media/movies
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-exportarr VMPodScrape).
- name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9708
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
persistentVolumeClaim:
claimName: radarr-config
emptyDir: {}
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
+2 -1
View File
@@ -3,9 +3,10 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml
+5 -4
View File
@@ -1,6 +1,7 @@
---
# Radarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite
# does not tolerate CephFS locking. Retain: this is state.
# Radarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -8,9 +9,9 @@ metadata:
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-retain
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr
podMetricsEndpoints:
- port: metrics
path: /metrics
+33
View File
@@ -0,0 +1,33 @@
---
# Non-secret env for the -unkin2 fork. The fork reads Servarr config from
# Sonarr__<Section>__<Key> env (no config.xml edits, no s6/PUID). Postgres wiring
# points every replica at the same shared DB (arrstack-postgres-rw / sonarr-main);
# Auth__Method=External defers UI auth to arrproxy/oauth2-proxy; Server__UrlBase
# keeps the /sonarr prefix so arrproxy path-routing works; App__InstanceName is
# identical across replicas (shared session-cookie name). User/Password/ApiKey
# come from Secrets (see deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /sonarr
Sonarr__Update__Mechanism: External
# Shared arrstack Valkey (valkey-operator). Setting Host is what activates the
# fork's #14 Redis features (SignalR backplane, cross-replica cache-invalidation
# bus, distributed rate limiter): RedisOptions.IsConfigured gates purely on a
# non-empty Host, so there is no separate Enabled flag. The operator leaves the
# default user passwordless (jellyfin parity), so no Password/Ssl is wired.
# Channels/keys are namespaced by this fork's sonarr:ratelimit: prefix, so the
# one cluster is safe to share with radarr/prowlarr.
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
+183 -55
View File
@@ -4,11 +4,21 @@ kind: Deployment
metadata:
name: sonarr
namespace: arrstack
annotations:
# sonarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom,
# so editing it does not roll the Deployment on its own. Reloader watches the
# referenced ConfigMap and triggers a rolling restart on change, so adding the
# Redis env activates the #14 features on the next ArgoCD sync without a manual
# `rollout restart`.
configmap.reloader.stakater.com/auto: "true"
spec:
replicas: 1
# Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr Service. RollingUpdate is safe —
# no SQLite, no RWO lock.
replicas: 3
strategy:
# RWO config PVC + single stateful SQLite DB: never run two pods at once.
type: Recreate
type: RollingUpdate
selector:
matchLabels:
app: sonarr
@@ -18,77 +28,141 @@ spec:
app: sonarr
spec:
securityContext:
# LinuxServer images init as root via s6 then step down to PUID/PGID.
# fsGroup makes the shared CephFS group-writable for that user;
# Fork image has no USER (runs as root by default); pin it to a non-root
# UID and group-write the shared RWX CephFS /config (MediaCover etc.).
# OnRootMismatch avoids a recursive chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Enforce the Vault-sourced API key in /config/config.xml before the app
# starts. Vault is source of truth (override bootstrap): the key is minted
# in Vault, synced by VSO into the sonarr-apikey Secret, and written here.
# Runs as root to fix ownership; touches only the <ApiKey> element.
- name: apikey-init
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/busybox:1.37.0
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
# Gate the app on its own Postgres database+role being reachable, instead
# of relying on ArgoCD sync-waves (which deadlock if apps aren't Healthy).
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv.
- name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env:
- name: API_KEY
- name: WAITFORDB_TIMEOUT
value: 5m
- name: WAITFORDB_SSLMODE
value: disable
- name: PGHOST
value: arrstack-postgres-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: sonarr-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-apikey
key: apitoken
command:
- sh
- -c
- |
set -eu
case "$API_KEY" in
"" | *[!0-9a-fA-F]*)
echo "apikey-init: API_KEY missing or not hex; refusing" >&2
exit 1
;;
esac
CFG=/config/config.xml
if [ ! -f "$CFG" ]; then
printf '<Config>\n <ApiKey>%s</ApiKey>\n</Config>\n' "$API_KEY" > "$CFG"
elif grep -q '<ApiKey>' "$CFG"; then
sed -i "s|<ApiKey>[^<]*</ApiKey>|<ApiKey>${API_KEY}</ApiKey>|" "$CFG"
else
sed -i "s|<Config>|<Config>\n <ApiKey>${API_KEY}</ApiKey>|" "$CFG"
fi
chown 1000:1000 "$CFG"
chmod 600 "$CFG"
echo "apikey-init: <ApiKey> enforced from Vault"
name: sonarr-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-db
key: password
resources:
requests:
cpu: 50m
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
cpu: 100m
memory: 64Mi
volumeMounts:
- name: config
mountPath: /config
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/sonarr:4.0.19
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Required: bypass the single-instance guard so multiple replicas
# can share one /config. Cross-replica safety is the Postgres layer,
# not a local lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-env
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned from radarr by the sonarr key prefix). Serves
# posters/fanart from S3 so any replica renders them instead of the
# leader-local emptyDir /config. Consumed by the -unkin3+ image;
# older images ignore these unknown config keys. Creds Secret minted
# by cephrgw-operator.
- name: Sonarr__MediaCoverS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: S3_ENDPOINT
- name: Sonarr__MediaCoverS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__MediaCoverS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-media-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix
value: sonarr
- name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off
# the ephemeral /config so any replica can write and restore them.
- name: Sonarr__BackupS3__Endpoint
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: S3_ENDPOINT
- name: Sonarr__BackupS3__AccessKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_ACCESS_KEY_ID
- name: Sonarr__BackupS3__SecretKey
valueFrom:
secretKeyRef:
name: arrstack-backups-s3
key: AWS_SECRET_ACCESS_KEY
- name: Sonarr__BackupS3__Bucket
value: arrstack-backups
- name: Sonarr__BackupS3__Prefix
value: sonarr
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /ping
path: /sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
@@ -96,7 +170,7 @@ spec:
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
path: /sonarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
@@ -114,10 +188,64 @@ spec:
mountPath: /config
- name: media-tv
mountPath: /media/tv
- name: vault-ca
mountPath: /etc/ssl/vault-ca
readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-exportarr VMPodScrape).
- name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-apikey
key: apitoken
ports:
- name: metrics
containerPort: 9707
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
volumes:
- name: config
persistentVolumeClaim:
claimName: sonarr-config
emptyDir: {}
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
# Estate CA for validating the Ceph RGW (s3.ceph.unkin.net) TLS cert.
- name: vault-ca
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
+2 -1
View File
@@ -3,9 +3,10 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml
+5 -4
View File
@@ -1,6 +1,7 @@
---
# Sonarr config + SQLite DB. RWO on cephrbd (block) — the arr apps' SQLite
# does not tolerate CephFS locking. Retain: this is state.
# Sonarr /config. RWX on CephFS so all replicas share it (the -unkin2 fork keeps
# the database in Postgres; /config now holds only config.xml + MediaCover, which
# tolerate — and want — shared access). Retain: this is state.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -8,9 +9,9 @@ metadata:
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-retain
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -0,0 +1,6 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- valkeycluster.yaml
@@ -0,0 +1,47 @@
---
# Single shared HA Valkey for the arr apps (sonarr/radarr/prowlarr), managed by
# valkey-operator. It activates the fork's #14 Redis features: the SignalR
# backplane, the cross-replica cache-invalidation bus, and the distributed rate
# limiter. One cluster is safe for all three because each fork namespaces its
# keys and pub/sub channels by a per-app prefix (sonarr:ratelimit: /
# radarr:ratelimit: / prowlarr:ratelimit:), so their state never collides.
#
# Modeled on jellyfin-valkey: shards:1 + replicas:2 is one primary with two
# replicas in a single shard group (three ValkeyNodes total); losing the primary
# triggers an automatic failover so a node/pod loss no longer drops the shared
# state the app replicas coordinate through. The operator runs Valkey
# cluster-mode-enabled with protected-mode off and leaves the built-in `default`
# user passwordless, so clients connect with no auth/TLS; StackExchange.Redis
# seeds off the single service and auto-discovers topology plus failovers.
# scheduling.node.spread.shard:Required keeps the three nodes on distinct hosts,
# so one host loss removes at most one node; podDisruptionBudget.mode:Cluster
# lets the operator manage a quorum-aware PDB. Persistence is omitted (/data is an
# emptyDir): the coordination state is ephemeral (short TTLs / transient pub/sub),
# replication+failover already provide redundancy, and an operator-managed PVC
# cannot carry the k8up.io/backup:"false" annotation the namespace k8up Schedule
# needs to skip in-use RWO volumes.
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: arrstack-valkey
namespace: arrstack
spec:
shards: 1
replicas: 2
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0
exporter:
enabled: false
scheduling:
node:
spread:
shard:
mode: Required
podDisruptionBudget:
mode: Cluster
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
+1
View File
@@ -18,4 +18,5 @@ resources:
- ui-hpa.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
@@ -53,4 +53,18 @@ spec:
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
restartPolicy: Always
+16
View File
@@ -0,0 +1,16 @@
---
# Scrape the redis_exporter sidecar (:9121) on the redis pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the redis Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: redis-exporter
namespace: artifactapi
spec:
selector:
matchLabels:
app: redis
podMetricsEndpoints:
- port: metrics
path: /metrics
+1
View File
@@ -18,4 +18,5 @@ resources:
- redis-service.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
+14
View File
@@ -52,6 +52,20 @@ spec:
volumeMounts:
- mountPath: /data
name: redis-data
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
volumes:
- name: redis-data
persistentVolumeClaim:
+16
View File
@@ -0,0 +1,16 @@
---
# Scrape the redis_exporter sidecar (:9121) on the redis pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the redis Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: redis-exporter
namespace: authentik
spec:
selector:
matchLabels:
app: redis
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -117,3 +117,18 @@ spec:
# traefik-EXTERNAL (DMZ) gateway VIP; the arrproxy Gateway serves the
# arrstack.unkin.net front door (oauth2-proxy) there.
- 198.18.199.0
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: logviewer-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: logviewer
type: A
ttl: 600
values:
# traefik-internal gateway VIP; the logviewer Gateway serves
# logviewer.unkin.net there.
- 198.18.200.4
+1
View File
@@ -6,5 +6,6 @@ resources:
- namespace.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vaultstaticsecret-mediafs.yaml
- storageclass.yaml
- vmservicescrape.yaml
@@ -0,0 +1,15 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: ceph-mediafs-secret
namespace: csi-cephfs
spec:
vaultAuthRef: ceph-csi-cephfs
mount: kv
type: kv-v2
path: service/kubernetes/au/syd1/csi/ceph-mediafs-secret
refreshAfter: 5m
destination:
name: ceph-mediafs-secret
create: true
+1
View File
@@ -10,6 +10,7 @@ resources:
- valkey-deployment.yaml
- valkey-pvc.yaml
- valkey-service.yaml
- vmpodscrape.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- gateway.yaml
+20
View File
@@ -82,6 +82,26 @@ spec:
volumeMounts:
- mountPath: /data
name: data
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop:
- ALL
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
restartPolicy: Always
volumes:
- name: data
+16
View File
@@ -0,0 +1,16 @@
---
# Scrape the redis_exporter sidecar (:9121) on the gitea-valkey pod. Picked up
# by the observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the valkey Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: valkey-exporter
namespace: gitea
spec:
selector:
matchLabels:
app: gitea-valkey
podMetricsEndpoints:
- port: metrics
path: /metrics
+13
View File
@@ -0,0 +1,13 @@
---
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: redis
namespace: grafana
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
resyncPeriod: 5m
allowCrossNamespaceImport: false
gzipJson: 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
+13
View File
@@ -0,0 +1,13 @@
---
apiVersion: grafana.integreatly.org/v1beta1
kind: GrafanaDashboard
metadata:
name: traefik
namespace: grafana
spec:
instanceSelector:
matchLabels:
dashboards: "grafana"
resyncPeriod: 5m
allowCrossNamespaceImport: false
gzipJson: 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
+2
View File
@@ -23,6 +23,8 @@ resources:
- dashboards/nzbget.yaml
- dashboards/postgresql-database.yaml
- dashboards/puppet-report.yaml
- dashboards/redis.yaml
- dashboards/traefik.yaml
- dashboards/victorialogs-cluster.yaml
- dashboards/victoriametrics-cluster.yaml
- dashboards/victoriametrics-vmagent.yaml
+1
View File
@@ -21,5 +21,6 @@ resources:
- pdb.yaml
- service.yaml
- valkey.yaml
- vmpodscrape.yaml
- gateway.yaml
- httproute.yaml
+7 -1
View File
@@ -25,8 +25,14 @@ spec:
shards: 1
replicas: 2
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0
# redis_exporter sidecar (:9121, port name `metrics`) on every ValkeyNode pod;
# the operator manages a dedicated _exporter ACL user for it. Image overridden
# from the operator default (bare dockerhub oliver006/redis_exporter:v1.80.0)
# to the artifactapi-proxied pin. Scraped by the valkey-exporter VMPodScrape
# in vmpodscrape.yaml alongside this file.
exporter:
enabled: false
enabled: true
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
scheduling:
node:
spread:
+19
View File
@@ -0,0 +1,19 @@
---
# Scrape the operator-injected redis_exporter sidecar (:9121, port name
# `metrics`) on the jellyfin-valkey ValkeyNode pods. The valkey-operator gives
# its pods fixed labels only (no pod-label passthrough on the ValkeyCluster CR),
# so select on the operator-managed labels. Picked up by the observability
# VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: valkey-exporter
namespace: jellyfin
spec:
selector:
matchLabels:
app.kubernetes.io/managed-by: valkey-operator
app.kubernetes.io/component: valkey-node
podMetricsEndpoints:
- port: metrics
path: /metrics
+1
View File
@@ -16,6 +16,7 @@ resources:
- services.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- vmpodscrape.yaml
- vpa.yaml
configMapGenerator:
+14
View File
@@ -60,6 +60,20 @@ spec:
- mountPath: /data
mountPropagation: None
name: data
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
restartPolicy: Always
volumes:
- name: data
+16
View File
@@ -0,0 +1,16 @@
---
# Scrape the redis_exporter sidecar (:9121) on the redis pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the redis Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: redis-exporter
namespace: litellm
spec:
selector:
matchLabels:
app: redis
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -29,9 +29,26 @@ spec:
default/networks/ip:
- "127.0.0.1"
- "::1"
# Read-only user for CLI tools + the logviewer UI. Hash sourced from the
# Vault-synced clickhouse-logreader Secret, same mechanism as vector.
# Scoped to the logs database only (unlike vector, which bootstraps it).
logreader/password_sha256_hex:
valueFrom:
secretKeyRef:
name: clickhouse-logreader
key: password_sha256_hex
logreader/networks/ip:
- "::/0"
logreader/profile: readonly
logreader/quota: default
logreader/allow_databases/database:
- "logs"
profiles:
default/max_memory_usage: "10000000000"
default/max_execution_time: "120"
readonly/readonly: "2"
readonly/max_memory_usage: "10000000000"
readonly/max_execution_time: "120"
clusters:
- name: logs
layout:
+1
View File
@@ -15,6 +15,7 @@ resources:
- serviceaccount_logarchiver.yaml
- configmap_logarchiver.yaml
- deployment_logarchiver.yaml
- logviewer
# Vector pipelines are the single source of truth (also validated by
# `vector test` in CI). Mounted into each tier via `existingConfigMaps`.
@@ -0,0 +1,80 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: logviewer
namespace: logging
annotations:
secret.reloader.stakater.com/reload: "clickhouse-logreader"
spec:
replicas: 1
selector:
matchLabels:
app: logviewer
strategy:
type: Recreate
template:
metadata:
labels:
app: logviewer
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: logviewer
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/logviewer:v0.1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http
protocol: TCP
env:
- name: CH_URL
value: http://clickhouse-logs.logging.svc.cluster.local:8123
- name: CH_USER
valueFrom:
secretKeyRef:
name: clickhouse-logreader
key: username
- name: CH_PASSWORD
valueFrom:
secretKeyRef:
name: clickhouse-logreader
key: password
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
restartPolicy: Always
+38
View File
@@ -0,0 +1,38 @@
---
# Internal front for the logviewer UI (cf. mediamover/pdbmux).
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: logviewer.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: logviewer.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: logviewer
namespace: logging
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: logviewer.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: logviewer.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: logviewer-tls
mode: Terminate
@@ -0,0 +1,49 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: logviewer-http-redirect
namespace: logging
spec:
hostnames:
- logviewer.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: logviewer
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: logviewer
namespace: logging
spec:
hostnames:
- logviewer.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: logviewer
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: logviewer-oauth2
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -0,0 +1,12 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- deployment.yaml
- oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml
- service.yaml
- gateway.yaml
- httproute.yaml
@@ -0,0 +1,32 @@
---
# Non-secret oauth2-proxy configuration (client_id/secret/cookie_secret come
# from the logviewer-oauth-credentials Secret). Single auth front for the
# logviewer UI: everything requires an Authentik session in akP-logviewer-admin.
apiVersion: v1
kind: ConfigMap
metadata:
name: logviewer-oauth2-env
namespace: logging
data:
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
OAUTH2_PROXY_PROVIDER: "oidc"
# Authentik logviewer app discovery issuer (terraform-authentik PR #21).
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/logviewer/"
OAUTH2_PROXY_REDIRECT_URL: "https://logviewer.unkin.net/oauth2/callback"
OAUTH2_PROXY_UPSTREAMS: "http://logviewer.logging.svc.cluster.local:8080/"
OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik ak_groups claim.
OAUTH2_PROXY_OIDC_GROUPS_CLAIM: "ak_groups"
OAUTH2_PROXY_ALLOWED_GROUPS: "akP-logviewer-admin"
OAUTH2_PROXY_PASS_USER_HEADERS: "true"
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; authorization is
# enforced via ak_groups, so accepting the unverified email is safe.
OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true"
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_COOKIE_DOMAINS: "logviewer.unkin.net"
OAUTH2_PROXY_WHITELIST_DOMAINS: "logviewer.unkin.net"
OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_PROVIDER_CA_FILES: "/etc/ssl/combined/ca-certificates.crt"
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: "S256"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
@@ -0,0 +1,132 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: logviewer-oauth2
namespace: logging
annotations:
configmap.reloader.stakater.com/auto: "true"
secret.reloader.stakater.com/reload: "logviewer-oauth-credentials,vault-ca-cert"
spec:
replicas: 1
selector:
matchLabels:
app: logviewer-oauth2
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: logviewer-oauth2
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
initContainers:
# identity.unkin.net serves a Vault-PKI cert; combine the system roots
# with the internal CA so oauth2-proxy's OIDC HTTP client trusts it.
- name: combine-certs
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/alpine:3
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- cat /etc/ssl/certs/ca-certificates.crt /custom-ca/ca.crt > /combined-certs/ca-certificates.crt
volumeMounts:
- name: vault-ca-cert
mountPath: /custom-ca
readOnly: true
- name: combined-certs
mountPath: /combined-certs
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.3
imagePullPolicy: IfNotPresent
ports:
- containerPort: 4180
name: http
protocol: TCP
envFrom:
- configMapRef:
name: logviewer-oauth2-env
optional: false
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: logviewer-oauth-credentials
key: client_id
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: logviewer-oauth-credentials
key: client_secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: logviewer-oauth-credentials
key: cookie_secret
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: vault-ca-cert
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
- name: combined-certs
emptyDir: {}
restartPolicy: Always
+36
View File
@@ -0,0 +1,36 @@
---
apiVersion: v1
kind: Service
metadata:
name: logviewer
namespace: logging
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
app: logviewer
sessionAffinity: None
type: ClusterIP
---
# Front-door entry Service: the HTTPRoute for logviewer.unkin.net targets this;
# all traffic enters via oauth2-proxy.
apiVersion: v1
kind: Service
metadata:
name: logviewer-oauth2
namespace: logging
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
selector:
app: logviewer-oauth2
sessionAffinity: None
type: ClusterIP
@@ -0,0 +1,21 @@
---
# Authentik OIDC client for logviewer (client_id, client_secret, cookie_secret)
# seeded at kv/kubernetes/namespace/logging/default/oauth-credentials; the
# logging/default templated policy already grants read, so no terraform-vault
# change is needed.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: logviewer-oauth-credentials
namespace: logging
spec:
destination:
create: true
name: logviewer-oauth-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/logging/default/oauth-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+23
View File
@@ -27,6 +27,29 @@ spec:
type: kv-v2
vaultAuthRef: default
---
# ClickHouse credentials for the read-only `logreader` user (CLI tools +
# logviewer UI). Seeded the same way as clickhouse-credentials above:
# PW=$(openssl rand -hex 24)
# HASH=$(printf '%s' "$PW" | sha256sum | cut -d' ' -f1)
# vault kv put kv/kubernetes/namespace/logging/default/clickhouse-logreader \
# username=logreader password="$PW" password_sha256_hex="$HASH"
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: clickhouse-logreader
namespace: logging
spec:
destination:
create: true
name: clickhouse-logreader
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/logging/default/clickhouse-logreader
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# NATS JetStream auth. Distinct passwords for the producer (edge), consumer
# (transform tier + archiver) and admin (bootstrap Job) users. Seed once:
# for k in admin producer consumer; do declare P_$k=$(openssl rand -base64 24); done
+1
View File
@@ -10,6 +10,7 @@ resources:
- valkey-deployment.yaml
- valkey-pvc.yaml
- valkey-service.yaml
- vmpodscrape.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
- gateway.yaml
+20
View File
@@ -82,6 +82,26 @@ spec:
volumeMounts:
- mountPath: /data
name: data
- name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9121
name: metrics
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
capabilities:
drop:
- ALL
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
restartPolicy: Always
volumes:
- name: data
+16
View File
@@ -0,0 +1,16 @@
---
# Scrape the redis_exporter sidecar (:9121) on the netbox-valkey pod. Picked up
# by the observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the valkey Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: valkey-exporter
namespace: netbox
spec:
selector:
matchLabels:
app: netbox-valkey
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -0,0 +1,201 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-state-metrics
namespace: observability
labels:
app.kubernetes.io/name: kube-state-metrics
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kube-state-metrics
labels:
app.kubernetes.io/name: kube-state-metrics
rules:
- apiGroups: [""]
resources:
- configmaps
- secrets
- nodes
- pods
- services
- serviceaccounts
- resourcequotas
- replicationcontrollers
- limitranges
- persistentvolumeclaims
- persistentvolumes
- namespaces
- endpoints
verbs: [list, watch]
- apiGroups: [apps]
resources:
- statefulsets
- daemonsets
- deployments
- replicasets
verbs: [list, watch]
- apiGroups: [batch]
resources:
- cronjobs
- jobs
verbs: [list, watch]
- apiGroups: [autoscaling]
resources:
- horizontalpodautoscalers
verbs: [list, watch]
- apiGroups: [authentication.k8s.io]
resources:
- tokenreviews
verbs: [create]
- apiGroups: [authorization.k8s.io]
resources:
- subjectaccessreviews
verbs: [create]
- apiGroups: [policy]
resources:
- poddisruptionbudgets
verbs: [list, watch]
- apiGroups: [certificates.k8s.io]
resources:
- certificatesigningrequests
verbs: [list, watch]
- apiGroups: [discovery.k8s.io]
resources:
- endpointslices
verbs: [list, watch]
- apiGroups: [storage.k8s.io]
resources:
- storageclasses
- volumeattachments
verbs: [list, watch]
- apiGroups: [admissionregistration.k8s.io]
resources:
- mutatingwebhookconfigurations
- validatingwebhookconfigurations
verbs: [list, watch]
- apiGroups: [networking.k8s.io]
resources:
- networkpolicies
- ingressclasses
- ingresses
verbs: [list, watch]
- apiGroups: [coordination.k8s.io]
resources:
- leases
verbs: [list, watch]
- apiGroups: [rbac.authorization.k8s.io]
resources:
- clusterrolebindings
- clusterroles
- rolebindings
- roles
verbs: [list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kube-state-metrics
labels:
app.kubernetes.io/name: kube-state-metrics
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kube-state-metrics
subjects:
- kind: ServiceAccount
name: kube-state-metrics
namespace: observability
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-state-metrics
namespace: observability
labels:
app.kubernetes.io/name: kube-state-metrics
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: kube-state-metrics
template:
metadata:
labels:
app.kubernetes.io/name: kube-state-metrics
spec:
serviceAccountName: kube-state-metrics
automountServiceAccountToken: true
containers:
- name: kube-state-metrics
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.20.0
ports:
- name: http-metrics
containerPort: 8080
- name: telemetry
containerPort: 8081
livenessProbe:
httpGet:
path: /livez
port: http-metrics
initialDelaySeconds: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /readyz
port: telemetry
initialDelaySeconds: 5
timeoutSeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: kube-state-metrics
namespace: observability
labels:
app.kubernetes.io/name: kube-state-metrics
spec:
clusterIP: None
selector:
app.kubernetes.io/name: kube-state-metrics
ports:
- name: http-metrics
port: 8080
targetPort: http-metrics
- name: telemetry
port: 8081
targetPort: telemetry
---
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
name: kube-state-metrics
namespace: observability
spec:
# jobLabel yields job="kube-state-metrics" as kube-prometheus selectors expect.
jobLabel: app.kubernetes.io/name
selector:
matchLabels:
app.kubernetes.io/name: kube-state-metrics
endpoints:
- port: http-metrics
honorLabels: true
- port: telemetry
@@ -6,6 +6,10 @@ resources:
- namespace.yaml
- vmcluster.yaml
- vmagent.yaml
- vmalert.yaml
- vmrule-k8s.yaml
- kube-state-metrics.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape-cnpg.yaml
- vmpodscrape-traefik.yaml
+12 -6
View File
@@ -49,7 +49,8 @@ spec:
- __meta_kubernetes_endpoint_port_name
action: keep
regex: default;kubernetes;https
- job_name: "kubernetes-nodes"
# Only the node name goes onto series: a blanket node-label labelmap pushes past vminsert's 40-label limit and drops everything.
- job_name: "kubelet"
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
@@ -58,9 +59,11 @@ spec:
kubernetes_sd_configs:
- role: node
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- job_name: "kubernetes-nodes-cadvisor"
- source_labels: [__meta_kubernetes_node_name]
target_label: node
- source_labels: [__metrics_path__]
target_label: metrics_path
- job_name: "kubelet-cadvisor"
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
@@ -70,10 +73,13 @@ spec:
- role: node
metrics_path: /metrics/cadvisor
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- source_labels: [__meta_kubernetes_node_name]
target_label: node
- source_labels: [__metrics_path__]
target_label: metrics_path
# job_name must be unique; kube-prometheus selectors expect job="kubelet"
- target_label: job
replacement: kubelet
metric_relabel_configs:
- action: replace
source_labels: [pod]
+27
View File
@@ -0,0 +1,27 @@
---
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMAlert
metadata:
name: main
namespace: observability
spec:
replicaCount: 1
selectAllByDefault: true
evaluationInterval: 30s
datasource:
url: http://vmselect-main.observability.svc.cluster.local:8481/select/0/prometheus/
remoteWrite:
url: http://vminsert-main.observability.svc.cluster.local:8480/insert/0/prometheus/
remoteRead:
url: http://vmselect-main.observability.svc.cluster.local:8481/select/0/prometheus/
extraArgs:
loggerFormat: json
# recording rules only for now; no alertmanager deployed
notifier.blackhole: "true"
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
@@ -0,0 +1,23 @@
---
# Scrape metrics (:9100) from the traefik-internal and traefik-external gateway
# pods. Picked up by the observability VMAgent (selectAllByDefault). The
# app.kubernetes.io/instance pod label is kept as traefik_instance so series
# from the two releases stay distinguishable.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: traefik
namespace: observability
spec:
namespaceSelector:
matchNames:
- traefik-system
selector:
matchLabels:
app.kubernetes.io/name: traefik
podMetricsEndpoints:
- port: metrics
path: /metrics
relabelConfigs:
- sourceLabels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance]
targetLabel: traefik_instance
+133
View File
@@ -0,0 +1,133 @@
---
# kube-prometheus k8s.rules recording rules (dashboard queries depend on these).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
name: k8s-rules
namespace: observability
spec:
groups:
- name: k8s.rules
rules:
- record: node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate
expr: |
sum by (cluster, namespace, pod, container) (
irate(container_cpu_usage_seconds_total{job="kubelet", metrics_path="/metrics/cadvisor", image!=""}[5m])
) * on (cluster, namespace, pod) group_left(node) topk by (cluster, namespace, pod) (
1, max by (cluster, namespace, pod, node) (kube_pod_info{node!=""})
)
- record: node_namespace_pod_container:container_memory_working_set_bytes
expr: |
container_memory_working_set_bytes{job="kubelet", metrics_path="/metrics/cadvisor", image!=""}
* on (cluster, namespace, pod) group_left(node) topk by (cluster, namespace, pod) (
1, max by (cluster, namespace, pod, node) (kube_pod_info{node!=""})
)
- record: node_namespace_pod_container:container_memory_rss
expr: |
container_memory_rss{job="kubelet", metrics_path="/metrics/cadvisor", image!=""}
* on (cluster, namespace, pod) group_left(node) topk by (cluster, namespace, pod) (
1, max by (cluster, namespace, pod, node) (kube_pod_info{node!=""})
)
- record: node_namespace_pod_container:container_memory_cache
expr: |
container_memory_cache{job="kubelet", metrics_path="/metrics/cadvisor", image!=""}
* on (cluster, namespace, pod) group_left(node) topk by (cluster, namespace, pod) (
1, max by (cluster, namespace, pod, node) (kube_pod_info{node!=""})
)
- record: node_namespace_pod_container:container_memory_swap
expr: |
container_memory_swap{job="kubelet", metrics_path="/metrics/cadvisor", image!=""}
* on (cluster, namespace, pod) group_left(node) topk by (cluster, namespace, pod) (
1, max by (cluster, namespace, pod, node) (kube_pod_info{node!=""})
)
- record: namespace_memory:kube_pod_container_resource_requests:sum
expr: |
sum by (namespace, cluster) (
sum by (namespace, pod, cluster) (
max by (namespace, pod, container, cluster) (
kube_pod_container_resource_requests{resource="memory",job="kube-state-metrics"}
) * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) (
kube_pod_status_phase{phase=~"Pending|Running"} == 1
)
)
)
- record: namespace_cpu:kube_pod_container_resource_requests:sum
expr: |
sum by (namespace, cluster) (
sum by (namespace, pod, cluster) (
max by (namespace, pod, container, cluster) (
kube_pod_container_resource_requests{resource="cpu",job="kube-state-metrics"}
) * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) (
kube_pod_status_phase{phase=~"Pending|Running"} == 1
)
)
)
- record: namespace_memory:kube_pod_container_resource_limits:sum
expr: |
sum by (namespace, cluster) (
sum by (namespace, pod, cluster) (
max by (namespace, pod, container, cluster) (
kube_pod_container_resource_limits{resource="memory",job="kube-state-metrics"}
) * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) (
kube_pod_status_phase{phase=~"Pending|Running"} == 1
)
)
)
- record: namespace_cpu:kube_pod_container_resource_limits:sum
expr: |
sum by (namespace, cluster) (
sum by (namespace, pod, cluster) (
max by (namespace, pod, container, cluster) (
kube_pod_container_resource_limits{resource="cpu",job="kube-state-metrics"}
) * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) (
kube_pod_status_phase{phase=~"Pending|Running"} == 1
)
)
)
- record: namespace_workload_pod:kube_pod_owner:relabel
labels:
workload_type: deployment
expr: |
max by (cluster, namespace, workload, pod) (
label_replace(
label_replace(
kube_pod_owner{job="kube-state-metrics", owner_kind="ReplicaSet"},
"replicaset", "$1", "owner_name", "(.*)"
) * on (replicaset, namespace) group_left(owner_name) topk by (replicaset, namespace) (
1, max by (replicaset, namespace, owner_name) (
kube_replicaset_owner{job="kube-state-metrics"}
)
),
"workload", "$1", "owner_name", "(.*)"
)
)
- record: namespace_workload_pod:kube_pod_owner:relabel
labels:
workload_type: daemonset
expr: |
max by (cluster, namespace, workload, pod) (
label_replace(
kube_pod_owner{job="kube-state-metrics", owner_kind="DaemonSet"},
"workload", "$1", "owner_name", "(.*)"
)
)
- record: namespace_workload_pod:kube_pod_owner:relabel
labels:
workload_type: statefulset
expr: |
max by (cluster, namespace, workload, pod) (
label_replace(
kube_pod_owner{job="kube-state-metrics", owner_kind="StatefulSet"},
"workload", "$1", "owner_name", "(.*)"
)
)
- record: namespace_workload_pod:kube_pod_owner:relabel
labels:
workload_type: job
expr: |
max by (cluster, namespace, workload, pod) (
label_replace(
kube_pod_owner{job="kube-state-metrics", owner_kind="Job"},
"workload", "$1", "owner_name", "(.*)"
)
)
@@ -29,6 +29,11 @@ providers:
nativeLBByDefault: false
labelSelector: "traefik.io/instance=external"
metrics:
prometheus:
addEntryPointsLabels: true
addRoutersLabels: true
logs:
access:
enabled: true
@@ -29,6 +29,11 @@ providers:
nativeLBByDefault: false
labelSelector: "traefik.io/instance=internal"
metrics:
prometheus:
addEntryPointsLabels: true
addRoutersLabels: true
logs:
access:
enabled: true