diff --git a/k8s/apps/immich/configmap.yaml b/k8s/apps/immich/configmap.yaml new file mode 100644 index 0000000..7399d67 --- /dev/null +++ b/k8s/apps/immich/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: immich-config +data: + DB_HOSTNAME: "immich-db-rw" + DB_DATABASE_NAME: "immich" + # Only pgvector is installed (see db.yaml) - no vectorchord extension image + # exists for pg18 in CNPG's catalog yet. Explicit instead of relying on + # auto-detect's vectorchord-first preference order. + DB_VECTOR_EXTENSION: "pgvector" + REDIS_HOSTNAME: "immich-redis" + IMMICH_MACHINE_LEARNING_URL: "http://immich-machine-learning:3003" + TZ: "America/Los_Angeles" diff --git a/k8s/apps/immich/db.yaml b/k8s/apps/immich/db.yaml new file mode 100644 index 0000000..ac3fbc2 --- /dev/null +++ b/k8s/apps/immich/db.yaml @@ -0,0 +1,48 @@ +# Dedicated CNPG Postgres for Immich. Same recipe as paperless-db/authentik-db +# (2 instances, default longhorn storage class) except the operand is +# PostgreSQL 18, not 16.2 - the official CNPG pgvector extension image +# (ghcr.io/cloudnative-pg/pgvector) is only published for pg18, no pg16 tags +# exist in that registry. Immich itself supports pg18 fine (immich-app's own +# postgres image already ships 18-vectorchord builds). +# +# pgvector loaded via CNPG's ImageVolume extension mechanism (CNPG 1.27+, +# k8s ImageVolume feature - both present here: operator is 1.30.0, cluster is +# v1.36.1). No shared_preload_libraries needed - pgvector doesn't require +# preload, just CREATE EXTENSION, which immich-server issues itself at +# startup. Distro/pg-major must match between the operand image and the +# extension image (both "18"+"trixie" here) - CNPG's own compatibility rule. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: immich-db + annotations: + argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true +spec: + instances: 2 + imageName: ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie + postgresql: + extensions: + - name: pgvector + image: + reference: ghcr.io/cloudnative-pg/pgvector:0.8.1-18-trixie + bootstrap: + initdb: + database: immich + owner: app + encoding: UTF8 + localeCollate: C + localeCType: C + enableSuperuserAccess: false + resources: + requests: { memory: "512Mi", cpu: "250m" } + limits: { memory: "2Gi", cpu: "1" } + storage: + size: 20Gi + storageClass: longhorn + affinity: + podAntiAffinityType: preferred + topologyKey: kubernetes.io/hostname + tolerations: + - key: node-role.kubernetes.io/control-plane + operator: Exists + effect: NoSchedule diff --git a/k8s/apps/immich/deployment.yaml b/k8s/apps/immich/deployment.yaml new file mode 100644 index 0000000..26e3b56 --- /dev/null +++ b/k8s/apps/immich/deployment.yaml @@ -0,0 +1,98 @@ +# immich-server: pinned to talos-cp-3, same reasoning as paperless +# (deployment.yaml comment there) - immich-media is a ReadWriteOnce Longhorn +# volume with a single replica physically on that node's disk (shared with +# paperless-media on the same 4TB HDD). Recreate strategy for the same +# reason: two pods can't both attach an RWO volume. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-server +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: immich-server + template: + metadata: + labels: + app: immich-server + spec: + nodeSelector: + kubernetes.io/hostname: talos-cp-3 + containers: + - name: immich-server + image: ghcr.io/immich-app/immich-server:release + ports: + - containerPort: 2283 + envFrom: + - configMapRef: + name: immich-config + env: + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: immich-db-app + key: username + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: immich-db-app + key: password + # Composed by k8s/infra/iam's provisioning script (system-config + # JSON, oauth section) - see immich-oidc Secret. + - name: IMMICH_CONFIG_FILE + value: /config/immich.json + resources: + requests: { cpu: "500m", memory: "1Gi" } + limits: { cpu: "2", memory: "4Gi" } + volumeMounts: + - name: media + mountPath: /usr/src/app/upload + - name: oidc-config + mountPath: /config + readOnly: true + volumes: + - name: media + persistentVolumeClaim: + claimName: immich-media + - name: oidc-config + secret: + secretName: immich-oidc + items: + - key: config.json + path: immich.json +--- +# CPU-only for now - the cluster's one GPU node (worker-1) is already +# dedicated to llm-serving predictors. Not node-pinned: its cache PVC is on +# the default 3-replica pool, not the single-disk cp-3 HDD. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-machine-learning +spec: + replicas: 1 + selector: + matchLabels: + app: immich-machine-learning + template: + metadata: + labels: + app: immich-machine-learning + spec: + containers: + - name: immich-machine-learning + image: ghcr.io/immich-app/immich-machine-learning:release + ports: + - containerPort: 3003 + resources: + requests: { cpu: "500m", memory: "1Gi" } + limits: { cpu: "2", memory: "4Gi" } + volumeMounts: + - name: ml-cache + mountPath: /cache + volumes: + - name: ml-cache + persistentVolumeClaim: + claimName: immich-ml-cache diff --git a/k8s/apps/immich/ingress.yaml b/k8s/apps/immich/ingress.yaml new file mode 100644 index 0000000..d25b435 --- /dev/null +++ b/k8s/apps/immich/ingress.yaml @@ -0,0 +1,24 @@ +# Direct nginx ingress, same reasoning as paperless: large uploads (photos/ +# videos) and long-lived operations (video transcode, big batch uploads) need +# proxy-body-size/timeouts raised past nginx's defaults. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: immich + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" +spec: + ingressClassName: nginx + rules: + - host: immich.riotpiao.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: immich-server + port: + number: 2283 diff --git a/k8s/apps/immich/kustomization.yaml b/k8s/apps/immich/kustomization.yaml new file mode 100644 index 0000000..515be26 --- /dev/null +++ b/k8s/apps/immich/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: immich +resources: + - db.yaml + - pvc.yaml + - configmap.yaml + - redis.yaml + - deployment.yaml + - service.yaml + - ingress.yaml + - rbac.yaml +# immich-oidc Secret written by the PostSync provisioning Job in +# k8s/infra/iam (same as paperless-oidc) - not duplicated here. diff --git a/k8s/apps/immich/pvc.yaml b/k8s/apps/immich/pvc.yaml new file mode 100644 index 0000000..a2af6d2 --- /dev/null +++ b/k8s/apps/immich/pvc.yaml @@ -0,0 +1,34 @@ +# Two volumes: +# +# - media: original photos/videos + generated thumbnails/encoded videos. +# Shares the cp-3 USB HDD with paperless-media - the 4TB disk is split +# 2TB/2TB between the two (see k8s/apps/paperless/pvc.yaml), same +# StorageClass/disk tag, single replica (single disk, no redundancy +# possible - same tradeoff paperless already accepts). +# - ml-cache: downloaded ML model weights for immich-machine-learning +# (face detection / CLIP embeddings). Small, disposable (re-downloads on +# loss), but persisted so a pod restart doesn't re-pull multi-GB models - +# default 3-replica pool, not node-pinned. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-media +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn-paperless-media + resources: + requests: + storage: 2000Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-ml-cache +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 5Gi diff --git a/k8s/apps/immich/rbac.yaml b/k8s/apps/immich/rbac.yaml new file mode 100644 index 0000000..759637d --- /dev/null +++ b/k8s/apps/immich/rbac.yaml @@ -0,0 +1,35 @@ +# Scoped operator access for immich-admins: restart/config-edit rights on +# just this service's own resources, nothing CNPG-managed (immich-db-*) or +# provisioning-managed (immich-oidc). Same pattern as +# k8s/apps/paperless/rbac.yaml. Inert until kube-apiserver's OIDC wiring +# lands (--oidc-groups-claim=groups, --oidc-groups-prefix=oidc:). +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: immich-operator +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["immich-server", "immich-machine-learning"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["immich-config"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["immich-oidc"] + verbs: ["get", "list", "watch", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: immich-admins-binding +subjects: + - kind: Group + name: "oidc:immich-admins" + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: immich-operator + apiGroup: rbac.authorization.k8s.io diff --git a/k8s/apps/immich/redis.yaml b/k8s/apps/immich/redis.yaml new file mode 100644 index 0000000..27d5d30 --- /dev/null +++ b/k8s/apps/immich/redis.yaml @@ -0,0 +1,37 @@ +# Job queue broker for immich-server. No PVC: queue state is disposable - a +# lost queue on restart just re-triggers the affected background jobs +# (thumbnail generation, ML jobs, etc.), no photo data loss since originals +# live on immich-media. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-redis +spec: + replicas: 1 + selector: + matchLabels: + app: immich-redis + template: + metadata: + labels: + app: immich-redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "250m", memory: "256Mi" } +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-redis +spec: + selector: + app: immich-redis + ports: + - port: 6379 + targetPort: 6379 diff --git a/k8s/apps/immich/service.yaml b/k8s/apps/immich/service.yaml new file mode 100644 index 0000000..a0fc5ac --- /dev/null +++ b/k8s/apps/immich/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: immich-server +spec: + selector: + app: immich-server + ports: + - port: 2283 + targetPort: 2283 +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-machine-learning +spec: + selector: + app: immich-machine-learning + ports: + - port: 3003 + targetPort: 3003 diff --git a/k8s/argocd/apps/60-applications.yaml b/k8s/argocd/apps/60-applications.yaml index baebc51..95f526e 100644 --- a/k8s/argocd/apps/60-applications.yaml +++ b/k8s/argocd/apps/60-applications.yaml @@ -170,6 +170,38 @@ spec: syncOptions: - CreateNamespace=true --- +# Photo/video backup. Self-contained (unlike paperless, its CNPG Postgres +# lives here too, not in k8s/infra/databases) - CreateNamespace=true creates +# the namespace before any manifest in this Application applies, including +# the Cluster CR, so no separate wave-2 pre-creation step is needed. Postgres +# is pg18 (not this repo's usual 16.2) because CNPG's official pgvector +# extension image only publishes pg18 builds - see k8s/apps/immich/db.yaml. +# media PVC shares the cp-3 HDD 2TB/2TB with paperless-media. OIDC via +# Authentik provisioned by k8s/infra/iam's PostSync job (immich entry in +# SERVICES + immich_role scope mapping for admin-via-claim). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: immich + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + project: homelab + source: + repoURL: https://forgejo.riotpiao.com/rock/homelab.git + targetRevision: main + path: k8s/apps/immich + destination: + server: https://kubernetes.default.svc + namespace: immich + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +--- # Consolidated: homarr + homarr-patches → homarr # Helm chart + values + PostSync hook patch (fix-probes-job.yaml) apiVersion: argoproj.io/v1alpha1 diff --git a/k8s/infra/databases/namespaces.yaml b/k8s/infra/databases/namespaces.yaml index 97a661b..c3d145b 100644 --- a/k8s/infra/databases/namespaces.yaml +++ b/k8s/infra/databases/namespaces.yaml @@ -16,3 +16,11 @@ apiVersion: v1 kind: Namespace metadata: name: paperless +--- +# Needed here (not just immich's own CreateNamespace=true at wave 8) because +# k8s/infra/iam's PostSync job (wave 3) has a RoleBinding targeting this +# namespace - same ordering reason as paperless above. +apiVersion: v1 +kind: Namespace +metadata: + name: immich diff --git a/k8s/infra/iam/authentik-provision-job.yaml b/k8s/infra/iam/authentik-provision-job.yaml index 5e19754..7bff3ac 100644 --- a/k8s/infra/iam/authentik-provision-job.yaml +++ b/k8s/infra/iam/authentik-provision-job.yaml @@ -115,6 +115,20 @@ roleRef: name: authentik-provisioner apiGroup: rbac.authorization.k8s.io --- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: authentik-provisioner + namespace: immich +subjects: + - kind: ServiceAccount + name: authentik-provisioner + namespace: iam +roleRef: + kind: ClusterRole + name: authentik-provisioner + apiGroup: rbac.authorization.k8s.io +--- apiVersion: batch/v1 kind: Job metadata: diff --git a/k8s/infra/iam/scripts/authentik-provision.py b/k8s/infra/iam/scripts/authentik-provision.py index 7465f41..bf8943f 100644 --- a/k8s/infra/iam/scripts/authentik-provision.py +++ b/k8s/infra/iam/scripts/authentik-provision.py @@ -173,6 +173,7 @@ GROUP_PERMISSIONS = { "temporal-admins": ["temporal:read", "temporal:write"], "llm-admins": ["llm:read", "llm:write"], "paperless-admins": ["paperless:read", "paperless:write"], + "immich-admins": ["immich:read", "immich:write"], "k8s-devops-admin": ["k8s:devops"], } perms = set() @@ -193,6 +194,28 @@ permissions_mapping = get_or_create( ) PERMISSIONS_MAPPING_PK = permissions_mapping["pk"] +# Immich reads a "immich_role" claim on every login (not just user-creation - +# fixed upstream in immich-app/immich#29991) and syncs isAdmin from it, so +# this is the actual mechanism that makes "rock" an Immich admin - not +# Immich's first-user-is-admin fallback, which races badly with OAuth login. +_IMMICH_ROLE_EXPR = ( + "return {\"immich_role\": \"admin\" " + "if request.user.ak_groups.filter(name__in=[\"homelab-admins\", \"immich-admins\"]).exists() " + "else \"user\"}" +) +immich_role_mapping = get_or_create( + "/api/v3/propertymappings/provider/scope/", + "/api/v3/propertymappings/provider/scope/", + "scope_name=immich_role", + { + "name": "homelab: immich role claim", + "scope_name": "immich_role", + "expression": _IMMICH_ROLE_EXPR, + }, + patch_existing={"expression": _IMMICH_ROLE_EXPR}, +) +IMMICH_ROLE_MAPPING_PK = immich_role_mapping["pk"] + # MinIO maps OIDC users to a MinIO policy via a "policy" claim # (MINIO_IDENTITY_OPENID_CLAIM_NAME=policy). Emit consoleAdmin (full admin) for # homelab-admins members, readonly for everyone else. Without this claim MinIO @@ -242,7 +265,7 @@ homelab_admins = get_or_create( SERVICE_ADMIN_GROUP_NAMES = [ "grafana-admins", "minio-admins", "forgejo-admins", "homarr-admins", "portainer-admins", "kmsvc-admins", "temporal-admins", "llm-admins", - "paperless-admins", "k8s-devops-admin", + "paperless-admins", "immich-admins", "k8s-devops-admin", ] service_admin_groups = {} for group_name in SERVICE_ADMIN_GROUP_NAMES: @@ -353,6 +376,22 @@ SERVICES = { "launch_url": "https://paperless.riotpiao.com", "display_name": "Paperless-ngx", }, + "immich": { + # No secret exists yet for immich - generate + store on first run. + "client_secret_source": ("immich", "immich-oidc", "CLIENT_SECRET"), + "generate_if_missing": True, + # /auth/login + /user-settings are Immich's own web callback routes; + # /api/oauth/mobile-redirect forwards to the app.immich:///oauth-callback + # custom scheme Authentik can't register directly (see docs.immich.app/ + # administration/oauth - "custom scheme" workaround). + "redirect_uris": [ + "https://immich.riotpiao.com/auth/login", + "https://immich.riotpiao.com/user-settings", + "https://immich.riotpiao.com/api/oauth/mobile-redirect", + ], + "launch_url": "https://immich.riotpiao.com", + "display_name": "Immich", + }, } app_pks_for_binding = [] @@ -360,7 +399,9 @@ app_pks_for_binding = [] for name, cfg in SERVICES.items(): # MinIO also needs the "policy" claim (via the minio scope mapping) so its # MINIO_IDENTITY_OPENID_CLAIM_NAME=policy maps homelab-admins -> consoleAdmin. - provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) + # Immich needs "immich_role" so its OAuth roleClaim can grant admin. + provider_mappings = SCOPE_PKS + ([POLICY_MAPPING_PK] if name == "minio" else []) \ + + ([IMMICH_ROLE_MAPPING_PK] if name == "immich" else []) ns, secret_name, key = cfg["client_secret_source"] client_secret = kubectl_get_secret_key(ns, secret_name, key) if client_secret is None: @@ -409,6 +450,34 @@ for name, cfg in SERVICES.items(): "SOCIALACCOUNT_PROVIDERS_JSON": providers_json, }) + if name == "immich": + # Immich reads its whole system-config from IMMICH_CONFIG_FILE (a + # mounted JSON file, see k8s/apps/immich/deployment.yaml), not + # discrete env vars. "immich_role" must be in `scope` for Authentik + # to actually include that claim in the token (non-default scopes + # are opt-in per-client, same reason paperless requests "permissions" + # explicitly). roleClaim is re-evaluated on every login (immich-app/ + # immich#29991) so this is the actual admin-grant mechanism for rock, + # not Immich's racy first-user-is-admin fallback. + immich_config_json = json.dumps({ + "oauth": { + "enabled": True, + "issuerUrl": "https://authentik.riotpiao.com/application/o/immich/", + "clientId": "immich", + "clientSecret": client_secret, + "scope": "openid email profile immich_role", + "roleClaim": "immich_role", + "autoRegister": True, + "autoLaunch": False, + "buttonText": "Login with Authentik", + "mobileRedirectUri": "app.immich:///oauth-callback", + }, + }) + kubectl_create_secret("immich", "immich-oidc", { + "CLIENT_SECRET": client_secret, + "config.json": immich_config_json, + }) + provider = get_or_create( "/api/v3/providers/oauth2/", "/api/v3/providers/oauth2/", f"name={name}", @@ -544,6 +613,7 @@ SERVICE_GROUP_TO_APP_SLUG = { "forgejo-admins": "forgejo", "homarr-admins": "homarr", "paperless-admins": "paperless", + "immich-admins": "immich", } for group_name, app_slug in SERVICE_GROUP_TO_APP_SLUG.items(): app_pk = next((pk for n, pk in app_pks_for_binding if n == app_slug), None)