Hybrid cloud cluster monorepo with Ansible, K8s, NixOS, and Terraform. cute.haus
ansible terraform nix k8s
0

Configure Feed

Select the types of activity you want to include in your feed.

ansible: break into parts for easy additions

Aly Raffauf (Jun 26, 2026, 1:02 PM EDT) bf9a56d3 6b5685d5

+285 -187
+18 -6
ansible/playbooks/pocket-id-bootstrap.yml
··· 1 1 --- 2 - # Reconciles Pocket ID app config + OIDC clients + per-app integrations. 2 + # Reconciles Pocket ID application config + OIDC clients + per-app integrations. 3 + # 4 + # To add a new OIDC client: 5 + # 1. Append an entry to vars/oidc-clients.yml. 6 + # 2. If the app runs in k3s and needs its credentials wired in, add a task 7 + # file under tasks/integrations/<integration>.yml and set 8 + # `integration: <integration>` on the client. 9 + # 3. If the app is configured elsewhere (NixOS, manual UI), omit 10 + # `integration`; the oidc-client-<slug> K8s Secret still holds the creds. 3 11 # Requires an admin API key in secrets/pocket-id.yaml. 4 12 - name: Bootstrap Pocket ID 5 13 hosts: localhost ··· 9 17 vars: 10 18 pocket_id_url: https://id.cute.haus 11 19 k8s_namespace: default 20 + oidc_client_list_limit: 200 12 21 13 22 vars_files: 14 23 - vars/pocket-id.yml ··· 25 34 26 35 - name: List existing OIDC clients 27 36 ansible.builtin.uri: 28 - url: "{{ pocket_id_url }}/api/oidc/clients?pagination[limit]=200" 37 + url: "{{ pocket_id_url }}/api/oidc/clients?pagination[limit]={{ oidc_client_list_limit }}" 29 38 headers: 30 39 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 31 40 return_content: true 32 - register: existing_response 41 + register: existing_clients_response 42 + check_mode: false 33 43 34 44 - name: Index existing clients by name 35 45 ansible.builtin.set_fact: 36 - existing_clients: >- 46 + existing_clients_by_name: >- 37 47 {{ 38 48 dict( 39 - (existing_response.json.data | default([])) 49 + (existing_clients_response.json.data | default([])) 40 50 | map(attribute='name') 41 - | zip(existing_response.json.data | default([])) 51 + | zip(existing_clients_response.json.data | default([])) 42 52 ) 43 53 }} 44 54 ··· 55 65 loop_control: 56 66 loop_var: client_spec 57 67 label: "{{ client_spec.integration }}" 68 + vars: 69 + oidc_client_credentials: "{{ oidc_client_credentials_by_slug[client_spec.slug] }}"
+47 -24
ansible/playbooks/reboot.yml
··· 5 5 6 6 vars: 7 7 drain_timeout: 600 8 + ready_timeout: 600 9 + systemctl_path: /run/current-system/sw/bin/systemctl 8 10 9 11 tasks: 10 12 - name: Cordon node ··· 12 14 delegate_to: localhost 13 15 changed_when: true 14 16 15 - # --disable-eviction bypasses PDBs (Longhorn instance-manager blocks otherwise). 16 - - name: Drain node 17 - ansible.builtin.command: >- 18 - kubectl drain {{ inventory_hostname }} 19 - --ignore-daemonsets 20 - --delete-emptydir-data 21 - --force 22 - --disable-eviction 23 - --timeout={{ drain_timeout }}s 24 - delegate_to: localhost 25 - changed_when: true 17 + - block: 18 + # --disable-eviction bypasses PDBs (Longhorn instance-manager blocks otherwise). 19 + - name: Drain node 20 + ansible.builtin.command: >- 21 + kubectl drain {{ inventory_hostname }} 22 + --ignore-daemonsets 23 + --delete-emptydir-data 24 + --force 25 + --disable-eviction 26 + --timeout={{ drain_timeout }}s 27 + delegate_to: localhost 28 + changed_when: true 29 + 30 + - name: Reboot host 31 + ansible.builtin.reboot: 32 + reboot_command: "{{ systemctl_path }} reboot" 33 + 34 + - name: Wait for node to be Ready 35 + ansible.builtin.command: >- 36 + kubectl wait --for=condition=Ready node/{{ inventory_hostname }} 37 + --timeout={{ ready_timeout }}s 38 + delegate_to: localhost 39 + changed_when: false 40 + 41 + - name: Mark node as ready 42 + ansible.builtin.set_fact: 43 + node_ready: true 26 44 27 - - name: Reboot host 28 - ansible.builtin.reboot: 29 - reboot_command: /run/current-system/sw/bin/systemctl reboot 45 + - name: Uncordon node 46 + ansible.builtin.command: kubectl uncordon {{ inventory_hostname }} 47 + delegate_to: localhost 48 + changed_when: true 30 49 31 - - name: Wait for node to be Ready 32 - ansible.builtin.command: >- 33 - kubectl wait --for=condition=Ready node/{{ inventory_hostname }} 34 - --timeout={{ drain_timeout }}s 35 - delegate_to: localhost 36 - changed_when: false 50 + rescue: 51 + # Restore scheduling capacity so the cluster isn't left with a cordoned 52 + # node. The operator should still verify node health before re-running. 53 + # Only uncordon if the block failed before the normal uncordon ran. 54 + - name: Uncordon node on failure 55 + when: not (node_ready | default(false)) 56 + ansible.builtin.command: kubectl uncordon {{ inventory_hostname }} 57 + delegate_to: localhost 58 + changed_when: true 37 59 38 - - name: Uncordon node 39 - ansible.builtin.command: kubectl uncordon {{ inventory_hostname }} 40 - delegate_to: localhost 41 - changed_when: true 60 + - name: Abort reboot sequence 61 + ansible.builtin.fail: 62 + msg: >- 63 + Reboot sequence failed for {{ inventory_hostname }}. Node has 64 + been uncordoned; verify its health before re-running.
+1
ansible/playbooks/tasks/ensure-app-config.yml
··· 6 6 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 7 7 return_content: true 8 8 register: app_config_response 9 + check_mode: false 9 10 10 11 - name: Flatten current config (list → dict) 11 12 ansible.builtin.set_fact:
+44 -135
ansible/playbooks/tasks/ensure-oidc-client.yml
··· 1 1 --- 2 - - name: "[{{ client_spec.slug }}] Check K8s secret" 2 + # Orchestrate the lifecycle of one OIDC client: 3 + # 1. Resolve the current K8s + Pocket ID state. 4 + # 2. Create the client if it does not exist. 5 + # 3. Sync any configuration drift. 6 + # 4. Ensure a K8s Secret exists with valid credentials. 7 + # 5. Upload the app logo if provided. 8 + 9 + # ----------------------------------------------------------------------------- 10 + # Phase 1: Resolve current state 11 + # ----------------------------------------------------------------------------- 12 + - name: "[{{ client_spec.slug }}] Look up K8s Secret" 3 13 kubernetes.core.k8s_info: 4 14 api_version: v1 5 15 kind: Secret 6 16 namespace: "{{ k8s_namespace }}" 7 17 name: "oidc-client-{{ client_spec.slug }}" 8 - register: k8s_secret_lookup 18 + register: kubernetes_secret_lookup 9 19 10 - - name: "[{{ client_spec.slug }}] Current state" 20 + - name: "[{{ client_spec.slug }}] Initialize state facts" 11 21 ansible.builtin.set_fact: 12 - pocket_id_client: "{{ existing_clients[client_spec.name] | default(none) }}" 13 - k8s_secret_present: "{{ (k8s_secret_lookup.resources | length) > 0 }}" 22 + oidc_client_record: "{{ existing_clients_by_name[client_spec.name] | default(none) }}" 23 + kubernetes_secret_exists: "{{ (kubernetes_secret_lookup.resources | length) > 0 }}" 14 24 15 - - name: "[{{ client_spec.slug }}] Delete stale K8s secret" 25 + - name: "[{{ client_spec.slug }}] Delete stale K8s Secret" 16 26 when: 17 - - pocket_id_client is none 18 - - k8s_secret_present 27 + - oidc_client_record is none 28 + - kubernetes_secret_exists 19 29 kubernetes.core.k8s: 20 30 state: absent 21 31 api_version: v1 ··· 23 33 namespace: "{{ k8s_namespace }}" 24 34 name: "oidc-client-{{ client_spec.slug }}" 25 35 26 - # API rejects `launchURL: ""`, so build the payload in two steps. 27 - - name: "[{{ client_spec.slug }}] Build request payload" 28 - ansible.builtin.set_fact: 29 - client_payload: 30 - name: "{{ client_spec.name }}" 31 - callbackURLs: "{{ client_spec.callbackURLs }}" 32 - logoutCallbackURLs: "{{ client_spec.logoutCallbackURLs | default([]) }}" 33 - isPublic: "{{ client_spec.isPublic | default(false) }}" 34 - pkceEnabled: "{{ client_spec.pkceEnabled | default(true) }}" 35 - requiresReauthentication: "{{ client_spec.requiresReauthentication | default(false) }}" 36 - 37 - - name: "[{{ client_spec.slug }}] Add launchURL if set" 38 - when: (client_spec.launchURL | default('')) | length > 0 39 - ansible.builtin.set_fact: 40 - client_payload: "{{ client_payload | combine({'launchURL': client_spec.launchURL}) }}" 41 - 42 - - name: "[{{ client_spec.slug }}] Create client" 43 - when: pocket_id_client is none 44 - ansible.builtin.uri: 45 - url: "{{ pocket_id_url }}/api/oidc/clients" 46 - method: POST 47 - headers: 48 - X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 49 - body_format: json 50 - body: "{{ client_payload }}" 51 - status_code: [200, 201] 52 - register: created_client 36 + # ----------------------------------------------------------------------------- 37 + # Phase 2: Build payloads and create the client if missing 38 + # ----------------------------------------------------------------------------- 39 + - name: "[{{ client_spec.slug }}] Build desired/current client payloads" 40 + ansible.builtin.include_tasks: oidc/build-client-payloads.yml 53 41 54 - - name: "[{{ client_spec.slug }}] Adopt created client" 55 - when: pocket_id_client is none 56 - ansible.builtin.set_fact: 57 - pocket_id_client: "{{ created_client.json }}" 42 + - name: "[{{ client_spec.slug }}] Create client if missing" 43 + ansible.builtin.include_tasks: oidc/create-client.yml 58 44 59 - - name: "[{{ client_spec.slug }}] Sync metadata" 60 - when: k8s_secret_present and (existing_clients[client_spec.name] is defined) 45 + # ----------------------------------------------------------------------------- 46 + # Phase 3: Sync configuration drift for existing clients 47 + # ----------------------------------------------------------------------------- 48 + - name: "[{{ client_spec.slug }}] Sync client configuration" 49 + when: 50 + - existing_clients_by_name[client_spec.name] is defined 51 + - (desired_oidc_client | to_json) != (current_oidc_client | default({}) | to_json) 61 52 ansible.builtin.uri: 62 - url: "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}" 53 + url: "{{ pocket_id_url }}/api/oidc/clients/{{ oidc_client_record.id }}" 63 54 method: PUT 64 55 headers: 65 56 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 66 57 body_format: json 67 - body: "{{ client_payload }}" 58 + body: "{{ desired_oidc_client }}" 68 59 status_code: [200, 204] 69 60 70 - - name: "[{{ client_spec.slug }}] Warn: rotating secret" 71 - when: 72 - - not k8s_secret_present 73 - - existing_clients[client_spec.name] is defined 74 - ansible.builtin.debug: 75 - msg: >- 76 - Client existed in Pocket ID but K8s Secret was missing — rotating 77 - client_secret. Apps using the old value will break until they pick 78 - up the new K8s Secret. 61 + # ----------------------------------------------------------------------------- 62 + # Phase 4: Ensure credentials are available in K8s 63 + # ----------------------------------------------------------------------------- 64 + - name: "[{{ client_spec.slug }}] Ensure credentials Secret" 65 + ansible.builtin.include_tasks: oidc/ensure-credentials-secret.yml 79 66 80 - - name: "[{{ client_spec.slug }}] Generate client_secret" 81 - when: not k8s_secret_present 82 - ansible.builtin.uri: 83 - url: "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}/secret" 84 - method: POST 85 - headers: 86 - X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 87 - status_code: [200, 201] 88 - register: secret_response 89 - no_log: true 90 - 91 - - name: "[{{ client_spec.slug }}] Write K8s Secret" 92 - when: not k8s_secret_present 93 - kubernetes.core.k8s: 94 - state: present 95 - definition: 96 - apiVersion: v1 97 - kind: Secret 98 - metadata: 99 - name: "oidc-client-{{ client_spec.slug }}" 100 - namespace: "{{ k8s_namespace }}" 101 - labels: 102 - app.kubernetes.io/managed-by: ansible-oidc-bootstrap 103 - app.kubernetes.io/component: oidc-client 104 - annotations: 105 - helm.sh/resource-policy: keep 106 - oidc.cute.haus/client-name: "{{ client_spec.name }}" 107 - oidc.cute.haus/client-id: "{{ pocket_id_client.id }}" 108 - type: Opaque 109 - stringData: 110 - client_id: "{{ pocket_id_client.id }}" 111 - client_secret: "{{ secret_response.json.secret }}" 112 - no_log: true 113 - 114 - - name: "[{{ client_spec.slug }}] Read back creds" 115 - kubernetes.core.k8s_info: 116 - api_version: v1 117 - kind: Secret 118 - namespace: "{{ k8s_namespace }}" 119 - name: "oidc-client-{{ client_spec.slug }}" 120 - register: k8s_secret_final 121 - no_log: true 122 - 123 - - name: "[{{ client_spec.slug }}] Stash for integration tasks" 124 - ansible.builtin.set_fact: 125 - client_credentials_by_slug: >- 126 - {{ 127 - (client_credentials_by_slug | default({})) | combine({ 128 - client_spec.slug: { 129 - 'client_id': k8s_secret_final.resources[0].data.client_id | b64decode, 130 - 'client_secret': k8s_secret_final.resources[0].data.client_secret | b64decode, 131 - } 132 - }) 133 - }} 134 - no_log: true 135 - 67 + # ----------------------------------------------------------------------------- 68 + # Phase 5: Upload app logo 69 + # ----------------------------------------------------------------------------- 136 70 - name: "[{{ client_spec.slug }}] Upload logo" 137 71 when: 138 72 - client_spec.logoUrl is defined 139 - - not (pocket_id_client.hasLogo | default(false)) 140 - block: 141 - - name: "[{{ client_spec.slug }}] Download logo" 142 - ansible.builtin.get_url: 143 - url: "{{ client_spec.logoUrl }}" 144 - # Pocket ID rejects uploads without a file extension. 145 - dest: "/tmp/oidc-logo-{{ client_spec.slug }}.{{ client_spec.logoUrl | split('.') | last }}" 146 - mode: "0644" 147 - register: logo_download 148 - 149 - # ansible.builtin.uri's form-multipart can't handle binary file content 150 - # (treats it as a UTF-8 string), so shell out to curl. 151 - - name: "[{{ client_spec.slug }}] POST logo to Pocket ID" 152 - ansible.builtin.command: 153 - argv: 154 - - curl 155 - - --silent 156 - - --show-error 157 - - --fail 158 - - -X 159 - - POST 160 - - -H 161 - - "X-API-KEY: {{ pocket_id_secrets.admin_api_key }}" 162 - - -F 163 - - "file=@{{ logo_download.dest }}" 164 - - "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}/logo" 165 - no_log: true 73 + - not (oidc_client_record.hasLogo | default(false)) 74 + ansible.builtin.include_tasks: oidc/upload-logo.yml
+18 -7
ansible/playbooks/tasks/integrations/forgejo.yml
··· 19 19 20 20 - name: "[forgejo] Pick a pod" 21 21 ansible.builtin.set_fact: 22 - forgejo_pod: "{{ forgejo_pods.resources[0].metadata.name }}" 22 + forgejo_pod: >- 23 + {{ (forgejo_pods.resources | map(attribute='metadata') | map(attribute='name') | list | sort)[0] }} 23 24 24 25 - name: "[forgejo] List auth sources" 25 26 kubernetes.core.k8s_exec: ··· 29 30 register: forgejo_auth_list 30 31 31 32 # Output is a whitespace-aligned table: `<id> <name> <type> <enabled>`. 32 - # Find the row whose name is cute.haus and take its first column (the ID). 33 + # cute.haus only appears in the name column; the ID is that row's first token. 34 + - name: "[forgejo] Find cute.haus auth source rows" 35 + ansible.builtin.set_fact: 36 + forgejo_matching_rows: >- 37 + {{ forgejo_auth_list.stdout_lines | select('search', '^\s*\d+\s+cute\.haus\s') | list }} 38 + 39 + - name: "[forgejo] Fail on duplicate cute.haus auth sources" 40 + when: (forgejo_matching_rows | length) > 1 41 + ansible.builtin.fail: 42 + msg: >- 43 + Found {{ forgejo_matching_rows | length }} auth sources named cute.haus 44 + in forgejo — expected at most one. Resolve duplicates manually. 45 + 33 46 - name: "[forgejo] Extract cute.haus auth source ID (empty if absent)" 34 47 ansible.builtin.set_fact: 35 48 forgejo_source_id: >- 36 49 {{ 37 - (forgejo_auth_list.stdout_lines 38 - | select('search', '\scute\.haus\s') 50 + (forgejo_matching_rows | first | default('')).split() 39 51 | first | default('') 40 - ).split() | first | default('') 41 52 }} 42 53 43 54 # Explicit empty strings clear fields that update-oauth's cli.IsSet semantics ··· 47 58 forgejo_oauth_flags: >- 48 59 --name 'cute.haus' 49 60 --provider 'openidConnect' 50 - --key '{{ client_credentials_by_slug[client_spec.slug].client_id }}' 51 - --secret '{{ client_credentials_by_slug[client_spec.slug].client_secret }}' 61 + --key '{{ oidc_client_credentials.client_id }}' 62 + --secret '{{ oidc_client_credentials.client_secret }}' 52 63 --auto-discover-url '{{ pocket_id_url }}/.well-known/openid-configuration' 53 64 --scopes 'openid profile email' 54 65 --group-claim-name ''
+2 -2
ansible/playbooks/tasks/integrations/forward-auth.yml
··· 3 3 vars: 4 4 secret_name: "{{ client_spec.slug }}-oidc-env" 5 5 secret_data: 6 - PROVIDERS_OIDC_CLIENT_ID: "{{ client_credentials_by_slug[client_spec.slug].client_id }}" 7 - PROVIDERS_OIDC_CLIENT_SECRET: "{{ client_credentials_by_slug[client_spec.slug].client_secret }}" 6 + PROVIDERS_OIDC_CLIENT_ID: "{{ oidc_client_credentials.client_id }}" 7 + PROVIDERS_OIDC_CLIENT_SECRET: "{{ oidc_client_credentials.client_secret }}" 8 8 deployment_name: "{{ client_spec.slug }}"
+2 -2
ansible/playbooks/tasks/integrations/nextcloud.yml
··· 3 3 vars: 4 4 secret_name: nextcloud-sso-env 5 5 secret_data: 6 - OIDC_CLIENT_ID: "{{ client_credentials_by_slug[client_spec.slug].client_id }}" 7 - OIDC_CLIENT_SECRET: "{{ client_credentials_by_slug[client_spec.slug].client_secret }}" 6 + OIDC_CLIENT_ID: "{{ oidc_client_credentials.client_id }}" 7 + OIDC_CLIENT_SECRET: "{{ oidc_client_credentials.client_secret }}" 8 8 deployment_name: nextcloud
+2 -2
ansible/playbooks/tasks/integrations/paperless.yml
··· 6 6 APPS: 7 7 - provider_id: pocket-id 8 8 name: cute.haus 9 - client_id: "{{ client_credentials_by_slug[client_spec.slug].client_id }}" 10 - secret: "{{ client_credentials_by_slug[client_spec.slug].client_secret }}" 9 + client_id: "{{ oidc_client_credentials.client_id }}" 10 + secret: "{{ oidc_client_credentials.client_secret }}" 11 11 settings: 12 12 server_url: "{{ pocket_id_url }}/.well-known/openid-configuration" 13 13 OAUTH_PKCE_ENABLED: true
+2 -2
ansible/playbooks/tasks/integrations/tranquil.yml
··· 4 4 vars: 5 5 secret_name: tranquil-sso-env 6 6 secret_data: 7 - SSO_OIDC_CLIENT_ID: "{{ client_credentials_by_slug[client_spec.slug].client_id }}" 8 - SSO_OIDC_CLIENT_SECRET: "{{ client_credentials_by_slug[client_spec.slug].client_secret }}" 7 + SSO_OIDC_CLIENT_ID: "{{ oidc_client_credentials.client_id }}" 8 + SSO_OIDC_CLIENT_SECRET: "{{ oidc_client_credentials.client_secret }}" 9 9 deployment_name: tranquil-pds
+35
ansible/playbooks/tasks/oidc/build-client-payloads.yml
··· 1 + --- 2 + # Central mapping between our client spec and Pocket ID's client API shape. 3 + # When adding/removing client fields, update only this file. 4 + - name: "[{{ client_spec.slug }}] Build desired OIDC client payload" 5 + ansible.builtin.set_fact: 6 + desired_oidc_client: 7 + name: "{{ client_spec.name }}" 8 + callbackURLs: "{{ client_spec.callbackURLs }}" 9 + logoutCallbackURLs: "{{ client_spec.logoutCallbackURLs | default([]) }}" 10 + isPublic: "{{ client_spec.isPublic | default(false) }}" 11 + pkceEnabled: "{{ client_spec.pkceEnabled | default(true) }}" 12 + requiresReauthentication: "{{ client_spec.requiresReauthentication | default(false) }}" 13 + 14 + - name: "[{{ client_spec.slug }}] Add optional launchURL to desired payload" 15 + when: (client_spec.launchURL | default('')) | length > 0 16 + ansible.builtin.set_fact: 17 + desired_oidc_client: "{{ desired_oidc_client | combine({'launchURL': client_spec.launchURL}) }}" 18 + 19 + - name: "[{{ client_spec.slug }}] Build current OIDC client payload from API" 20 + when: existing_clients_by_name[client_spec.name] is defined 21 + ansible.builtin.set_fact: 22 + current_oidc_client: 23 + name: "{{ oidc_client_record.name }}" 24 + callbackURLs: "{{ oidc_client_record.callbackURLs }}" 25 + logoutCallbackURLs: "{{ oidc_client_record.logoutCallbackURLs | default([]) }}" 26 + isPublic: "{{ oidc_client_record.isPublic | default(false) }}" 27 + pkceEnabled: "{{ oidc_client_record.pkceEnabled | default(true) }}" 28 + requiresReauthentication: "{{ oidc_client_record.requiresReauthentication | default(false) }}" 29 + 30 + - name: "[{{ client_spec.slug }}] Add optional launchURL to current payload" 31 + when: 32 + - existing_clients_by_name[client_spec.name] is defined 33 + - (client_spec.launchURL | default('')) | length > 0 34 + ansible.builtin.set_fact: 35 + current_oidc_client: "{{ current_oidc_client | combine({'launchURL': oidc_client_record.launchURL | default('')}) }}"
+17
ansible/playbooks/tasks/oidc/create-client.yml
··· 1 + --- 2 + - name: "[{{ client_spec.slug }}] Create client in Pocket ID" 3 + when: oidc_client_record is none 4 + ansible.builtin.uri: 5 + url: "{{ pocket_id_url }}/api/oidc/clients" 6 + method: POST 7 + headers: 8 + X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 9 + body_format: json 10 + body: "{{ desired_oidc_client }}" 11 + status_code: [200, 201] 12 + register: created_oidc_client 13 + 14 + - name: "[{{ client_spec.slug }}] Adopt newly created client" 15 + when: created_oidc_client is changed 16 + ansible.builtin.set_fact: 17 + oidc_client_record: "{{ created_oidc_client.json }}"
+68
ansible/playbooks/tasks/oidc/ensure-credentials-secret.yml
··· 1 + --- 2 + # The K8s Secret may be missing even when the client exists (manual cleanup, 3 + # restore from backup, etc.). Generate a fresh secret and write it back. 4 + - name: "[{{ client_spec.slug }}] Warn about rotating a missing Secret" 5 + when: 6 + - not kubernetes_secret_exists 7 + - existing_clients_by_name[client_spec.name] is defined 8 + ansible.builtin.debug: 9 + msg: >- 10 + Client existed in Pocket ID but K8s Secret was missing — rotating 11 + client_secret. Apps using the old value will break until they pick 12 + up the new K8s Secret. 13 + 14 + - name: "[{{ client_spec.slug }}] Generate client_secret" 15 + when: not kubernetes_secret_exists 16 + ansible.builtin.uri: 17 + url: "{{ pocket_id_url }}/api/oidc/clients/{{ oidc_client_record.id }}/secret" 18 + method: POST 19 + headers: 20 + X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 21 + status_code: [200, 201] 22 + register: secret_rotation_response 23 + no_log: true 24 + 25 + - name: "[{{ client_spec.slug }}] Write K8s Secret" 26 + when: not kubernetes_secret_exists 27 + kubernetes.core.k8s: 28 + state: present 29 + definition: 30 + apiVersion: v1 31 + kind: Secret 32 + metadata: 33 + name: "oidc-client-{{ client_spec.slug }}" 34 + namespace: "{{ k8s_namespace }}" 35 + labels: 36 + app.kubernetes.io/managed-by: ansible-oidc-bootstrap 37 + app.kubernetes.io/component: oidc-client 38 + annotations: 39 + helm.sh/resource-policy: keep 40 + oidc.cute.haus/client-name: "{{ client_spec.name }}" 41 + oidc.cute.haus/client-id: "{{ oidc_client_record.id }}" 42 + type: Opaque 43 + stringData: 44 + client_id: "{{ oidc_client_record.id }}" 45 + client_secret: "{{ secret_rotation_response.json.secret }}" 46 + no_log: true 47 + 48 + - name: "[{{ client_spec.slug }}] Read back credentials" 49 + kubernetes.core.k8s_info: 50 + api_version: v1 51 + kind: Secret 52 + namespace: "{{ k8s_namespace }}" 53 + name: "oidc-client-{{ client_spec.slug }}" 54 + register: kubernetes_secret_final 55 + no_log: true 56 + 57 + - name: "[{{ client_spec.slug }}] Stash credentials for integration tasks" 58 + ansible.builtin.set_fact: 59 + oidc_client_credentials_by_slug: >- 60 + {{ 61 + (oidc_client_credentials_by_slug | default({})) | combine({ 62 + client_spec.slug: { 63 + 'client_id': kubernetes_secret_final.resources[0].data.client_id | b64decode, 64 + 'client_secret': kubernetes_secret_final.resources[0].data.client_secret | b64decode, 65 + } 66 + }) 67 + }} 68 + no_log: true
+26
ansible/playbooks/tasks/oidc/upload-logo.yml
··· 1 + --- 2 + - name: "[{{ client_spec.slug }}] Download logo" 3 + ansible.builtin.get_url: 4 + url: "{{ client_spec.logoUrl }}" 5 + # Pocket ID rejects uploads without a file extension. 6 + dest: "/tmp/oidc-logo-{{ client_spec.slug }}.{{ client_spec.logoUrl | split('.') | last }}" 7 + mode: "0644" 8 + register: logo_download_result 9 + 10 + # ansible.builtin.uri's form-multipart can't handle binary file content 11 + # (treats it as a UTF-8 string), so shell out to curl. 12 + - name: "[{{ client_spec.slug }}] POST logo to Pocket ID" 13 + ansible.builtin.command: 14 + argv: 15 + - curl 16 + - --silent 17 + - --show-error 18 + - --fail 19 + - -X 20 + - POST 21 + - -H 22 + - "X-API-KEY: {{ pocket_id_secrets.admin_api_key }}" 23 + - -F 24 + - "file=@{{ logo_download_result.dest }}" 25 + - "{{ pocket_id_url }}/api/oidc/clients/{{ oidc_client_record.id }}/logo" 26 + no_log: true
+3 -7
ansible/playbooks/vars/oidc-clients.yml
··· 5 5 launchURL: https://git.aly.codes/ 6 6 callbackURLs: 7 7 - https://git.aly.codes/user/oauth2/cute.haus/callback 8 - logoutCallbackURLs: [] 9 8 isPublic: false 10 9 pkceEnabled: true 11 10 integration: forgejo ··· 15 14 launchURL: https://pds.cute.haus/ 16 15 callbackURLs: 17 16 - https://pds.cute.haus/oauth/sso/callback 18 - logoutCallbackURLs: [] 19 17 isPublic: false 20 18 pkceEnabled: true 21 19 integration: tranquil ··· 27 25 launchURL: https://navidrome.cute.haus/ 28 26 callbackURLs: 29 27 - https://auth-navidrome.cute.haus/_oauth 30 - logoutCallbackURLs: [] 31 28 isPublic: false 32 29 pkceEnabled: false 33 30 integration: forward-auth ··· 42 39 callbackURLs: 43 40 - https://immich.cute.haus/auth/login 44 41 - app.immich:/ 45 - logoutCallbackURLs: [] 46 42 isPublic: false 47 43 pkceEnabled: true 48 44 45 + # In k3s, but OIDC is configured in the audiobookshelf admin UI (not env), 46 + # so no integration task runs. The oidc-client-audiobookshelf K8s Secret 47 + # holds creds for manual entry. 49 48 - slug: audiobookshelf 50 49 name: Audiobookshelf 51 50 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/audiobookshelf.png ··· 53 52 callbackURLs: 54 53 - https://audiobookshelf.cute.haus/audiobookshelf/auth/openid/callback 55 54 - https://audiobookshelf.cute.haus/audiobookshelf/auth/openid/mobile-redirect 56 - logoutCallbackURLs: [] 57 55 isPublic: false 58 56 pkceEnabled: true 59 57 ··· 63 61 launchURL: https://paperless.cute.haus/ 64 62 callbackURLs: 65 63 - https://paperless.cute.haus/accounts/oidc/pocket-id/login/callback/ 66 - logoutCallbackURLs: [] 67 64 isPublic: false 68 65 pkceEnabled: true 69 66 integration: paperless ··· 74 71 launchURL: https://nextcloud.cute.haus/ 75 72 callbackURLs: 76 73 - https://nextcloud.cute.haus/apps/user_oidc/code 77 - logoutCallbackURLs: [] 78 74 isPublic: false 79 75 pkceEnabled: true 80 76 integration: nextcloud