Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 378e433086 feat(lidarr): missing-album view over the Navidrome library
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Lidarr in the navidrome namespace as a "what is missing" UI: artist
discographies (Wanted -> Missing) and Spotify followed artists / saved
albums / playlists as import lists.

- Mounts navidrome-music-bulk read-write (Lidarr has no read-only root
  folder mode); config on ceph-block.
- A reconcile sidecar (same image, curl+jq) reads the API key from
  /config/config.xml and on every start sets renameTracks=false,
  writeAudioTags=no, no recycle bin, no empty-folder deletes, no
  permission changes -- then adds /music as root folder only if absent,
  so the automatic library import never runs with file-mutating
  settings.
- No download client or indexer.
- AuthenticationMethod=External behind a dedicated lidarr-gate outpost
  restricted to a new "Music Librarians" group (k.renner).
- Image automation on the linuxserver build number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:33:54 +02:00
12 changed files with 370 additions and 37 deletions
@@ -16,7 +16,7 @@ credentials are out of scope (see "Deferred").
|---|---|
| Execution model | **B — server-local runs** (upstream default): agent CLIs are child processes of the server, in the server container. Trial only. |
| Upgrade path | If Paperclip is kept: **C** — `@paperclipai/plugin-kubernetes` sandbox provider + kubernetes-sigs agent-sandbox controller (`agents.x-k8s.io`), per-run Pods and Secrets. Separate spec. |
| Claude auth | **One shared Claude subscription AI connection**, created once via Paperclip's local sign-in (`claude auth login` into `ai-local-logins/<id>`); Paperclip stores and refreshes it. Server-env `CLAUDE_CODE_OAUTH_TOKEN` is not used: `stripAiAuthBindings` blanks it for managed runs, and upstream accepts no pasted setup-token (the device-code flow needs a Daytona sandbox). |
| Claude auth | **One shared subscription OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN`, from `claude setup-token`) for all agents. |
| Bot credentials | Out of scope. Future "AIR-Manager" Paperclip role handles permission requests. |
| Exposure | LAN-only, behind an Authentik forward-auth gate, then Paperclip's own login. |
@@ -25,8 +25,8 @@ credentials are out of scope (see "Deferred").
Upstream `runChildProcess()` spawns every local adapter with
`{...sanitizeInheritedPaperclipEnv(process.env), ...opts.env}`; the sanitizer strips only
`PAPERCLIP_*` keys. Every agent therefore sees `DATABASE_URL`, `BETTER_AUTH_SECRET`,
the shared filesystem (including every other agent's workspace and the
`ai-local-logins` credentials) and every agent-scoped secret it can read from disk. Under B, no bot may hold
`CLAUDE_CODE_OAUTH_TOKEN`, the shared filesystem (including every other agent's
workspace) and every agent-scoped secret it can read from disk. Under B, no bot may hold
a credential we would not tolerate any other bot using. Per-bot isolation is C's job.
## Architecture
@@ -79,11 +79,6 @@ All modeled on `kubernetes/apps/paperless/` unless stated.
claim (`/bootstrap/claim`) requires an already signed-in Better Auth account.
A follow-up flips it to `true`.
- `PAPERCLIP_TELEMETRY_DISABLED=1` (read by `resolveTelemetryConfig`).
- `PAPERCLIP_API_URL=http://paperclip.paperclip.svc.cluster.local:3100` plus
`PAPERCLIP_ALLOWED_HOSTNAMES=paperclip.paperclip.svc.cluster.local`: upstream
otherwise hands agents the public URL, and the gate (`intercept_header_auth`)
rejects their bearer tokens. The Service name also works for sandbox pods under
option C; agent JWTs still authenticate each call.
- `envFrom: secretRef paperclip-secrets`
- Reloader: `secret.reloader.stakater.com/reload: paperclip-secrets`.
@@ -111,6 +106,7 @@ extract):
- `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`
- `PAPERCLIP_SECRETS_MASTER_KEY` (64-char hex). Held in OpenBao, not on the PVC, so
encrypted agent secrets in Postgres survive PVC loss.
- `CLAUDE_CODE_OAUTH_TOKEN` (shared subscription token)
### Ingress and SSO
- Hostname `paperclip.renner.uno`, Traefik `Ingress`, `cert-manager.io/cluster-issuer:
@@ -156,15 +152,10 @@ available since v0.35.0). First use of digest reflection in this repo.
- Nothing is granted under B that we would not tolerate any agent misusing.
## Bootstrap (one-time)
1. Place `apps/paperclip` values in OpenBao (the three generated secrets).
2. Merge; Flux deploys with sign-up enabled.
3. Sign up through the gate and claim the instance as admin; then set
`PAPERCLIP_AUTH_DISABLE_SIGN_UP=true`.
4. In onboarding "Connect a model", choose Claude subscription and run the shown login
**as the node user** (the entrypoint makes exec sessions root, and root-owned 0600
credentials are unreadable to the server):
`kubectl exec -it -n paperclip deploy/paperclip -- gosu node sh -c 'export CLAUDE_CONFIG_DIR=<shown path> && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login'`
Repeat after loss of the PVC or database.
1. Place `apps/paperclip` values in OpenBao (generated secrets + shared OAuth token).
2. Merge; Flux deploys.
3. Claim the admin account via the `bootstrap-ceo` invite, add yourself to
"Paperclip Users" in Authentik.
## Verification
- Flux Kustomization `paperclip` Ready; CNPG `pclp-pg` healthy; first ScheduledBackup
@@ -172,7 +163,7 @@ available since v0.35.0). First use of digest reflection in this repo.
- `curl https://paperclip.renner.uno/api/health` through the gate returns healthy after
Authentik login; unauthenticated request redirects to `auth.renner.uno`.
- Direct pod access from another namespace is denied (CNP).
- A test agent (claude-local) completes a trivial task using the shared Claude connection.
- A test agent (claude-local) completes a trivial task using the shared OAuth token.
- Flux ImagePolicy reports a `nightly@sha256:` digest.
## Deferred
@@ -1171,6 +1171,85 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# lidarr-gate: Lidarr runs with AuthenticationMethod=External, so the gate
# is its only access control.
- id: music-librarians-group
model: authentik_core.group
identifiers:
name: Music Librarians
state: present
attrs:
is_superuser: false
# Membership is owned here; blueprint apply reverts UI edits.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: lidarr-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: lidarr-gate
state: present
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://lidarr.navidrome.svc.cluster.local
internal_host_ssl_validation: false
external_host: https://lidarr.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
- model: authentik_core.application
identifiers:
slug: lidarr-gate
state: present
attrs:
name: Lidarr
slug: lidarr-gate
provider: !KeyOf lidarr-gate-provider
meta_launch_url: https://lidarr.renner.uno
meta_description: "Music library gaps: discographies, Spotify artists"
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, lidarr-gate]]
group: !KeyOf music-librarians-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
- model: authentik_outposts.outpost
identifiers:
name: lidarr-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf lidarr-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
@@ -19,6 +19,7 @@ resources:
- llama-gate-egress.yaml
- wiki-gate-egress.yaml
- navidrome-gate-egress.yaml
- lidarr-gate-egress.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
@@ -0,0 +1,20 @@
# Lets the lidarr-gate outpost reach Lidarr; the matching ingress rule is in
# network-policies/navidrome-ns.yaml.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: lidarr-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: lidarr-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": navidrome
app.kubernetes.io/name: lidarr
toPorts:
- ports:
- port: "8686"
protocol: TCP
@@ -52,23 +52,19 @@ spec:
- alert: CephPoolLostRedundancy
# Names the pool, which POOL_NO_REDUNDANCY does not.
#
# There is no pool-size metric, but bytes_used/stored is the
# replication factor for replicated pools and (k+m)/k for EC pools.
# The EC 2+1 pools (cephfs-bulk, rgw buckets.data) sit at exactly
# 1.5 with ~1e-7 accounting drift either side, so the cut-off is
# midway between them and an unreplicated pool (1.0) (#968).
# The >1e6 floor drops pools too small to measure meaningfully,
# empty ones included.
# There is no pool-size metric, but bytes_used/stored IS the
# replication factor. The >1e6 floor drops pools too small to measure
# meaningfully, empty ones included.
#
# No pool is exempt.
expr: |
ceph_pool_bytes_used / ceph_pool_stored < 1.25 and ceph_pool_stored > 1e6
ceph_pool_bytes_used / ceph_pool_stored < 1.5 and ceph_pool_stored > 1e6
for: 30m
labels:
severity: critical
annotations:
summary: "Ceph pool {{ $labels.pool_id }} is running unreplicated"
description: "Pool {{ $labels.pool_id }} has a raw/stored ratio below 1.25 (replicated size 1), meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
description: "Pool {{ $labels.pool_id }} has a replication factor below 1.5, meaning a single OSD loss destroys data. No pool is meant to be unreplicated — an unreplicated pool also blocks ALL OSD maintenance cluster-wide, since `ceph osd ok-to-stop` refuses while its PGs would go offline. Check with `kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd pool ls detail`."
- alert: CephHealthError
# HEALTH_ERR means data availability or redundancy is at risk.
@@ -5,3 +5,4 @@ resources:
- deployment.yaml
- gate-service.yaml
- ingress.yaml
- lidarr.yaml
+224
View File
@@ -0,0 +1,224 @@
# Lidarr as a read-mostly "what is missing" view over the Navidrome library.
# No download client or indexer is configured.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lidarr-config
namespace: navidrome
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 5Gi
---
# Applied on every pod start, before the root folder exists: no renames, no tag
# writes, no deletes. Adding /music as root folder starts Lidarr's library import.
apiVersion: v1
kind: ConfigMap
metadata:
name: lidarr-reconcile
namespace: navidrome
data:
reconcile.sh: |
set -eu
API=http://localhost:8686/api/v1
until curl -sf http://localhost:8686/ping >/dev/null; do sleep 5; done
KEY=$(sed -n 's:.*<ApiKey>\(.*\)</ApiKey>.*:\1:p' /config/config.xml)
if [ -z "$KEY" ]; then
echo "FATAL: no ApiKey in /config/config.xml" >&2
exit 1
fi
H="X-Api-Key: $KEY"
put() {
cur=$(curl -sf -H "$H" "$API/$1")
id=$(printf '%s' "$cur" | jq -r '.id')
printf '%s' "$cur" | jq -c "$2" \
| curl -sf -X PUT -H "$H" -H 'Content-Type: application/json' -d @- "$API/$1/$id" >/dev/null
echo "reconciled $1"
}
put config/naming '.renameTracks = false'
put config/metadataprovider '.writeAudioTags = "no" | .scrubAudioTags = false | .embedCoverArt = false'
put config/mediamanagement '.deleteEmptyFolders = false | .createEmptyArtistFolders = false
| .recycleBin = "" | .setPermissionsLinux = false | .importExtraFiles = false
| .skipFreeSpaceCheckWhenImporting = true'
roots=$(curl -sf -H "$H" "$API/rootfolder" | jq '[.[] | select(.path == "/music")] | length')
if [ "$roots" = "0" ]; then
qp=$(curl -sf -H "$H" "$API/qualityprofile" | jq '.[0].id')
mp=$(curl -sf -H "$H" "$API/metadataprofile" | jq '([.[] | select(.name == "Standard")][0].id) // .[0].id')
jq -nc --argjson qp "$qp" --argjson mp "$mp" '{
name: "Music", path: "/music",
defaultQualityProfileId: $qp, defaultMetadataProfileId: $mp,
defaultMonitorOption: "all", defaultNewItemMonitorOption: "all",
defaultTags: []
}' \
| curl -sf -X POST -H "$H" -H 'Content-Type: application/json' -d @- "$API/rootfolder" >/dev/null
echo "added root folder /music"
fi
echo "reconcile done"
while true; do sleep 3600; done
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lidarr
namespace: navidrome
labels:
app.kubernetes.io/name: lidarr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: lidarr
template:
metadata:
labels:
app.kubernetes.io/name: lidarr
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: lidarr
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: Europe/Berlin
# Identity is enforced by the lidarr-gate outpost.
- name: LIDARR__AUTH__METHOD
value: External
ports:
- name: http
containerPort: 8686
volumeMounts:
- name: config
mountPath: /config
- name: music
mountPath: /music
livenessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /ping
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 4Gi
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
- name: reconcile
image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls42 # {"$imagepolicy": "flux-system:lidarr"}
command: ["sh", "/scripts/reconcile.sh"]
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: reconcile
mountPath: /scripts
readOnly: true
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 64Mi
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: config
persistentVolumeClaim:
claimName: lidarr-config
- name: music
persistentVolumeClaim:
claimName: navidrome-music-bulk
- name: reconcile
configMap:
name: lidarr-reconcile
---
apiVersion: v1
kind: Service
metadata:
name: lidarr
namespace: navidrome
spec:
selector:
app.kubernetes.io/name: lidarr
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-lidarr-gate
namespace: navidrome
spec:
type: ExternalName
externalName: ak-outpost-lidarr-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lidarr
namespace: navidrome
labels:
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
rules:
- host: lidarr.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ak-outpost-lidarr-gate
port:
number: 9000
tls:
- secretName: lidarr-tls
hosts:
- lidarr.renner.uno
+2 -6
View File
@@ -49,13 +49,9 @@ spec:
value: private
- name: PAPERCLIP_PUBLIC_URL
value: https://paperclip.renner.uno
# Agents reach the API via the Service, not through the Authentik gate.
- name: PAPERCLIP_API_URL
value: http://paperclip.paperclip.svc.cluster.local:3100
- name: PAPERCLIP_ALLOWED_HOSTNAMES
value: paperclip.paperclip.svc.cluster.local
# Sign-up stays open until the first admin is claimed; the gate restricts who reaches it.
- name: PAPERCLIP_AUTH_DISABLE_SIGN_UP
value: "true"
value: "false"
- name: PAPERCLIP_TELEMETRY_DISABLED
value: "1"
envFrom:
@@ -42,10 +42,6 @@ spec:
remoteRef:
key: apps/ai/wiki-api-keys
property: workstation
- secretKey: Auth__ApiKeys__paperclip
remoteRef:
key: apps/ai/wiki-api-keys
property: paperclip
---
# TypeSafe System One key for the judge adapter (Wiki:Judges:Models:typesafe).
apiVersion: external-secrets.io/v1
@@ -9,6 +9,7 @@ resources:
- gotenberg.yaml
- home-assistant.yaml
- immich.yaml
- lidarr.yaml
- matter-server.yaml
- mmwave-configurator.yaml
- music-assistant.yaml
@@ -0,0 +1,24 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: lidarr
namespace: flux-system
spec:
image: lscr.io/linuxserver/lidarr
interval: 1h
---
# Tags are <major>.<minor>.<patch>.<build>-ls<n>; the build number is monotonic.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: lidarr
namespace: flux-system
spec:
imageRepositoryRef:
name: lidarr
filterTags:
pattern: '^\d+\.\d+\.\d+\.(?P<build>\d+)-ls\d+$'
extract: '$build'
policy:
numerical:
order: asc
@@ -18,6 +18,10 @@ spec:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: navidrome-gate
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: lidarr-gate
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring