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