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.

clean up ansible and k8s + add check scripts.

Aly Raffauf (May 14, 2026, 12:02 PM EDT) 2fc44f30 c2784263

+718 -263
+8 -1
.envrc
··· 1 1 # shellcheck disable=SC2148 2 2 export DIRENV_WARN_TIMEOUT=0 3 - use flake 3 + export NIXPKGS_ALLOW_UNFREE=1 4 + use flake . --impure 5 + 6 + CLOUDFLARE_API_TOKEN="$(sops -d --extract '["api_token"]' secrets/cloudflare.yaml)" 7 + export CLOUDFLARE_API_TOKEN 8 + 9 + HCLOUD_TOKEN="$(sops -d --extract '["api_token"]' secrets/hetzner.yaml)" 10 + export HCLOUD_TOKEN
+3
.gitignore
··· 8 8 9 9 # Materialized helm subchart deps (regenerated by helmfile) 10 10 k8s/charts/*/charts/ 11 + 12 + # Bun deps for scripts/ (regenerated by `bun install`) 13 + scripts/node_modules/
+15 -51
.justfile
··· 28 28 # Update caddy-tailscale plugin to latest commit (also refreshes hash if stale). 29 29 [group('flake')] 30 30 update-caddy-tailscale: 31 - #!/usr/bin/env bash 32 - set -euo pipefail 33 - CADDY_FILE="nix/modules/nixos/services/caddy/default.nix" 34 - RESPONSE=$(curl -sf "https://api.github.com/repos/tailscale/caddy-tailscale/commits?per_page=1") 35 - SHA=$(echo "$RESPONSE" | grep -m1 '"sha"' | sed 's/.*"sha": *"//;s/".*//') 36 - SHA12=${SHA:0:12} 37 - COMMITTER_DATE=$(echo "$RESPONSE" | sed -n '/"committer": {/{n;n;n;s/.*"date": *"//;s/".*//;p;q;}') 38 - TIMESTAMP=$(echo "$COMMITTER_DATE" | sed 's/[-T:]//g;s/Z//') 39 - NEW_VERSION="v0.0.0-${TIMESTAMP}-${SHA12}" 40 - OLD_VERSION=$(grep -o 'caddy-tailscale@[^"]*' "$CADDY_FILE" | sed 's/caddy-tailscale@//') 41 - VERSION_CHANGED=false 42 - if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then 43 - VERSION_CHANGED=true 44 - echo "Updating caddy-tailscale: $OLD_VERSION -> $NEW_VERSION" 45 - sed -i "s|caddy-tailscale@[^\"]*|caddy-tailscale@${NEW_VERSION}|" "$CADDY_FILE" 46 - else 47 - echo "caddy-tailscale already at latest: $NEW_VERSION" 48 - fi 49 - # Always try building with the current hash first; rehash if it fails. 50 - if nix build .#nixosConfigurations.celestic.config.services.caddy.package 2>/dev/null; then 51 - if [ "$VERSION_CHANGED" = true ]; then 52 - git add "$CADDY_FILE" 53 - git commit -m "caddy: update caddy-tailscale to $NEW_VERSION" 54 - echo "Done! caddy-tailscale updated to $NEW_VERSION" 55 - else 56 - echo "Already up to date." 57 - fi 58 - exit 0 59 - fi 60 - echo "Hash is stale, determining new hash..." 61 - sed -i 's|hash = "sha256-[^"]*"|hash = ""|' "$CADDY_FILE" 62 - BUILD_OUTPUT=$(nix build .#nixosConfigurations.celestic.config.services.caddy.package 2>&1 || true) 63 - NEW_HASH=$(echo "$BUILD_OUTPUT" | sed -n 's/.*got: *//p' | tr -d ' ') 64 - if [ -z "$NEW_HASH" ]; then 65 - echo "Error: could not determine hash from build output." 66 - echo "$BUILD_OUTPUT" 67 - exit 1 68 - fi 69 - sed -i "s|hash = \"\"|hash = \"${NEW_HASH}\"|" "$CADDY_FILE" 70 - echo "Updated hash: $NEW_HASH" 71 - echo "Verifying build..." 72 - nix build .#nixosConfigurations.celestic.config.services.caddy.package 73 - git add "$CADDY_FILE" 74 - if [ "$VERSION_CHANGED" = true ]; then 75 - git commit -m "caddy: update caddy-tailscale to $NEW_VERSION" 76 - else 77 - git commit -m "caddy: update caddy-tailscale hash to $NEW_HASH" 78 - fi 79 - echo "Done!" 31 + bun scripts/update-caddy-tailscale.ts 80 32 81 33 ############################################################################ 82 34 # ··· 190 142 # 191 143 ############################################################################ 192 144 145 + # Run all consistency + flake checks. 146 + [group('kubes')] 147 + check: 148 + bun scripts/check.ts 149 + nix flake check --impure 150 + 151 + # Bump a digest-pinned chart image to its current upstream digest. 152 + # Usage: just bump <chart> | just bump --all | just bump --check 153 + [group('kubes')] 154 + bump TARGET: 155 + bun scripts/bump-image.ts {{ TARGET }} 156 + 193 157 # Scaffold a new app chart under k8s/charts/<name>. After running, edit the 194 158 # values.yaml and add a release block to k8s/helmfile.yaml. See k8s/charts/README.md. 195 159 [group('kubes')] ··· 220 184 221 185 image: 222 186 repository: TODO 223 - tag: latest 224 - pullPolicy: Always 187 + tag: TODO@sha256:TODO 188 + pullPolicy: IfNotPresent 225 189 226 190 resources: 227 191 requests: { cpu: 50m, memory: 64Mi }
+1 -1
ansible/playbooks/pocket-id-bootstrap.yml
··· 47 47 loop: "{{ oidc_clients }}" 48 48 loop_control: 49 49 loop_var: client_spec 50 - label: "{{ client_spec.key }}" 50 + label: "{{ client_spec.slug }}" 51 51 52 52 - name: Wire each client into its target app 53 53 ansible.builtin.include_tasks: "tasks/integrations/{{ client_spec.integration }}.yml"
+1 -1
ansible/playbooks/tasks/ensure-app-config.yml
··· 18 18 ) 19 19 }} 20 20 21 - - name: PUT merged configuration 21 + - name: Apply application configuration 22 22 ansible.builtin.uri: 23 23 url: "{{ pocket_id_url }}/api/application-configuration" 24 24 method: PUT
+40 -40
ansible/playbooks/tasks/ensure-oidc-client.yml
··· 1 1 --- 2 - - name: "[{{ client_spec.key }}] Check K8s secret" 2 + - name: "[{{ client_spec.slug }}] Check K8s secret" 3 3 kubernetes.core.k8s_info: 4 4 api_version: v1 5 5 kind: Secret 6 6 namespace: "{{ k8s_namespace }}" 7 - name: "oidc-client-{{ client_spec.key }}" 7 + name: "oidc-client-{{ client_spec.slug }}" 8 8 register: k8s_secret_lookup 9 9 10 - - name: "[{{ client_spec.key }}] Current state" 10 + - name: "[{{ client_spec.slug }}] Current state" 11 11 ansible.builtin.set_fact: 12 - api_client: "{{ existing_clients[client_spec.name] | default(none) }}" 12 + pocket_id_client: "{{ existing_clients[client_spec.name] | default(none) }}" 13 13 k8s_secret_present: "{{ (k8s_secret_lookup.resources | length) > 0 }}" 14 14 15 - - name: "[{{ client_spec.key }}] Delete stale K8s secret" 15 + - name: "[{{ client_spec.slug }}] Delete stale K8s secret" 16 16 when: 17 - - api_client is none 17 + - pocket_id_client is none 18 18 - k8s_secret_present 19 19 kubernetes.core.k8s: 20 20 state: absent 21 21 api_version: v1 22 22 kind: Secret 23 23 namespace: "{{ k8s_namespace }}" 24 - name: "oidc-client-{{ client_spec.key }}" 24 + name: "oidc-client-{{ client_spec.slug }}" 25 25 26 - # API rejects `launchURL: ""`, so build the body in two steps. 27 - - name: "[{{ client_spec.key }}] Build request body" 26 + # API rejects `launchURL: ""`, so build the payload in two steps. 27 + - name: "[{{ client_spec.slug }}] Build request payload" 28 28 ansible.builtin.set_fact: 29 - oidc_body: 29 + client_payload: 30 30 name: "{{ client_spec.name }}" 31 31 callbackURLs: "{{ client_spec.callbackURLs }}" 32 32 logoutCallbackURLs: "{{ client_spec.logoutCallbackURLs | default([]) }}" ··· 34 34 pkceEnabled: "{{ client_spec.pkceEnabled | default(true) }}" 35 35 requiresReauthentication: "{{ client_spec.requiresReauthentication | default(false) }}" 36 36 37 - - name: "[{{ client_spec.key }}] Add launchURL if set" 37 + - name: "[{{ client_spec.slug }}] Add launchURL if set" 38 38 when: (client_spec.launchURL | default('')) | length > 0 39 39 ansible.builtin.set_fact: 40 - oidc_body: "{{ oidc_body | combine({'launchURL': client_spec.launchURL}) }}" 40 + client_payload: "{{ client_payload | combine({'launchURL': client_spec.launchURL}) }}" 41 41 42 - - name: "[{{ client_spec.key }}] Create client" 43 - when: api_client is none 42 + - name: "[{{ client_spec.slug }}] Create client" 43 + when: pocket_id_client is none 44 44 ansible.builtin.uri: 45 45 url: "{{ pocket_id_url }}/api/oidc/clients" 46 46 method: POST 47 47 headers: 48 48 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 49 49 body_format: json 50 - body: "{{ oidc_body }}" 50 + body: "{{ client_payload }}" 51 51 status_code: [200, 201] 52 52 register: created_client 53 53 54 - - name: "[{{ client_spec.key }}] Adopt created client" 55 - when: api_client is none 54 + - name: "[{{ client_spec.slug }}] Adopt created client" 55 + when: pocket_id_client is none 56 56 ansible.builtin.set_fact: 57 - api_client: "{{ created_client.json }}" 57 + pocket_id_client: "{{ created_client.json }}" 58 58 59 - - name: "[{{ client_spec.key }}] Sync metadata" 59 + - name: "[{{ client_spec.slug }}] Sync metadata" 60 60 when: k8s_secret_present and (existing_clients[client_spec.name] is defined) 61 61 ansible.builtin.uri: 62 - url: "{{ pocket_id_url }}/api/oidc/clients/{{ api_client.id }}" 62 + url: "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}" 63 63 method: PUT 64 64 headers: 65 65 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" 66 66 body_format: json 67 - body: "{{ oidc_body }}" 67 + body: "{{ client_payload }}" 68 68 status_code: [200, 204] 69 69 70 - - name: "[{{ client_spec.key }}] Warn: rotating secret" 70 + - name: "[{{ client_spec.slug }}] Warn: rotating secret" 71 71 when: 72 72 - not k8s_secret_present 73 73 - existing_clients[client_spec.name] is defined ··· 77 77 client_secret. Apps using the old value will break until they pick 78 78 up the new K8s Secret. 79 79 80 - - name: "[{{ client_spec.key }}] Generate client_secret" 80 + - name: "[{{ client_spec.slug }}] Generate client_secret" 81 81 when: not k8s_secret_present 82 82 ansible.builtin.uri: 83 - url: "{{ pocket_id_url }}/api/oidc/clients/{{ api_client.id }}/secret" 83 + url: "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}/secret" 84 84 method: POST 85 85 headers: 86 86 X-API-KEY: "{{ pocket_id_secrets.admin_api_key }}" ··· 88 88 register: secret_response 89 89 no_log: true 90 90 91 - - name: "[{{ client_spec.key }}] Write K8s Secret" 91 + - name: "[{{ client_spec.slug }}] Write K8s Secret" 92 92 when: not k8s_secret_present 93 93 kubernetes.core.k8s: 94 94 state: present ··· 96 96 apiVersion: v1 97 97 kind: Secret 98 98 metadata: 99 - name: "oidc-client-{{ client_spec.key }}" 99 + name: "oidc-client-{{ client_spec.slug }}" 100 100 namespace: "{{ k8s_namespace }}" 101 101 labels: 102 102 app.kubernetes.io/managed-by: ansible-oidc-bootstrap ··· 104 104 annotations: 105 105 helm.sh/resource-policy: keep 106 106 oidc.cute.haus/client-name: "{{ client_spec.name }}" 107 - oidc.cute.haus/client-id: "{{ api_client.id }}" 107 + oidc.cute.haus/client-id: "{{ pocket_id_client.id }}" 108 108 type: Opaque 109 109 stringData: 110 - client_id: "{{ api_client.id }}" 110 + client_id: "{{ pocket_id_client.id }}" 111 111 client_secret: "{{ secret_response.json.secret }}" 112 112 no_log: true 113 113 114 - - name: "[{{ client_spec.key }}] Read back creds" 114 + - name: "[{{ client_spec.slug }}] Read back creds" 115 115 kubernetes.core.k8s_info: 116 116 api_version: v1 117 117 kind: Secret 118 118 namespace: "{{ k8s_namespace }}" 119 - name: "oidc-client-{{ client_spec.key }}" 119 + name: "oidc-client-{{ client_spec.slug }}" 120 120 register: k8s_secret_final 121 121 no_log: true 122 122 123 - - name: "[{{ client_spec.key }}] Stash for integration tasks" 123 + - name: "[{{ client_spec.slug }}] Stash for integration tasks" 124 124 ansible.builtin.set_fact: 125 - client_credentials_map: >- 125 + client_credentials_by_slug: >- 126 126 {{ 127 - (client_credentials_map | default({})) | combine({ 128 - client_spec.key: { 127 + (client_credentials_by_slug | default({})) | combine({ 128 + client_spec.slug: { 129 129 'client_id': k8s_secret_final.resources[0].data.client_id | b64decode, 130 130 'client_secret': k8s_secret_final.resources[0].data.client_secret | b64decode, 131 131 } ··· 133 133 }} 134 134 no_log: true 135 135 136 - - name: "[{{ client_spec.key }}] Upload logo" 136 + - name: "[{{ client_spec.slug }}] Upload logo" 137 137 when: 138 138 - client_spec.logoUrl is defined 139 - - not (api_client.hasLogo | default(false)) 139 + - not (pocket_id_client.hasLogo | default(false)) 140 140 block: 141 - - name: "[{{ client_spec.key }}] Download logo" 141 + - name: "[{{ client_spec.slug }}] Download logo" 142 142 ansible.builtin.get_url: 143 143 url: "{{ client_spec.logoUrl }}" 144 144 # Pocket ID rejects uploads without a file extension. 145 - dest: "/tmp/oidc-logo-{{ client_spec.key }}.{{ client_spec.logoUrl | split('.') | last }}" 145 + dest: "/tmp/oidc-logo-{{ client_spec.slug }}.{{ client_spec.logoUrl | split('.') | last }}" 146 146 mode: "0644" 147 147 register: logo_download 148 148 149 149 # ansible.builtin.uri's form-multipart can't handle binary file content 150 150 # (treats it as a UTF-8 string), so shell out to curl. 151 - - name: "[{{ client_spec.key }}] POST logo to Pocket ID" 151 + - name: "[{{ client_spec.slug }}] POST logo to Pocket ID" 152 152 ansible.builtin.command: 153 153 argv: 154 154 - curl ··· 161 161 - "X-API-KEY: {{ pocket_id_secrets.admin_api_key }}" 162 162 - -F 163 163 - "file=@{{ logo_download.dest }}" 164 - - "{{ pocket_id_url }}/api/oidc/clients/{{ api_client.id }}/logo" 164 + - "{{ pocket_id_url }}/api/oidc/clients/{{ pocket_id_client.id }}/logo" 165 165 no_log: true
+39
ansible/playbooks/tasks/integrations/_write-secret-and-restart.yml
··· 1 + --- 2 + # Shared helper: write/update an SSO env Secret and rollout-restart the 3 + # Deployment if it changed. Caller passes: 4 + # secret_name — K8s Secret name 5 + # secret_data — dict of env var names → values 6 + # deployment_name — Deployment to restart on change 7 + - name: "[{{ client_spec.slug }}] Write SSO env Secret" 8 + kubernetes.core.k8s: 9 + state: present 10 + definition: 11 + apiVersion: v1 12 + kind: Secret 13 + metadata: 14 + name: "{{ secret_name }}" 15 + namespace: "{{ k8s_namespace }}" 16 + labels: 17 + app.kubernetes.io/managed-by: ansible-oidc-bootstrap 18 + app.kubernetes.io/component: sso-env 19 + annotations: 20 + helm.sh/resource-policy: keep 21 + type: Opaque 22 + stringData: "{{ secret_data }}" 23 + no_log: true 24 + register: sso_secret 25 + 26 + - name: "[{{ client_spec.slug }}] Rollout restart to pick up new env" 27 + when: sso_secret.changed 28 + kubernetes.core.k8s: 29 + state: patched 30 + api_version: apps/v1 31 + kind: Deployment 32 + namespace: "{{ k8s_namespace }}" 33 + name: "{{ deployment_name }}" 34 + definition: 35 + spec: 36 + template: 37 + metadata: 38 + annotations: 39 + kubectl.kubernetes.io/restartedAt: "{{ lookup('pipe', 'date -u +%FT%TZ') }}"
+24 -31
ansible/playbooks/tasks/integrations/forgejo.yml
··· 28 28 command: su -s /bin/sh git -c "forgejo admin auth list" 29 29 register: forgejo_auth_list 30 30 31 - - name: "[forgejo] Find cute.haus source ID (if any)" 31 + # 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 + - name: "[forgejo] Extract cute.haus auth source ID (empty if absent)" 32 34 ansible.builtin.set_fact: 33 35 forgejo_source_id: >- 34 36 {{ 35 - forgejo_auth_list.stdout_lines[1:] 36 - | map('regex_search', '^(\d+)\s+cute\.haus\s+', '\1') 37 - | select('truthy') 38 - | flatten 39 - | first 40 - | default(none) 37 + (forgejo_auth_list.stdout_lines 38 + | select('search', '\scute\.haus\s') 39 + | first | default('') 40 + ).split() | first | default('') 41 41 }} 42 42 43 43 # Explicit empty strings clear fields that update-oauth's cli.IsSet semantics 44 44 # would otherwise leave untouched on re-runs. 45 - - name: "[forgejo] Add auth source" 46 - when: forgejo_source_id is none 47 - kubernetes.core.k8s_exec: 48 - namespace: "{{ k8s_namespace }}" 49 - pod: "{{ forgejo_pod }}" 50 - command: >- 51 - su -s /bin/sh git -c "forgejo admin auth add-oauth 45 + - name: "[forgejo] Build shared OAuth flags" 46 + ansible.builtin.set_fact: 47 + forgejo_oauth_flags: >- 52 48 --name 'cute.haus' 53 49 --provider 'openidConnect' 54 - --key '{{ client_credentials_map[client_spec.key].client_id }}' 55 - --secret '{{ client_credentials_map[client_spec.key].client_secret }}' 50 + --key '{{ client_credentials_by_slug[client_spec.slug].client_id }}' 51 + --secret '{{ client_credentials_by_slug[client_spec.slug].client_secret }}' 56 52 --auto-discover-url '{{ pocket_id_url }}/.well-known/openid-configuration' 57 53 --scopes 'openid profile email' 58 54 --group-claim-name '' 59 55 --admin-group '' 60 56 --required-claim-name '' 61 57 --required-claim-value '' 62 - --restricted-group ''" 58 + --restricted-group '' 59 + no_log: true 60 + 61 + - name: "[forgejo] Add auth source" 62 + when: forgejo_source_id == '' 63 + kubernetes.core.k8s_exec: 64 + namespace: "{{ k8s_namespace }}" 65 + pod: "{{ forgejo_pod }}" 66 + command: >- 67 + su -s /bin/sh git -c "forgejo admin auth add-oauth {{ forgejo_oauth_flags }}" 63 68 no_log: true 64 69 65 70 - name: "[forgejo] Update auth source" 66 - when: forgejo_source_id is not none 71 + when: forgejo_source_id != '' 67 72 kubernetes.core.k8s_exec: 68 73 namespace: "{{ k8s_namespace }}" 69 74 pod: "{{ forgejo_pod }}" 70 75 command: >- 71 - su -s /bin/sh git -c "forgejo admin auth update-oauth 72 - --id {{ forgejo_source_id }} 73 - --name 'cute.haus' 74 - --provider 'openidConnect' 75 - --key '{{ client_credentials_map[client_spec.key].client_id }}' 76 - --secret '{{ client_credentials_map[client_spec.key].client_secret }}' 77 - --auto-discover-url '{{ pocket_id_url }}/.well-known/openid-configuration' 78 - --scopes 'openid profile email' 79 - --group-claim-name '' 80 - --admin-group '' 81 - --required-claim-name '' 82 - --required-claim-value '' 83 - --restricted-group ''" 76 + su -s /bin/sh git -c "forgejo admin auth update-oauth --id {{ forgejo_source_id }} {{ forgejo_oauth_flags }}" 84 77 no_log: true
+7 -35
ansible/playbooks/tasks/integrations/forward-auth.yml
··· 1 1 --- 2 - - name: "[{{ client_spec.key }}] Write OIDC env Secret" 3 - kubernetes.core.k8s: 4 - state: present 5 - definition: 6 - apiVersion: v1 7 - kind: Secret 8 - metadata: 9 - name: "{{ client_spec.key }}-oidc-env" 10 - namespace: "{{ k8s_namespace }}" 11 - labels: 12 - app.kubernetes.io/managed-by: ansible-oidc-bootstrap 13 - app.kubernetes.io/component: sso-env 14 - annotations: 15 - helm.sh/resource-policy: keep 16 - type: Opaque 17 - stringData: 18 - PROVIDERS_OIDC_CLIENT_ID: "{{ client_credentials_map[client_spec.key].client_id }}" 19 - PROVIDERS_OIDC_CLIENT_SECRET: "{{ client_credentials_map[client_spec.key].client_secret }}" 20 - no_log: true 21 - register: forward_auth_oidc_secret 22 - 23 - - name: "[{{ client_spec.key }}] Rollout restart to pick up new env" 24 - when: forward_auth_oidc_secret.changed 25 - kubernetes.core.k8s: 26 - state: patched 27 - api_version: apps/v1 28 - kind: Deployment 29 - namespace: "{{ k8s_namespace }}" 30 - name: "{{ client_spec.key }}" 31 - definition: 32 - spec: 33 - template: 34 - metadata: 35 - annotations: 36 - kubectl.kubernetes.io/restartedAt: "{{ lookup('pipe', 'date -u +%FT%TZ') }}" 2 + - ansible.builtin.include_tasks: _write-secret-and-restart.yml 3 + vars: 4 + secret_name: "{{ client_spec.slug }}-oidc-env" 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 }}" 8 + deployment_name: "{{ client_spec.slug }}"
+8 -36
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_map[client_spec.key].client_id }}" 10 - secret: "{{ client_credentials_map[client_spec.key].client_secret }}" 9 + client_id: "{{ client_credentials_by_slug[client_spec.slug].client_id }}" 10 + secret: "{{ client_credentials_by_slug[client_spec.slug].client_secret }}" 11 11 settings: 12 12 server_url: "{{ pocket_id_url }}/.well-known/openid-configuration" 13 13 OAUTH_PKCE_ENABLED: true 14 14 no_log: true 15 15 16 - - name: "[paperless] Write SSO env Secret" 17 - kubernetes.core.k8s: 18 - state: present 19 - definition: 20 - apiVersion: v1 21 - kind: Secret 22 - metadata: 23 - name: paperless-sso-env 24 - namespace: "{{ k8s_namespace }}" 25 - labels: 26 - app.kubernetes.io/managed-by: ansible-oidc-bootstrap 27 - app.kubernetes.io/component: sso-env 28 - annotations: 29 - helm.sh/resource-policy: keep 30 - type: Opaque 31 - stringData: 32 - PAPERLESS_SOCIALACCOUNT_PROVIDERS: "{{ paperless_social_providers | to_json }}" 33 - no_log: true 34 - register: paperless_sso_secret 35 - 36 - - name: "[paperless] Rollout restart to pick up new env" 37 - when: paperless_sso_secret.changed 38 - kubernetes.core.k8s: 39 - state: patched 40 - api_version: apps/v1 41 - kind: Deployment 42 - namespace: "{{ k8s_namespace }}" 43 - name: paperless 44 - definition: 45 - spec: 46 - template: 47 - metadata: 48 - annotations: 49 - kubectl.kubernetes.io/restartedAt: "{{ lookup('pipe', 'date -u +%FT%TZ') }}" 16 + - ansible.builtin.include_tasks: _write-secret-and-restart.yml 17 + vars: 18 + secret_name: paperless-sso-env 19 + secret_data: 20 + PAPERLESS_SOCIALACCOUNT_PROVIDERS: "{{ paperless_social_providers | to_json }}" 21 + deployment_name: paperless
+8 -38
ansible/playbooks/tasks/integrations/tranquil.yml
··· 1 1 --- 2 - # Maps the generic oidc-client-tranquil Secret's keys into the SSO_OIDC_* 3 - # env names tranquil's chart expects via envFromSecret. 4 - - name: "[tranquil] Write SSO env Secret" 5 - kubernetes.core.k8s: 6 - state: present 7 - definition: 8 - apiVersion: v1 9 - kind: Secret 10 - metadata: 11 - name: tranquil-sso-env 12 - namespace: "{{ k8s_namespace }}" 13 - labels: 14 - app.kubernetes.io/managed-by: ansible-oidc-bootstrap 15 - app.kubernetes.io/component: sso-env 16 - annotations: 17 - helm.sh/resource-policy: keep 18 - type: Opaque 19 - stringData: 20 - SSO_OIDC_CLIENT_ID: "{{ client_credentials_map[client_spec.key].client_id }}" 21 - SSO_OIDC_CLIENT_SECRET: "{{ client_credentials_map[client_spec.key].client_secret }}" 22 - no_log: true 23 - register: tranquil_sso_secret 24 - 25 - # envFrom values are read only at pod start, so rotations need a restart. 26 - - name: "[tranquil] Rollout restart to pick up new env" 27 - when: tranquil_sso_secret.changed 28 - kubernetes.core.k8s: 29 - state: patched 30 - api_version: apps/v1 31 - kind: Deployment 32 - namespace: "{{ k8s_namespace }}" 33 - name: tranquil-pds 34 - definition: 35 - spec: 36 - template: 37 - metadata: 38 - annotations: 39 - kubectl.kubernetes.io/restartedAt: "{{ lookup('pipe', 'date -u +%FT%TZ') }}" 2 + # tranquil's chart expects SSO_OIDC_* env names. 3 + - ansible.builtin.include_tasks: _write-secret-and-restart.yml 4 + vars: 5 + secret_name: tranquil-sso-env 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 }}" 9 + deployment_name: tranquil-pds
+7 -7
ansible/playbooks/vars/oidc-clients.yml
··· 1 1 oidc_clients: 2 - - key: forgejo 2 + - slug: forgejo 3 3 name: Forgejo 4 4 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/forgejo.png 5 5 launchURL: https://git.aly.codes/ ··· 10 10 pkceEnabled: true 11 11 integration: forgejo 12 12 13 - - key: tranquil 13 + - slug: tranquil 14 14 name: Tranquil 15 15 launchURL: https://pds.cute.haus/ 16 16 callbackURLs: ··· 20 20 pkceEnabled: true 21 21 integration: tranquil 22 22 23 - # The key here has to match the forward-auth-<app> Deployment name. 24 - - key: forward-auth-navidrome 23 + # Slug must match the forward-auth-<app> Deployment name. 24 + - slug: forward-auth-navidrome 25 25 name: Navidrome 26 26 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/navidrome.png 27 27 launchURL: https://navidrome.cute.haus/ ··· 35 35 # immich runs on jubilife (NixOS), not k8s — no integration task. Read 36 36 # creds from the oidc-client-immich K8s Secret and feed them into the 37 37 # immich NixOS module via sops. 38 - - key: immich 38 + - slug: immich 39 39 name: Immich 40 40 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/immich.png 41 41 launchURL: https://immich.cute.haus/ ··· 46 46 isPublic: false 47 47 pkceEnabled: true 48 48 49 - - key: audiobookshelf 49 + - slug: audiobookshelf 50 50 name: Audiobookshelf 51 51 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/audiobookshelf.png 52 52 launchURL: https://audiobookshelf.cute.haus/ ··· 57 57 isPublic: false 58 58 pkceEnabled: true 59 59 60 - - key: paperless 60 + - slug: paperless 61 61 name: Paperless 62 62 logoUrl: https://cdn.jsdelivr.net/gh/selfhst/icons@main/png/paperless-ngx.png 63 63 launchURL: https://paperless.cute.haus/
+1 -1
k8s/charts/audiobookshelf/values.yaml
··· 40 40 rclone: 41 41 image: 42 42 repository: rclone/rclone 43 - tag: "1.71" 43 + tag: 1.71@sha256:3103526c506266a9ecdf064efe99bf3677d92ef6407af124d8c56b4f49cbaa51 44 44 pullPolicy: IfNotPresent 45 45 remote: b2:aly-audiobooks 46 46 mountPath: /audiobooks
+1 -3
k8s/charts/forgejo/values.yaml
··· 4 4 5 5 image: 6 6 repository: codeberg.org/forgejo/forgejo 7 - tag: "15" 8 - # Stops a registry hiccup at pod-restart time from wedging the deployment 9 - # (codeberg has had 502s). Tradeoff: bumping tag is the only way to update. 7 + tag: 15@sha256:db04c7114b656f896e206ba3873fe8d3a7adf2daa44907037f0274f4ba653fb9 10 8 pullPolicy: IfNotPresent 11 9 12 10 resources:
+2 -2
k8s/charts/pg-shared/templates/databases.yaml
··· 1 - {{- range $name, $cfg := .Values.roles }} 2 - {{- $db := $cfg.database | default $name }} 1 + {{- range $name, $role := .Values.roles }} 2 + {{- $db := $role.database | default $name }} 3 3 --- 4 4 apiVersion: postgresql.cnpg.io/v1 5 5 kind: Database
+2 -2
k8s/charts/pg-shared/templates/role-secrets.yaml
··· 1 - {{- range $name, $cfg := .Values.roles }} 1 + {{- range $name, $role := .Values.roles }} 2 2 --- 3 3 apiVersion: v1 4 4 kind: Secret ··· 9 9 type: kubernetes.io/basic-auth 10 10 stringData: 11 11 username: {{ $name | quote }} 12 - password: {{ $cfg.password | quote }} 12 + password: {{ $role.password | quote }} 13 13 {{- end }}
+2 -2
k8s/charts/uptime-kuma/values.yaml
··· 4 4 5 5 image: 6 6 repository: louislam/uptime-kuma 7 - tag: "2" 8 - pullPolicy: Always 7 + tag: 2@sha256:9aeb4e51d038047f414309c77a1af553281ca535723cb88907d907269d0a908e 8 + pullPolicy: IfNotPresent 9 9 10 10 resources: 11 11 requests:
+1 -1
k8s/charts/watsup/values.yaml
··· 4 4 5 5 image: 6 6 repository: ghcr.io/alyraffauf/watsup 7 - tag: latest@sha256:ba7a3b2f42bb7f3a5ecb11aa15c4fb3e2efb3152f23dc376911c6f65e82cc8f1 7 + tag: latest@sha256:c97fdb235397c5ba6061ea4f8727aebe4845995994c567f52ad5ae1d8a47ccae 8 8 pullPolicy: IfNotPresent 9 9 10 10 resources:
+2 -5
k8s/helmfile.yaml
··· 1 - # Keep helm release history small. Each revision is stored as a Secret 2 - # containing gzip+base64 of all rendered manifests; CRD-bundling charts 3 - # (cert-manager, traefik, cnpg) make each revision ~1MB, so the default 10 4 - # means ~10MB per release and helm's pre-upgrade LIST query stalls the 5 - # apiserver's HTTP/2 stream over the tailnet. 1 + # Low history; CRD-heavy charts (cert-manager, traefik, cnpg) push each 2 + # revision Secret to ~1MB, and the default of 10 stalls the apiserver. 6 3 helmDefaults: 7 4 historyMax: 3 8 5
+2
nix/modules/flake/devShells.nix
··· 21 21 (lib.hiPrio uutils-coreutils-noprefix) 22 22 age 23 23 ansibleWithK8s 24 + bun 24 25 git 25 26 helmfile 26 27 just 27 28 kubectl 28 29 kubernetes-helm 29 30 nh 31 + skopeo 30 32 sops 31 33 ssh-to-age 32 34 terraform
+1
nix/modules/flake/treefmt.nix
··· 8 8 shellcheck.enable = true; 9 9 shfmt.enable = true; 10 10 statix.enable = true; 11 + taplo.enable = true; 11 12 terraform.enable = true; 12 13 }; 13 14 };
+167
scripts/bump-image.ts
··· 1 + #!/usr/bin/env bun 2 + // Bumps the digest of every digest-pinned image (including sidecars) in a 3 + // chart's values.yaml to the current upstream. 4 + // 5 + // Usage: 6 + // bun scripts/bump-image.ts <chart> bump one chart (top-level + sidecars) 7 + // bun scripts/bump-image.ts --all bump every digest-pinned chart 8 + // bun scripts/bump-image.ts --check report only, exit 1 if anything is stale 9 + // 10 + // Discovers images by walking each chart's values.yaml for any `image:` block 11 + // with `repository` and `tag` fields, then keeps only the ones whose tag 12 + // already contains `@sha256:` (i.e. is digest-pinned). Floating tags are 13 + // skipped — they don't need bumping. 14 + 15 + import { $ } from "bun"; 16 + 17 + type PinnedImage = { 18 + chartName: string; 19 + valuesPath: string; 20 + imagePath: string; // dotted path like "image" or "rclone.image" 21 + repository: string; 22 + floatTag: string; // e.g. "15", "latest", "1.71" 23 + currentDigest: string; // e.g. "sha256:abc..." 24 + }; 25 + 26 + function chartNameFromPath(valuesPath: string): string { 27 + return valuesPath.split("/")[2]; 28 + } 29 + 30 + // Walks a parsed YAML tree and yields every `image: {repository, tag}` block. 31 + function* findImageBlocks( 32 + node: unknown, 33 + pathSegments: string[] = [], 34 + ): Generator<{ path: string; repository: string; tag: string }> { 35 + if (!node || typeof node !== "object") return; 36 + 37 + if (Array.isArray(node)) { 38 + for (let i = 0; i < node.length; i++) { 39 + yield* findImageBlocks(node[i], [...pathSegments, `[${i}]`]); 40 + } 41 + return; 42 + } 43 + 44 + for (const [key, value] of Object.entries(node as Record<string, unknown>)) { 45 + if (key === "image" && value && typeof value === "object") { 46 + const repository = (value as { repository?: unknown }).repository; 47 + const tag = (value as { tag?: unknown }).tag; 48 + if ( 49 + typeof repository === "string" && 50 + (typeof tag === "string" || typeof tag === "number") 51 + ) { 52 + yield { 53 + path: [...pathSegments, "image"].join("."), 54 + repository, 55 + tag: String(tag), 56 + }; 57 + } 58 + } 59 + yield* findImageBlocks(value, [...pathSegments, key]); 60 + } 61 + } 62 + 63 + async function discoverPinnedImages(): Promise<PinnedImage[]> { 64 + const glob = new Bun.Glob("k8s/charts/*/values.yaml"); 65 + const pinned: PinnedImage[] = []; 66 + 67 + for await (const valuesPath of glob.scan(".")) { 68 + const values = Bun.YAML.parse(await Bun.file(valuesPath).text()); 69 + const chartName = chartNameFromPath(valuesPath); 70 + 71 + for (const block of findImageBlocks(values)) { 72 + const [floatTag, digestHex] = block.tag.split("@sha256:"); 73 + if (!digestHex) continue; // floating tag, skip 74 + 75 + pinned.push({ 76 + chartName, 77 + valuesPath, 78 + imagePath: block.path, 79 + repository: block.repository, 80 + floatTag, 81 + currentDigest: `sha256:${digestHex}`, 82 + }); 83 + } 84 + } 85 + 86 + return pinned; 87 + } 88 + 89 + async function fetchUpstreamDigest( 90 + repository: string, 91 + floatTag: string, 92 + ): Promise<string> { 93 + const result = await $` 94 + skopeo inspect --no-creds docker://${repository}:${floatTag} --format ${"{{.Digest}}"} 95 + ` 96 + .quiet() 97 + .nothrow(); 98 + if (result.exitCode !== 0) { 99 + throw new Error( 100 + `skopeo failed for ${repository}:${floatTag}: ${result.stderr.toString().trim()}`, 101 + ); 102 + } 103 + return result.stdout.toString().trim(); 104 + } 105 + 106 + function label(image: PinnedImage): string { 107 + // Top-level images are unsurprising; for sidecars, include the dotted path. 108 + return image.imagePath === "image" 109 + ? image.chartName 110 + : `${image.chartName}/${image.imagePath}`; 111 + } 112 + 113 + async function bumpImage(image: PinnedImage, write: boolean): Promise<boolean> { 114 + const upstream = await fetchUpstreamDigest(image.repository, image.floatTag); 115 + if (upstream === image.currentDigest) { 116 + console.log(`${label(image)}: ✓ up to date`); 117 + return false; 118 + } 119 + 120 + console.log( 121 + `${label(image)}: ${image.currentDigest.slice(7, 19)} → ${upstream.slice(7, 19)}`, 122 + ); 123 + 124 + if (write) { 125 + const text = await Bun.file(image.valuesPath).text(); 126 + const updated = text.replace(image.currentDigest, upstream); 127 + await Bun.write(image.valuesPath, updated); 128 + } 129 + return true; 130 + } 131 + 132 + // --- main --- 133 + 134 + const arg = process.argv[2]; 135 + if (!arg || arg === "-h" || arg === "--help") { 136 + console.error("usage: bump-image.ts <chart> | --all | --check"); 137 + process.exit(2); 138 + } 139 + 140 + const allImages = await discoverPinnedImages(); 141 + 142 + let imagesToBump: PinnedImage[]; 143 + let shouldWrite = true; 144 + 145 + if (arg === "--all") { 146 + imagesToBump = allImages; 147 + } else if (arg === "--check") { 148 + imagesToBump = allImages; 149 + shouldWrite = false; 150 + } else { 151 + imagesToBump = allImages.filter((image) => image.chartName === arg); 152 + if (imagesToBump.length === 0) { 153 + console.error(`no chart '${arg}' with a digest-pinned image`); 154 + process.exit(1); 155 + } 156 + } 157 + 158 + let staleCount = 0; 159 + for (const image of imagesToBump) { 160 + const wasStale = await bumpImage(image, shouldWrite); 161 + if (wasStale) staleCount++; 162 + } 163 + 164 + if (arg === "--check" && staleCount > 0) { 165 + console.error(`\n${staleCount} image(s) stale`); 166 + process.exit(1); 167 + }
+21
scripts/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "cute-haus-scripts", 7 + "devDependencies": { 8 + "@types/bun": "latest", 9 + }, 10 + }, 11 + }, 12 + "packages": { 13 + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], 14 + 15 + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], 16 + 17 + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], 18 + 19 + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], 20 + } 21 + }
+69
scripts/check-forward-auth.ts
··· 1 + // Every `integration: forward-auth` OIDC client must have a matching app in 2 + // the forward-auth chart, and vice versa. 3 + 4 + const OIDC_CLIENTS_FILE = "ansible/playbooks/vars/oidc-clients.yml"; 5 + const FORWARD_AUTH_VALUES_FILE = "k8s/values/secrets/forward-auth.yaml"; 6 + const SLUG_PREFIX = "forward-auth-"; 7 + 8 + type OIDCClient = { slug: string; integration?: string }; 9 + type OIDCFile = { oidc_clients: OIDCClient[] }; 10 + type ChartFile = { apps?: Record<string, unknown> }; 11 + 12 + async function readYaml<T>(path: string): Promise<T> { 13 + return Bun.YAML.parse(await Bun.file(path).text()) as T; 14 + } 15 + 16 + function difference(a: Set<string>, b: Set<string>): string[] { 17 + const result: string[] = []; 18 + for (const item of a) { 19 + if (!b.has(item)) result.push(item); 20 + } 21 + return result.sort(); 22 + } 23 + 24 + export async function checkForwardAuth(): Promise<string[]> { 25 + const oidc = await readYaml<OIDCFile>(OIDC_CLIENTS_FILE); 26 + const chart = await readYaml<ChartFile>(FORWARD_AUTH_VALUES_FILE); 27 + 28 + const forwardAuthClients = oidc.oidc_clients.filter( 29 + (client) => client.integration === "forward-auth", 30 + ); 31 + 32 + const errors: string[] = []; 33 + 34 + // Every forward-auth client's slug must start with `forward-auth-` so the 35 + // integration task can derive the Deployment name from it. 36 + const slugsWithBadPrefix: string[] = []; 37 + for (const client of forwardAuthClients) { 38 + if (!client.slug.startsWith(SLUG_PREFIX)) 39 + slugsWithBadPrefix.push(client.slug); 40 + } 41 + if (slugsWithBadPrefix.length > 0) { 42 + errors.push( 43 + `OIDC slug(s) missing '${SLUG_PREFIX}' prefix: ${slugsWithBadPrefix.join(", ")}`, 44 + ); 45 + } 46 + 47 + // The app name is the slug with the prefix stripped, e.g. 48 + // `forward-auth-navidrome` → `navidrome`. That must equal a key in the chart. 49 + const oidcAppNames = new Set( 50 + forwardAuthClients.map((client) => client.slug.replace(SLUG_PREFIX, "")), 51 + ); 52 + const chartAppNames = new Set(Object.keys(chart.apps ?? {})); 53 + 54 + const onlyInOIDC = difference(oidcAppNames, chartAppNames); 55 + if (onlyInOIDC.length > 0) { 56 + errors.push( 57 + `OIDC client(s) without a matching app in ${FORWARD_AUTH_VALUES_FILE}: ${onlyInOIDC.join(", ")}`, 58 + ); 59 + } 60 + 61 + const onlyInChart = difference(chartAppNames, oidcAppNames); 62 + if (onlyInChart.length > 0) { 63 + errors.push( 64 + `App(s) in ${FORWARD_AUTH_VALUES_FILE} without a matching OIDC client: ${onlyInChart.join(", ")}`, 65 + ); 66 + } 67 + 68 + return errors; 69 + }
+91
scripts/check-pinned-images.ts
··· 1 + // Every chart that's actually deployed must pin its image (or any sidecar 2 + // image, like the redis or rclone sidecars) to a specific digest. Charts in 3 + // ALLOW_FLOATING are exempt — usually because they live on a private 4 + // registry with active dev where pinning would break the push-and-redeploy 5 + // loop. 6 + 7 + const HELMFILE = "k8s/helmfile.yaml"; 8 + const ALLOW_FLOATING = new Set(["tranquil-pds"]); 9 + 10 + const DIGEST_SUFFIX = /@sha256:[0-9a-f]{64}$/; 11 + 12 + type Release = { chart: string }; 13 + type Helmfile = { releases: Release[] }; 14 + 15 + function chartNameFromPath(valuesPath: string): string { 16 + // valuesPath looks like "k8s/charts/<name>/values.yaml" 17 + return valuesPath.split("/")[2]; 18 + } 19 + 20 + async function deployedChartNames(): Promise<Set<string>> { 21 + const helmfile = Bun.YAML.parse(await Bun.file(HELMFILE).text()) as Helmfile; 22 + const names = new Set<string>(); 23 + for (const release of helmfile.releases) { 24 + if (release.chart.startsWith("./charts/")) { 25 + names.add(release.chart.replace(/^\.\/charts\//, "")); 26 + } 27 + } 28 + return names; 29 + } 30 + 31 + // Walks a parsed YAML tree and returns every `image: {tag: ...}` it finds, 32 + // along with the dotted path that gets you there (e.g. "image", 33 + // "rclone.image"). Mirrors what renovate's helm-values manager looks for. 34 + function findImageTags( 35 + node: unknown, 36 + pathSegments: string[] = [], 37 + ): Array<{ path: string; tag: string }> { 38 + if (!node || typeof node !== "object") return []; 39 + 40 + const found: Array<{ path: string; tag: string }> = []; 41 + 42 + if (Array.isArray(node)) { 43 + for (let i = 0; i < node.length; i++) { 44 + found.push(...findImageTags(node[i], [...pathSegments, `[${i}]`])); 45 + } 46 + return found; 47 + } 48 + 49 + for (const [key, value] of Object.entries(node as Record<string, unknown>)) { 50 + if ( 51 + key === "image" && 52 + value && 53 + typeof value === "object" && 54 + "tag" in value 55 + ) { 56 + const tag = (value as { tag?: unknown }).tag; 57 + if (tag) { 58 + found.push({ 59 + path: [...pathSegments, "image"].join("."), 60 + tag: String(tag), 61 + }); 62 + } 63 + } 64 + found.push(...findImageTags(value, [...pathSegments, key])); 65 + } 66 + 67 + return found; 68 + } 69 + 70 + export async function checkPinnedImages(): Promise<string[]> { 71 + const deployed = await deployedChartNames(); 72 + const errors: string[] = []; 73 + 74 + const glob = new Bun.Glob("k8s/charts/*/values.yaml"); 75 + for await (const valuesPath of glob.scan(".")) { 76 + const chartName = chartNameFromPath(valuesPath); 77 + if (!deployed.has(chartName)) continue; 78 + if (ALLOW_FLOATING.has(chartName)) continue; 79 + 80 + const values = Bun.YAML.parse(await Bun.file(valuesPath).text()); 81 + for (const { path, tag } of findImageTags(values)) { 82 + if (!DIGEST_SUFFIX.test(tag)) { 83 + errors.push( 84 + `${chartName}: ${path}.tag '${tag}' is not pinned to a sha256 digest`, 85 + ); 86 + } 87 + } 88 + } 89 + 90 + return errors; 91 + }
+27
scripts/check-release-names.ts
··· 1 + // Every helm release in helmfile.yaml that points to a local chart 2 + // (./charts/<name>) must reference an actual k8s/charts/<name>/Chart.yaml. 3 + 4 + const HELMFILE = "k8s/helmfile.yaml"; 5 + 6 + type Release = { name: string; chart: string }; 7 + type Helmfile = { releases: Release[] }; 8 + 9 + export async function checkReleaseNames(): Promise<string[]> { 10 + const helmfile = Bun.YAML.parse(await Bun.file(HELMFILE).text()) as Helmfile; 11 + const errors: string[] = []; 12 + 13 + for (const release of helmfile.releases) { 14 + const isLocalChart = release.chart.startsWith("./charts/"); 15 + if (!isLocalChart) continue; 16 + 17 + const chartYaml = release.chart.replace(/^\.\//, "k8s/") + "/Chart.yaml"; 18 + const exists = await Bun.file(chartYaml).exists(); 19 + if (!exists) { 20 + errors.push( 21 + `release '${release.name}' points to ${release.chart} but ${chartYaml} does not exist`, 22 + ); 23 + } 24 + } 25 + 26 + return errors; 27 + }
+28
scripts/check.ts
··· 1 + #!/usr/bin/env bun 2 + // Runs every consistency check and reports all failures (so one broken 3 + // check doesn't hide another). Exits non-zero if any check fails. 4 + 5 + import { checkForwardAuth } from "./check-forward-auth.ts"; 6 + import { checkPinnedImages } from "./check-pinned-images.ts"; 7 + import { checkReleaseNames } from "./check-release-names.ts"; 8 + 9 + const checks = [ 10 + { name: "forward-auth", run: checkForwardAuth }, 11 + { name: "release-names", run: checkReleaseNames }, 12 + { name: "pinned-images", run: checkPinnedImages }, 13 + ]; 14 + 15 + let failedCount = 0; 16 + for (const check of checks) { 17 + const errors = await check.run(); 18 + if (errors.length === 0) { 19 + console.log(`${check.name}: ✓`); 20 + continue; 21 + } 22 + failedCount++; 23 + for (const error of errors) { 24 + console.error(`${check.name}: ${error}`); 25 + } 26 + } 27 + 28 + if (failedCount > 0) process.exit(1);
+8
scripts/package.json
··· 1 + { 2 + "name": "cute-haus-scripts", 3 + "private": true, 4 + "type": "module", 5 + "devDependencies": { 6 + "@types/bun": "latest" 7 + } 8 + }
+13
scripts/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ESNext"], 4 + "target": "ESNext", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "moduleDetection": "force", 8 + "types": ["bun"], 9 + "strict": true, 10 + "skipLibCheck": true, 11 + "noEmit": true 12 + } 13 + }
+119
scripts/update-caddy-tailscale.ts
··· 1 + #!/usr/bin/env bun 2 + // Bumps the caddy-tailscale plugin pseudo-version + content hash to the 3 + // latest commit on master, then commits the change. If nothing changed, 4 + // exits silently. Mirrors the original bash recipe. 5 + // 6 + // Two-step build dance: 7 + // 1. Update the version, try to build with the current hash. 8 + // 2. If nix rejects the hash, blank it out, build again to capture 9 + // the correct hash from nix's "got: sha256-..." error, write it back. 10 + 11 + import { $ } from "bun"; 12 + 13 + const CADDY_FILE = "nix/modules/nixos/services/caddy/default.nix"; 14 + const GITHUB_REPO = "tailscale/caddy-tailscale"; 15 + const NIX_BUILD_TARGET = 16 + ".#nixosConfigurations.celestic.config.services.caddy.package"; 17 + 18 + type GithubCommit = { 19 + sha: string; 20 + commit: { committer: { date: string } }; 21 + }; 22 + 23 + async function fetchLatestVersion(): Promise<string> { 24 + const url = `https://api.github.com/repos/${GITHUB_REPO}/commits?per_page=1`; 25 + const response = await fetch(url); 26 + if (!response.ok) { 27 + console.error(`github api: ${response.status} ${response.statusText}`); 28 + process.exit(1); 29 + } 30 + const [latestCommit] = (await response.json()) as GithubCommit[]; 31 + const shortSha = latestCommit.sha.slice(0, 12); 32 + // YYYY-MM-DDTHH:MM:SSZ → YYYYMMDDHHMMSS, the Go pseudo-version style. 33 + const timestamp = latestCommit.commit.committer.date.replaceAll( 34 + /[-T:Z]/g, 35 + "", 36 + ); 37 + return `v0.0.0-${timestamp}-${shortSha}`; 38 + } 39 + 40 + async function readCaddyNix(): Promise<string> { 41 + return Bun.file(CADDY_FILE).text(); 42 + } 43 + 44 + async function writeCaddyNix(contents: string): Promise<void> { 45 + await Bun.write(CADDY_FILE, contents); 46 + } 47 + 48 + function currentVersionIn(caddyNix: string): string { 49 + const match = caddyNix.match(/caddy-tailscale@([^"]+)/); 50 + if (!match) { 51 + console.error(`could not find caddy-tailscale version in ${CADDY_FILE}`); 52 + process.exit(1); 53 + } 54 + return match[1]; 55 + } 56 + 57 + async function tryBuild(): Promise<{ ok: boolean; output: string }> { 58 + const result = await $`nix build ${NIX_BUILD_TARGET}`.quiet().nothrow(); 59 + const output = result.stderr.toString() + result.stdout.toString(); 60 + return { ok: result.exitCode === 0, output }; 61 + } 62 + 63 + async function commit(message: string): Promise<void> { 64 + await $`git add ${CADDY_FILE}`; 65 + await $`git commit -m ${message}`; 66 + console.log(`done — committed`); 67 + } 68 + 69 + // --- main --- 70 + 71 + const latestVersion = await fetchLatestVersion(); 72 + let caddyNix = await readCaddyNix(); 73 + const oldVersion = currentVersionIn(caddyNix); 74 + 75 + const versionChanged = oldVersion !== latestVersion; 76 + if (versionChanged) { 77 + console.log(`updating caddy-tailscale: ${oldVersion} → ${latestVersion}`); 78 + caddyNix = caddyNix.replace( 79 + /caddy-tailscale@[^"]+/, 80 + `caddy-tailscale@${latestVersion}`, 81 + ); 82 + await writeCaddyNix(caddyNix); 83 + } else { 84 + console.log(`caddy-tailscale already at ${latestVersion}`); 85 + } 86 + 87 + const firstAttempt = await tryBuild(); 88 + if (firstAttempt.ok) { 89 + if (versionChanged) { 90 + await commit(`caddy: update caddy-tailscale to ${latestVersion}`); 91 + } else { 92 + console.log("already up to date"); 93 + } 94 + process.exit(0); 95 + } 96 + 97 + // Hash mismatch — blank it so nix tells us the correct one. 98 + console.log("hash is stale, fetching new hash..."); 99 + caddyNix = (await readCaddyNix()).replace(/hash = "sha256-[^"]*"/, 'hash = ""'); 100 + await writeCaddyNix(caddyNix); 101 + 102 + const blankAttempt = await tryBuild(); 103 + const newHash = blankAttempt.output.match(/got:\s+(sha256-\S+)/)?.[1]; 104 + if (!newHash) { 105 + console.error("could not extract new hash from nix output"); 106 + process.stderr.write(blankAttempt.output); 107 + process.exit(1); 108 + } 109 + 110 + caddyNix = (await readCaddyNix()).replace('hash = ""', `hash = "${newHash}"`); 111 + await writeCaddyNix(caddyNix); 112 + console.log(`updated hash: ${newHash}; verifying build`); 113 + await $`nix build ${NIX_BUILD_TARGET}`; 114 + 115 + await commit( 116 + versionChanged 117 + ? `caddy: update caddy-tailscale to ${latestVersion}` 118 + : `caddy: update caddy-tailscale hash to ${newHash}`, 119 + );
secrets/pg-roles.yaml.new

This is a binary file and will not be displayed.

-6
terraform/.envrc
··· 1 - #!/usr/bin/env bash 2 - CLOUDFLARE_API_TOKEN="$(sops -d --extract '["api_token"]' ../secrets/cloudflare.yaml)" 3 - export CLOUDFLARE_API_TOKEN 4 - 5 - HCLOUD_TOKEN="$(sops -d --extract '["api_token"]' ../secrets/hetzner.yaml)" 6 - export HCLOUD_TOKEN