Compare commits

...

71 Commits

Author SHA1 Message Date
unkin-agent fe51aa07be Give the puppetserver compilers the Vault cert helpers (#482)
profiles::pki::vault and profiles::ssh::sign shell out to
/usr/local/bin/certmanager and /usr/local/bin/sshsignhost from generate()
during catalog compilation. Neither binary exists in the compiler image, so
every node using them fails to compile.

- install certmanager v0.2.0 and sshsignhost v0.1.0 onto the shared bin volume with sha256 verification
- wrap both at /usr/local/bin from a pre-default entrypoint hook, failing startup loudly if either is missing
- mount read-only Vault configs for both: kubernetes auth on k8s/au/syd1, internal CA verified rather than skipped

Reviewed-on: #482
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-24 21:15:09 +10:00
unkin-agent cdaab736b5 Bump jellyfin-ha to v0.4.0 (#483)
The deployed v0.3.3 build returns 500 from /Shows/NextUp on PostgreSQL, breaking the home screen, and lets replicas diverge: library-visibility and shared-config changes never propagate, user data (resume, played state, favourites, ratings) is overwritten between pods, and eight scheduled tasks run on every replica instead of only the scan leader. v0.4.0 carries the fixes.

- Pin cheeztv and fafflix to jellyfin-ha:v0.4.0.

No config change needed: cross-pod invalidation reuses the transcode-store Redis connection string both apps already set.

Reviewed-on: #483
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-22 22:57:06 +10:00
unkin-agent b31517e6d9 Merge pull request #481 from benvin/jellyfin-sso-valkey-state
Roll jellyfin-ha to v0.3.3 and drop Service session affinity

Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-20 14:02:48 +10:00
unkin-agent 5a74b2cec6 Roll bind-operator to v0.2.7 (journal-aware zone seeding) (#480)
Deploy bind-operator v0.2.7. The operator seeded a fresh skeleton zone file at serial 1 over zones whose BIND journal was still on disk at a higher serial; BIND rejected the inconsistent pair (`addzone failed: out of range`) and, with a PVC per replica, the stale journal outlived restarts while every reconcile rewrote the skeleton, so it never converged. That SERVFAILed roughly 1 in 3 authoritative answers for k8s.syd1.au.unkin.net and resolvers cached the failures.

- Bumps the operator image to v0.2.7
- Bumps the CRD install pin to the v0.2.7 tag, which changes the CRDs

Expect one rolling restart of the operator Deployment as the new image lands.

Reviewed-on: #480
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-20 00:53:47 +10:00
unkin-agent f14bcc4d2a Add woodpecker ServiceAccount for jellyfin-plugin-sso CI (#479)
The new `unkin/jellyfin-plugin-sso` fork is getting a Woodpecker pipeline, and its build step will set `serviceAccountName: jellyfin-plugin-sso`. Without the SA declared here the pipeline pods fail to schedule.

- add a bare ServiceAccount `jellyfin-plugin-sso` in the `woodpecker` namespace
- register it in the woodpecker base kustomization

The step only builds .NET code, so no Vault kube-auth role or RBAC is needed.

Reviewed-on: #479
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 17:32:31 +10:00
unkin-agent b01e4c3241 Rewrite slash-less Authentik token endpoint to the canonical path (#478)
Authentik advertises the token endpoint with a trailing slash, but some OIDC clients (the ArgoCD iOS app) POST to /application/o/token without one; Django's APPEND_SLASH will not redirect a POST, so the token exchange gets 405 and login fails.

- Add an exact-match rule on /application/o/token to the authentik and authentik-internal HTTPRoutes.
- Rewrite it to /application/o/token/ with a URLRewrite ReplaceFullPath filter, preserving the method and the authentik-server backend.
- Leave the catch-all PathPrefix rule untouched; exact matches outrank it in Gateway API precedence.

Reviewed-on: #478
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 16:10:30 +10:00
unkin-agent bbd5bdaa95 Enable PKCE for ArgoCD OIDC login (#477)
The Authentik client for ArgoCD is now public (the iOS app can't hold
a secret), so Authentik no longer enforces client_secret on token
exchange. PKCE replaces that as the protection against
authorization-code interception.

- Add `enablePKCEAuthentication: true` to the `oidc.config` block in
  `argocd-cm-patch.yaml`
- Note why PKCE is needed now that the client is public

Reviewed-on: #477
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 16:10:05 +10:00
unkin-agent 4762cf9e03 Add VMPodScrape for authentik-server metrics (#476)
Authentik server pods expose django_prometheus metrics on :9300, but only ldap-outpost and redis-exporter are scraped in this namespace. Add the missing per-app scrape.

- add apps/base/authentik/server-vmpodscrape.yaml selecting app.kubernetes.io/name=authentik, component=server on the metrics port
- wire it into apps/base/authentik/kustomization.yaml

Reviewed-on: #476
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 15:34:26 +10:00
unkin-agent c83a886e74 Enable pg_stat_statements on the authentik postgres cluster (#475)
The cluster preloads no statement-statistics library, so there is no per-query cost attribution in postgres and slow query paths have to be inferred from application-side metrics instead of read straight out of the database.

- preload `pg_stat_statements`
- set `pg_stat_statements.max` and `.track`, which is what makes CNPG manage the extension and create it in every database

Requires a postgres restart. Stacked on `benvin/authentik-cnpg-resources`.

Reviewed-on: #475
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 14:48:45 +10:00
unkin-agent 9a7200636c Raise authentik postgres CPU, memory and buffer sizing (#474)
The 500m CPU limit is a 50ms CFS quota per 100ms period, so the postgres pods are throttled on every burst even at ~0.01 cores average and each query pays that latency. 128MB of `shared_buffers` and a 256MB cache estimate also under-serve the planner on the joins authentik issues on its hot read paths.

- raise resources to requests `500m`/`1Gi`, limits `2`/`2Gi`
- raise `shared_buffers` to 512MB and `effective_cache_size` to 1536MB
- hold the post-incident memory headroom multiple over `shared_buffers`

Rolling restart with switchover. Stacked on `benvin/authentik-hot-standby-feedback`.

Reviewed-on: #474
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 14:35:16 +10:00
unkin-agent 9535bad9bc Enable hot_standby_feedback on the authentik postgres cluster (#473)
Authentik serves multi-second API reads from the CNPG hot standbys. Those reads outlive `max_standby_streaming_delay`, so recovery cancels them with `canceling statement due to conflict with recovery`, which authentik surfaces as HTTP 500 — enough to break a terraform apply mid-run.

- set `hot_standby_feedback` on so replicas report their oldest xmin to the primary and long reads stop being cancelled
- SIGHUP reload only, no restart or switchover
- retained-dead-tuple cost is negligible on a ~155MB database

Reviewed-on: #473
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 14:34:33 +10:00
unkin-agent 34dd70435e Auto-reload cheeztv and fafflix on plugin ConfigMap change (#471)
Edits to the cheeztv/fafflix plugin ConfigMaps only reach the pods via the inject-plugin-config initContainer, so a config change sat inert until someone manually rolled the StatefulSet. Reloader is deployed cluster-wide with autoReloadAll disabled, so each workload has to opt in.

- annotate both StatefulSets with configmap.reloader.stakater.com/auto: "true"

Reviewed-on: #471
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 12:25:08 +10:00
unkin-agent 6cc752336e Point jellyfin SSO at public Authentik hostname (#470)
The internal-CA identity.k8s.syd1.au.unkin.net host has no CA bundle mounted in the jellyfin pods, so the OIDC discovery fetch fails TLS handshake (PartialChain). Authentik's discovery response is host-relative, so the browser-facing hostname must be used, not the internal one.

- Change OidEndpoint to identity.unkin.net in fafflix plugin config
- Change OidEndpoint to identity.unkin.net in cheeztv plugin config

Reviewed-on: #470
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-19 12:09:31 +10:00
unkin-agent 47a2ab9152 Pin jellyfin-ha image to v0.3.2 (#468)
v0.3.0 and v0.3.1 crash-looped on Postgres migration/reader bugs and were reverted. v0.3.2 fixes both and was validated end to end against production-baseline Postgres and valkey: full migration chain completes, all previously-500 endpoints return 200, RedisTranscodeSessionStore and scan-leader gating confirmed active.

- Bump jellyfin-ha image tag v0.2.0 -> v0.3.2 in cheeztv and fafflix statefulsets

Depends on a pre-sync duplicate-username check and fresh pg_dump of both databases.

Reviewed-on: #468
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-13 23:08:44 +10:00
unkin-agent 4748df497a puppet: install toml into the puppetserver gem path (#467)
Catalog compiles fail with `LoadError: no such file to load -- toml`: server-side functions run in the puppetserver JRuby, whose gem path is separate from the agent CRuby path this hook installs into. puppet-prod's `profiles::puppet::gems` covers both; the hook only did the agent half.

- Install toml via `puppetserver gem`, mirroring the `puppetserver_gem` resource in puppet-prod
- Note in a comment that under `set -e` a failed install takes down an already-serving compiler

Reviewed-on: #467
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-13 23:08:31 +10:00
unkin-agent ba14f85e51 Pin pdbmux to v0.4.0 (#466)
v0.3.0 still serves facts from cache and collapses non-4xx upstream rejections into a 502, so clients see stale facts and lose the real status.

- Pin the pdbmux image to v0.4.0

Reviewed-on: #466
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-13 20:36:49 +10:00
unkin-agent 8a00ddb82c Revert jellyfin-ha to v0.2.0 (#465)
v0.3.1 crash-loops both jellyfin StatefulSets deterministically on the
RatingLevels migration (concurrent Npgsql command in progress), failing
before any schema change commits. OrderedReady updates leave ordinal-1
stuck, stranding cheeztv and fafflix single-replica with no HA.

- revert cheeztv jellyfin-ha image v0.3.1 -> v0.2.0
- revert fafflix jellyfin-ha image v0.3.1 -> v0.2.0

Unblocks the stalled StatefulSet rollout.

Reviewed-on: #465
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 21:38:08 +10:00
unkin-agent d4aed39f6a pdbmux: bump image to v0.3.0 (#464)
The deployed pin sits on v0.2.0, so pdbmux still answers malformed queries with `502 all backends failed` and resolves per-certname routes by configured backend order rather than by which backend actually owns the node.

Bump the pdbmux image pin to v0.3.0:

- Replay a unanimous upstream rejection (PuppetDB's real 400 + parse message) instead of a 502.
- Resolve per-certname routes to the node's owning backend by report freshness.
- Match backend addresses case-insensitively when redacting.

Reviewed-on: #464
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 20:45:14 +10:00
unkin-agent d6a1279efe jellyfin: bump fafflix+cheeztv to v0.3.1 (#463)
v0.3.0 (#461) crash-looped existing databases on a broken Postgres
migration path; v0.3.1 restores the migration baseline, hardens guid/FK
handling, and fixes encoding.xml parsing.

- bump cheeztv jellyfin-ha image v0.2.0 -> v0.3.1
- bump fafflix jellyfin-ha image v0.2.0 -> v0.3.1

Requires manual pre-merge database verification before merge.

Reviewed-on: #463
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 20:32:41 +10:00
unkin-agent 55af4b2f16 allow catalog-diff to compile catalogs on the puppet compilers (#462)
catalog-diff compiles a host's catalog in two environments and diffs them to validate puppet-prod changes before merge, which means compiling catalogs on behalf of other nodes via POST /puppet/v4/catalog. The compilers run the image default auth.conf, where that endpoint is denied.

- add a compiler auth.conf allowing catalog-diff.main.unkin.net to POST /puppet/v4/catalog
- add a pre-default entrypoint script seeding it into conf.d, failing hard if the source is absent
- mount both onto the compiler deployment via configMapGenerator

Reviewed-on: #462
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 16:31:08 +10:00
unkin-agent 783a3db0fd revert jellyfin-ha to v0.2.0 (#461)
v0.3.0 fails an EF-Core migration on boot (NormalizedUsername column
missing), crash-looping ordinal-1 pods so the StatefulSet rolling
update stalls and cheeztv/fafflix stay single-replica. Unblocks the
stuck rollout.

- revert cheeztv statefulset image to jellyfin-ha:v0.2.0
- revert fafflix statefulset image to jellyfin-ha:v0.2.0

Reviewed-on: #461
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 15:22:20 +10:00
unkin-agent 84f09f89ff bump jellyfin-ha to v0.3.0 (#460)
jellyfin-ha v0.3.0 is the first build tracking Jellyfin 12.0 (.NET 10 runtime, jellyfin-web 12.0, LDAP plugin 24).

- bump cheeztv jellyfin-ha image tag to v0.3.0
- bump fafflix jellyfin-ha image tag to v0.3.0

Reviewed-on: #460
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 14:23:39 +10:00
unkin-agent 5b07157eeb woodpecker: raise agent workspace PVC to 20Gi (#459)
Woodpecker's k8s backend provisions a per-pipeline workspace PVC sized by
WOODPECKER_BACKEND_K8S_VOLUME_SIZE. At 10G, large builds (e.g. .NET clone +
build output) leave too little free space for tests that hard-require free
disk headroom, failing purely on disk exhaustion.

- raise WOODPECKER_BACKEND_K8S_VOLUME_SIZE from 10G to 20Gi

Reviewed-on: #459
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-12 12:47:07 +10:00
unkin-agent 7aec9a9021 artifactapi: restore combine-certs + PROVIDER_CA_FILES on oauth2-proxy (#458)
**Fix-forward companion to the #457 rollback. This is NOT the current outage fix — see below.**

## The actual outage

The UI is 503 because the Authentik application slug `artifactapi` **does not exist**. OIDC discovery 404s, so oauth2-proxy exits at startup, the Service has no ready endpoints, and Traefik answers `no available server`.

```
identity.unkin.net              /application/o/artifactapi/…  404
identity.k8s.syd1.au.unkin.net  /application/o/artifactapi/…  404
identity.unkin.net              /application/o/repospawner/…  200
identity.unkin.net              /application/o/argocd/…       200
```

Root cause is upstream in **terraform-authentik**: `ci/woodpecker/push/apply` on main HEAD `4e16401` **failed**. That apply has to succeed before any argocd-apps change can help. **This PR does not fix that.**

## What this PR does fix

#456 dropped the `combine-certs` initContainer and `OAUTH2_PROXY_PROVIDER_CA_FILES`, reasoning that `identity.unkin.net` serves a publicly trusted Let's Encrypt cert and so needs no internal CA. That holds for the browser redirect but not for oauth2-proxy's own back-channel discovery/token calls.

artifactapi is the **only one of six** oauth2-proxies in the estate without it:

| app | issuer host | `PROVIDER_CA_FILES` |
|---|---|---|
| arrproxy | identity.unkin.net | yes |
| logviewer | identity.unkin.net | yes |
| mediamark | identity.unkin.net | yes |
| repospawner | identity.unkin.net | yes |
| watchstate | identity.k8s… | yes |
| **artifactapi** | identity.unkin.net | **no** |

repospawner uses the **same public `identity.unkin.net` issuer** and still needs the internal bundle, which falsifies the removal reasoning. The existing comment on that initContainer states it plainly: *"The Authentik issuer is served behind the internal unkin.net CA."*

## Changes

- Add the `combine-certs` initContainer — byte-identical to repospawner's.
- Mount the combined bundle and set `OAUTH2_PROXY_PROVIDER_CA_FILES`.
- Reload the Deployment when `vault-ca-cert` rotates.

`vault-ca-cert` already exists in the `artifactapi` namespace (`api-deployment.yaml` uses it). `kustomize build apps/base/artifactapi` succeeds.

## Risk

Trust-only and strictly additive — it appends the internal CA to the system roots. Harmless if the back channel turns out to reach a publicly trusted endpoint after all. Expected to remove the *next* blocker, surfacing as x509, once the terraform-authentik apply lands.

## Sequencing

1. Fix and re-run terraform-authentik `push/apply` so the `artifactapi` application exists.
2. Merge this.
3. Confirm `/ui/` returns 200, then close #457 unmerged.

Only merge #457 instead if the UI must come back before step 1 can be done.

Reviewed-on: #458
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-07 23:05:39 +10:00
unkin-agent c98d88c197 Put the artifactapi web UI behind Authentik oauth2-proxy (#456)
The artifactapi web UI is open to anyone who can reach the host. Front it with Authentik SSO gated on akP-artifactapi-admin, while leaving the package-manager surfaces (/api/v1, /api/v2, /v2 docker registry, /terraform, /.well-known) untouched — dnf, containerd mirrors, buildah, terraform and CI publish steps cannot do a browser flow.

- Add the oauth2-proxy ConfigMap, Deployment, Service and VMPodScrape.
- Add the oauth-credentials VaultStaticSecret.
- Point the api-route /ui rule at oauth2-proxy and add a /oauth2 rule; the catch-all / rule still goes straight to the api Service on both listeners.

Requires terraform-authentik #34 applied and the Vault kv seed first.

Reviewed-on: #456
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-07 21:01:17 +10:00
unkin-agent 520da449c9 pdbmux: bump image to v0.2.0 (#455)
## Why
v0.2.0 ships the reports/events union, aggregate summing across backends, config-file support, and the removal of the primary/prefer ranking.

## How
- Pin the pdbmux Deployment image to `v0.2.0`.
- Leave `pdbmux-env` unchanged: `PDBMUX_LISTEN`, `PDBMUX_BACKENDS`, `PDBMUX_MERGE` are the only keys v0.2.0 reads from this ConfigMap, and `PDBMUX_PRIMARY`/`PDBMUX_PREFER` are already gone (#452).

Reviewed-on: #455
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 17:05:45 +10:00
unkin-agent 2fcb3d70d5 Bump artifactapi to v3.11.2 (#453)
Why: pick up v3.11.2, which moves DB migrations onto golib/pg with no behavior change.

- Bump the artifactapi API and UI image pins from v3.11.1 to v3.11.2.

Reviewed-on: #453
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 16:04:58 +10:00
unkin-agent 1172aa3e96 pdbmux: order backends new-first and drop primary/prefer (#452)
## Why
pdbmux#9 drops primary/prefer and makes configured backend order the only tie-break, so the current `old`-first list would silently reverse which PuppetDB wins.

## How
- Order `PDBMUX_BACKENDS` with `new=http://puppetdb.puppet.svc.cluster.local:8080` first and `old=http://puppetdbapi.service.consul:8080` second, URLs unchanged.
- Drop `PDBMUX_PRIMARY` and `PDBMUX_PREFER`; both already resolve to `new`, on the deployed v0.1.0 image (estate defaults) and on pdbmux main (first-backend fallback), so the rendered behaviour is unchanged today.
- Refresh the configmap and deployment comments to describe order-based precedence.

Merge this before pdbmux#9.

Reviewed-on: #452
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 16:04:40 +10:00
unkin-agent e24b19412a woodpecker: add vimpack-ci ServiceAccount (#454)
Why: the new vimpack repo's woodpecker pipelines set `serviceAccountName: vimpack-ci`, which does not exist in the woodpecker namespace yet.

- Add bare `vimpack-ci` ServiceAccount in `apps/base/woodpecker/` and register it in the kustomization.

Reviewed-on: #454
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 13:42:33 +10:00
unkin-agent b2b82e6a6c puppet: use OPENVOXSERVER_JAVA_ARGS for puppetserver JVM args (#451)
## Why

The `ghcr.io/openvoxproject/openvoxserver` image reads `OPENVOXSERVER_JAVA_ARGS` (`/etc/default/puppetserver`: `JAVA_ARGS=$OPENVOXSERVER_JAVA_ARGS`) and ships no `PUPPETSERVER_*` alias, so our heap/JMX flags have been inert since the fork switch — live masters and compilers run the image default `-Xms1024m -Xmx1024m` with no JMX.

## How

- Rename `PUPPETSERVER_JAVA_ARGS` to `OPENVOXSERVER_JAVA_ARGS` in `puppetserver-master-config`, `puppetserver-compiler-config` and `puppetserver-init-config`.
- Rename the same var on the `perms-and-dirs` init container in `deployment_puppetserver-compiler.yaml`.
- Flag values are unchanged (`-Xms1024m -Xmx3072m` plus the JMX flags); heap tuning is a separate call.

Reviewed-on: #451
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 11:30:22 +10:00
unkin-agent 7f15488332 Bump encapi image to v0.1.2 (#450)
Why: pick up encapi v0.1.2, which moves DB migrations onto golib/pg with no behavior change (release pipeline green).

- Bump the encapi deployment container image from v0.1.1 to v0.1.2

Reviewed-on: #450
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 11:23:45 +10:00
unkin-agent 8ea2925561 Set puppetserver environment timeout to 0 (#449)
## Why

An unlimited environment timeout serves stale catalogs after code changes.

## How

- Set `OPENVOXSERVER_ENVIRONMENT_TIMEOUT: "0"` in the puppetserver master and compiler ConfigMaps.

Reviewed-on: #449
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 11:23:04 +10:00
unkin-agent 236563a37f Bump arrproxy images to v0.6.1 (#448)
Pick up the golib/pg migration runner refactor released in arrproxy v0.6.1; no behavior change.

- Bump arrproxy-api and arrproxy-ui image tags v0.6.0 -> v0.6.1

Reviewed-on: #448
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-09-05 11:15:47 +10:00
unkin-agent df38b6b75f Add golib-ci ServiceAccount for woodpecker (#447)
## Why

The new `golib` repo's Woodpecker pipeline needs a dedicated ServiceAccount to run its CI steps under.

## How

- Add bare ServiceAccount `golib-ci` in the `woodpecker` namespace and wire it into the base kustomization.

Reviewed-on: #447
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-31 06:58:45 +10:00
unkin-agent c5fff07643 Bump repospawner to v0.1.1 (#446)
Why: repospawner v0.1.1 stops spawned job pods from automounting the API token.

- Bump the deployment image and the matching REPOSPAWNER_IMAGE env value to v0.1.1

Reviewed-on: #446
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 17:07:58 +10:00
unkin-agent a9a66a07b1 Deploy repospawner v0.1.0 (#445)
## Why

repospawner v0.1.0 is built and its Vault kubernetes auth role is applied, but nothing deploys it. It turns a "I want a new repository" request into a terraform-git pull request, follows that PR to merge, and optionally activates the repo in Woodpecker, so the review gate stays where it is instead of moving into an agent's hands.

## How

- Add `apps/base/repospawner/`: namespace, ServiceAccount `repospawner`, `default` VaultAuth for VSO, and a namespaced Role/RoleBinding granting jobs create/get/list/watch/delete plus pods and pods/log reads (mirrors mediamover).
- Deployment pinned to `artifactapi.k8s.syd1.au.unkin.net/docker-internal/repospawner:v0.1.0`, one replica with the `Recreate` strategy because request state is in memory and rebuilt from Job labels; the same image reference is passed down as `REPOSPAWNER_IMAGE` so the spawned Jobs stay in step.
- Mount a projected `audience: vault` service account token at `/var/run/secrets/vault` — the app logs into Vault natively rather than through VSO — and the `repospawner-woodpecker` Secret at `/etc/repospawner/woodpecker`, optional so the server still starts and refuses `woodpecker: true` with 503 when it is absent.
- Two VaultStaticSecrets: `oauth-credentials` from `kv/kubernetes/namespace/repospawner/default/oauth-credentials` and `repospawner-woodpecker` (key `token`) from `.../default/woodpecker`, with reloader annotations on both consumers.
- oauth2-proxy front door on the watchstate/mediamark pattern, gated on `akP-repospawner-admin` via the `ak_groups` claim and re-checked by the app from `X-Forwarded-Groups`; public `repospawner.unkin.net` on the reflected wildcard and internal `repospawner.k8s.syd1.au.unkin.net` on `vault-issuer`, both routed to the oauth2 Service.
- Register the overlay in the platform ApplicationSet and AppProject, and append `repospawner` to the wildcard Certificate's two reflector namespace lists.

Depends on the terraform-authentik `repospawner` client being applied and `kv/kubernetes/namespace/repospawner/default/oauth-credentials` + `.../woodpecker` being seeded.

Reviewed-on: #445
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 15:40:06 +10:00
unkin-agent abf73bb5bf arrproxy: v0.6.0 self-migrating, drop external migrate Job (#444)
## Why

arrproxy v0.6.0 applies its own schema at startup under a Postgres advisory lock and holds `/readyz` until the schema is current, so every replica is safe to roll without an external gate. The wave-1 psql `arrproxy-migrate` Job and its SQL ConfigMap now only re-run idempotent statements the app already owns — dead weight, a second source of truth for the schema, and a standing drift trap whenever the app's embedded migrations move ahead of the manifests.

## How

- Bump `arrproxy-api` and `arrproxy-ui` to `v0.6.0`.
- Delete `migrate-job.yaml` and `migrations-configmap.yaml` and drop both from the arrproxy kustomization.
- Keep the wave-0/wave-2 split: wave 2 still orders the api behind the wave-0 CNPG Cluster and VSO-synced Secrets, which is independent of the migrate Job; the stale "serve only after the wave-1 migrate Job" comment is corrected.
- Rendered diff vs `main` is exactly the two image bumps plus the `arrproxy-migrate` Job and `arrproxy-migrations` ConfigMap disappearing; `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` and pre-commit both clean.

Reviewed-on: #444
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 15:30:43 +10:00
unkin-agent b355d6aafb mediamark: deploy the media marking app (#441)
## Why

mediamark needs a home in the cluster: it marks/organises media on the shared mediastore tree and drives the adult-tier sonarr/radarr through arrproxy's hash routes. This adds the full app to the `media` project, mirroring the watchstate two-host oauth2-proxy pattern and the arrstack static-CephFS + projected-API-keys patterns.

## How

- Adds `apps/base/mediamark/`: namespace, VaultAuth (`k8s/au/syd1`, role `default`), three VaultStaticSecrets, static mediastore PV/PVC, the app Deployment, oauth2-proxy ConfigMap/Deployment, two Services, and internal + external Gateway/HTTPRoute pairs.
- Binds a dedicated static PV (`mediamark-mediastore`, own `volumeHandle`, `claimRef`-pinned) to the same CephFS mediastore subvolume arrstack/fafflix/cheeztv use, RWX 10Ti Retain, mounted at `/media`.
- Runs the app as 1000:1000 (deliberately not 65532) so it owns files on the shared media tree and hardlink/rename moves stay valid; read-only root filesystem, all caps dropped, no service-account token, `/livez` + `/readyz` probes.
- Projects the sonarr/radarr API keys as one file per app under `/etc/mediamark/keys`, mirroring arrproxy's keys projection, with reloader annotations on both secrets.
- Fronts both `mediamark.unkin.net` (traefik-external, reflected Let's Encrypt wildcard, no cert-manager annotations) and `mediamark.k8s.syd1.au.unkin.net` (traefik-internal, vault-issuer) with a single oauth2-proxy using a relative `/oauth2/callback` redirect; gated on `akP-mediamark-user` and passing identity to the app as `X-Forwarded-Groups` via `PASS_USER_HEADERS`.
- Appends `mediamark` to the `wildcard-unkin-net` Certificate's two reflector namespace lists, and registers the app in `argocd/applicationsets/media.yaml` + `argocd/projects/media.yaml` with a passthrough `apps/overlays/au-syd1/mediamark` overlay.

## Prerequisite seeds (Ben, before pods go Ready)

These KV paths must exist under `kv/kubernetes/namespace/mediamark/default/` — the `mediamark/default` templated policy already grants read, so no terraform-vault change is needed:

- `oauth-credentials` — needs `client_id` and `cookie_secret` added alongside the existing `client_secret` (Authentik mediamark provider; both absolute callback URIs registered there).
- `sonarr` — key `apitoken`.
- `radarr` — key `apitoken`.

## Validation

- `kustomize build --enable-helm apps/overlays/au-syd1/mediamark` (18 resources) and `.../cert-manager` both build.
- kubeconform clean on both touched overlays.
- `pre-commit run --all-files` passes.

Reviewed-on: #441
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 15:07:41 +10:00
unkin-agent 221c575a44 arrproxy: bump images to v0.5.0 (per-token method scoping) (#443)
## Why

arrproxy v0.5.0 ships per-token HTTP method scoping for machine tokens, so a minted token can be limited to e.g. `GET` only. Zero-downtime: the mint-API field is additive and existing tokens get an empty methods list, which means unrestricted — they behave exactly as before.

## How

- Bump `arrproxy-api` and `arrproxy-ui` pins from v0.4.0 to v0.5.0.
- Mirror repo migrations `0002_tier_tokens.sql` and `0003_token_methods.sql` into the migrations ConfigMap. It had drifted at 0001 while v0.4.0 already queried `tier`/`read_only`, and every v0.5.0 token query selects `methods` — without this the new API errors on every token read.
- Have the wave-1 migrate Job apply all three files in order. Every statement is `IF NOT EXISTS`, so a resync over an already-migrated database is a no-op.

Rendered `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` diff vs main is exactly the two image tags, the two added ConfigMap keys, and the two added `-f` args.

Reviewed-on: #443
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 14:23:44 +10:00
unkin-agent 1ba6180e96 woodpecker: add repospawner-ci ServiceAccount (#442)
The new repospawner repo needs a Woodpecker CI pipeline, and every pipeline step must run under its own ServiceAccount in the woodpecker namespace.

- Add `apps/base/woodpecker/serviceaccount_repospawner_ci.yaml` (bare SA `repospawner-ci`, ns woodpecker), mirroring the existing mediamark-ci SA.
- Register it in the woodpecker kustomization resources list.

Reviewed-on: #442
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 14:21:42 +10:00
unkin-agent d1085f0ae2 logging: use canonical upstream image names (#433)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit (logarchiver/logviewer are untouched).

Changes:
- Point the ClickHouseInstallation and the clickhouse-schema job at `docker.io/clickhouse/clickhouse-server:24.8`.
- Point the logviewer oauth2-proxy cert-combine init container at `docker.io/library/alpine:3`.
- Point the NATS bootstrap job at `docker.io/natsio/nats-box:0.18.0`.
- Point the NATS chart values at `docker.io/library/nats` and `docker.io/natsio/nats-server-config-reloader`.
- Point all three Vector values files (agent, aggregator, vm-ingest) at `docker.io/timberio/vector`.
- Drop the now-wrong "pulled through the artifactapi dockerhub remote" comments in the NATS and vector-agent values.

Tags/digests unchanged and the `repository`/`tag` split is preserved. `kustomize build --enable-helm apps/overlays/au-syd1/logging` differs from main only in those nine image strings.

Extra found, not changed here: `.woodpecker/vector-test.yaml` still pins its CI step image to `artifactapi.k8s.syd1.au.unkin.net/dockerhub/timberio/vector:0.57.0-debian`. That is a Woodpecker step image rather than a namespace manifest, so it is left out to keep this PR to the logging namespace — say the word and I will fix it separately.

Reviewed-on: #433
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:55:47 +10:00
unkin-agent e4d93ef4fe valkey-operator-system: use canonical ghcr.io registry (#437)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Set the valkey-operator chart `image.registry` to `ghcr.io`.

The `registry`/`repository`/`tag` split is untouched otherwise, so the rendered image is `ghcr.io/valkey-io/valkey-operator:v0.5.0`. `kustomize build --enable-helm apps/overlays/au-syd1/valkey-operator-system` differs from main only in that image string. No other proxied image refs in the file (the `helmCharts[].repo` entry in kustomization.yaml is a Helm chart repo, not a container registry, so it stays on artifactapi).

Reviewed-on: #437
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:52:52 +10:00
unkin-agent 1169d796e7 grafana: stop pinning the internal CA for Authentik OAuth (#440)
## Why

`identity.unkin.net` moved from an internal `unkin.net` CA-issued cert to the LetsEncrypt `*.unkin.net` wildcard. `auth.generic_oauth`'s `tls_client_ca` pointed Grafana at the internal root only, so the OAuth handshake to the LE-issued cert now fails. Grafana's image trust store already contains the public roots.

## Changes

- Remove `tls_client_ca: /etc/grafana/vault-ca/ca.crt` (and its stale comment) from `auth.generic_oauth`.
- Remove the now-unused `vault-ca-cert` volume and volumeMount from the Grafana pod spec — nothing else in the pod referenced it (the CNPG `endpointCA` reference to `vault-ca-cert` for `s3.ceph.unkin.net` is a separate resource and stays).
- Leave the auth/token/api URLs, scopes and `role_attribute_path` untouched.

Reviewed-on: #440
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:52:30 +10:00
unkin-agent aedb721b3e argocd: drop internal-CA rootCA pin from Authentik oidc.config (#439)
## Why

`identity.unkin.net` moved from an internal `unkin.net` CA-issued cert to the LetsEncrypt `*.unkin.net` wildcard. The `oidc.config` block pinned the internal root as the *only* trust anchor, so argocd-server now rejects OIDC discovery with `x509: certificate signed by unknown authority` and SSO login is broken. The stock image trust store already carries the public roots.

## Changes

- Remove the `rootCA:` block from `argocd-cm`'s `oidc.config` for the `https://identity.unkin.net/application/o/argocd/` issuer.
- Replace the now-false internal-CA rationale comment with a one-liner noting the LE-issued cert needs no pin.
- Leave issuer, clientID, clientSecret ref, `requestedScopes` (incl. `ak_groups`) and `requestedIDTokenClaims` untouched.

Reviewed-on: #439
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:52:12 +10:00
unkin-agent 74ad2c8773 woodpecker: add mediamark-ci service account (#438)
The mediamark Woodpecker docker step needs a dedicated ServiceAccount so it can push to the trusted in-cluster registry, mirroring the existing arrproxy-ci setup.

- Add bare ServiceAccount `mediamark-ci` in namespace `woodpecker` and register it in the woodpecker base kustomization.

Reviewed-on: #438
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:21:21 +10:00
unkin-agent 6b5b129ad6 clickhouse-system: use canonical upstream image names (#436)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the Altinity chart crdHook at `docker.io/bitnami/kubectl`.
- Point the operator at `docker.io/altinity/clickhouse-operator`.
- Point the metrics exporter at `docker.io/altinity/metrics-exporter`.
- Reword the header comment that claimed all images are pulled through the artifactapi dockerhub remote.

Only the `repository` keys change; the chart still supplies the tags (0.27.2 / latest), so rendered tags are identical. `kustomize build --enable-helm apps/overlays/au-syd1/clickhouse-system` differs from main only in those three image strings. No other proxied refs in the file.

Reviewed-on: #436
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:20:10 +10:00
unkin-agent 38a41bd44b watchstate: use canonical upstream image name for alpine (#435)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the watchstate oauth2-proxy cert-combine init container at `docker.io/library/alpine:3`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/watchstate` differs from main only in that image string. No extra proxied refs in the file (the oauth2-proxy image itself is already canonical `quay.io/...`).

Reviewed-on: #435
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:19:36 +10:00
unkin-agent da1d812eec netbox: use canonical upstream image name for redis_exporter (#434)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the netbox valkey metrics sidecar at `docker.io/oliver006/redis_exporter:v1.89.0`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/netbox` differs from main only in that image string. No extra proxied refs in the file (the `valkey/valkey:8-alpine` container is already a bare upstream name).

Reviewed-on: #434
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:19:27 +10:00
unkin-agent df89947f47 litellm: use canonical upstream image name for redis_exporter (#432)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the litellm redis metrics sidecar at `docker.io/oliver006/redis_exporter:v1.89.0`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/litellm` differs from main only in that image string. No extra proxied refs in the file (the `redis:7-alpine` container is already a bare upstream name).

Reviewed-on: #432
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:19:01 +10:00
unkin-agent 7f928dddfc gitea: use canonical upstream image name for redis_exporter (#431)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the gitea valkey metrics sidecar at `docker.io/oliver006/redis_exporter:v1.89.0`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/gitea` differs from main only in that image string. No extra proxied refs in the file (the `valkey/valkey:8-alpine` container is already a bare upstream name).

Reviewed-on: #431
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:18:44 +10:00
unkin-agent b459e9a90a authentik: use canonical upstream image name for redis_exporter (#430)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the authentik redis metrics sidecar at `docker.io/oliver006/redis_exporter:v1.89.0`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/authentik` differs from main only in that image string. No extra proxied refs in the file (the `redis:7-alpine` container is already a bare upstream name).

Reviewed-on: #430
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:18:33 +10:00
unkin-agent c8355967d4 artifactapi: use canonical upstream image name for redis_exporter (#429)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the artifactapi redis metrics sidecar at `docker.io/oliver006/redis_exporter:v1.89.0`.

Tag unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/artifactapi` differs from main only in that image string. No extra proxied refs in the file (the `redis:7-alpine` container is already a bare upstream name).

Reviewed-on: #429
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:18:27 +10:00
unkin-agent e10f0c09c9 arrstack,fafflix,cheeztv: provision shared mediastore CephFS volume (#428)
## Why

The media estate is currently split across two 1Ti CephFS subvolumes (`media-tv`
and `media-movies`), so a download that lands in one and imports to the other is
a cross-filesystem copy, not a hardlink move. A single 10Ti `mediastore`
subvolume (already created on `cephfs_csi_ssd_ec_4_1`) collapses that: one
filesystem holding `/{fafflix,cheeztv}/{tvseries,movies}` plus
`/nzbget/downloads/complete`, so every arr import is an atomic same-fs hardlink.

This PR only provisions the volume and seeds its tree. No existing PV, PVC or
workload is touched, and nothing mounts the new claims yet — the cutover of the
arrs and the two jellyfins is a separate change.

## How

- Add static PVs `arrstack-mediastore`, `fafflix-mediastore` and
  `cheeztv-mediastore`, all pointing at the same rootPath
  (`/volumes/csi_ssd_ec_4_1/mediastore/a0152dac-…`) with unique names and
  volumeHandles pinned by `claimRef` — the established pattern for the shared
  media subvolumes.
- Add the matching RWX 10Ti PVCs (`mediastore` in arrstack,
  `fafflix-mediastore`, `cheeztv-mediastore`), annotated
  `k8up.io/backup: "false"` and statically bound via `volumeName` +
  `storageClassName: ""`.
- Add `mediastore-bootstrap`, a one-shot ArgoCD Sync-hook Job
  (`hook-delete-policy: BeforeHookCreation`, no sync-wave needed) that mounts
  the arrstack claim and `mkdir -p`s the directory tree as uid/gid 1000 —
  the uid the arrstack media pods run as. Idempotent, so it self-heals on
  every sync.
- Wire the new manifests into the arrstack, fafflix and cheeztv bases.

## Validation

- `kustomize build` clean on `apps/overlays/au-syd1/{arrstack,fafflix,cheeztv}`
- `kubeconform` clean on all three overlays (91 / 29 / 32 resources valid)
- pre-commit (yamllint, check-yaml, no-plain-secrets) passed

Reviewed-on: #428
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:18:09 +10:00
unkin-agent 9616e0e2b3 arrstack: use canonical upstream image names (#427)
rke2's `registries.yaml` already rewrites upstream image names to the artifactapi mirror, so manifests must carry canonical upstream names. Only in-house `artifactapi.k8s.syd1.au.unkin.net/docker-internal/...` images stay explicit.

Changes:
- Point the arrproxy migrate job at `docker.io/library/postgres:18-alpine`.
- Point the arrproxy oauth2-proxy cert-combine init container at `docker.io/library/alpine:3`.
- Point the arrstack ValkeyCluster at `docker.io/valkey/valkey:9.0.0`.

Tags are unchanged. `kustomize build --enable-helm apps/overlays/au-syd1/arrstack` differs from main only in those three image strings. No extra proxied refs found in these files (the oauth2-proxy image itself is already canonical `quay.io/...`).

Reviewed-on: #427
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 21:16:59 +10:00
unkin-agent c313c7cc3f fafflix,cheeztv: declarative SSO + LDAP plugin auth config (#424)
## Summary

- Adds SSO-Auth.xml, LDAP-Auth.xml, and branding.xml plugin config (rendered by an initContainer into /config) to fafflix and cheeztv
- Adds a per-namespace oauth-credentials VaultStaticSecret to each instance
- Adds a "Sign in with SSO" login link via jellyfin branding config

## Why

Phase-1 jellyfin auth: browser SSO via the 9p4 SSO plugin (Authentik OIDC, ak_groups claim) and phone/TV app-passwords via the LDAP plugin against the Authentik LDAP outpost. Config is declarative — restarts overwrite UI edits. Activates when the plugin-baked jellyfin-ha image lands (separate PR).

Reviewed-on: #424
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 20:47:54 +10:00
unkin-agent 0322fc6989 jellyfin: bump fafflix+cheeztv to v0.2.0 (#426)
## Summary

- Bumps the jellyfin-ha image to v0.2.0 on fafflix and cheeztv

## Why

v0.2.0 bakes the LDAP and SSO auth plugins into the image; with the plugin config PR this enables browser SSO and app-password logins.

Reviewed-on: #426
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 13:22:40 +10:00
unkin-agent f8716dd16a kea-operator: bump to v0.1.5 (#425)
## Summary

- Bumps kea-operator image pin to v0.1.5

## Why

v0.1.4 fixed HA peer URLs but the HA hook then bound its dedicated listener to the ClusterIP and crashed; v0.1.5 disables the dedicated listener so HA flows via the ctrl-agent.

Reviewed-on: #425
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-29 12:55:13 +10:00
unkin-agent 74eb09b638 kea-operator: bump to v0.1.4 (#423)
why: production kea-dhcp4 is in a permanent crashloop — Kea's HA hook rejects DNS hostnames in peer URLs and v0.1.3 renders headless-DNS peer URLs; v0.1.4 ships the merged fix (per-peer ClusterIP Services + IP-literal URLs).

- bump kea-operator image pin to v0.1.4

Reviewed-on: #423
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-26 23:19:41 +10:00
unkin-agent 8eb57f9c19 authentik: deploy LDAP outpost for in-cluster app-password binds (#422)
## Why

Jellyfin authenticates users against Authentik over LDAP (app-password binds). The `authentik-ldap` Service, Gateway, and TLSRoute already exist in the authentik base, but nothing backed the Service — no LDAP outpost was ever deployed. This adds the missing Deployment so in-cluster clients (Jellyfin) can bind.

Deployed as a separate Deployment inside the existing `authentik` namespace rather than a new app/namespace: the authentik overlay is already wired into the `platform` ApplicationSet and the LDAP Service/Gateway/TLSRoute already live here, so this needs no new AppProject/ApplicationSet wiring and keeps the outpost next to the core it serves.

## How

- `ldap-outpost-deployment.yaml`: 2-replica (stateless) Deployment, image `ghcr.io/goauthentik/ldap:2026.5.3` (canonical upstream name; matches the deployed authentik chart version; containerd mirrors route via artifactapi). Pod labels match the existing `authentik-ldap` Service selector.
  - `AUTHENTIK_HOST=https://identity.k8s.syd1.au.unkin.net`, `AUTHENTIK_INSECURE=false`.
  - Trusts the internal CA via the established combine-certs initContainer pattern (concatenates the base roots with the reflected `vault-ca-cert`) + `SSL_CERT_FILE`.
  - `AUTHENTIK_TOKEN` sourced from secret `authentik-ldap-outpost-token`.
  - Resources set; TCP probes on the LDAP port; reloader annotation for token + CA rotation.
- `ldap-outpost-vaultstaticsecret.yaml`: VSS (reuses the namespace `default` VaultAuth) materialising the token from KV `kubernetes/namespace/authentik/default/outpost-token` (key `token`).
- `ldap-service.yaml`: adds the plaintext `ldap` port 3389 alongside the existing `ldaps` 6636 (ClusterIP only, no public exposure — external reach is via the pre-existing internal Gateway/TLSRoute).
- `ldap-outpost-vmpodscrape.yaml`: VMPodScrape for the outpost metrics endpoint (:9300).
- No sync-wave annotations.

## Dependency — token seed (blocking)

The outpost pods CrashLoop until the token exists. After merge, the Authentik LDAP outpost token must be seeded into Vault KV at `kv/kubernetes/namespace/authentik/default/outpost-token` with key `token`. This path is provided by the corresponding terraform-authentik PR; the value must match the outpost token authentik issues.

## Validation

`kustomize build --enable-helm apps/overlays/au-syd1/authentik` + repo kubeconform: 38 resources, Valid: 38, Invalid: 0, Errors: 0.

Reviewed-on: #422
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-26 23:19:24 +10:00
unkin-agent b9f727bc97 watchstate: expose externally at watchstate.unkin.net (#420)
## Why

WatchState currently only has an internal front door (watchstate.k8s.syd1.au.unkin.net, PR #419, merged). This adds a public front door at **watchstate.unkin.net** via the external (DMZ) Traefik so the admin UI is reachable off-cluster, still fully gated by oauth2-proxy/Authentik. The internal gateway and routes are untouched; oauth2-proxy now fronts BOTH hostnames.

Stacked base note: the intended base (benvin/watchstate-deploy, PR #419) merged into main before this PR opened, so this targets `main`.

## How

- **DNS**: `watchstate-dns-internal` DNSRecord in `apps/base/bind-internal/authoritative/records.yaml` (unkin.net zone, bind-internal) -> `A 198.18.199.0`, the traefik-external VIP. Mirrors the `arrstack-dns-internal` precedent (external front door via bind, not external-dns). Kept in the central bind-internal location, so no AppProject destination widening is needed.
- **Gateway**: new `watchstate-external` Gateway (`gatewayClassName: traefik-external`), http + https listeners on hostname `watchstate.unkin.net`. TLS terminated with the Let's Encrypt `*.unkin.net` wildcard secret `wildcard-unkin-net-tls` — so **no cert-manager and no external-dns annotations**.
- **Routes**: `watchstate-external-http-redirect` (http->https 301) and `watchstate-external` (https -> `watchstate-oauth2:80`), mirroring the arrproxy/cheeztv external patterns.
- **oauth2-proxy dual-host**: `OAUTH2_PROXY_REDIRECT_URL` changed from the pinned cluster callback to the **relative** `/oauth2/callback`. With `OAUTH2_PROXY_REVERSE_PROXY=true` (already set), oauth2-proxy derives scheme+host per request from `X-Forwarded-Proto/Host` (verified in oauth2-proxy v7.15.3 `getOAuthRedirectURI`: host-less redirectURL falls through to request-derived), so the callback works on both `watchstate.unkin.net` and `watchstate.k8s.syd1.au.unkin.net`. `COOKIE_DOMAINS` and `WHITELIST_DOMAINS` now list both hosts (one cookie per host; a single parent-domain cookie can't cleanly span unkin.net vs k8s.syd1.au.unkin.net).
- Dropped the no-op `argocd.argoproj.io/sync-wave: "0"` annotation on the `default` VaultAuth. New resources intentionally carry no sync-wave annotations.

## Validation

- `kustomize build --enable-helm` on `apps/overlays/au-syd1/watchstate` and `.../bind-internal`: OK.
- kubeconform (repo CI config, k8s 1.33.7): watchstate overlay 15/15 valid (2 Gateways, 4 HTTPRoutes); bind-internal overlay 67/67 valid (incl. `watchstate-dns-internal`).

## Dependencies

- **Reflector allow-list (argocd-apps PR #418)**: reflects `wildcard-unkin-net-tls` into the `watchstate` namespace. This PR references that secret as if present.
- **Authentik (terraform-authentik, separate PR)**: register both `https://watchstate.unkin.net/oauth2/callback` and `https://watchstate.k8s.syd1.au.unkin.net/oauth2/callback` as redirect URIs on the watchstate provider.

Reviewed-on: #420
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-26 21:47:03 +10:00
unkin-agent 062d217ea8 bind-internal: add cheeztv.unkin.net record (#421)
why: cheeztv.unkin.net never resolved — external-dns ignores unkin.net (domain-filter is k8s.syd1.au.unkin.net only); *.unkin.net names are DNSRecord CRs.

- add cheeztv-dns-internal A record → 198.18.200.4 (internal traefik VIP serving the cheeztv gateway)

Reviewed-on: #421
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-26 20:09:08 +10:00
unkin-agent 5da12e398d Extend LE *.unkin.net wildcard to arrstack + authentik (reflect into gitea) (#418)
## Why

#417 stood up the central Let's Encrypt `*.unkin.net` wildcard (Certificate `wildcard-unkin-net` in `cert-manager`, secret `wildcard-unkin-net-tls`, emberstack-reflected). This wires the remaining single-label public hosts onto that publicly-trusted leaf instead of the internal Vault-PKI CA.

A `*.unkin.net` wildcard matches **single-label** subdomains only, so cluster hostnames under `*.k8s.syd1.au.unkin.net` are deliberately left on `vault-issuer`.

> Note: PR #417 already merged to `main`, so this is no longer stacked — it targets `main` directly.

## How

- **cert-manager**: reflect `wildcard-unkin-net-tls` into `arrstack`, `authentik`, `gitea` (appended to both reflector namespace lists → `cheeztv,arrstack,authentik,gitea`).
- **arrstack**: repoint the public `arrproxy` gateway listener (`arrstack.unkin.net`) `certificateRefs` → `wildcard-unkin-net-tls`; drop its `cert-manager.io/*` vault-issuer annotations.
- **authentik**: repoint the `identity.unkin.net` https listener → `wildcard-unkin-net-tls`. The `identity.k8s.syd1.au.unkin.net` listeners keep `authentik-tls` (vault-issuer), so the gateway `common-name` is retargeted to that cluster host. `authentik-ldap` gateway untouched.
- **gitea**: secret **reflected only**; `git.unkin.net` cutover deferred (no manifest change), same deferred pattern as fafflix.

## Validation

- `kustomize build` OK for cert-manager / arrstack / authentik / gitea overlays.
- `kubeconform` all valid (0 invalid, 0 errors) across the four overlays.
- Verified no `*.k8s.syd1.au.unkin.net` listener lost its vault-issuer wiring.

Reviewed-on: #418
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 21:48:54 +10:00
unkin-agent 48632eb9f9 watchstate: deploy admin-gated jellyfin watch-state sync tool (#419)
## Why

Deploy WatchState (arabcoders/watchstate), the Jellyfin/Plex/Emby watch-state
sync tool, as an internal admin tool. It gets an admin-only web UI/API gated the
same way as logviewer: an oauth2-proxy front backed by Authentik OIDC.

## What

- New `watchstate` namespace + media-project app; base at `apps/base/watchstate`,
  overlay at `apps/overlays/au-syd1/watchstate`.
- Image `ghcr.io/arabcoders/watchstate:v1.10.3` (current release; canonical
  upstream name, containerd mirrors route ghcr via artifactapi).
- `replicas: 1`, `strategy: Recreate`, single `5Gi` `cephrbd-fast-delete` RWO PVC
  at `/config` — sqlite + the in-container cron/redis are single-writer.
- `runAsUser/runAsGroup/fsGroup: 1000` (image's rootless user); liveness/readiness
  `GET /v1/api/system/healthcheck` on 8080 (route confirmed in upstream
  `src/API/System/HealthCheck.php`, no auth guard).
- oauth2-proxy (mirrors logviewer 1:1) fronts every path. Authentik issuer
  `identity.k8s.syd1.au.unkin.net`, redirect
  `https://watchstate.k8s.syd1.au.unkin.net/oauth2/callback`. Authorization is
  enforced Authentik-side (akR-global-admin only), so no oauth2-proxy group
  allowlist is configured.
- Internal-only Gateway (`traefik-internal`) for
  `watchstate.k8s.syd1.au.unkin.net`, `vault-issuer` TLS leaf, external-dns to
  `198.18.200.4`. HTTP -> HTTPS redirect.
- `VaultStaticSecret` pulls OIDC creds from
  `kv/kubernetes/namespace/watchstate/default/oauth-credentials`; `vault-ca-cert`
  auto-reflects into the namespace.
- Registered in the media `ApplicationSet` + `AppProject`.

## Scrape decision

No `VMPodScrape`: WatchState exposes no Prometheus/`/metrics` endpoint.

## Follow-ups

- **Seed check:** the VaultStaticSecret expects `client_id`, `client_secret`, and
  `cookie_secret` keys at the kv path. Only `client_secret` was confirmed seeded;
  `client_id` and a generated `cookie_secret` must also be present or the
  oauth2-proxy pod will not start.
- **Webhook ingestion:** the Jellyfin webhook endpoint (`/v1/api/webhook`)
  currently sits behind oauth2-proxy like everything else. When sync is wired up,
  the jellyfins pushing webhooks will need an auth-bypass or an apikey route for
  that path.

Reviewed-on: #419
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 21:39:07 +10:00
unkin-agent d42d013541 cheeztv: terminate public cheeztv.unkin.net TLS with the LE *.unkin.net wildcard (#417)
why: cheeztv.unkin.net needs a publicly-trusted cert; DNS-01 via the delegated acme.unkin.net zone is now working (staging wildcard verified end-to-end in ~70s). A single centrally-managed wildcard avoids per-host CNAMEs and LE duplicate-order rate limits.

- add Certificate wildcard-unkin-net (cert-manager ns, ClusterIssuer letsencrypt prod, secret wildcard-unkin-net-tls) with emberstack reflector secretTemplate annotations
- reflect the secret into the cheeztv namespace (append future consumers to the two reflector namespace lists)
- point the public cheeztv gateway listener at wildcard-unkin-net-tls and drop its vault-issuer annotations (cheeztv-internal stays on vault-issuer)

Reviewed-on: #417
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 20:58:02 +10:00
unkin-agent 1ecba543aa arrstack: wire arrproxy v0.4.0 tier routing (fafflix/cheeztv) (#416)
## Why

The old bare `sonarr`/`radarr` Services are gone, but arrproxy still ran the v0.3.x built-in default topology pointing at non-existent `sonarr`/`radarr` upstreams and mounted dead `sonarr-apikey`/`radarr-apikey` Secrets. This cuts the front door over to arrproxy v0.4.0 tier routing so the adult (fafflix, `/3aa168`) and kids (cheeztv, `/3df803`) *arr instances are reachable again, with per-tier group authorization.

## Changes

- Bump `arrproxy-api` and `arrproxy-ui` images to `v0.4.0`.
- Add `arrproxy-tiers` ConfigMap (`ARRPROXY_TIERS_FILE`): `fafflix` + `cheeztv` tiers, each with sonarr/radarr upstreams, route hash, key subdir and group grants. Adults (`akP-media-fafflix`) reach both tiers all methods; kids (`akP-media-cheeztv`) reach only cheeztv, GET/HEAD. `legacyRoutes:false` retires the unprefixed routes.
- Because arrproxy strips `/<hash>` and proxies `/<app>/...`, each upstream URL carries the `/<hash>` path segment so the joined upstream path (`/3aa168/sonarr/...`) matches the *arr's own UrlBase.
- Mount the tiers file and rework the projected keys volume to the real per-instance `{sonarr,radarr}-{adult,kids}-apikey` Secrets under `<keyDir>/<app>` (`adult/*`, `kids/*`); refresh reloader annotations.
- oauth2-proxy: route `/3aa168` and `/3df803` to arrproxy-api (drop dead `/sonarr` `/radarr` `/prowlarr`; prowlarr is served directly), and widen `SKIP_AUTH_REGEX` to `^/[^/]+/[^/]+/api` for the hash-prefixed tier api paths.

Group headers keep flowing via the existing Authentik `ak_groups` claim -> oauth2-proxy `X-Forwarded-Groups` mechanism. Validated with `kustomize build` + repo kubeconform (88/88 valid). No new Vault seeds required — the per-instance apikey Secrets already exist.

Reviewed-on: #416
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 20:49:04 +10:00
unkin-agent fe689dc08e Replace legacy jellyfin app with fafflix (adult, cheeztv pattern) (#415)
## Why

Rebuild the adult media instance as `fafflix`, mirroring the kids instance (`cheeztv`) HA pattern (Postgres-backed jellyfin-ha fork, Valkey transcode-lease store, shared-RWX transcode, CNPG + k8up/restic backups, static CephFS media PVs). Ben: "replace the adult jellyfin with the same pattern as kids." The adult instance holds no data currently, so the wipe/replace is sanctioned.

Stacked on top of `benvin/cheeztv` (#413) — base is that branch, not main.

## How

- Add `apps/base/fafflix/` — cheeztv pattern with `s/cheeztv/fafflix` on names/namespace/labels/secrets/buckets and the Vault kv path `kubernetes/namespace/fafflix/default/k8up-restic`.
- Media: mount the shared movies/tv CephFS subvolumes' `adult` subtree at `/media/movies` and `/media/tv`, plus the `kids` subtree at `/media/movies-kids` and `/media/tv-kids` (same two static PVs, new unique volumeHandles `fafflix-media-{movies,tv}-static`) so fafflix can resume kids content started on cheeztv. All media mounts readOnly, matching cheeztv.
- **Hostname preserved:** fafflix keeps serving the legacy jellyfin host `jellyfin.k8s.syd1.au.unkin.net` (TLS secret `fafflix-tls`, cert-manager re-mints). The dedicated fafflix domain switch is explicitly deferred — no `fafflix.unkin.net` created.
- config PVC on `cephfs-raid5-delete` (delete variant).
- Remove `apps/base/jellyfin/` + its overlay; swap `jellyfin`->`fafflix` in the `media-apps` ApplicationSet directory glob and the `media` AppProject namespace destination.
- Third-party images stay canonical upstream; the in-house `jellyfin-ha` image ref is unchanged.

## Verification

- `kustomize build` + `kubeconform -strict` clean on the fafflix overlay and the whole media appset scope (fafflix/cheeztv/arrstack): 0 invalid, 0 errors.
- No dangling `apps/base/jellyfin` references; `jellyfin.k8s.syd1.au.unkin.net` now served by exactly one app (fafflix); no `fafflix.unkin.net` anywhere.

Reviewed-on: #415
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 20:06:53 +10:00
unkin-agent 9db52c5e26 arrstack: rebuild adult tier on the shared-Postgres pattern (#414)
## Why
Rebuild the ADULT arr tier on the same shared-Postgres, active-active pattern the kids tier now uses, replacing the legacy adult sonarr/radarr/nzbget. The old adult apps hold **no data**, so the wipe is sanctioned.

**Stacked on** `benvin/arrstack-kids` (#412) — review/merge that first.

## How
- **Add** `sonarr-adult` / `radarr-adult` / `nzbget-adult` (copies of the `*-kids` dirs, `s/kids/adult`):
  - own DBs `sonarr-adult-main` / `radarr-adult-main`, Vault paths `.../default/{sonarr,radarr}-adult(+-db)`
  - S3 prefixes `sonarr-adult` / `radarr-adult`; UrlBase + probe + exportarr URLs `/3aa168/{sonarr,radarr}` (adult hash)
  - media subPaths `tvshows/adult` + `movies/adult`; nzbget config PVC on `cephrbd-fast-delete`
- **postgres**: add managed roles `sonarr-adult`/`radarr-adult`, Database CRDs `{sonarr,radarr}-adult-main`, VaultStaticSecrets `{sonarr,radarr}-adult-db`
- **Remove** legacy `sonarr`/`radarr`/`nzbget` dirs (incl. their gateway/httproute), managed roles `sonarr`/`radarr`, databases `{sonarr,radarr}-main`, VSS `{sonarr,radarr}-db`, and all kustomization entries
- Third-party images stay canonical upstream; in-house images stay explicit `docker-internal`

`prowlarr`, `arrproxy`, and everything else untouched. arrproxy path re-wiring is a later PR.

Verified: `kustomize build` + `kubeconform` (87/87 valid), no dangling refs to removed apps, no `3df803` in adult files.
Reviewed-on: #414
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 20:06:39 +10:00
unkin-agent 94da740a9d Add cheeztv kids Jellyfin instance + fafflix kids mounts (#413)
## Why
Kids need their own safe Jellyfin front (cheeztv.unkin.net) with a library that only ever exposes kids titles, while those same titles stay resumable in the existing adult instance (fafflix). The fafflix.unkin.net domain switch is deliberately out of scope.

## How
- **New `cheeztv` instance** (`apps/base/cheeztv`): mirrors the fafflix HA Jellyfin stack 1:1 - same `jellyfin-ha:v0.1.3` fork image, 2-replica StatefulSet, RWX transcode PVC + HA takeover, Intel iGPU transcode, CNPG Postgres + PgBouncer, Valkey transcode store, k8up config backup, VMPodScrape.
- **Kids-only media**: its own static PVs on the *same* shared movies/tv CephFS subvolumes, mounted with `subPath: kids` so it sees only `movies/kids` and `tvshows/kids`.
- **Fully separate state**: own namespace, config PVC, `cheeztv-postgres` cluster, `cheeztv-valkey`, and `cnpg-cheeztv` / `cheeztv-config-backup` RGW buckets - nothing shared with fafflix.
- **DNS/cert**: `cheeztv.unkin.net` via internal Traefik + external-dns (198.18.200.4) + cert-manager `cheeztv-tls`, matching logviewer.unkin.net; plus the cluster hostname `cheeztv.k8s.syd1.au.unkin.net` mirroring fafflix.
- **fafflix change**: ADDS `movies/kids` + `tvshows/kids` subPath mounts alongside its current media mounts so kids libraries are resumable there. Existing mounts, hostname and ingress untouched.
- Registers cheeztv in the media ApplicationSet generator and AppProject destinations.

## Validation
kubeconform clean: cheeztv 28/28, jellyfin 25/25 valid. No arr/watchstate/domain changes.

Note: the k8up restic repo password must be seeded at `kv/kubernetes/namespace/cheeztv/default/k8up-restic` (same manual step as fafflix); templated Vault policy already grants read, no terraform-vault change needed.
Reviewed-on: #413
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 19:59:08 +10:00
unkin-agent b419635fc2 arrstack: add kids backend tier (sonarr/radarr/nzbget-kids) (#412)
Splits the arrstack media pipeline into an adult and a kids tier so kids content lives in its own libraries, databases, and downloader while reusing the shared prowlarr, CNPG cluster, Valkey, media PVCs, and S3 buckets. Backend workloads only — no routing/ingress, no arrproxy, no adult-instance changes in this PR.

How:
- sonarr-kids / radarr-kids: mirror the adult -unkin fork Deployments (same images/tags, 3 active-active replicas) on their own Postgres DBs (sonarr-kids-main / radarr-kids-main), UrlBase /3df803/sonarr and /3df803/radarr, and kids MediaCover/Backup S3 prefixes.
- Media mounts use the tvshows/kids and movies/kids subPaths of the shared media-tv / media-movies PVCs, so kids libraries are isolated on the same CephFS subvolumes.
- nzbget-kids: second downloader on its own RWO config PVC, completed downloads land in the kids media subtrees for same-filesystem hardlink imports.
- postgres: two managed roles + Database CRDs and their VSO-synced <app>-kids-db credential secrets.
- per-app VMPodScrape for each kids exportarr sidecar; new apps registered in the base kustomization.

Validated: kustomize build + kubeconform (96/96 valid), yamllint + pre-commit clean.
Reviewed-on: #412
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-25 08:03:21 +10:00
unkin-agent a064d9a3c4 arrproxy: bump images to v0.3.1 (#411)
Deploy the new space-pirate UI background released in arrproxy v0.3.1.

- Bump arrproxy-api image to v0.3.1
- Bump arrproxy-ui image to v0.3.1

Reviewed-on: #411
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-24 21:42:28 +10:00
unkin-agent aea319cec0 authentik: ClientIP session affinity on server service (#410)
## Why

Tofu applies against authentik hit a cross-replica read-back race: a create lands on one of the 3 server pods and the provider's immediate read-back hits another pod that returns stale data, failing the run with "inconsistent result after apply" (upstream bug goauthentik/terraform-provider-authentik#954, still open). ClientIP session affinity keeps a whole apply run on a single pod, sidestepping the race until the provider is fixed.

## Changes

- Sets `server.service.sessionAffinity: ClientIP` with `sessionAffinityConfig.clientIP.timeoutSeconds: 60` in the au-syd1 authentik overlay values (rendered by the vendored chart's `templates/server/service.yaml`)

Reviewed-on: #410
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 23:13:51 +10:00
227 changed files with 6106 additions and 874 deletions
+38 -15
View File
@@ -5,9 +5,12 @@ metadata:
name: arrproxy-api name: arrproxy-api
namespace: arrstack namespace: arrstack
annotations: annotations:
# Wave 2: serve only after the wave-1 migrate Job completes. # Wave 2: start only after the wave-0 CNPG Cluster and VSO-synced Secrets
# exist. The api self-migrates at startup under a Postgres advisory lock and
# holds /readyz until the schema is current, so no migration ordering is needed.
argocd.argoproj.io/sync-wave: "2" argocd.argoproj.io/sync-wave: "2"
secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-apikey,radarr-apikey,prowlarr-apikey" secret.reloader.stakater.com/reload: "arrproxy-pepper,arrproxy-admin-token,arrproxy-db-app,sonarr-adult-apikey,radarr-adult-apikey,sonarr-kids-apikey,radarr-kids-apikey"
configmap.reloader.stakater.com/reload: "arrproxy-tiers"
spec: spec:
replicas: 2 replicas: 2
selector: selector:
@@ -33,7 +36,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: api - name: api
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.3.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-api:v0.6.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8080 - containerPort: 8080
@@ -49,11 +52,18 @@ spec:
# reaches this upstream) so group-based authorization works. # reaches this upstream) so group-based authorization works.
- name: ARRPROXY_GROUPS_HEADER - name: ARRPROXY_GROUPS_HEADER
value: X-Forwarded-Groups value: X-Forwarded-Groups
# Real per-app *arr keys, projected one file per app under this dir # Real per-app *arr keys, projected one file per app under a per-tier
# (sourced from the existing <app>-apikey Secrets). The api injects # subdir (<keyDir>/<app>) matching the tiers file: adult/{sonarr,radarr}
# them server-side and redacts them from every proxied response. # and kids/{sonarr,radarr}. Sourced from the same <instance>-apikey
# Secrets the *arr Deployments consume. The api injects them server-side
# and redacts them from every proxied response.
- name: ARRPROXY_KEYS_DIR - name: ARRPROXY_KEYS_DIR
value: /etc/arrproxy/keys value: /etc/arrproxy/keys
# Tier topology (fafflix/cheeztv): upstreams, route hashes, key subdirs
# and group grants. Legacy unprefixed routes are retired in the file
# (legacyRoutes:false); see arrproxy-tiers ConfigMap.
- name: ARRPROXY_TIERS_FILE
value: /etc/arrproxy/tiers/tiers.json
- name: ARRPROXY_PEPPER - name: ARRPROXY_PEPPER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -86,6 +96,9 @@ spec:
- name: arr-keys - name: arr-keys
mountPath: /etc/arrproxy/keys mountPath: /etc/arrproxy/keys
readOnly: true readOnly: true
- name: tiers
mountPath: /etc/arrproxy/tiers
readOnly: true
- name: tmp - name: tmp
mountPath: /tmp mountPath: /tmp
livenessProbe: livenessProbe:
@@ -118,27 +131,37 @@ spec:
cpu: "1" cpu: "1"
memory: 512Mi memory: 512Mi
volumes: volumes:
# Real *arr API keys, one file per app named exactly <app> so the api # Real per-tier *arr API keys, projected as <keyDir>/<app> so the api
# reads /etc/arrproxy/keys/{sonarr,radarr,prowlarr}. Reuses the same # reads /etc/arrproxy/keys/{adult,kids}/{sonarr,radarr} (matching the
# <app>-apikey Secrets the *arr Deployments already consume. # tiers file keyDir). Reuses the same <instance>-apikey Secrets the *arr
# Deployments already consume (seeded by their VaultStaticSecrets).
- name: arr-keys - name: arr-keys
projected: projected:
sources: sources:
- secret: - secret:
name: sonarr-apikey name: sonarr-adult-apikey
items: items:
- key: apitoken - key: apitoken
path: sonarr path: adult/sonarr
- secret: - secret:
name: radarr-apikey name: radarr-adult-apikey
items: items:
- key: apitoken - key: apitoken
path: radarr path: adult/radarr
- secret: - secret:
name: prowlarr-apikey name: sonarr-kids-apikey
items: items:
- key: apitoken - key: apitoken
path: prowlarr path: kids/sonarr
- secret:
name: radarr-kids-apikey
items:
- key: apitoken
path: kids/radarr
# Tier topology JSON (ARRPROXY_TIERS_FILE).
- name: tiers
configMap:
name: arrproxy-tiers
- name: tmp - name: tmp
emptyDir: emptyDir:
sizeLimit: 64Mi sizeLimit: 64Mi
+7 -8
View File
@@ -1,9 +1,11 @@
--- ---
# External (DMZ) front for the arrstack, served on arrstack.unkin.net via the # External (DMZ) front for the arrstack, served on arrstack.unkin.net via the
# external Traefik (LB VIP 198.18.199.0). cert-manager mints arrproxy-gateway-tls # external Traefik (LB VIP 198.18.199.0). The apex arrstack.unkin.net A record
# (CN arrstack.unkin.net) off the internal Vault-PKI CA. The apex arrstack.unkin.net # lives in the bind-operator unkin.net zone (bind-internal/authoritative), NOT
# A record lives in the bind-operator unkin.net zone (bind-internal/authoritative), # external-dns, so no external-dns annotation here. Public TLS is terminated with
# NOT external-dns, so no external-dns annotation here. # the real Let's Encrypt *.unkin.net wildcard, centrally minted once in the
# cert-manager namespace (Certificate wildcard-unkin-net) and reflected into this
# namespace by the emberstack reflector as wildcard-unkin-net-tls, not Vault PKI.
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
@@ -11,9 +13,6 @@ metadata:
traefik.io/instance: external traefik.io/instance: external
annotations: annotations:
argocd.argoproj.io/sync-wave: "2" argocd.argoproj.io/sync-wave: "2"
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: arrstack.unkin.net
cert-manager.io/private-key-size: "4096"
name: arrproxy name: arrproxy
namespace: arrstack namespace: arrstack
spec: spec:
@@ -38,4 +37,4 @@ spec:
certificateRefs: certificateRefs:
- group: "" - group: ""
kind: Secret kind: Secret
name: arrproxy-gateway-tls name: wildcard-unkin-net-tls
@@ -5,9 +5,8 @@ kind: Kustomization
resources: resources:
- cnpg_cluster.yaml - cnpg_cluster.yaml
- cnpg_backup.yaml - cnpg_backup.yaml
- migrations-configmap.yaml
- migrate-job.yaml
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- tiers-configmap.yaml
- oauth2-proxy-configmap.yaml - oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml - oauth2-proxy-deployment.yaml
- api-deployment.yaml - api-deployment.yaml
@@ -1,92 +0,0 @@
---
# Applies the arrproxy schema once per sync, before the api rolls, so the serve
# replicas never race migrations (arrproxy-api does not self-migrate). Runs as the
# CNPG-minted app user so the tokens table is owned by that role.
#
# Sync-phase hook at wave 1 (NOT PreSync): the CNPG Cluster + generated
# arrproxy-db-app Secret apply at wave 0 and ArgoCD waits for the Cluster to be
# Healthy before starting wave 1, so Postgres exists before migrate connects.
apiVersion: batch/v1
kind: Job
metadata:
name: arrproxy-migrate
namespace: arrstack
annotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "1"
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: arrproxy-migrate
spec:
serviceAccountName: default
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: migrate
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/postgres:18-alpine
imagePullPolicy: IfNotPresent
env:
- name: HOME
value: /tmp
- name: PGUSER
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: arrproxy-db-app
key: password
- name: PGHOST
value: arrproxy-db-rw.arrstack.svc.cluster.local
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: arrproxy
- name: PGSSLMODE
value: require
command:
- psql
- -v
- ON_ERROR_STOP=1
- -f
- /migrations/0001_init.sql
volumeMounts:
- name: migrations
mountPath: /migrations
readOnly: true
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: migrations
configMap:
name: arrproxy-migrations
- name: tmp
emptyDir:
sizeLimit: 64Mi
@@ -1,29 +0,0 @@
---
# arrproxy schema, mirrored from the arrproxy repo migrations/0001_init.sql
# (v0.1.0). arrproxy-api does NOT self-migrate, so the wave-1 migrate Job applies
# this once per sync as the app user. Keep in sync with the repo on schema bumps.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-migrations
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
data:
0001_init.sql: |
-- arrproxy token store. Only token hashes are persisted; plaintext is shown
-- once at mint time and never recoverable.
CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
apps TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
disabled BOOLEAN NOT NULL DEFAULT false,
last_used_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS tokens_subject_idx ON tokens (subject);
CREATE INDEX IF NOT EXISTS tokens_token_hash_idx ON tokens (token_hash);
@@ -21,9 +21,11 @@ data:
# application (terraform-authentik PR #18). # application (terraform-authentik PR #18).
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/arrstack/" OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/arrstack/"
OAUTH2_PROXY_REDIRECT_URL: "https://arrstack.unkin.net/oauth2/callback" OAUTH2_PROXY_REDIRECT_URL: "https://arrstack.unkin.net/oauth2/callback"
# Longest-prefix wins: /api and /<app> go to arrproxy-api, everything else # Longest-prefix wins: /api and the per-tier hash routes (/3aa168, /3df803) go
# (the SPA + static assets) to arrproxy-ui. # to arrproxy-api, everything else (the SPA + static assets) to arrproxy-ui.
OAUTH2_PROXY_UPSTREAMS: "http://arrproxy-ui.arrstack.svc.cluster.local:8080/,http://arrproxy-api.arrstack.svc.cluster.local:8080/api/,http://arrproxy-api.arrstack.svc.cluster.local:8080/sonarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/radarr/,http://arrproxy-api.arrstack.svc.cluster.local:8080/prowlarr/" # Tier routes carry /<hash>/<app>/...; the bare /sonarr /radarr /prowlarr
# upstreams are retired (legacy routes off; prowlarr is served directly).
OAUTH2_PROXY_UPSTREAMS: "http://arrproxy-ui.arrstack.svc.cluster.local:8080/,http://arrproxy-api.arrstack.svc.cluster.local:8080/api/,http://arrproxy-api.arrstack.svc.cluster.local:8080/3aa168/,http://arrproxy-api.arrstack.svc.cluster.local:8080/3df803/"
OAUTH2_PROXY_SCOPE: "openid email profile ak_groups" OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik ak_groups claim; pass-user-headers # Populate session.Groups from the Authentik ak_groups claim; pass-user-headers
# then emits it as a single comma-joined X-Forwarded-Groups header. # then emits it as a single comma-joined X-Forwarded-Groups header.
@@ -33,14 +35,16 @@ data:
# set-xauthrequest is intentionally NOT set -- it only populates auth_request # set-xauthrequest is intentionally NOT set -- it only populates auth_request
# *response* headers, which never reach an --upstreams-proxied backend. # *response* headers, which never reach an --upstreams-proxied backend.
OAUTH2_PROXY_PASS_USER_HEADERS: "true" OAUTH2_PROXY_PASS_USER_HEADERS: "true"
# Bypass auth for the *arr proxy API (/<app>/api...) and the machine-mint # Bypass auth for the tier *arr proxy API (/<hash>/<app>/api...) and the
# admin route (/api/admin/...). The first matches /sonarr/api; the second # machine-mint admin route (/api/admin/...). The first matches
# matches /api/admin/ only -- both routed to the arrproxy-api upstream by the # /3aa168/sonarr/api (two path segments before /api, since tier routes are
# catch-all /api/ prefix above. /api/admin/ is protected by arrproxy's OWN # hash-prefixed); the second matches /api/admin/ only -- both routed to the
# ARRPROXY_ADMIN_TOKEN bearer (OpenBao on the VMs reaches it via the ingress), # arrproxy-api upstream by the tier and catch-all /api/ prefixes above.
# so it is intentionally oauth-skipped. /api/tokens and /api/me are NOT # /api/admin/ is protected by arrproxy's OWN ARRPROXY_ADMIN_TOKEN bearer
# matched and stay oauth-authenticated. # (OpenBao on the VMs reaches it via the ingress), so it is intentionally
OAUTH2_PROXY_SKIP_AUTH_REGEX: "^/[^/]+/api,^/api/admin/" # 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: "*" OAUTH2_PROXY_EMAIL_DOMAINS: "*"
# Authentik hardcodes email_verified=false in the id_token; without this # Authentik hardcodes email_verified=false in the id_token; without this
# oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on # oauth2-proxy rejects the session ("email ... isn't verified") -> 500 on
@@ -35,7 +35,7 @@ spec:
# identity.unkin.net serves a Vault-PKI cert; combine the system roots # 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. # with the internal CA so oauth2-proxy's OIDC HTTP client trusts it.
- name: combine-certs - name: combine-certs
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/library/alpine:3 image: docker.io/library/alpine:3
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- sh - sh
@@ -0,0 +1,53 @@
---
# arrproxy v0.4.0 tier topology (ARRPROXY_TIERS_FILE). Two isolated instance
# tiers, each with its own *arr backends, real-key subdir, and group grants:
# fafflix (adult, /3aa168/*) and cheeztv (kids, /3df803/*).
# arrproxy strips the /<hash> route prefix and proxies "/<app>/..." upstream, so
# each app's upstream URL carries the "/<hash>" path segment: the joined upstream
# path (/3aa168/sonarr/...) then matches the *arr's own UrlBase (/3aa168/sonarr).
# Access: the adult group reaches BOTH tiers with any method; the kids group
# reaches ONLY the cheeztv tier and only GET/HEAD. Groups arrive on the
# hierarchical Authentik ak_groups claim, forwarded by oauth2-proxy as
# X-Forwarded-Groups. Legacy unprefixed routes are retired (legacyRoutes:false);
# the old bare sonarr/radarr Services no longer exist.
apiVersion: v1
kind: ConfigMap
metadata:
name: arrproxy-tiers
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "2"
data:
tiers.json: |
{
"legacyTier": "fafflix",
"legacyRoutes": false,
"tiers": [
{
"name": "fafflix",
"hash": "3aa168",
"keyDir": "adult",
"grants": [
{ "group": "akP-media-fafflix" }
],
"apps": [
{ "name": "sonarr", "upstream": "http://sonarr-adult.arrstack.svc.cluster.local:8989/3aa168", "urlBase": "/3aa168/sonarr" },
{ "name": "radarr", "upstream": "http://radarr-adult.arrstack.svc.cluster.local:7878/3aa168", "urlBase": "/3aa168/radarr" }
]
},
{
"name": "cheeztv",
"hash": "3df803",
"keyDir": "kids",
"readOnly": true,
"grants": [
{ "group": "akP-media-fafflix" },
{ "group": "akP-media-cheeztv", "methods": ["GET", "HEAD"] }
],
"apps": [
{ "name": "sonarr", "upstream": "http://sonarr-kids.arrstack.svc.cluster.local:8989/3df803", "urlBase": "/3df803/sonarr" },
{ "name": "radarr", "upstream": "http://radarr-kids.arrstack.svc.cluster.local:7878/3df803", "urlBase": "/3df803/radarr" }
]
}
]
}
@@ -31,7 +31,7 @@ spec:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: ui - name: ui
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.3.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/arrproxy-ui:v0.6.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8080 - containerPort: 8080
+9 -3
View File
@@ -8,16 +8,22 @@ resources:
- pv-media-tv.yaml - pv-media-tv.yaml
- pv-media-movies.yaml - pv-media-movies.yaml
- pv-mediafs.yaml - pv-mediafs.yaml
- pv-mediastore.yaml
- pvc-media-tv.yaml - pvc-media-tv.yaml
- pvc-media-movies.yaml - pvc-media-movies.yaml
- pvc-mediafs.yaml - pvc-mediafs.yaml
- pvc-mediastore.yaml
- mediastore-bootstrap-job.yaml
- media-bucket.yaml - media-bucket.yaml
- backups-bucket.yaml - backups-bucket.yaml
- postgres - postgres
- valkey - valkey
- sonarr
- radarr
- prowlarr - prowlarr
- nzbget - sonarr-kids
- radarr-kids
- nzbget-kids
- sonarr-adult
- radarr-adult
- nzbget-adult
- arrproxy - arrproxy
- mediamover - mediamover
@@ -0,0 +1,74 @@
---
# Seeds the directory skeleton on the freshly created mediastore subvolume so
# the arrs, nzbget and both jellyfins mount subPaths that already exist and are
# owned by uid/gid 1000 (the uid every arrstack media pod runs as). mkdir -p is
# idempotent, so re-running it on every sync is harmless and self-heals a tree
# someone deleted by hand.
#
# Sync hook with BeforeHookCreation delete: ArgoCD replaces the completed Job
# each sync instead of failing on the immutable pod template. No sync-wave is
# needed -- the PVC applies in the same wave and the pod simply stays Pending
# until it binds.
apiVersion: batch/v1
kind: Job
metadata:
name: mediastore-bootstrap
namespace: arrstack
annotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 6
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: mediastore-bootstrap
spec:
serviceAccountName: default
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: mkdir
image: docker.io/library/alpine:3
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -eu
mkdir -p \
/media/fafflix/tvseries \
/media/fafflix/movies \
/media/cheeztv/tvseries \
/media/cheeztv/movies \
/media/nzbget/downloads/complete
ls -la /media
volumeMounts:
- name: mediastore
mountPath: /media
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumes:
- name: mediastore
persistentVolumeClaim:
claimName: mediastore
@@ -0,0 +1,142 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nzbget-adult
namespace: arrstack
spec:
replicas: 1
strategy:
# RWO config PVC + single queue state: never run two pods at once.
type: Recreate
selector:
matchLabels:
app: nzbget-adult
template:
metadata:
labels:
app: nzbget-adult
spec:
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Seed download layout onto the adult media subtrees (not /config or an
# emptyDir) so completed adult downloads land beside the adult arr libraries
# and imports are same-filesystem hardlink moves. The media mounts use the
# tvshows/adult and movies/adult subPaths, so the in-container paths match
# the kids nzbget while the data stays scoped to the adult subtree. Reuses
# the image's own template and appends the path/category overrides once;
# the grep guard keeps re-runs idempotent so admin UI edits survive.
- name: seed-config
image: docker.io/linuxserver/nzbget:version-v26.2
command:
- sh
- -c
- |
set -e
if [ ! -f /config/nzbget.conf ]; then
cp /app/nzbget/share/nzbget/nzbget.conf /config/nzbget.conf
fi
if ! grep -q '# arrstack-managed' /config/nzbget.conf; then
cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr-adult/
# radarr-adult import with atomic hardlink moves (download dir +
# library share one filesystem per media type). InterDir is empty:
# nzbget writes each download straight into its category DestDir, so
# BOTH tv and movies stay on their own PVC with no cross-filesystem
# intermediate copy.
MainDir=/media/tv
InterDir=
DestDir=/media/tv/downloads
NzbDir=/config/nzb
QueueDir=/config/queue
TempDir=/config/tmp
ControlIP=0.0.0.0
ControlPort=6789
Category1.Name=tv
Category1.DestDir=/media/tv/downloads
Category2.Name=movies
Category2.DestDir=/media/movies/downloads
CONF
fi
mkdir -p /media/tv/series /media/tv/downloads /media/movies/films /media/movies/downloads
chown 1000:1000 /config/nzbget.conf \
/media/tv /media/tv/series /media/tv/downloads \
/media/movies /media/movies/films /media/movies/downloads
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/adult
- name: media-movies
mountPath: /media/movies
subPath: movies/adult
containers:
- name: nzbget
image: docker.io/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 6789
protocol: TCP
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Australia/Sydney
livenessProbe:
# nzbget's root path requires auth (401); a TCP check is the
# dependency-free liveness signal for the web/JSON-RPC server.
tcpSocket:
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
# Headroom for par2 repair + unpack of large downloads.
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /config
- name: media-tv
mountPath: /media/tv
subPath: tvshows/adult
- name: media-movies
mountPath: /media/movies
subPath: movies/adult
volumes:
- name: config
persistentVolumeClaim:
claimName: nzbget-adult-config
- name: media-tv
persistentVolumeClaim:
claimName: media-tv
- name: media-movies
persistentVolumeClaim:
claimName: media-movies
@@ -6,5 +6,3 @@ resources:
- pvc-config.yaml - pvc-config.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
@@ -0,0 +1,16 @@
---
# NZBGet (adult) config + queue/temp state. RWO on cephrbd (block, fast-delete).
# The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-adult-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: nzbget-adult
namespace: arrstack
spec:
ports:
- name: http
port: 6789
protocol: TCP
targetPort: http
selector:
app: nzbget-adult
type: ClusterIP
@@ -2,7 +2,7 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: nzbget name: nzbget-kids
namespace: arrstack namespace: arrstack
spec: spec:
replicas: 1 replicas: 1
@@ -11,25 +11,25 @@ spec:
type: Recreate type: Recreate
selector: selector:
matchLabels: matchLabels:
app: nzbget app: nzbget-kids
template: template:
metadata: metadata:
labels: labels:
app: nzbget app: nzbget-kids
spec: spec:
securityContext: securityContext:
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Seed download layout onto the shared media PVCs (not /config or an # Seed download layout onto the kids media subtrees (not /config or an
# emptyDir) so completed downloads land beside the arr libraries and # emptyDir) so completed kids downloads land beside the kids arr libraries
# imports are same-filesystem hardlink moves. Reuses the image's own # and imports are same-filesystem hardlink moves. The media mounts use the
# template (inherits correct WebDir/ConfigTemplate) and appends the # tvshows/kids and movies/kids subPaths, so the in-container paths match
# path/category overrides once; nzbget honours the last value for a # the adult nzbget while the data stays scoped to the kids subtree. Reuses
# repeated option, and the grep guard keeps re-runs idempotent so admin # the image's own template and appends the path/category overrides once;
# UI edits to the persisted /config/nzbget.conf survive restarts. # the grep guard keeps re-runs idempotent so admin UI edits survive.
- name: seed-config - name: seed-config
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget:version-v26.2 image: docker.io/linuxserver/nzbget:version-v26.2
command: command:
- sh - sh
- -c - -c
@@ -42,11 +42,12 @@ spec:
cat >> /config/nzbget.conf << 'CONF' cat >> /config/nzbget.conf << 'CONF'
# arrstack-managed download layout (appended once; last value wins). # arrstack-managed download layout (appended once; last value wins).
# Downloads land on the shared media PVCs by category so sonarr/radarr # Downloads land on the shared media PVCs by category so sonarr-kids/
# import with atomic hardlink moves (download dir + library share one # radarr-kids import with atomic hardlink moves (download dir +
# filesystem per media type). InterDir is empty: nzbget writes each # library share one filesystem per media type). InterDir is empty:
# download straight into its category DestDir, so BOTH tv and movies # nzbget writes each download straight into its category DestDir, so
# stay on their own PVC with no cross-filesystem intermediate copy. # BOTH tv and movies stay on their own PVC with no cross-filesystem
# intermediate copy.
MainDir=/media/tv MainDir=/media/tv
InterDir= InterDir=
DestDir=/media/tv/downloads DestDir=/media/tv/downloads
@@ -77,11 +78,13 @@ spec:
mountPath: /config mountPath: /config
- name: media-tv - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/kids
containers: containers:
- name: nzbget - name: nzbget
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/linuxserver/nzbget:version-v26.2 image: docker.io/linuxserver/nzbget:version-v26.2
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: http - name: http
@@ -123,12 +126,14 @@ spec:
mountPath: /config mountPath: /config
- name: media-tv - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/kids
- name: media-movies - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/kids
volumes: volumes:
- name: config - name: config
persistentVolumeClaim: persistentVolumeClaim:
claimName: nzbget-config claimName: nzbget-kids-config
- name: media-tv - name: media-tv
persistentVolumeClaim: persistentVolumeClaim:
claimName: media-tv claimName: media-tv
@@ -0,0 +1,8 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc-config.yaml
- deployment.yaml
- service.yaml
@@ -0,0 +1,16 @@
---
# NZBGet (kids) config + queue/temp state. RWO on cephrbd (block, fast-delete).
# The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-kids-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-delete
volumeMode: Filesystem
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: nzbget name: nzbget-kids
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: nzbget app: nzbget-kids
type: ClusterIP type: ClusterIP
-16
View File
@@ -1,16 +0,0 @@
---
# NZBGet config + queue/temp state. RWO on cephrbd (block). Retain: this is
# state. The download data itself lives on the shared media PVCs, not here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nzbget-config
namespace: arrstack
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: cephrbd-fast-retain
volumeMode: Filesystem
+48 -24
View File
@@ -50,30 +50,6 @@ spec:
owner: app owner: app
managed: managed:
roles: 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 - name: prowlarr
ensure: present ensure: present
comment: Prowlarr application role (owns prowlarr-main) comment: Prowlarr application role (owns prowlarr-main)
@@ -86,6 +62,54 @@ spec:
connectionLimit: -1 connectionLimit: -1
passwordSecret: passwordSecret:
name: prowlarr-db name: prowlarr-db
- name: sonarr-kids
ensure: present
comment: Sonarr (kids) application role (owns sonarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-kids-db
- name: radarr-kids
ensure: present
comment: Radarr (kids) application role (owns radarr-kids-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-kids-db
- name: sonarr-adult
ensure: present
comment: Sonarr (adult) application role (owns sonarr-adult-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: sonarr-adult-db
- name: radarr-adult
ensure: present
comment: Radarr (adult) application role (owns radarr-adult-main)
login: true
superuser: false
createdb: false
createrole: false
inherit: true
replication: false
connectionLimit: -1
passwordSecret:
name: radarr-adult-db
enablePDB: true enablePDB: true
enableSuperuserAccess: false enableSuperuserAccess: false
failoverDelay: 0 failoverDelay: 0
@@ -0,0 +1,15 @@
---
# Per-app database owned by the radarr-adult 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-adult-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-adult-main
owner: radarr-adult
databaseReclaimPolicy: retain
@@ -0,0 +1,15 @@
---
# Per-app database owned by the radarr-kids 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-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: radarr-kids-main
owner: radarr-kids
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# 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-adult 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-adult-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-adult-main
owner: sonarr-adult
databaseReclaimPolicy: retain
@@ -0,0 +1,15 @@
---
# Per-app database owned by the sonarr-kids 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-kids-main
namespace: arrstack
spec:
cluster:
name: arrstack-postgres
name: sonarr-kids-main
owner: sonarr-kids
databaseReclaimPolicy: retain
@@ -1,15 +0,0 @@
---
# 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
@@ -6,6 +6,8 @@ resources:
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- cnpg_cluster.yaml - cnpg_cluster.yaml
- cnpg_backup.yaml - cnpg_backup.yaml
- database-sonarr.yaml
- database-radarr.yaml
- database-prowlarr.yaml - database-prowlarr.yaml
- database-sonarr-kids.yaml
- database-radarr-kids.yaml
- database-sonarr-adult.yaml
- database-radarr-adult.yaml
@@ -10,40 +10,6 @@
# (wave 1) reconciles the roles. # (wave 1) reconciles the roles.
apiVersion: secrets.hashicorp.com/v1beta1 apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret 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: metadata:
name: prowlarr-db name: prowlarr-db
namespace: arrstack namespace: arrstack
@@ -58,3 +24,71 @@ spec:
refreshAfter: 5m refreshAfter: 5m
type: kv-v2 type: kv-v2
vaultAuthRef: default vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-kids-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-adult-db
namespace: arrstack
spec:
destination:
create: true
name: sonarr-adult-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-adult-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-adult-db
namespace: arrstack
spec:
destination:
create: true
name: radarr-adult-db
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-adult-db
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+32
View File
@@ -0,0 +1,32 @@
---
# Static PV for the shared MEDIASTORE CephFS subvolume: one 10Ti filesystem
# holding every library plus the nzbget download tree, so arr imports are
# same-filesystem hardlink moves across tv AND movies. Same rootPath as the
# fafflix/cheeztv mediastore PVs; each namespace gets its own PV (unique name +
# volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: arrstack-mediastore
spec:
capacity:
storage: 10Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: arrstack
name: mediastore
csi:
driver: cephfs.csi.ceph.com
volumeHandle: arrstack-mediastore-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: cephfs
rootPath: /volumes/csi_ssd_ec_4_1/mediastore/a0152dac-a51b-4b95-ac5e-ecdd99bfe3f1
+22
View File
@@ -0,0 +1,22 @@
---
# Whole media tree (/fafflix, /cheeztv, /nzbget) on one RWX filesystem, shared
# across the sonarr/radarr/nzbget pods. Statically bound to the
# arrstack-mediastore PV (the same CephFS subvolume fafflix and cheeztv mount).
# storageClassName "" + volumeName disables dynamic provisioning and binds the
# pre-created static PV.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mediastore
namespace: arrstack
annotations:
k8up.io/backup: "false"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Ti
storageClassName: ""
volumeName: arrstack-mediastore
volumeMode: Filesystem
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (adult tier). Identical mechanism to the
# kids radarr, pointed at its own shared-Postgres database (radarr-adult-main)
# and its own UrlBase (/3aa168/radarr) so arrproxy path-routing reaches the adult
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's radarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-adult-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-adult-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /3aa168/radarr
Radarr__Update__Mechanism: External
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -2,43 +2,40 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: radarr name: radarr-adult
namespace: arrstack namespace: arrstack
annotations: annotations:
# radarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom, # Reloader rolls the Deployment when radarr-adult-env changes (plain envFrom
# so editing it does not roll the Deployment on its own. Reloader watches the # ConfigMap does not trigger a rollout on its own).
# 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" configmap.reloader.stakater.com/auto: "true"
spec: spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres # Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N # (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the radarr Service. RollingUpdate is safe — # replicas run concurrently behind the radarr-adult Service. RollingUpdate is
# no SQLite, no RWO lock. # safe — no SQLite, no RWO lock.
replicas: 3 replicas: 3
strategy: strategy:
type: RollingUpdate type: RollingUpdate
selector: selector:
matchLabels: matchLabels:
app: radarr app: radarr-adult
template: template:
metadata: metadata:
labels: labels:
app: radarr app: radarr-adult
spec: spec:
securityContext: securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root # Fork image has no USER; pin it to a non-root UID and group-write the
# UID and group-write the shared RWX CephFS /config (MediaCover etc.). # shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# OnRootMismatch avoids a recursive chown of the whole media tree. # chown of the whole media tree.
runAsUser: 1000 runAsUser: 1000
runAsGroup: 1000 runAsGroup: 1000
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Gate the app on its own Postgres database+role being reachable, instead # Gate the app on its own Postgres database+role being reachable.
# 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
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv. # lands in argv.
- name: wait-for-db - name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env: env:
@@ -51,16 +48,16 @@ spec:
- name: PGPORT - name: PGPORT
value: "5432" value: "5432"
- name: PGDATABASE - name: PGDATABASE
value: radarr-main value: radarr-adult-main
- name: PGUSER - name: PGUSER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-db name: radarr-adult-db
key: username key: username
- name: PGPASSWORD - name: PGPASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-db name: radarr-adult-db
key: password key: password
resources: resources:
requests: requests:
@@ -78,9 +75,8 @@ spec:
args: args:
- -nobrowser - -nobrowser
- -data=/config - -data=/config
# Required: bypass the single-instance guard so multiple replicas # Bypass the single-instance guard so multiple replicas can share one
# can share one /config. Cross-replica safety is the Postgres layer, # /config. Cross-replica safety is the Postgres layer, not a lock file.
# not a local lock file.
- -nosingleinstancecheck - -nosingleinstancecheck
ports: ports:
- name: http - name: http
@@ -88,27 +84,25 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: radarr-env name: radarr-adult-env
env: env:
- name: Radarr__Postgres__User - name: Radarr__Postgres__User
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-db name: radarr-adult-db
key: username key: username
- name: Radarr__Postgres__Password - name: Radarr__Postgres__Password
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-db name: radarr-adult-db
key: password key: password
- name: Radarr__Auth__ApiKey - name: Radarr__Auth__ApiKey
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-apikey name: radarr-adult-apikey
key: apitoken key: apitoken
# MediaCover object store (shared Ceph RGW bucket). Serves posters/fanart # MediaCover object store (shared arrstack-media Ceph RGW bucket,
# from S3 so any replica can render them instead of the leader-local # partitioned by the radarr-adult key prefix).
# 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 - name: Radarr__MediaCover__S3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -127,14 +121,13 @@ spec:
- name: Radarr__MediaCover__S3__Bucket - name: Radarr__MediaCover__S3__Bucket
value: arrstack-media value: arrstack-media
- name: Radarr__MediaCover__S3__Prefix - name: Radarr__MediaCover__S3__Prefix
value: radarr value: radarr-adult
- name: Radarr__MediaCover__S3__ForcePathStyle - name: Radarr__MediaCover__S3__ForcePathStyle
value: "true" value: "true"
- name: Radarr__MediaCover__S3__CaCertPath - name: Radarr__MediaCover__S3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket, # Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off # partitioned by the radarr-adult key prefix).
# the ephemeral /config so any replica can write and restore them.
- name: Radarr__BackupS3__Endpoint - name: Radarr__BackupS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -153,14 +146,14 @@ spec:
- name: Radarr__BackupS3__Bucket - name: Radarr__BackupS3__Bucket
value: arrstack-backups value: arrstack-backups
- name: Radarr__BackupS3__Prefix - name: Radarr__BackupS3__Prefix
value: radarr value: radarr-adult
- name: Radarr__BackupS3__ForcePathStyle - name: Radarr__BackupS3__ForcePathStyle
value: "true" value: "true"
- name: Radarr__BackupS3__CaCertPath - name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /radarr/ping path: /3aa168/radarr/ping
port: http port: http
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
@@ -168,7 +161,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /radarr/ping path: /3aa168/radarr/ping
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
@@ -184,28 +177,31 @@ spec:
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /config mountPath: /config
# Adult movies subtree of the shared media-movies PVC (same CephFS
# subvolume the kids radarr writes and jellyfin reads).
- name: media-movies - name: media-movies
mountPath: /media/movies mountPath: /media/movies
subPath: movies/adult
- name: vault-ca - name: vault-ca
mountPath: /etc/ssl/vault-ca mountPath: /etc/ssl/vault-ca
readOnly: true readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus # exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9708 (scraped by the radarr-exportarr VMPodScrape). # metrics on :9708 (scraped by the radarr-adult-exportarr VMPodScrape).
- name: exportarr - name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0 image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
args: args:
- radarr - radarr
env: env:
- name: PORT - name: PORT
value: "9708" value: "9708"
# URL includes the /radarr UrlBase (Radarr__Server__UrlBase). # URL includes the /3aa168/radarr UrlBase (Radarr__Server__UrlBase).
- name: URL - name: URL
value: http://localhost:7878/radarr value: http://localhost:7878/3aa168/radarr
- name: APIKEY - name: APIKEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: radarr-apikey name: radarr-adult-apikey
key: apitoken key: apitoken
ports: ports:
- name: metrics - name: metrics
@@ -7,6 +7,4 @@ resources:
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml - vmpodscrape.yaml
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: radarr-adult
namespace: arrstack
spec:
ports:
- name: http
port: 7878
protocol: TCP
targetPort: http
selector:
app: radarr-adult
type: ClusterIP
@@ -0,0 +1,25 @@
---
# radarr-adult API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/radarr-adult (key: apitoken); 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 radarr-adult-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-adult-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-adult-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-adult
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9708) on every radarr-adult pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr-adult Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: radarr-adult-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: radarr-adult
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult radarr, pointed at its own shared-Postgres database (radarr-kids-main)
# and its own UrlBase (/3df803/radarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's radarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: radarr-kids-env
namespace: arrstack
data:
Radarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Radarr__Postgres__Port: "5432"
Radarr__Postgres__MainDb: radarr-kids-main
Radarr__Log__DbEnabled: "false"
Radarr__Auth__Method: External
Radarr__Auth__Required: DisabledForLocalAddresses
Radarr__App__InstanceName: Radarr
Radarr__Server__Port: "7878"
Radarr__Server__UrlBase: /3df803/radarr
Radarr__Update__Mechanism: External
Radarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Radarr__Redis__Port: "6379"
@@ -0,0 +1,245 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when radarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# 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-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: radarr-kids
template:
metadata:
labels:
app: radarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# 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: 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-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: radarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/radarr:v6.4.2-unkin7
imagePullPolicy: IfNotPresent
command:
- /app/Radarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 7878
protocol: TCP
envFrom:
- configMapRef:
name: radarr-kids-env
env:
- name: Radarr__Postgres__User
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: username
- name: Radarr__Postgres__Password
valueFrom:
secretKeyRef:
name: radarr-kids-db
key: password
- name: Radarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: radarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the radarr-kids key prefix).
- 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-kids
- 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,
# partitioned by the radarr-kids key prefix).
- 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-kids
- name: Radarr__BackupS3__ForcePathStyle
value: "true"
- name: Radarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/radarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids movies subtree of the shared media-movies PVC (same CephFS
# subvolume the adult radarr writes and jellyfin reads).
- name: media-movies
mountPath: /media/movies
subPath: movies/kids
- 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-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- radarr
env:
- name: PORT
value: "9708"
# URL includes the /3df803/radarr UrlBase (Radarr__Server__UrlBase).
- name: URL
value: http://localhost:7878/3df803/radarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: radarr-kids-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
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
@@ -7,6 +7,4 @@ resources:
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- gateway.yaml
- httproute.yaml
- vmpodscrape.yaml - vmpodscrape.yaml
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: radarr name: radarr-kids
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: radarr app: radarr-kids
type: ClusterIP type: ClusterIP
@@ -0,0 +1,25 @@
---
# radarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/radarr-kids (key: apitoken); 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 radarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +1,16 @@
--- ---
# Scrape the exportarr sidecar (:9708) on every radarr pod. Picked up by the # Scrape the exportarr sidecar (:9708) on every radarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than # observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the radarr Service doesn't expose the metrics port. # VMServiceScrape because the radarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1 apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape kind: VMPodScrape
metadata: metadata:
name: radarr-exportarr name: radarr-kids-exportarr
namespace: arrstack namespace: arrstack
spec: spec:
selector: selector:
matchLabels: matchLabels:
app: radarr app: radarr-kids
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /metrics path: /metrics
-33
View File
@@ -1,33 +0,0 @@
---
# 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"
-17
View File
@@ -1,17 +0,0 @@
---
# 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:
name: radarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -1,25 +0,0 @@
---
# radarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/radarr
# (key: apitoken); 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 radarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: radarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: radarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/radarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (adult tier). Identical mechanism to the
# kids sonarr, pointed at its own shared-Postgres database (sonarr-adult-main)
# and its own UrlBase (/3aa168/sonarr) so arrproxy path-routing reaches the adult
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's sonarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-adult-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-adult-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /3aa168/sonarr
Sonarr__Update__Mechanism: External
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -2,43 +2,40 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: sonarr name: sonarr-adult
namespace: arrstack namespace: arrstack
annotations: annotations:
# sonarr-env is a plain (unhashed) ConfigMap consumed by fixed-name envFrom, # Reloader rolls the Deployment when sonarr-adult-env changes (plain envFrom
# so editing it does not roll the Deployment on its own. Reloader watches the # ConfigMap does not trigger a rollout on its own).
# 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" configmap.reloader.stakater.com/auto: "true"
spec: spec:
# Active-active: the -unkin2 fork keeps all state in the shared Postgres # Active-active: the -unkin2 fork keeps all state in the shared Postgres
# (arrstack-postgres) and coordinates via Postgres advisory locks, so N # (arrstack-postgres) and coordinates via Postgres advisory locks, so N
# replicas run concurrently behind the sonarr Service. RollingUpdate is safe — # replicas run concurrently behind the sonarr-adult Service. RollingUpdate is
# no SQLite, no RWO lock. # safe — no SQLite, no RWO lock.
replicas: 3 replicas: 3
strategy: strategy:
type: RollingUpdate type: RollingUpdate
selector: selector:
matchLabels: matchLabels:
app: sonarr app: sonarr-adult
template: template:
metadata: metadata:
labels: labels:
app: sonarr app: sonarr-adult
spec: spec:
securityContext: securityContext:
# Fork image has no USER (runs as root by default); pin it to a non-root # Fork image has no USER; pin it to a non-root UID and group-write the
# UID and group-write the shared RWX CephFS /config (MediaCover etc.). # shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# OnRootMismatch avoids a recursive chown of the whole media tree. # chown of the whole media tree.
runAsUser: 1000 runAsUser: 1000
runAsGroup: 1000 runAsGroup: 1000
fsGroup: 1000 fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
initContainers: initContainers:
# Gate the app on its own Postgres database+role being reachable, instead # Gate the app on its own Postgres database+role being reachable.
# 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
# waitfordb reads the PG* env as a libpq fallback, so the password never lands in argv. # lands in argv.
- name: wait-for-db - name: wait-for-db
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/waitfordb:v0.1.0
env: env:
@@ -51,16 +48,16 @@ spec:
- name: PGPORT - name: PGPORT
value: "5432" value: "5432"
- name: PGDATABASE - name: PGDATABASE
value: sonarr-main value: sonarr-adult-main
- name: PGUSER - name: PGUSER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-db name: sonarr-adult-db
key: username key: username
- name: PGPASSWORD - name: PGPASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-db name: sonarr-adult-db
key: password key: password
resources: resources:
requests: requests:
@@ -78,9 +75,8 @@ spec:
args: args:
- -nobrowser - -nobrowser
- -data=/config - -data=/config
# Required: bypass the single-instance guard so multiple replicas # Bypass the single-instance guard so multiple replicas can share one
# can share one /config. Cross-replica safety is the Postgres layer, # /config. Cross-replica safety is the Postgres layer, not a lock file.
# not a local lock file.
- -nosingleinstancecheck - -nosingleinstancecheck
ports: ports:
- name: http - name: http
@@ -88,29 +84,25 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: sonarr-env name: sonarr-adult-env
env: env:
- name: Sonarr__Postgres__User - name: Sonarr__Postgres__User
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-db name: sonarr-adult-db
key: username key: username
- name: Sonarr__Postgres__Password - name: Sonarr__Postgres__Password
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-db name: sonarr-adult-db
key: password key: password
- name: Sonarr__Auth__ApiKey - name: Sonarr__Auth__ApiKey
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-apikey name: sonarr-adult-apikey
key: apitoken key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket, # MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned from radarr by the sonarr key prefix). Serves # partitioned by the sonarr-adult key prefix).
# 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 - name: Sonarr__MediaCoverS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -129,14 +121,13 @@ spec:
- name: Sonarr__MediaCoverS3__Bucket - name: Sonarr__MediaCoverS3__Bucket
value: arrstack-media value: arrstack-media
- name: Sonarr__MediaCoverS3__Prefix - name: Sonarr__MediaCoverS3__Prefix
value: sonarr value: sonarr-adult
- name: Sonarr__MediaCoverS3__ForcePathStyle - name: Sonarr__MediaCoverS3__ForcePathStyle
value: "true" value: "true"
- name: Sonarr__MediaCoverS3__CaCertPath - name: Sonarr__MediaCoverS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
# Backup object store (shared arrstack-backups Ceph RGW bucket, # Backup object store (shared arrstack-backups Ceph RGW bucket,
# per-app key prefix). Routes the periodic config+DB zip backups off # partitioned by the sonarr-adult key prefix).
# the ephemeral /config so any replica can write and restore them.
- name: Sonarr__BackupS3__Endpoint - name: Sonarr__BackupS3__Endpoint
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -155,14 +146,14 @@ spec:
- name: Sonarr__BackupS3__Bucket - name: Sonarr__BackupS3__Bucket
value: arrstack-backups value: arrstack-backups
- name: Sonarr__BackupS3__Prefix - name: Sonarr__BackupS3__Prefix
value: sonarr value: sonarr-adult
- name: Sonarr__BackupS3__ForcePathStyle - name: Sonarr__BackupS3__ForcePathStyle
value: "true" value: "true"
- name: Sonarr__BackupS3__CaCertPath - name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt value: /etc/ssl/vault-ca/ca.crt
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /sonarr/ping path: /3aa168/sonarr/ping
port: http port: http
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
@@ -170,7 +161,7 @@ spec:
failureThreshold: 3 failureThreshold: 3
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /sonarr/ping path: /3aa168/sonarr/ping
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
@@ -186,28 +177,31 @@ spec:
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /config mountPath: /config
# Adult TV subtree of the shared media-tv PVC (same CephFS subvolume
# the kids sonarr writes and jellyfin reads).
- name: media-tv - name: media-tv
mountPath: /media/tv mountPath: /media/tv
subPath: tvshows/adult
- name: vault-ca - name: vault-ca
mountPath: /etc/ssl/vault-ca mountPath: /etc/ssl/vault-ca
readOnly: true readOnly: true
# exportarr sidecar: polls the local replica's API and exposes Prometheus # exportarr sidecar: polls the local replica's API and exposes Prometheus
# metrics on :9707 (scraped by the sonarr-exportarr VMPodScrape). # metrics on :9707 (scraped by the sonarr-adult-exportarr VMPodScrape).
- name: exportarr - name: exportarr
image: artifactapi.k8s.syd1.au.unkin.net/ghcr/onedr0p/exportarr:v2.3.0 image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
args: args:
- sonarr - sonarr
env: env:
- name: PORT - name: PORT
value: "9707" value: "9707"
# URL includes the /sonarr UrlBase (Sonarr__Server__UrlBase). # URL includes the /3aa168/sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL - name: URL
value: http://localhost:8989/sonarr value: http://localhost:8989/3aa168/sonarr
- name: APIKEY - name: APIKEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: sonarr-apikey name: sonarr-adult-apikey
key: apitoken key: apitoken
ports: ports:
- name: metrics - name: metrics
@@ -0,0 +1,10 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -0,0 +1,15 @@
---
apiVersion: v1
kind: Service
metadata:
name: sonarr-adult
namespace: arrstack
spec:
ports:
- name: http
port: 8989
protocol: TCP
targetPort: http
selector:
app: sonarr-adult
type: ClusterIP
@@ -0,0 +1,25 @@
---
# sonarr-adult API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/sonarr-adult (key: apitoken); 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 sonarr-adult-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-adult-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-adult-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-adult
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,16 @@
---
# Scrape the exportarr sidecar (:9707) on every sonarr-adult pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr-adult Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: sonarr-adult-exportarr
namespace: arrstack
spec:
selector:
matchLabels:
app: sonarr-adult
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -0,0 +1,25 @@
---
# Non-secret env for the -unkin2 fork (kids tier). Identical mechanism to the
# adult sonarr, pointed at its own shared-Postgres database (sonarr-kids-main)
# and its own UrlBase (/3df803/sonarr) so arrproxy path-routing reaches the kids
# instance separately. Shares the one arrstack Valkey (keys namespaced by the
# fork's sonarr:ratelimit: prefix). User/Password/ApiKey come from Secrets (see
# deployment.yaml), not here.
apiVersion: v1
kind: ConfigMap
metadata:
name: sonarr-kids-env
namespace: arrstack
data:
Sonarr__Postgres__Host: arrstack-postgres-rw.arrstack.svc.cluster.local
Sonarr__Postgres__Port: "5432"
Sonarr__Postgres__MainDb: sonarr-kids-main
Sonarr__Log__DbEnabled: "false"
Sonarr__Auth__Method: External
Sonarr__Auth__Required: DisabledForLocalAddresses
Sonarr__App__InstanceName: Sonarr
Sonarr__Server__Port: "8989"
Sonarr__Server__UrlBase: /3df803/sonarr
Sonarr__Update__Mechanism: External
Sonarr__Redis__Host: valkey-arrstack-valkey.arrstack.svc.cluster.local
Sonarr__Redis__Port: "6379"
@@ -0,0 +1,245 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr-kids
namespace: arrstack
annotations:
# Reloader rolls the Deployment when sonarr-kids-env changes (plain envFrom
# ConfigMap does not trigger a rollout on its own).
configmap.reloader.stakater.com/auto: "true"
spec:
# 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-kids Service. RollingUpdate is
# safe — no SQLite, no RWO lock.
replicas: 3
strategy:
type: RollingUpdate
selector:
matchLabels:
app: sonarr-kids
template:
metadata:
labels:
app: sonarr-kids
spec:
securityContext:
# Fork image has no USER; pin it to a non-root UID and group-write the
# shared RWX CephFS media subtree. OnRootMismatch avoids a recursive
# chown of the whole media tree.
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
initContainers:
# Gate the app on its own Postgres database+role being reachable.
# 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: 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-kids-main
- name: PGUSER
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
containers:
- name: sonarr
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/sonarr:v5.0.0-unkin6
imagePullPolicy: IfNotPresent
command:
- /app/Sonarr
args:
- -nobrowser
- -data=/config
# Bypass the single-instance guard so multiple replicas can share one
# /config. Cross-replica safety is the Postgres layer, not a lock file.
- -nosingleinstancecheck
ports:
- name: http
containerPort: 8989
protocol: TCP
envFrom:
- configMapRef:
name: sonarr-kids-env
env:
- name: Sonarr__Postgres__User
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: username
- name: Sonarr__Postgres__Password
valueFrom:
secretKeyRef:
name: sonarr-kids-db
key: password
- name: Sonarr__Auth__ApiKey
valueFrom:
secretKeyRef:
name: sonarr-kids-apikey
key: apitoken
# MediaCover object store (shared arrstack-media Ceph RGW bucket,
# partitioned by the sonarr-kids key prefix).
- 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-kids
- 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,
# partitioned by the sonarr-kids key prefix).
- 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-kids
- name: Sonarr__BackupS3__ForcePathStyle
value: "true"
- name: Sonarr__BackupS3__CaCertPath
value: /etc/ssl/vault-ca/ca.crt
livenessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /3df803/sonarr/ping
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /config
# Kids TV subtree of the shared media-tv PVC (same CephFS subvolume
# the adult sonarr writes and jellyfin reads).
- name: media-tv
mountPath: /media/tv
subPath: tvshows/kids
- 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-kids-exportarr VMPodScrape).
- name: exportarr
image: ghcr.io/onedr0p/exportarr:v2.3.0
imagePullPolicy: IfNotPresent
args:
- sonarr
env:
- name: PORT
value: "9707"
# URL includes the /3df803/sonarr UrlBase (Sonarr__Server__UrlBase).
- name: URL
value: http://localhost:8989/3df803/sonarr
- name: APIKEY
valueFrom:
secretKeyRef:
name: sonarr-kids-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
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
@@ -0,0 +1,10 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- vaultstaticsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- vmpodscrape.yaml
@@ -2,7 +2,7 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: sonarr name: sonarr-kids
namespace: arrstack namespace: arrstack
spec: spec:
ports: ports:
@@ -11,5 +11,5 @@ spec:
protocol: TCP protocol: TCP
targetPort: http targetPort: http
selector: selector:
app: sonarr app: sonarr-kids
type: ClusterIP type: ClusterIP
@@ -0,0 +1,25 @@
---
# sonarr-kids API key. Seeded out-of-band at
# kv/kubernetes/namespace/arrstack/default/sonarr-kids (key: apitoken); 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 sonarr-kids-apikey Secret consumed by the Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-kids-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-kids-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr-kids
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -1,16 +1,16 @@
--- ---
# Scrape the exportarr sidecar (:9707) on every sonarr pod. Picked up by the # Scrape the exportarr sidecar (:9707) on every sonarr-kids pod. Picked up by the
# observability VMAgent (selectAllByDefault). Pod-level rather than # observability VMAgent (selectAllByDefault). Pod-level rather than
# VMServiceScrape because the sonarr Service doesn't expose the metrics port. # VMServiceScrape because the sonarr-kids Service doesn't expose the metrics port.
apiVersion: operator.victoriametrics.com/v1beta1 apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape kind: VMPodScrape
metadata: metadata:
name: sonarr-exportarr name: sonarr-kids-exportarr
namespace: arrstack namespace: arrstack
spec: spec:
selector: selector:
matchLabels: matchLabels:
app: sonarr app: sonarr-kids
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /metrics path: /metrics
-33
View File
@@ -1,33 +0,0 @@
---
# 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"
-17
View File
@@ -1,17 +0,0 @@
---
# 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:
name: sonarr-config
namespace: arrstack
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: cephfs-raid5-retain
volumeMode: Filesystem
@@ -1,25 +0,0 @@
---
# sonarr API key. Seeded at kv/kubernetes/namespace/arrstack/default/sonarr
# (key: apitoken); 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 sonarr-apikey Secret that the apikey-init initContainer reads
# to enforce <ApiKey> in /config/config.xml (Vault is source of truth).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: sonarr-apikey
namespace: arrstack
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
destination:
create: true
name: sonarr-apikey
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/arrstack/default/sonarr
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+1 -1
View File
@@ -28,7 +28,7 @@ metadata:
spec: spec:
shards: 1 shards: 1
replicas: 2 replicas: 2
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/valkey/valkey:9.0.0 image: docker.io/valkey/valkey:9.0.0
exporter: exporter:
enabled: false enabled: false
scheduling: scheduling:
+1 -1
View File
@@ -36,7 +36,7 @@ spec:
mountPath: /combined-certs mountPath: /combined-certs
containers: containers:
- name: api - name: api
image: git.unkin.net/unkin/artifactapi:v3.11.1 image: git.unkin.net/unkin/artifactapi:v3.11.2
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8000 - containerPort: 8000
+28 -1
View File
@@ -1,4 +1,21 @@
--- ---
# Path split between the authenticated UI and the unauthenticated machine API.
# Longest matching prefix wins, so the two UI rules take precedence over "/".
#
# AUTHENTICATED (oauth2 Service -> oauth2-proxy -> ui Service):
# /oauth2 oauth2-proxy sign_in / start / callback / sign_out
# /ui the human-facing SPA
#
# NOT AUTHENTICATED (artifactapi Service, unchanged):
# /api/v1/{remote,local,virtual}/* package proxy reads (yum/dnf, pip, ...)
# /api/v2/remotes|virtuals|locals/* management API + the UI's own XHR calls
# /api/v2/remotes/{name}/files/* CI publish uploads (PUT) and downloads
# /v2/* Docker Registry V2 (containerd, buildah)
# /terraform/v1/providers/* Terraform provider registry
# /.well-known/terraform.json Terraform service discovery
# /health, /version, / probes and the redirect to /ui/
# Those clients cannot complete a browser OIDC flow, so they must never be
# routed through oauth2-proxy.
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
@@ -22,7 +39,17 @@ spec:
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
name: ui name: oauth2
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /oauth2
- backendRefs:
- group: ""
kind: Service
name: oauth2
port: 80 port: 80
weight: 1 weight: 1
matches: matches:
+2
View File
@@ -12,6 +12,8 @@ resources:
- gateway.yaml - gateway.yaml
- httproute.yaml - httproute.yaml
- namespace.yaml - namespace.yaml
- oauth2-proxy-configmap.yaml
- oauth2-proxy-deployment.yaml
- redis-deployment.yaml - redis-deployment.yaml
- services.yaml - services.yaml
- ui-deployment.yaml - ui-deployment.yaml
@@ -0,0 +1,46 @@
---
# Non-secret oauth2-proxy configuration (client_id/secret/cookie_secret come
# from the oauth-credentials Secret).
#
# SCOPE: this proxy fronts the artifactapi web UI ONLY. The HTTPRoute sends just
# /ui and /oauth2 here; every machine surface (/api/v1, /api/v2, /v2 docker
# registry, /terraform, /.well-known/terraform.json, /health, /version, /) goes
# straight to the api Service and is NOT authenticated. yum/dnf, containerd
# registry mirrors, docker/buildah, terraform init and Woodpecker publish steps
# cannot complete a browser OIDC flow, so they must never reach this container.
# Its only upstream is the ui Service -- there is deliberately no api upstream.
apiVersion: v1
kind: ConfigMap
metadata:
name: artifactapi-oauth2-env
namespace: artifactapi
data:
OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180"
OAUTH2_PROXY_METRICS_ADDRESS: "0.0.0.0:44180"
OAUTH2_PROXY_PROVIDER: "oidc"
# Publicly-trusted Authentik host: the authorize step is a browser redirect,
# so the issuer must present a cert every user's browser already trusts (the
# k8s host serves an internal-CA cert). Slug from terraform-authentik.
OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.unkin.net/application/o/artifactapi/"
OAUTH2_PROXY_REDIRECT_URL: "https://artifactapi.k8s.syd1.au.unkin.net/oauth2/callback"
OAUTH2_PROXY_UPSTREAMS: "http://ui.artifactapi.svc.cluster.local:80/"
OAUTH2_PROXY_SCOPE: "openid email profile ak_groups"
# Populate session.Groups from the Authentik hierarchical ak_groups claim.
OAUTH2_PROXY_OIDC_GROUPS_CLAIM: "ak_groups"
OAUTH2_PROXY_ALLOWED_GROUPS: "akP-artifactapi-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: "artifactapi.k8s.syd1.au.unkin.net"
OAUTH2_PROXY_WHITELIST_DOMAINS: "artifactapi.k8s.syd1.au.unkin.net"
OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: "S256"
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
# Back-channel discovery/token calls resolve the issuer inside the cluster,
# where it is served under the internal unkin.net CA rather than the publicly
# trusted cert the browser sees. Trust the bundle the combine-certs init
# container assembles, as every other oauth2-proxy in the estate does.
OAUTH2_PROXY_PROVIDER_CA_FILES: "/etc/ssl/combined/ca-certificates.crt"
@@ -0,0 +1,136 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2
namespace: artifactapi
annotations:
configmap.reloader.stakater.com/auto: "true"
secret.reloader.stakater.com/reload: "oauth-credentials,vault-ca-cert"
spec:
replicas: 2
selector:
matchLabels:
app: oauth2
strategy:
rollingUpdate:
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: oauth2
spec:
serviceAccountName: default
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
initContainers:
# The Authentik issuer is served behind the internal unkin.net CA;
# combine the system roots with it so oauth2-proxy's OIDC HTTP client
# trusts the discovery endpoint.
- name: combine-certs
image: docker.io/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
- containerPort: 44180
name: metrics
protocol: TCP
envFrom:
- configMapRef:
name: artifactapi-oauth2-env
optional: false
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: oauth-credentials
key: client_id
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oauth-credentials
key: client_secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: oauth-credentials
key: cookie_secret
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
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
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
+1 -1
View File
@@ -54,7 +54,7 @@ spec:
successThreshold: 1 successThreshold: 1
timeoutSeconds: 5 timeoutSeconds: 5
- name: metrics-exporter - name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0 image: docker.io/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 9121 - containerPort: 9121
+20
View File
@@ -16,6 +16,26 @@ spec:
sessionAffinity: None sessionAffinity: None
type: ClusterIP type: ClusterIP
--- ---
# Authenticated front door for the web UI only: api-route sends /ui and /oauth2
# here, oauth2-proxy authenticates and forwards to the ui Service. Every other
# path reaches the api Service above directly and stays unauthenticated.
apiVersion: v1
kind: Service
metadata:
name: oauth2
namespace: artifactapi
spec:
internalTrafficPolicy: Cluster
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
selector:
app: oauth2
sessionAffinity: None
type: ClusterIP
---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
automountServiceAccountToken: true automountServiceAccountToken: true
containers: containers:
- name: ui - name: ui
image: git.unkin.net/unkin/artifactapi-ui:v3.11.1 image: git.unkin.net/unkin/artifactapi-ui:v3.11.2
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 80 - containerPort: 80
@@ -32,3 +32,26 @@ spec:
refreshAfter: 5m refreshAfter: 5m
type: kv-v2 type: kv-v2
vaultAuthRef: default vaultAuthRef: default
---
# Authentik OIDC client for the artifactapi UI front door (client_id,
# client_secret, cookie_secret). Seeded out of band at
# kv/kubernetes/namespace/artifactapi/default/oauth-credentials; the default
# k8s auth role already grants the artifactapi/default ServiceAccount read on
# kv/data/kubernetes/namespace/{{sa_namespace}}/{{sa_name}}/*, so no
# terraform-vault change is needed. Consumed by the oauth2 Deployment.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: oauth-credentials
namespace: artifactapi
spec:
destination:
create: true
name: oauth-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/artifactapi/default/oauth-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+14
View File
@@ -14,3 +14,17 @@ spec:
podMetricsEndpoints: podMetricsEndpoints:
- port: metrics - port: metrics
path: /metrics path: /metrics
---
# Scrape the UI oauth2-proxy (:44180), which exposes sign-in/authz counters.
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: oauth2
namespace: artifactapi
spec:
selector:
matchLabels:
app: oauth2
podMetricsEndpoints:
- port: metrics
path: /metrics
+21 -6
View File
@@ -64,8 +64,12 @@ spec:
archive_mode: "on" archive_mode: "on"
archive_timeout: 5min archive_timeout: 5min
dynamic_shared_memory_type: posix dynamic_shared_memory_type: posix
effective_cache_size: 256MB effective_cache_size: 1536MB
full_page_writes: "on" full_page_writes: "on"
# Replicas report their oldest xmin to the primary, so multi-second reads on
# a hot standby stop exhausting max_standby_streaming_delay and being
# cancelled. Retained-dead-tuple cost is negligible on a ~155MB database.
hot_standby_feedback: "on"
log_destination: csvlog log_destination: csvlog
log_directory: /controller/log log_directory: /controller/log
log_filename: postgres log_filename: postgres
@@ -77,7 +81,12 @@ spec:
max_parallel_workers: "16" max_parallel_workers: "16"
max_replication_slots: "16" max_replication_slots: "16"
max_worker_processes: "16" max_worker_processes: "16"
shared_buffers: 128MB # A pg_stat_statements.* parameter is what makes CNPG treat the extension as
# managed and run CREATE EXTENSION in every database; preloading alone does
# not create it.
pg_stat_statements.max: "10000"
pg_stat_statements.track: top
shared_buffers: 512MB
shared_memory_type: mmap shared_memory_type: mmap
ssl_max_protocol_version: TLSv1.3 ssl_max_protocol_version: TLSv1.3
ssl_min_protocol_version: TLSv1.3 ssl_min_protocol_version: TLSv1.3
@@ -86,6 +95,9 @@ spec:
wal_log_hints: "on" wal_log_hints: "on"
wal_receiver_timeout: 5s wal_receiver_timeout: 5s
wal_sender_timeout: 5s wal_sender_timeout: 5s
# CNPG merges this with the libraries it manages itself.
shared_preload_libraries:
- pg_stat_statements
syncReplicaElectionConstraint: syncReplicaElectionConstraint:
enabled: false enabled: false
primaryUpdateMethod: restart primaryUpdateMethod: restart
@@ -105,13 +117,16 @@ spec:
updateInterval: 30 updateInterval: 30
resources: resources:
limits: limits:
cpu: 500m # 500m is a 50ms CFS quota per 100ms period, exhausted by bursts even at
# ~0.01 cores average, so every query pays throttle latency.
cpu: "2"
# 512Mi OOMKilled replicas under load (shared_buffers 128MB + # 512Mi OOMKilled replicas under load (shared_buffers 128MB +
# max_connections 200 leave no headroom) — see incident 2026-07-28. # max_connections 200 leave no headroom) — see incident 2026-07-28.
memory: 1Gi # shared_buffers 512MB needs the same headroom multiple, hence 2Gi.
memory: 2Gi
requests: requests:
cpu: 50m cpu: 500m
memory: 512Mi memory: 1Gi
smartShutdownTimeout: 180 smartShutdownTimeout: 180
startDelay: 3600 startDelay: 3600
stopDelay: 1800 stopDelay: 1800
+33 -10
View File
@@ -1,19 +1,22 @@
--- ---
# External (DMZ) front for public identity.unkin.net, served via the external
# Traefik (LB VIP 198.18.199.0). The apex identity.unkin.net A record lives in
# the bind-operator unkin.net zone (bind-internal/authoritative), NOT
# external-dns, so no external-dns annotation here. Public TLS is terminated with
# the real Let's Encrypt *.unkin.net wildcard, centrally minted once in the
# cert-manager namespace (Certificate wildcard-unkin-net) and reflected into this
# namespace by the emberstack reflector as wildcard-unkin-net-tls, not Vault PKI.
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: Gateway kind: Gateway
metadata: metadata:
labels: labels:
traefik.io/instance: internal traefik.io/instance: external
annotations: annotations:
cert-manager.io/cluster-issuer: vault-issuer argocd.argoproj.io/sync-wave: "2"
cert-manager.io/common-name: identity.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: identity.unkin.net,identity.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: authentik name: authentik
namespace: authentik namespace: authentik
spec: spec:
gatewayClassName: traefik-internal gatewayClassName: traefik-external
listeners: listeners:
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
@@ -33,20 +36,40 @@ spec:
certificateRefs: certificateRefs:
- group: "" - group: ""
kind: Secret kind: Secret
name: authentik-tls name: wildcard-unkin-net-tls
mode: Terminate mode: Terminate
---
# Cluster hostname variant, identity.k8s.syd1.au.unkin.net. Internal Traefik,
# external-dns at 198.18.200.4. Own leaf from the Vault PKI issuer via the
# cert-manager gateway-shim; the common-name keys off this cluster host.
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: identity.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: identity.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: authentik-internal
namespace: authentik
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: identity.k8s.syd1.au.unkin.net hostname: identity.k8s.syd1.au.unkin.net
name: http-internal name: http
port: 80 port: 80
protocol: HTTP protocol: HTTP
- allowedRoutes: - allowedRoutes:
namespaces: namespaces:
from: Same from: Same
hostname: identity.k8s.syd1.au.unkin.net hostname: identity.k8s.syd1.au.unkin.net
name: https-internal name: https
port: 443 port: 443
protocol: HTTPS protocol: HTTPS
tls: tls:
+81 -10
View File
@@ -7,16 +7,11 @@ metadata:
spec: spec:
hostnames: hostnames:
- identity.unkin.net - identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: authentik name: authentik
sectionName: http sectionName: http
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: http-internal
rules: rules:
- filters: - filters:
- type: RequestRedirect - type: RequestRedirect
@@ -36,17 +31,93 @@ metadata:
spec: spec:
hostnames: hostnames:
- identity.unkin.net - identity.unkin.net
- identity.k8s.syd1.au.unkin.net
parentRefs: parentRefs:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
name: authentik name: authentik
sectionName: https sectionName: https
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik
sectionName: https-internal
rules: rules:
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /application/o/token/
matches:
- path:
type: Exact
value: /application/o/token
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: authentik-http-redirect-internal
namespace: authentik
spec:
hostnames:
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik-internal
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: authentik-internal
namespace: authentik
spec:
hostnames:
- identity.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: authentik-internal
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: authentik-server
port: 80
weight: 1
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /application/o/token/
matches:
- path:
type: Exact
value: /application/o/token
- backendRefs: - backendRefs:
- group: "" - group: ""
kind: Service kind: Service
+4
View File
@@ -10,12 +10,16 @@ resources:
- httproute.yaml - httproute.yaml
- ldap-gateway.yaml - ldap-gateway.yaml
- ldap-httproute.yaml - ldap-httproute.yaml
- ldap-outpost-deployment.yaml
- ldap-outpost-vaultstaticsecret.yaml
- ldap-outpost-vmpodscrape.yaml
- ldap-service.yaml - ldap-service.yaml
- ldap-tlsroute.yaml - ldap-tlsroute.yaml
- namespace.yaml - namespace.yaml
- redis-deployment.yaml - redis-deployment.yaml
- redis-pvc.yaml - redis-pvc.yaml
- redis-service.yaml - redis-service.yaml
- server-vmpodscrape.yaml
- vaultauth.yaml - vaultauth.yaml
- vaultstaticsecret.yaml - vaultstaticsecret.yaml
- vmpodscrape.yaml - vmpodscrape.yaml
@@ -0,0 +1,104 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authentik-ldap-outpost
namespace: authentik
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
spec:
# Outposts are stateless; run two replicas for availability.
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
template:
metadata:
annotations:
secret.reloader.stakater.com/reload: "authentik-ldap-outpost-token,vault-ca-cert"
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
spec:
# The outpost validates the authentik core cert (identity.k8s.syd1.au.unkin.net,
# signed by the internal unkin.net CA). Combine the base image's public roots
# with the reflected vault-ca-cert into one bundle that SSL_CERT_FILE points at,
# so AUTHENTIK_INSECURE stays false.
initContainers:
- name: combine-certs
image: alpine:3
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
resources:
limits:
cpu: 100m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi
containers:
- name: ldap
image: ghcr.io/goauthentik/ldap:2026.5.3
imagePullPolicy: IfNotPresent
env:
- name: AUTHENTIK_HOST
value: https://identity.k8s.syd1.au.unkin.net
- name: AUTHENTIK_INSECURE
value: "false"
- name: SSL_CERT_FILE
value: /etc/ssl/combined/ca-certificates.crt
- name: AUTHENTIK_TOKEN
valueFrom:
secretKeyRef:
name: authentik-ldap-outpost-token
key: token
ports:
- containerPort: 3389
name: ldap
protocol: TCP
- containerPort: 6636
name: ldaps
protocol: TCP
- containerPort: 9300
name: metrics
protocol: TCP
livenessProbe:
tcpSocket:
port: ldap
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
tcpSocket:
port: ldap
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
volumeMounts:
- name: combined-certs
mountPath: /etc/ssl/combined
readOnly: true
volumes:
- name: vault-ca-cert
secret:
secretName: vault-ca-cert
items:
- key: ca.crt
path: ca.crt
- name: combined-certs
emptyDir: {}
@@ -0,0 +1,20 @@
---
# Outpost API token, issued by authentik for the LDAP outpost and seeded into
# Vault by the terraform-authentik apply. The KV value must exist at this path
# with a `token` key before the outpost can connect.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: authentik-ldap-outpost-token
namespace: authentik
spec:
destination:
create: true
name: authentik-ldap-outpost-token
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/authentik/default/outpost-token
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
@@ -0,0 +1,16 @@
---
# Scrape the LDAP outpost's Prometheus endpoint (:9300). Picked up by the
# observability VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: authentik-ldap-outpost
namespace: authentik
spec:
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: ldap
podMetricsEndpoints:
- port: metrics
path: /metrics
+4
View File
@@ -7,6 +7,10 @@ metadata:
spec: spec:
internalTrafficPolicy: Cluster internalTrafficPolicy: Cluster
ports: ports:
- name: ldap
port: 3389
protocol: TCP
targetPort: 3389
- name: ldaps - name: ldaps
port: 6636 port: 6636
protocol: TCP protocol: TCP
+1 -1
View File
@@ -53,7 +53,7 @@ spec:
- mountPath: /data - mountPath: /data
name: redis-data name: redis-data
- name: metrics-exporter - name: metrics-exporter
image: artifactapi.k8s.syd1.au.unkin.net/dockerhub/oliver006/redis_exporter:v1.89.0 image: docker.io/oliver006/redis_exporter:v1.89.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 9121 - containerPort: 9121
@@ -0,0 +1,16 @@
---
# Scrape the authentik server's django_prometheus endpoint (:9300). Picked up
# by the observability VMAgent (selectAllByDefault).
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMPodScrape
metadata:
name: authentik-server
namespace: authentik
spec:
selector:
matchLabels:
app.kubernetes.io/name: authentik
app.kubernetes.io/component: server
podMetricsEndpoints:
- port: metrics
path: /metrics
@@ -16,9 +16,9 @@ spec:
type: A type: A
ttl: 600 ttl: 600
values: values:
# traefik-internal gateway VIP; the authentik Gateway serves the # traefik-EXTERNAL (DMZ) gateway VIP; the authentik Gateway serves the
# identity.unkin.net hostname there. # identity.unkin.net hostname there.
- 198.18.200.4 - 198.18.199.0
--- ---
# PRODUCTION CUTOVER RECORD — intentionally commented out. # PRODUCTION CUTOVER RECORD — intentionally commented out.
# git.unkin.net currently resolves to the LIVE VM forge (HAProxy VRRP VIP # git.unkin.net currently resolves to the LIVE VM forge (HAProxy VRRP VIP
@@ -132,3 +132,33 @@ spec:
# traefik-internal gateway VIP; the logviewer Gateway serves # traefik-internal gateway VIP; the logviewer Gateway serves
# logviewer.unkin.net there. # logviewer.unkin.net there.
- 198.18.200.4 - 198.18.200.4
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: cheeztv-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: cheeztv
type: A
ttl: 600
values:
# traefik-internal gateway VIP; the cheeztv Gateway serves cheeztv.unkin.net
# there.
- 198.18.200.4
---
apiVersion: bind.unkin.net/v1alpha1
kind: DNSRecord
metadata:
name: watchstate-dns-internal
namespace: bind-internal
spec:
zoneRef: unkin-net
name: watchstate
type: A
ttl: 600
values:
# traefik-EXTERNAL (DMZ) gateway VIP; the watchstate-external Gateway serves
# the watchstate.unkin.net front door (oauth2-proxy) there.
- 198.18.199.0
+1 -1
View File
@@ -21,7 +21,7 @@ spec:
runAsNonRoot: true runAsNonRoot: true
containers: containers:
- name: operator - name: operator
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator:v0.2.6 image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/bind-operator:v0.2.7
args: args:
- --metrics-bind-address=:8080 - --metrics-bind-address=:8080
- --health-probe-bind-address=:8081 - --health-probe-bind-address=:8081
+1 -1
View File
@@ -6,7 +6,7 @@ resources:
- namespace.yaml - namespace.yaml
# CRDs are pulled from the bind-operator repo at the matching tag rather than # CRDs are pulled from the bind-operator repo at the matching tag rather than
# vendored here, so they never drift from the operator. # vendored here, so they never drift from the operator.
- https://git.unkin.net/unkin/bind-operator/raw/tag/v0.2.6/config/crd/install.yaml - https://git.unkin.net/unkin/bind-operator/raw/tag/v0.2.7/config/crd/install.yaml
- rbac.yaml - rbac.yaml
- agent-dns-rbac.yaml - agent-dns-rbac.yaml
- deployment.yaml - deployment.yaml
@@ -0,0 +1,27 @@
---
# Single, centrally-managed Let's Encrypt *.unkin.net wildcard. DNS-01 validates
# once against the delegated _acme-challenge.unkin.net CNAME, so consumers get a
# real public leaf without per-host challenge records. The minted secret carries
# emberstack reflector annotations (via secretTemplate) so it is auto-reflected
# into consumer namespaces; append future consumers to the *-namespaces lists.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-unkin-net
namespace: cert-manager
spec:
secretName: wildcard-unkin-net-tls
secretTemplate:
annotations:
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "cheeztv,arrstack,authentik,gitea,watchstate,mediamark,repospawner"
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "cheeztv,arrstack,authentik,gitea,watchstate,mediamark,repospawner"
privateKey:
size: 4096
dnsNames:
- "*.unkin.net"
issuerRef:
name: letsencrypt
kind: ClusterIssuer
group: cert-manager.io
@@ -11,3 +11,4 @@ resources:
- vmservicescrape.yaml - vmservicescrape.yaml
- clusterissuer_letsencrypt.yaml - clusterissuer_letsencrypt.yaml
- clusterissuer_letsencrypt-staging.yaml - clusterissuer_letsencrypt-staging.yaml
- certificate_wildcard-unkin-net.yaml
@@ -1,32 +1,32 @@
--- ---
# Second Ceph RGW (S3) bucket owned by the existing jellyfin backup user # Second Ceph RGW (S3) bucket owned by the existing cheeztv backup user
# (cnpg-jellyfin-backup, defined in cnpg_backup.yaml) — one user, two buckets: # (cnpg-cheeztv-backup, defined in cnpg_backup.yaml) — one user, two buckets:
# the CNPG barman bucket plus this one, which k8up uses to hold restic backups # the CNPG barman bucket plus this one, which k8up uses to hold restic backups
# of the jellyfin-config PVC. The BucketAccess emits read-write S3 creds into a # of the cheeztv-config PVC. The BucketAccess emits read-write S3 creds into a
# Secret the k8up Schedule consumes. # Secret the k8up Schedule consumes.
apiVersion: ceph.unkin.net/v1alpha1 apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket kind: Bucket
metadata: metadata:
name: jellyfin-config-backup name: cheeztv-config-backup
namespace: jellyfin namespace: cheeztv
spec: spec:
placementTarget: ec placementTarget: ec
bucketName: jellyfin-config-backup bucketName: cheeztv-config-backup
ownerRef: cnpg-jellyfin-backup ownerRef: cnpg-cheeztv-backup
versioning: false versioning: false
tags: tags:
app: jellyfin app: cheeztv
purpose: config-backup purpose: config-backup
retainOnDelete: true retainOnDelete: true
--- ---
apiVersion: ceph.unkin.net/v1alpha1 apiVersion: ceph.unkin.net/v1alpha1
kind: BucketAccess kind: BucketAccess
metadata: metadata:
name: jellyfin-config-backup name: cheeztv-config-backup
namespace: jellyfin namespace: cheeztv
spec: spec:
bucketRef: jellyfin-config-backup bucketRef: cheeztv-config-backup
level: read-write level: read-write
# Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT, # Operator writes AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ S3_ENDPOINT,
# BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys. # BUCKET_NAME) into this Secret; the k8up Schedule reads the access keys.
secretName: jellyfin-config-backup-s3 secretName: cheeztv-config-backup-s3
@@ -1,31 +1,31 @@
--- ---
# Ceph RGW (S3) backup target for the jellyfin CNPG cluster, provisioned by the # Ceph RGW (S3) backup target for the cheeztv CNPG cluster, provisioned by the
# in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the # in-estate cephrgw-operator: one dedicated bucket + owner user. CNPG reads the
# S3 credential Secret from its own namespace. # S3 credential Secret from its own namespace.
apiVersion: ceph.unkin.net/v1alpha1 apiVersion: ceph.unkin.net/v1alpha1
kind: ObjectStoreUser kind: ObjectStoreUser
metadata: metadata:
name: cnpg-jellyfin-backup name: cnpg-cheeztv-backup
namespace: jellyfin namespace: cheeztv
spec: spec:
displayName: "CNPG backup owner (jellyfin)" displayName: "CNPG backup owner (cheeztv)"
uid: cnpg-jellyfin-backup uid: cnpg-cheeztv-backup
maxBuckets: 5 maxBuckets: 5
secretName: cnpg-jellyfin-backup-s3 secretName: cnpg-cheeztv-backup-s3
retainOnDelete: true retainOnDelete: true
--- ---
apiVersion: ceph.unkin.net/v1alpha1 apiVersion: ceph.unkin.net/v1alpha1
kind: Bucket kind: Bucket
metadata: metadata:
name: cnpg-jellyfin name: cnpg-cheeztv
namespace: jellyfin namespace: cheeztv
spec: spec:
placementTarget: ec placementTarget: ec
bucketName: cnpg-jellyfin bucketName: cnpg-cheeztv
ownerRef: cnpg-jellyfin-backup ownerRef: cnpg-cheeztv-backup
versioning: false versioning: false
tags: tags:
app: jellyfin app: cheeztv
purpose: cnpg-backup purpose: cnpg-backup
retainOnDelete: true retainOnDelete: true
--- ---
@@ -34,12 +34,12 @@ spec:
apiVersion: postgresql.cnpg.io/v1 apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup kind: ScheduledBackup
metadata: metadata:
name: cnpg-jellyfin-nightly name: cnpg-cheeztv-nightly
namespace: jellyfin namespace: cheeztv
spec: spec:
schedule: "0 35 3 * * *" schedule: "0 35 3 * * *"
immediate: false immediate: false
backupOwnerReference: self backupOwnerReference: self
method: barmanObjectStore method: barmanObjectStore
cluster: cluster:
name: jellyfin-postgres name: cheeztv-postgres
@@ -1,17 +1,17 @@
--- ---
# Main Jellyfin database. The jellyfin-ha fork's experimental EF Core provider # Main Jellyfin database. The cheeztv-ha fork's experimental EF Core provider
# moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL, # moves the entire Jellyfin DB (incl. library items) off SQLite into PostgreSQL,
# which is what makes a shared-nothing multi-replica deployment possible. No # which is what makes a shared-nothing multi-replica deployment possible. No
# bootstrap secret is given, so CNPG generates the jellyfin-postgres-app secret # bootstrap secret is given, so CNPG generates the cheeztv-postgres-app secret
# (username/password/dbname) that the StatefulSet composes its DSN from. # (username/password/dbname) that the StatefulSet composes its DSN from.
apiVersion: postgresql.cnpg.io/v1 apiVersion: postgresql.cnpg.io/v1
kind: Cluster kind: Cluster
metadata: metadata:
name: jellyfin-postgres name: cheeztv-postgres
namespace: jellyfin namespace: cheeztv
spec: spec:
# Exclude the operator-managed data PVCs (jellyfin-postgres-N) from the # Exclude the operator-managed data PVCs (cheeztv-postgres-N) from the
# jellyfin-config k8up Schedule (skipWithoutAnnotation is false cluster-wide, # cheeztv-config k8up Schedule (skipWithoutAnnotation is false cluster-wide,
# so unannotated PVCs are swept in). Postgres has its own barmanObjectStore # so unannotated PVCs are swept in). Postgres has its own barmanObjectStore
# backup below; restic must not touch the raw RWO data volumes. # backup below; restic must not touch the raw RWO data volumes.
inheritedMetadata: inheritedMetadata:
@@ -23,19 +23,19 @@ spec:
retentionPolicy: 30d retentionPolicy: 30d
barmanObjectStore: barmanObjectStore:
# Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it). # Dedicated per-cluster Ceph RGW bucket (cephrgw-operator provisions it).
destinationPath: s3://cnpg-jellyfin destinationPath: s3://cnpg-cheeztv
endpointURL: https://s3.ceph.unkin.net endpointURL: https://s3.ceph.unkin.net
endpointCA: endpointCA:
name: vault-ca-cert name: vault-ca-cert
key: ca.crt key: ca.crt
s3Credentials: s3Credentials:
accessKeyId: accessKeyId:
name: cnpg-jellyfin-backup-s3 name: cnpg-cheeztv-backup-s3
key: AWS_ACCESS_KEY_ID key: AWS_ACCESS_KEY_ID
secretAccessKey: secretAccessKey:
name: cnpg-jellyfin-backup-s3 name: cnpg-cheeztv-backup-s3
key: AWS_SECRET_ACCESS_KEY key: AWS_SECRET_ACCESS_KEY
serverName: jellyfin serverName: cheeztv
data: data:
compression: bzip2 compression: bzip2
jobs: 2 jobs: 2
@@ -44,11 +44,11 @@ spec:
maxParallel: 2 maxParallel: 2
bootstrap: bootstrap:
initdb: initdb:
database: jellyfin database: cheeztv
encoding: UTF8 encoding: UTF8
localeCType: C localeCType: C
localeCollate: C localeCollate: C
owner: jellyfin owner: cheeztv
enablePDB: true enablePDB: true
enableSuperuserAccess: false enableSuperuserAccess: false
failoverDelay: 0 failoverDelay: 0
@@ -1,15 +1,15 @@
--- ---
# PgBouncer pooler in front of the jellyfin-postgres cluster. Jellyfin connects # PgBouncer pooler in front of the cheeztv-postgres cluster. Jellyfin connects
# here (jellyfin-postgres-pooler:5432) rather than the -rw service so EF Core's # here (cheeztv-postgres-pooler:5432) rather than the -rw service so EF Core's
# connection churn is absorbed by the pool. # connection churn is absorbed by the pool.
apiVersion: postgresql.cnpg.io/v1 apiVersion: postgresql.cnpg.io/v1
kind: Pooler kind: Pooler
metadata: metadata:
name: jellyfin-postgres-pooler name: cheeztv-postgres-pooler
namespace: jellyfin namespace: cheeztv
spec: spec:
cluster: cluster:
name: jellyfin-postgres name: cheeztv-postgres
instances: 2 instances: 2
pgbouncer: pgbouncer:
parameters: parameters:
@@ -20,7 +20,7 @@ spec:
template: template:
metadata: metadata:
labels: labels:
app: jellyfin-pooler app: cheeztv-pooler
spec: spec:
affinity: affinity:
podAntiAffinity: podAntiAffinity:
@@ -30,7 +30,7 @@ spec:
- key: app - key: app
operator: In operator: In
values: values:
- jellyfin-pooler - cheeztv-pooler
topologyKey: kubernetes.io/hostname topologyKey: kubernetes.io/hostname
containers: [] containers: []
type: rw type: rw
+81
View File
@@ -0,0 +1,81 @@
---
# Friendly public hostname for the kids instance, cheeztv.unkin.net. Same
# internal-Traefik + external-dns pattern as logviewer.unkin.net: external-dns
# publishes the A record at the internal LB VIP (198.18.200.4). Public TLS is
# terminated with the real Let's Encrypt *.unkin.net wildcard, centrally minted
# once in the cert-manager namespace (Certificate wildcard-unkin-net) and
# reflected into this namespace by the emberstack reflector as the secret
# wildcard-unkin-net-tls, not the Vault PKI issuer.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
external-dns.alpha.kubernetes.io/hostname: cheeztv.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: wildcard-unkin-net-tls
mode: Terminate
---
# Cluster hostname variant, mirroring fafflix's jellyfin.k8s.syd1.au.unkin.net
# gateway 1:1 (internal Traefik, external-dns at 198.18.200.4). Own cert secret
# so each hostname gets its own leaf.
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: cheeztv.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: cheeztv.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: cheeztv-internal
namespace: cheeztv
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: cheeztv.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: cheeztv-internal-tls
mode: Terminate
+98
View File
@@ -0,0 +1,98 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect
namespace: cheeztv
spec:
hostnames:
- cheeztv.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv
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: cheeztv-route
namespace: cheeztv
spec:
hostnames:
- cheeztv.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: cheeztv
port: 8096
weight: 1
matches:
- path:
type: PathPrefix
value: /
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect-internal
namespace: cheeztv
spec:
hostnames:
- cheeztv.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv-internal
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: cheeztv-route-internal
namespace: cheeztv
spec:
hostnames:
- cheeztv.k8s.syd1.au.unkin.net
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: cheeztv-internal
sectionName: https
rules:
- backendRefs:
- group: ""
kind: Service
name: cheeztv
port: 8096
weight: 1
matches:
- path:
type: PathPrefix
value: /
@@ -15,9 +15,12 @@ resources:
- pvc-transcode.yaml - pvc-transcode.yaml
- pv-media-tv.yaml - pv-media-tv.yaml
- pv-media-movies.yaml - pv-media-movies.yaml
- pv-mediastore.yaml
- pvc-media-tv.yaml - pvc-media-tv.yaml
- pvc-media-movies.yaml - pvc-media-movies.yaml
- pvc-mediastore.yaml
- statefulset.yaml - statefulset.yaml
- plugin-configmap.yaml
- pdb.yaml - pdb.yaml
- service.yaml - service.yaml
- valkey.yaml - valkey.yaml
@@ -2,4 +2,4 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: jellyfin name: cheeztv
@@ -4,10 +4,10 @@
apiVersion: policy/v1 apiVersion: policy/v1
kind: PodDisruptionBudget kind: PodDisruptionBudget
metadata: metadata:
name: jellyfin name: cheeztv
namespace: jellyfin namespace: cheeztv
spec: spec:
minAvailable: 1 minAvailable: 1
selector: selector:
matchLabels: matchLabels:
app: jellyfin app: cheeztv
+97
View File
@@ -0,0 +1,97 @@
---
# Declarative config for the browser-auth plugins bundled in the jellyfin-ha
# image (jellyfin-plugin-sso, jellyfin-plugin-ldapauth). Rendered into
# /config/plugins/configurations/ by the inject-plugin-config initContainer,
# which substitutes the OidSecret / LdapBindPassword placeholders from the
# VSO-synced oauth-credentials Secret so no secret is committed here. The SSO
# provider key "authentik" must match the redirect path segment registered on
# the shared Authentik "jellyfin" OAuth2 client. Roles/AdminRoles are matched
# against the hierarchical Authentik groups claim (akP-jellyfin-user grants
# login, akP-jellyfin-admin grants Jellyfin admin; global admins inherit the
# latter via akR-global-admin).
apiVersion: v1
kind: ConfigMap
metadata:
name: cheeztv-plugin-config
namespace: cheeztv
data:
SSO-Auth.xml: |
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<SamlConfigs />
<OidConfigs>
<item>
<key>
<string>authentik</string>
</key>
<value>
<PluginConfiguration>
<OidEndpoint>https://identity.unkin.net/application/o/jellyfin/</OidEndpoint>
<OidClientId>jellyfin</OidClientId>
<OidSecret>@@CLIENT_SECRET@@</OidSecret>
<Enabled>true</Enabled>
<EnableAuthorization>true</EnableAuthorization>
<EnableAllFolders>true</EnableAllFolders>
<EnabledFolders />
<AdminRoles>
<string>akP-jellyfin-admin</string>
</AdminRoles>
<Roles>
<string>akP-jellyfin-user</string>
<string>akP-jellyfin-admin</string>
</Roles>
<EnableFolderRoles>false</EnableFolderRoles>
<EnableLiveTvRoles>false</EnableLiveTvRoles>
<EnableLiveTv>false</EnableLiveTv>
<EnableLiveTvManagement>false</EnableLiveTvManagement>
<LiveTvRoles />
<LiveTvManagementRoles />
<FolderRoleMappings />
<RoleClaim>ak_groups</RoleClaim>
<OidScopes>
<string>openid</string>
<string>profile</string>
<string>email</string>
<string>ak_groups</string>
</OidScopes>
<CanonicalLinks></CanonicalLinks>
<DisableHttps>false</DisableHttps>
<DoNotValidateEndpoints>false</DoNotValidateEndpoints>
<DoNotValidateIssuerName>false</DoNotValidateIssuerName>
<SchemeOverride>https</SchemeOverride>
</PluginConfiguration>
</value>
</item>
</OidConfigs>
</PluginConfiguration>
LDAP-Auth.xml: |
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<LdapServer>authentik-ldap.authentik.svc.cluster.local</LdapServer>
<LdapPort>6636</LdapPort>
<UseSsl>true</UseSsl>
<UseStartTls>false</UseStartTls>
<SkipSslVerify>true</SkipSslVerify>
<LdapBindUser>cn=jellyfin-ldap,ou=users,DC=ldap,DC=goauthentik,DC=io</LdapBindUser>
<LdapBindPassword>@@LDAP_BIND_PASSWORD@@</LdapBindPassword>
<LdapBaseDn>ou=users,DC=ldap,DC=goauthentik,DC=io</LdapBaseDn>
<LdapSearchFilter>(objectClass=user)</LdapSearchFilter>
<LdapAdminBaseDn>ou=users,DC=ldap,DC=goauthentik,DC=io</LdapAdminBaseDn>
<LdapAdminFilter>(memberOf=cn=akP-jellyfin-admin,ou=groups,DC=ldap,DC=goauthentik,DC=io)</LdapAdminFilter>
<EnableLdapAdminFilterMemberUid>false</EnableLdapAdminFilterMemberUid>
<LdapSearchAttributes>uid, cn, mail, displayName</LdapSearchAttributes>
<CreateUsersFromLdap>true</CreateUsersFromLdap>
<AllowPassChange>false</AllowPassChange>
<LdapUidAttribute>cn</LdapUidAttribute>
<LdapUsernameAttribute>cn</LdapUsernameAttribute>
<LdapPasswordAttribute>userPassword</LdapPasswordAttribute>
<EnableAllFolders>true</EnableAllFolders>
<EnabledFolders />
</PluginConfiguration>
branding.xml: |
<?xml version="1.0" encoding="utf-8"?>
<BrandingOptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<LoginDisclaimer>&lt;p style="text-align:center"&gt;&lt;a href="/sso/OID/start/authentik"&gt;Sign in with SSO&lt;/a&gt;&lt;/p&gt;</LoginDisclaimer>
<CustomCss></CustomCss>
<SplashscreenEnabled>false</SplashscreenEnabled>
</BrandingOptions>
@@ -1,11 +1,11 @@
--- ---
# Static PV for the shared MOVIES CephFS subvolume. Same rootPath as arrstack's # Static PV for the shared MOVIES CephFS subvolume. Same rootPath as arrstack's
# movies PV so radarr writes and jellyfin reads the identical library tree; each # movies PV so radarr writes and cheeztv reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. # namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1 apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
metadata: metadata:
name: jellyfin-media-movies name: cheeztv-media-movies
spec: spec:
capacity: capacity:
storage: 1Ti storage: 1Ti
@@ -15,11 +15,11 @@ spec:
storageClassName: "" storageClassName: ""
volumeMode: Filesystem volumeMode: Filesystem
claimRef: claimRef:
namespace: jellyfin namespace: cheeztv
name: jellyfin-media-movies name: cheeztv-media-movies
csi: csi:
driver: cephfs.csi.ceph.com driver: cephfs.csi.ceph.com
volumeHandle: jellyfin-media-movies-static volumeHandle: cheeztv-media-movies-static
nodeStageSecretRef: nodeStageSecretRef:
name: csi-cephfs-secret name: csi-cephfs-secret
namespace: csi-cephfs namespace: csi-cephfs
@@ -1,11 +1,11 @@
--- ---
# Static PV for the shared TV CephFS subvolume. Same rootPath as arrstack's # Static PV for the shared TV CephFS subvolume. Same rootPath as arrstack's
# TV PV so sonarr writes and jellyfin reads the identical library tree; each # TV PV so sonarr writes and cheeztv reads the identical library tree; each
# namespace gets its own PV (unique name + volumeHandle) pinned by claimRef. # namespace gets its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1 apiVersion: v1
kind: PersistentVolume kind: PersistentVolume
metadata: metadata:
name: jellyfin-media-tv name: cheeztv-media-tv
spec: spec:
capacity: capacity:
storage: 1Ti storage: 1Ti
@@ -15,11 +15,11 @@ spec:
storageClassName: "" storageClassName: ""
volumeMode: Filesystem volumeMode: Filesystem
claimRef: claimRef:
namespace: jellyfin namespace: cheeztv
name: jellyfin-media-tv name: cheeztv-media-tv
csi: csi:
driver: cephfs.csi.ceph.com driver: cephfs.csi.ceph.com
volumeHandle: jellyfin-media-tv-static volumeHandle: cheeztv-media-tv-static
nodeStageSecretRef: nodeStageSecretRef:
name: csi-cephfs-secret name: csi-cephfs-secret
namespace: csi-cephfs namespace: csi-cephfs
+31
View File
@@ -0,0 +1,31 @@
---
# Static PV for the shared MEDIASTORE CephFS subvolume. Same rootPath as
# arrstack's mediastore PV so the arrs write and cheeztv reads the identical
# library tree (cheeztv scans /cheeztv/{tvseries,movies}); each namespace gets
# its own PV (unique name + volumeHandle) pinned by claimRef.
apiVersion: v1
kind: PersistentVolume
metadata:
name: cheeztv-mediastore
spec:
capacity:
storage: 10Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
volumeMode: Filesystem
claimRef:
namespace: cheeztv
name: cheeztv-mediastore
csi:
driver: cephfs.csi.ceph.com
volumeHandle: cheeztv-mediastore-static
nodeStageSecretRef:
name: csi-cephfs-secret
namespace: csi-cephfs
volumeAttributes:
staticVolume: "true"
clusterID: cephfs_csi_ssd_ec_4_1
fsName: cephfs
rootPath: /volumes/csi_ssd_ec_4_1/mediastore/a0152dac-a51b-4b95-ac5e-ecdd99bfe3f1

Some files were not shown because too many files have changed in this diff Show More