diff --git a/.github/deprecated/cd.yaml b/.github/deprecated/cd.yaml new file mode 100644 index 000000000..4b9386dcf --- /dev/null +++ b/.github/deprecated/cd.yaml @@ -0,0 +1,407 @@ +name: CD +on: + workflow_dispatch: + inputs: + job: + description: specific job to run (leave empty to run all) + required: false + type: string + debug_enabled: + description: "Run the workflow with tmate.io debugging enabled" + required: false + type: boolean + default: false + deploy_enabled: + description: "Deploy documentation to Cloudflare Workers" + required: false + type: boolean + default: false + force_run: + description: "Force execution even if already successful for this commit" + required: false + type: boolean + default: false + workflow_call: + inputs: + target_configs: + description: comma-separated list of configs to build + required: false + type: string + cache_control: + description: cache control (use_cache, skip_cache) + required: false + type: string + default: use_cache + job_selection: + description: comma-separated list of jobs to run + required: false + type: string + pull_request: + types: [opened, reopened, synchronize] + paths-ignore: + - "*.md" + push: + branches: + - "main" + paths-ignore: + - "*.md" + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +permissions: + contents: read + deployments: write + +jobs: + set-variables: + runs-on: ubuntu-latest + if: | + !cancelled() && + (github.event_name != 'workflow_dispatch' || + inputs.job == '' || + inputs.job == 'set-variables') + outputs: + debug: ${{ steps.set-variables.outputs.debug }} + deploy_enabled: ${{ steps.set-variables.outputs.deploy_enabled }} + deploy_environment: ${{ steps.set-variables.outputs.deploy_environment }} + checkout_ref: ${{ steps.set-variables.outputs.checkout_ref }} + checkout_rev: ${{ steps.set-variables.outputs.checkout_rev }} + sanitized_branch: ${{ steps.set-variables.outputs.sanitized_branch }} + packages: ${{ steps.discover-packages.outputs.packages }} + force-ci: ${{ steps.compute-force-ci.outputs.force-ci }} + + steps: + - name: Set action variables + id: set-variables + run: | + DEBUG="false" + DEPLOY_ENABLED="false" + DEPLOY_ENVIRONMENT="preview" + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + DEBUG="${{ inputs.debug_enabled }}" + DEPLOY_ENABLED="${{ inputs.deploy_enabled }}" + fi + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + CHECKOUT_REF="${{ github.event.pull_request.head.ref }}" + CHECKOUT_REV="${{ github.event.pull_request.head.sha }}" + else + CHECKOUT_REF="${{ github.ref_name }}" + CHECKOUT_REV="${{ github.sha }}" + fi + + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then + DEPLOY_ENABLED="true" + DEPLOY_ENVIRONMENT="production" + fi + + # Sanitize for Cloudflare subdomain label (≤63 chars; truncate to 40 for safety) + SANITIZED_BRANCH=$(echo "$CHECKOUT_REF" | tr '/' '-' | tr -c 'a-zA-Z0-9-' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-40) + + echo "debug=$DEBUG" >> $GITHUB_OUTPUT + echo "deploy_enabled=$DEPLOY_ENABLED" >> $GITHUB_OUTPUT + echo "deploy_environment=$DEPLOY_ENVIRONMENT" >> $GITHUB_OUTPUT + echo "checkout_ref=$CHECKOUT_REF" >> $GITHUB_OUTPUT + echo "checkout_rev=$CHECKOUT_REV" >> $GITHUB_OUTPUT + echo "sanitized_branch=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT + + - name: Compute force-ci flag + id: compute-force-ci + run: | + # Compute once for all jobs: workflow_dispatch force_run input OR force-ci PR label + if [[ "${{ inputs.force_run }}" == "true" ]] || \ + [[ "${{ github.event_name }}" == "pull_request" && "${{ contains(github.event.pull_request.labels.*.name, 'force-ci') }}" == "true" ]]; then + echo "force-ci=true" >> $GITHUB_OUTPUT + else + echo "force-ci=false" >> $GITHUB_OUTPUT + fi + + - name: Checkout for package discovery + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Setup Nix + uses: ./.github/actions/setup-nix + with: + installer: full + system: x86_64-linux + + # alternative: extractions/setup-just@v3 (blocked on node20->node24 upgrade via extractions/setup-crate#9) + - name: Install just + uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 # v2.75.19 + with: + tool: just@1 + + - name: Discover packages + id: discover-packages + run: | + PACKAGES=$(just list-packages-json) + echo "packages=$PACKAGES" >> $GITHUB_OUTPUT + echo "Discovered packages: $PACKAGES" + + preview-release-version: + needs: [set-variables] + if: | + !cancelled() && + github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.set-variables.outputs.packages) }} + runs-on: ubuntu-latest + # semantic-release verifyAuth requires push permission even in dry-run mode + # https://github.com/semantic-release/semantic-release/blob/v25.0.1/index.js#L87-L98 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.head_ref }} # Checkout actual PR branch, not merge commit + fetch-depth: 0 # Full history needed for semantic-release analysis + fetch-tags: true # Explicitly fetch all tags for version detection + + - name: Check execution cache + id: cache + uses: ./.github/actions/cached-ci-job + with: + check-name: ${{ matrix.package.name }}-preview-release + hash-sources: "packages/${{ matrix.package.name }}/**/* .github/actions/setup-nix/action.yml .github/workflows/cd.yaml flake.nix flake.lock bun.lock modules/apps/docs/**/* pkgs/by-name/vanixiets-docs/**/*" + # Always run: semantic-release analyzes commit history which changes constantly + force-run: "true" + + - name: Fetch target branch for preview + if: steps.cache.outputs.should-run == 'true' + run: | + git fetch origin + git branch -f main origin/main + + - name: Configure git identity for temporary commits + if: steps.cache.outputs.should-run == 'true' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Nix + if: steps.cache.outputs.should-run == 'true' + uses: ./.github/actions/setup-nix + with: + installer: full + system: x86_64-linux + + - name: Setup tmate debug session + if: steps.cache.outputs.should-run == 'true' && needs.set-variables.outputs.debug == 'true' + uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3 + + - name: Preview version for ${{ matrix.package.name }} + if: steps.cache.outputs.should-run == 'true' + env: + CURRENT_BRANCH: ${{ github.head_ref }} + PACKAGE_PATH: ${{ matrix.package.path }} + run: | + echo "::group::Preview semantic-release version" + OUTPUT=$(nix run --accept-flake-config .#preview-version -- main "$PACKAGE_PATH" 2>&1 | tee /dev/stderr) + echo "::endgroup::" + + # Extract and annotate the next version (grep returns 1 on no match, so suppress with || true) + VERSION=$(echo "$OUTPUT" | grep "next version:" | awk '{print $3}') || true + if [ -n "$VERSION" ]; then + echo "::notice title=Next Version (${{ matrix.package.name }})::$VERSION" + else + echo "::notice title=Next Version (${{ matrix.package.name }})::No release pending" + fi + + - name: Create job result marker + # Only create marker if cache didn't exist (cache-source == 'none') + # With force-run: 'true', job runs even on cache hit, but we shouldn't overwrite existing cache + if: success() && steps.cache.outputs.should-run == 'true' && steps.cache.outputs.cache-source == 'none' + shell: bash + run: | + mkdir -p "${{ steps.cache.outputs.cache-path }}" + cat > "${{ steps.cache.outputs.cache-path }}/marker" < /dev/null; then + echo "nix not found in PATH" + exit 1 + fi + echo "nix found at: $(command -v nix)" + echo "nix store path: $(readlink -f $(which nix))" + nix --version + + - name: verify direnv configured + if: steps.cache.outputs.should-run == 'true' + run: | + if ! command -v direnv &> /dev/null; then + echo "direnv not found in PATH" + exit 1 + fi + echo "direnv found at: $(command -v direnv)" + + - name: run make verify + if: steps.cache.outputs.should-run == 'true' + run: make verify + + - name: run make setup-user + if: steps.cache.outputs.should-run == 'true' + run: make setup-user + + - name: verify age key generated + if: steps.cache.outputs.should-run == 'true' + run: | + if [ ! -f ~/.config/sops/age/keys.txt ]; then + echo "age key not generated" + exit 1 + fi + echo "age key generated successfully" + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh && \ + nix shell nixpkgs#age -c age-keygen -y ~/.config/sops/age/keys.txt + + - name: Create job result marker + if: success() && steps.cache.outputs.should-run == 'true' + shell: bash + run: | + mkdir -p "${{ steps.cache.outputs.cache-path }}" + cat > "${{ steps.cache.outputs.cache-path }}/marker" <> $GITHUB_OUTPUT - echo "deploy_enabled=$DEPLOY_ENABLED" >> $GITHUB_OUTPUT - echo "deploy_environment=$DEPLOY_ENVIRONMENT" >> $GITHUB_OUTPUT - echo "checkout_ref=$CHECKOUT_REF" >> $GITHUB_OUTPUT - echo "checkout_rev=$CHECKOUT_REV" >> $GITHUB_OUTPUT - echo "sanitized_branch=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT + run: echo "debug=${{ inputs.debug_enabled }}" >> "$GITHUB_OUTPUT" - name: Compute force-ci flag id: compute-force-ci - run: | - # Compute once for all jobs: workflow_dispatch force_run input OR force-ci PR label - if [[ "${{ inputs.force_run }}" == "true" ]] || \ - [[ "${{ github.event_name }}" == "pull_request" && "${{ contains(github.event.pull_request.labels.*.name, 'force-ci') }}" == "true" ]]; then - echo "force-ci=true" >> $GITHUB_OUTPUT - else - echo "force-ci=false" >> $GITHUB_OUTPUT - fi - - - name: Checkout for package discovery - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - sparse-checkout: | - packages - justfile - sparse-checkout-cone-mode: false - - # alternative: extractions/setup-just@v3 (blocked on node20->node24 upgrade via extractions/setup-crate#9) - - name: Install just - uses: taiki-e/install-action@5f57d6cb7cd20b14a8a27f522884c4bc8a187458 # v2.75.19 - with: - tool: just@1 - - - name: Discover packages - id: discover-packages - run: | - PACKAGES=$(just list-packages-json) - echo "packages=$PACKAGES" >> $GITHUB_OUTPUT - echo "Discovered packages: $PACKAGES" - - # job 2: preview-release-version - # Preview semantic-release version for each package (PR only, fast feedback) - preview-release-version: - needs: [set-variables] - if: | - !cancelled() && - github.event_name == 'pull_request' - strategy: - fail-fast: false - matrix: - package: ${{ fromJson(needs.set-variables.outputs.packages) }} - runs-on: ubuntu-latest - # semantic-release verifyAuth requires push permission even in dry-run mode - # https://github.com/semantic-release/semantic-release/blob/v25.0.1/index.js#L87-L98 - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.head_ref }} # Checkout actual PR branch, not merge commit - fetch-depth: 0 # Full history needed for semantic-release analysis - fetch-tags: true # Explicitly fetch all tags for version detection - - - name: Check execution cache - id: cache - uses: ./.github/actions/cached-ci-job - with: - check-name: ${{ matrix.package.name }}-preview-release - hash-sources: 'packages/${{ matrix.package.name }}/**/* .github/actions/setup-nix/action.yml .github/workflows/cd.yaml flake.nix flake.lock bun.lock modules/apps/docs/**/* pkgs/by-name/vanixiets-docs/**/*' - # Always run: semantic-release analyzes commit history which changes constantly - force-run: 'true' - - - name: Fetch target branch for preview - if: steps.cache.outputs.should-run == 'true' - run: | - git fetch origin - git branch -f main origin/main - - - name: Configure git identity for temporary commits - if: steps.cache.outputs.should-run == 'true' - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Setup Nix - if: steps.cache.outputs.should-run == 'true' - uses: ./.github/actions/setup-nix - with: - installer: quick - system: x86_64-linux - - - name: Setup tmate debug session - if: steps.cache.outputs.should-run == 'true' && needs.set-variables.outputs.debug == 'true' - uses: mxschmitt/action-tmate@c0afd6f790e3a5564914980036ebf83216678101 # v3 - - - name: Preview version for ${{ matrix.package.name }} - if: steps.cache.outputs.should-run == 'true' - env: - CURRENT_BRANCH: ${{ github.head_ref }} - PACKAGE_PATH: ${{ matrix.package.path }} - run: | - echo "::group::Preview semantic-release version" - OUTPUT=$(nix run --accept-flake-config .#preview-version -- main "$PACKAGE_PATH" 2>&1 | tee /dev/stderr) - echo "::endgroup::" - - # Extract and annotate the next version (grep returns 1 on no match, so suppress with || true) - VERSION=$(echo "$OUTPUT" | grep "next version:" | awk '{print $3}') || true - if [ -n "$VERSION" ]; then - echo "::notice title=Next Version (${{ matrix.package.name }})::$VERSION" - else - echo "::notice title=Next Version (${{ matrix.package.name }})::No release pending" - fi - - - name: Create job result marker - # Only create marker if cache didn't exist (cache-source == 'none') - # With force-run: 'true', job runs even on cache hit, but we shouldn't overwrite existing cache - if: success() && steps.cache.outputs.should-run == 'true' && steps.cache.outputs.cache-source == 'none' - shell: bash - run: | - mkdir -p "${{ steps.cache.outputs.cache-path }}" - cat > "${{ steps.cache.outputs.cache-path }}/marker" <> "$GITHUB_OUTPUT" - - name: Save job result to cache - if: success() && steps.cache.outputs.should-run == 'true' && steps.cache.outputs.cache-source == 'none' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: ${{ steps.cache.outputs.cache-path }} - key: ${{ steps.cache.outputs.cache-key }} - - # job 3: preview-docs-deploy - # Deploy docs to preview environment (PR only, fast feedback) - preview-docs-deploy: - needs: [set-variables] - if: | - !cancelled() && - github.event_name == 'pull_request' - permissions: - contents: read - deployments: write - uses: ./.github/workflows/deploy-docs.yaml - with: - branch: ${{ github.head_ref }} - sanitized_branch: ${{ needs.set-variables.outputs.sanitized_branch }} - environment: preview - debug_enabled: ${{ needs.set-variables.outputs.debug }} - force_run: ${{ needs.set-variables.outputs.force-ci }} - secrets: inherit - - # job 4: bootstrap-verification - # validates Makefile bootstrap workflow on clean ubuntu system bootstrap-verification: needs: [set-variables] runs-on: ubuntu-latest if: | !cancelled() && - (github.event_name != 'workflow_dispatch' || - inputs.job == '' || - inputs.job == 'bootstrap-verification') + (inputs.job == '' || inputs.job == 'bootstrap-verification') steps: - name: checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - fetch-depth: 0 # for git diff in composite action + fetch-depth: 0 # for git diff in composite action - name: Check execution cache id: cache uses: ./.github/actions/cached-ci-job with: check-name: ${{ github.job }} - hash-sources: 'Makefile .envrc .github/actions/setup-nix/action.yml' + hash-sources: "Makefile .envrc .github/actions/setup-nix/action.yml" force-run: ${{ needs.set-variables.outputs.force-ci }} - name: run make bootstrap @@ -358,66 +132,14 @@ jobs: path: ${{ steps.cache.outputs.cache-path }} key: ${{ steps.cache.outputs.cache-key }} - # job 7: test-cluster - # validates kubernetes manifests and local cluster integration - # informational only - does not block production releases + # Informational only - does not block production releases test-cluster: needs: [set-variables] if: | !cancelled() && - (github.event_name != 'workflow_dispatch' || - inputs.job == '' || - inputs.job == 'test-cluster') + (inputs.job == '' || inputs.job == 'test-cluster') uses: ./.github/workflows/test-cluster.yaml with: debug_enabled: ${{ needs.set-variables.outputs.debug }} secrets: inherit - # job 11: production-release-packages - # Release packages via semantic-release on main/beta branches - # Semantic-release determines if actual release is needed - production-release-packages: - needs: [set-variables] - if: | - !cancelled() && - github.repository_owner == 'cameronraysmith' && - (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && - (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') - strategy: - fail-fast: false - matrix: - package: ${{ fromJson(needs.set-variables.outputs.packages) }} - permissions: - contents: write - id-token: write - uses: ./.github/workflows/package-release.yaml - with: - package-path: ${{ matrix.package.path }} - package-name: ${{ matrix.package.name }} - release-dry-run: false - debug-enabled: ${{ needs.set-variables.outputs.debug == 'true' }} - checkout-ref: ${{ needs.set-variables.outputs.checkout_ref }} - force-run: ${{ needs.set-variables.outputs.force-ci }} - secrets: - SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} - - # job 12: production-docs-deploy - # Documentation deployment to production (conditional) - # Depends on production-release-packages to ensure packages are released first - production-docs-deploy: - needs: [set-variables, production-release-packages] - if: | - !cancelled() && - needs.production-release-packages.result == 'success' && - needs.set-variables.outputs.deploy_enabled == 'true' && - (github.event_name != 'workflow_dispatch' || - inputs.job == '' || - inputs.job == 'docs-deploy') - uses: ./.github/workflows/deploy-docs.yaml - with: - debug_enabled: ${{ needs.set-variables.outputs.debug }} - branch: ${{ needs.set-variables.outputs.checkout_ref }} - sanitized_branch: ${{ needs.set-variables.outputs.sanitized_branch }} - environment: ${{ needs.set-variables.outputs.deploy_environment }} - force_run: ${{ needs.set-variables.outputs.force-ci }} - secrets: inherit diff --git a/flake.lock b/flake.lock index 82fecd6f4..78dcb80b2 100644 --- a/flake.lock +++ b/flake.lock @@ -677,6 +677,29 @@ "type": "github" } }, + "hercules-ci-effects_2": { + "inputs": { + "flake-parts": [ + "flake-parts" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1776603440, + "narHash": "sha256-wA+ONiwbvQIy7ERJx/ruhV7y5xku6XKstXCII5bIbdI=", + "owner": "hercules-ci", + "repo": "hercules-ci-effects", + "rev": "e2456ee419f9d75f8382e3d6c5af4690b316a5a8", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "hercules-ci-effects", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -758,11 +781,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1777019177, - "narHash": "sha256-YjPvucTsKmGO9QVNz07x7sSsK11PB0jtMniRkTolbq4=", + "lastModified": 1777078377, + "narHash": "sha256-2EykR9XvDFwO2t/pWgKzfmwwjGX2YsD6LPU4D2PuWyY=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "8ff0f2a7fcd176b4547da6879ad549de2bbded41", + "rev": "f5f1cc1c90316b8ef96cf009ce3a290e6955da80", "type": "github" }, "original": { @@ -1365,6 +1388,7 @@ "flake-parts": "flake-parts", "gateway-api-src": "gateway-api-src", "git-hooks": "git-hooks", + "hercules-ci-effects": "hercules-ci-effects_2", "home-manager": "home-manager", "import-tree": "import-tree", "lazyvim-nix": "lazyvim-nix", diff --git a/flake.nix b/flake.nix index 0457c36ca..077b0a639 100644 --- a/flake.nix +++ b/flake.nix @@ -148,6 +148,10 @@ buildbot-nix.inputs.nixpkgs.follows = "nixpkgs"; buildbot-nix.inputs.flake-parts.follows = "flake-parts"; buildbot-nix.inputs.treefmt-nix.follows = "treefmt-nix"; + + hercules-ci-effects.url = "github:hercules-ci/hercules-ci-effects"; + hercules-ci-effects.inputs.flake-parts.follows = "flake-parts"; + hercules-ci-effects.inputs.nixpkgs.follows = "nixpkgs"; }; # sync with lib/caches.nix for machine modules diff --git a/justfile b/justfile index 3b974e864..8e5f88281 100644 --- a/justfile +++ b/justfile @@ -1,18 +1,8 @@ -# This is a jusfile for the vanixiets repository. -# Sections are separated by ## and recipes are documented with a single # -# on lines preceding the recipe. - -## nix -## clan -## k3d -## secrets -## sops -## CI/CD +# justfile for vanixiets. Sections separated by ##; recipes documented with single # on the preceding line. nix_cmd := "nix --accept-flake-config" # Default command when 'just' is run without arguments -# Run 'just ' to execute a command. default: help # Display help @@ -231,7 +221,6 @@ nix-flake-io: tests_count=$(nix eval --raw .#tests --apply 'x: toString (builtins.length (builtins.attrNames x))' 2>/dev/null || echo "0") echo "(${tests_count} top-level test attrs)" - # Flake inputs printf "\n## inputs\n" nix flake metadata --json 2>/dev/null | jq -r '.locks.nodes | keys[] | select(. != "root")' @@ -331,7 +320,26 @@ bootstrap-shell: "nixpkgs#git" \ "nixpkgs#just" -# nix run home-manager -- build --flake ".#{{ profile }}" +# Idempotent post-nix bootstrap: install direnv if missing, report status +# Body lives in modules/apps/bootstrap/bootstrap.{nix,sh}. +# Chicken-and-egg: for first-contact nix install, use `make bootstrap`. +[group('bootstrap')] +bootstrap *ARGS: + {{nix_cmd}} run --no-warn-dirty .#bootstrap -- {{ARGS}} + +# Verify host nix/flakes/direnv/flake-metadata (mirror of `make verify`) +# Body lives in modules/apps/bootstrap/verify.{nix,sh}. +[group('bootstrap')] +bootstrap-verify *ARGS: + {{nix_cmd}} run --no-warn-dirty .#verify -- {{ARGS}} + +# Generate ~/.config/sops/age/keys.txt (mirror of `make setup-user`) +# Body lives in modules/apps/bootstrap/setup-user.{nix,sh}. +# Idempotent: re-print public key and exit 0 if the key already exists. +[group('bootstrap')] +bootstrap-setup-user *ARGS: + {{nix_cmd}} run --no-warn-dirty .#setup-user -- {{ARGS}} + # Bootstrap build home-manager with flake [group('nix-home-manager')] home-manager-bootstrap-build profile="aarch64-linux": @@ -343,7 +351,6 @@ home-manager-bootstrap-build profile="aarch64-linux": --show-trace \ --print-build-logs -# nix run home-manager -- switch --flake ".#{{ profile }}" # Bootstrap switch home-manager with flake [group('nix-home-manager')] home-manager-bootstrap-switch profile="aarch64-linux": @@ -749,15 +756,26 @@ docs-test-e2e-report: docs-test-coverage: cd packages/docs && bun run test:coverage -# Deploy documentation to Cloudflare Workers (preview) +# Deploy documentation to Cloudflare Workers (preview). +# Wraps with `sops exec-env secrets/shared.yaml ''` so +# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are exported per the +# deploy-docs env-var contract (ADR-002 / env-var-contract-design.md +# §2.1.3 Call site A). Devs with a local `.env` already exporting the +# vars can skip the wrap; the sops prefix is idempotent and keeps fresh +# clones without `.env` working. `sops exec-env` requires exactly two +# positional args (file + single shell-command string), so the nix-run +# invocation is quoted as one arg. [group('docs')] docs-deploy-preview branch=`git branch --show-current`: - nix run --accept-flake-config .#deploy-docs -- preview "{{branch}}" + sops exec-env secrets/shared.yaml \ + 'nix run --accept-flake-config .#deploy-docs -- preview "{{branch}}"' -# Deploy documentation to Cloudflare Workers (production) +# Deploy documentation to Cloudflare Workers (production). +# See docs-deploy-preview header for the sops wrap rationale. [group('docs')] docs-deploy-production: - nix run --accept-flake-config .#deploy-docs -- production + sops exec-env secrets/shared.yaml \ + 'nix run --accept-flake-config .#deploy-docs -- production' # List recent Cloudflare deployments [group('docs')] @@ -898,49 +916,17 @@ k3d-up: # Bootstrap secrets required before first deployment (idempotent) # Supports both CI (SOPS_AGE_KEY env var) and local dev (file-based) workflows +# Body lives in modules/apps/cluster/k3d-bootstrap-secrets.{nix,sh}. [group('k3d')] -k3d-bootstrap-secrets: - #!/usr/bin/env bash - set -euo pipefail - kubectl create namespace sops-secrets-operator --dry-run=client -o yaml | kubectl apply -f - - # Determine age key file: env var (CI) or local file (dev) - if [ -n "${SOPS_AGE_KEY:-}" ]; then - echo "Using SOPS_AGE_KEY from environment variable" - KEYFILE=$(mktemp) - echo "${SOPS_AGE_KEY}" > "$KEYFILE" - trap "rm -f '$KEYFILE'" EXIT - else - echo "Using SOPS age key from file: ${HOME}/.config/sops/age/keys.txt" - KEYFILE="${HOME}/.config/sops/age/keys.txt" - fi - kubectl create secret generic sops-age-key \ - --namespace=sops-secrets-operator \ - --from-file=age.key="$KEYFILE" \ - --dry-run=client -o yaml | kubectl apply -f - +k3d-bootstrap-secrets *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-bootstrap-secrets -- {{ARGS}} # Configure CoreDNS to forward sslip.io queries to public DNS resolvers # Required because OrbStack's DNS (192.168.107.1) cannot resolve sslip.io wildcards +# Body lives in modules/apps/cluster/k3d-configure-dns.{nix,sh}. [group('k3d')] -k3d-configure-dns: - #!/usr/bin/env bash - set -euo pipefail - echo "Waiting for CoreDNS to be running..." - kubectl wait --for=condition=Ready pod -l k8s-app=kube-dns -n kube-system --timeout=120s - echo "Patching CoreDNS ConfigMap to forward sslip.io to public DNS..." - CURRENT=$(kubectl get configmap coredns -n kube-system -o jsonpath='{.data.Corefile}') - if echo "$CURRENT" | grep -q "sslip.io"; then - echo "CoreDNS already configured for sslip.io forwarding" - exit 0 - fi - SSLIP_BLOCK=$'sslip.io:53 {\n forward . 1.1.1.1 8.8.8.8\n cache 30\n}\n' - PATCHED="${SSLIP_BLOCK}${CURRENT}" - PATCH_JSON=$(jq -n --arg corefile "$PATCHED" '{"data": {"Corefile": $corefile}}') - kubectl patch configmap coredns -n kube-system --type=merge -p "$PATCH_JSON" - echo "Restarting CoreDNS deployment..." - kubectl rollout restart deployment coredns -n kube-system - echo "Waiting for CoreDNS to be ready..." - kubectl rollout status deployment coredns -n kube-system --timeout=120s - echo "CoreDNS configured for sslip.io forwarding" +k3d-configure-dns *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-configure-dns -- {{ARGS}} # Delete local k3d cluster [group('k3d')] @@ -1009,11 +995,11 @@ k3d-deploy-infrastructure: {{nix_cmd}} run .#k8s-deploy-local-k3d-infrastructure -- --yes # Full k3d workflow: create cluster, bootstrap secrets, deploy all layers +# Body lives in modules/apps/cluster/k3d-full.{nix,sh}; delegates back to +# the k3d-down, k3d-up, and k3d-deploy recipes above. [group('k3d')] -k3d-full: - just k3d-down || true - just k3d-up - just k3d-deploy +k3d-full *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-full -- {{ARGS}} # Run all kubernetes tests (foundation + infrastructure) [group('k3d')] @@ -1032,106 +1018,23 @@ k3d-test-infrastructure: # Run tests with coverage report showing tested vs deployed resources # Respects NO_COLOR env var and auto-detects CI environments +# Body lives in modules/apps/cluster/k3d-test-coverage.{nix,sh}; +# scripts/k3d-test-coverage.sh retained as a thin backward-compat shim. [group('k3d')] k3d-test-coverage *ARGS: - ./scripts/k3d-test-coverage.sh {{ARGS}} + {{nix_cmd}} run --no-warn-dirty .#k3d-test-coverage -- {{ARGS}} # Wait for kluctl-deployed foundation and infrastructure pods to be ready +# Body lives in modules/apps/cluster/k3d-wait-ready.{nix,sh}. [group('k3d')] -k3d-wait-ready: - #!/usr/bin/env bash - set -euo pipefail - - echo "=== Waiting for Foundation (CNI) ===" - echo "Waiting for Cilium Agent..." - kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=cilium-agent -n kube-system --timeout=300s - - echo "Waiting for Cilium Operator..." - kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=cilium-operator -n kube-system --timeout=300s - - echo "" - echo "=== Waiting for Infrastructure ===" - echo "Waiting for ArgoCD deployments..." - kubectl wait --for=condition=Available deployment --all -n argocd --timeout=300s - - echo "Waiting for ArgoCD Application Controller..." - kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=argocd-application-controller -n argocd --timeout=300s - - echo "Waiting for step-ca..." - # Use StatefulSet pod label to exclude Helm test-connection pod (which always fails) - kubectl wait --for=condition=Ready pod -l statefulset.kubernetes.io/pod-name=step-ca-step-certificates-0 -n step-ca --timeout=300s - - echo "Waiting for sops-secrets-operator..." - kubectl wait --for=condition=Available deployment --all -n sops-secrets-operator --timeout=300s - - echo "" - echo "=== All foundation and infrastructure pods ready ===" +k3d-wait-ready *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-wait-ready -- {{ARGS}} # Wait for all ArgoCD Applications to reach Synced + Healthy status +# Body lives in modules/apps/cluster/k3d-wait-argocd-sync.{nix,sh}. [group('k3d')] -k3d-wait-argocd-sync: - #!/usr/bin/env bash - set -euo pipefail - - echo "=== Waiting for ArgoCD Applications ===" - echo "Applications managed by nixidy sync waves:" - echo " Wave -1 (adoption): cilium, argocd, sops-secrets-operator, step-ca" - echo " Wave 0: cert-manager" - echo " Wave 1-2: cluster-issuer, gateway, gateway-api" - echo " Wave 3: argocd-route" - echo "" - - # All expected applications (app-of-apps creates these asynchronously) - EXPECTED_APPS=( - apps - argocd - argocd-route - cert-manager - cilium - cluster-issuer - gateway - gateway-api - sops-secrets-operator - step-ca - ) - - echo "Waiting for all ${#EXPECTED_APPS[@]} applications to exist..." - for app in "${EXPECTED_APPS[@]}"; do - echo -n " Waiting for $app... " - # kubectl wait fails immediately if resource doesn't exist, so poll instead - timeout 300 bash -c "until kubectl get application/$app -n argocd &>/dev/null; do sleep 2; done" - echo "exists" - done - - echo "" - echo "Listing applications..." - kubectl get applications -n argocd -o wide || true - echo "" - - echo "Waiting for all applications to be Healthy..." - for app in "${EXPECTED_APPS[@]}"; do - echo -n " Waiting for $app to be Healthy... " - kubectl wait --for=jsonpath='{.status.health.status}'=Healthy application/"$app" -n argocd --timeout=600s >/dev/null - echo "done" - done - - echo "" - echo "Waiting for all applications to be Synced..." - for app in "${EXPECTED_APPS[@]}"; do - echo -n " Waiting for $app to be Synced... " - kubectl wait --for=jsonpath='{.status.sync.status}'=Synced application/"$app" -n argocd --timeout=300s >/dev/null - echo "done" - done - - echo "" - echo "=== Waiting for Gateway to be programmed ===" - # ArgoCD reports Healthy before Cilium fully programs the Gateway - # Wait for the actual Gateway condition, not just ArgoCD's view - kubectl wait --for=condition=Programmed gateway/main-gateway -n gateway-system --timeout=300s - - echo "" - echo "=== All ArgoCD applications synced and healthy ===" - kubectl get applications -n argocd -o wide +k3d-wait-argocd-sync *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-wait-argocd-sync -- {{ARGS}} # Full integration test: cluster creation, deployment, GitOps sync, and validation [group('k3d')] @@ -1168,52 +1071,10 @@ k3d-integration: # Full CI integration test: local manifests, cluster, GitOps sync, tests # Uses file:///manifests instead of remote repo - no GitHub credentials needed # The /tmp/k3d-manifests directory is volume-mounted into the cluster at /manifests +# Body lives in modules/apps/cluster/k3d-integration-ci.{nix,sh}. [group('k3d')] -k3d-integration-ci: - #!/usr/bin/env bash - set -euo pipefail - - echo "=== Phase 1: Build manifests with local repo URL ===" - export ARGOCD_REPO_URL="file:///manifests" - just nixidy-build - - echo "" - echo "=== Phase 2: Prepare local git repo (before cluster for volume mount) ===" - # Ensure writable before cleanup (Nix store copies may be read-only) - chmod -R +w /tmp/k3d-manifests 2>/dev/null || true - rm -rf /tmp/k3d-manifests - mkdir -p /tmp/k3d-manifests - rsync -aL --delete --chmod=Du+w,Fu+w result/ /tmp/k3d-manifests/ - cd /tmp/k3d-manifests - git init -b main - git config user.email "ci@localhost" - git config user.name "CI" - git add . - git commit -m "CI manifests" - cd - - - echo "" - echo "=== Phase 3: Create cluster and deploy via kluctl ===" - just k3d-full - - echo "" - echo "=== Phase 4: Wait for infrastructure ready ===" - just k3d-wait-ready - - echo "" - echo "=== Phase 5: Bootstrap ArgoCD (syncs from file:///manifests) ===" - just nixidy-bootstrap - - echo "" - echo "=== Phase 6: Wait for ArgoCD sync ===" - just k3d-wait-argocd-sync - - echo "" - echo "=== Phase 7: Run integration tests ===" - just k3d-test-coverage - - echo "" - echo "=== CI integration complete ===" +k3d-integration-ci *ARGS: + {{nix_cmd}} run --no-warn-dirty .#k3d-integration-ci -- {{ARGS}} ## nixidy (Phase 4 GitOps) # Per ADR-006: Rendered manifests are pushed to separate private repos per cluster. @@ -1223,9 +1084,10 @@ k3d-integration-ci: local_k3d_repo := env("LOCAL_K3D_REPO", home_directory() / "projects/nix-workspace/local-k3d") # Build nixidy manifests for local-k3d environment (renders to ./result) +# Body lives in modules/apps/cluster/nixidy-build.{nix,sh}. [group('nixidy')] -nixidy-build: - {{nix_cmd}} run .#nixidy -- build .#local-k3d +nixidy-build *ARGS: + {{nix_cmd}} run --no-warn-dirty .#nixidy-build -- {{ARGS}} # Show nixidy environment info [group('nixidy')] @@ -1234,49 +1096,26 @@ nixidy-info: # Push rendered manifests to local-k3d private repository # Prerequisites: nixidy-build must be run first, local-k3d repo must exist +# Body lives in modules/apps/cluster/nixidy-push.{nix,sh}. +# LOCAL_K3D_REPO env var overrides the default target path; justfile- +# level local_k3d_repo is preserved as a convenience for scripted callers. [group('nixidy')] -nixidy-push: - #!/usr/bin/env bash - set -euo pipefail - - if [[ ! -d "result" ]]; then - echo "Error: result/ directory not found. Run 'just nixidy-build' first." - exit 1 - fi - - if [[ ! -d "{{ local_k3d_repo }}" ]]; then - echo "Error: local-k3d repo not found at {{ local_k3d_repo }}" - echo "Clone it with: git clone git@github.com:cameronraysmith/local-k3d.git {{ local_k3d_repo }}" - exit 1 - fi - - echo "Syncing rendered manifests to {{ local_k3d_repo }}..." - # -L dereferences symlinks (nix store paths) to copy actual content - # --checksum compares by content hash (Nix store files have epoch timestamps) - # --chmod fixes read-only permissions from nix store - rsync -aL --delete --checksum --chmod=Du+w,Fu+w --exclude='.git' result/ "{{ local_k3d_repo }}/" - - echo "Committing and pushing to local-k3d repo..." - cd "{{ local_k3d_repo }}" - git add -A - if git diff --cached --quiet; then - echo "No changes to push." - else - git commit -m "chore: update rendered manifests from vanixiets" - git push - echo "Manifests pushed to local-k3d repo." - fi +nixidy-push *ARGS: + LOCAL_K3D_REPO="{{ local_k3d_repo }}" {{nix_cmd}} run --no-warn-dirty .#nixidy-push -- {{ARGS}} # Build and push manifests in one step +# Body lives in modules/apps/cluster/nixidy-sync.{nix,sh}. [group('nixidy')] -nixidy-sync: nixidy-build nixidy-push +nixidy-sync *ARGS: + LOCAL_K3D_REPO="{{ local_k3d_repo }}" {{nix_cmd}} run --no-warn-dirty .#nixidy-sync -- {{ARGS}} # Bootstrap ArgoCD app-of-apps (transition from Phase 3 to Phase 4) # Prerequisites: k3d-full must complete, manifests must be pushed to local-k3d repo # Note: ArgoCD needs credentials to access private repo (configure via argocd CLI or UI) +# Body lives in modules/apps/cluster/nixidy-bootstrap.{nix,sh}. [group('nixidy')] -nixidy-bootstrap: - {{nix_cmd}} run .#nixidy -- bootstrap .#local-k3d | kubectl apply -f - +nixidy-bootstrap *ARGS: + {{nix_cmd}} run --no-warn-dirty .#nixidy-bootstrap -- {{ARGS}} # Full GitOps workflow: Phase 3 bootstrap + Phase 4 ArgoCD takeover # Note: Requires local-k3d repo to exist and ArgoCD to have access credentials @@ -1359,29 +1198,22 @@ hash-encrypt source_file user="crs58": #!/usr/bin/env bash set -euo pipefail - # Generate content-based hash for filename HASH=$(nix hash file --type sha256 --base64 "{{source_file}}" | cut -d'-' -f2 | head -c 32) - # Extract base filename without extension BASE_NAME=$(basename "{{source_file}}" .yaml) BASE_NAME=$(basename "$BASE_NAME" .yml) - # Create target path TARGET_DIR="secrets/users/{{user}}" TARGET_FILE="${TARGET_DIR}/${HASH}-${BASE_NAME}.yaml" - # Ensure target directory exists mkdir -p "$TARGET_DIR" - # Copy file with hash-based name cp "{{source_file}}" "$TARGET_FILE" echo "Copied {{source_file}} → $TARGET_FILE" - # Encrypt in place with sops sops encrypt --in-place "$TARGET_FILE" echo "Encrypted $TARGET_FILE with sops" - # Display verification info echo "Hash: $HASH" echo "Final path: $TARGET_FILE" @@ -1391,21 +1223,16 @@ verify-hash original_file secret_file: #!/usr/bin/env bash set -euo pipefail - # Extract hash from secret filename SECRET_BASENAME=$(basename "{{secret_file}}") EXPECTED_HASH=$(echo "$SECRET_BASENAME" | cut -d'-' -f1) - # Generate hash of original file ACTUAL_HASH=$(nix hash file --type sha256 --base64 "{{original_file}}" | cut -d'-' -f2 | head -c 32) - # Create temporary file for decrypted content TEMP_FILE=$(mktemp) trap "rm -f $TEMP_FILE" EXIT - # Decrypt secret file to temp location sops decrypt "{{secret_file}}" > "$TEMP_FILE" - # Generate hash of decrypted content DECRYPTED_HASH=$(nix hash file --type sha256 --base64 "$TEMP_FILE" | cut -d'-' -f2 | head -c 32) echo "Original file: {{original_file}}" @@ -1415,7 +1242,6 @@ verify-hash original_file secret_file: echo "Decrypted hash: $DECRYPTED_HASH" echo - # Verify original matches filename hash if [ "$ACTUAL_HASH" = "$EXPECTED_HASH" ]; then echo "Original file hash matches secret filename hash" else @@ -1423,7 +1249,6 @@ verify-hash original_file secret_file: exit 1 fi - # Verify decrypted content matches original if [ "$DECRYPTED_HASH" = "$ACTUAL_HASH" ]; then echo "Decrypted content matches original file" else @@ -1473,10 +1298,8 @@ ci-run-watch workflow="ci.yaml": echo "triggering workflow: {{workflow}} on branch: $(git branch --show-current)" gh workflow run {{workflow}} --ref $(git branch --show-current) - # wait a moment for run to start sleep 5 - # get the latest run ID RUN_ID=$(gh run list --workflow={{workflow}} --limit 1 --json databaseId --jq '.[0].databaseId') echo "watching run: $RUN_ID" @@ -1554,9 +1377,6 @@ test-flake-workflow: --matrix os:ubuntu-latest \ --container-architecture linux/amd64' -# Command to run sethvargo/ratchet to pin GitHub Actions workflows version tags to commit hashes -# If not installed, you can use docker to run the command -# ratchet_base := "docker run -it --rm -v \"${PWD}:${PWD}\" -w \"${PWD}\" ghcr.io/sethvargo/ratchet:0.9.2" ratchet_base := "ratchet" # List of GitHub Actions workflows @@ -1590,7 +1410,6 @@ cache-rosetta-builder: echo "Finding nix-rosetta-builder VM image in current system..." - # Find the rosetta-builder.yaml from current system YAML_PATH=$(nix-store --query --requisites /run/current-system | grep 'rosetta-builder.yaml$' || true) if [ -z "$YAML_PATH" ]; then @@ -1613,7 +1432,6 @@ cache-rosetta-builder: IMAGE_SIZE=$(du -h "$IMAGE_PATH" | cut -f1) echo "Size: $IMAGE_SIZE" - # Push to cachix echo "" echo "Pushing to Cachix (this may take a few minutes for ~2GB image)..." sops exec-env secrets/shared.yaml "cachix push \$CACHIX_CACHE_NAME $IMAGE_PATH" @@ -1640,7 +1458,6 @@ check-rosetta-cache: echo "Checking if nix-rosetta-builder image is cached..." - # Find the image from current system YAML_PATH=$(nix-store --query --requisites /run/current-system | grep 'rosetta-builder.yaml$' || true) if [ -z "$YAML_PATH" ]; then @@ -1659,7 +1476,6 @@ check-rosetta-cache: echo "Checking cache for: $IMAGE_PATH" - # Check if the image is in cache CACHE_NAME=$(sops exec-env secrets/shared.yaml 'echo $CACHIX_CACHE_NAME') if {{nix_cmd}} path-info --store "https://$CACHE_NAME.cachix.org" "$IMAGE_PATH" &>/dev/null; then @@ -1680,15 +1496,12 @@ test-cachix: set -euo pipefail echo "Testing cachix push/pull..." - # Build a simple derivation STORE_PATH=$({{nix_cmd}} build nixpkgs#hello --no-link --print-out-paths) echo "Built: $STORE_PATH" - # Push to cachix echo "Pushing to cachix..." sops exec-env secrets/shared.yaml "cachix push \$CACHIX_CACHE_NAME $STORE_PATH" - # Verify it's in the cache by trying to pull it from another location CACHE_NAME=$(sops exec-env secrets/shared.yaml 'echo $CACHIX_CACHE_NAME') echo "● Push completed. Verify at: https://app.cachix.org/cache/$CACHE_NAME" echo "Store path: $STORE_PATH" @@ -1705,7 +1518,6 @@ cache-darwin-system: echo "Cache: https://app.cachix.org/cache/$CACHE_NAME" echo "" - # Check if already cached FLAKE_OUTPUT=".#darwinConfigurations.$HOSTNAME.system" echo "Checking if system is already cached..." if {{nix_cmd}} path-info --store "https://$CACHE_NAME.cachix.org" "$FLAKE_OUTPUT" &>/dev/null; then @@ -1731,7 +1543,6 @@ cache-darwin-system: echo "Built: $SYSTEM_PATH" echo "" - # Push the path and all its runtime dependencies echo "Pushing system and all dependencies to cachix..." echo "(This may take several minutes depending on what's not already cached)" nix-store --query --requisites --include-outputs "$SYSTEM_PATH" | \ @@ -1750,18 +1561,10 @@ list-packages: @ls -1 packages/ # List packages in JSON format for CI matrix +# Body lives in modules/apps/cluster/list-packages-json.{nix,sh}. [group('CI/CD')] -list-packages-json: - #!/usr/bin/env bash - cd packages - packages=() - for dir in */; do - pkg_name="${dir%/}" - if [ -f "$dir/package.json" ]; then - packages+=("{\"name\":\"$pkg_name\",\"path\":\"packages/$pkg_name\"}") - fi - done - echo "[$(IFS=,; echo "${packages[*]}")]" +list-packages-json *ARGS: + @{{nix_cmd}} run --no-warn-dirty .#list-packages-json -- {{ARGS}} # Validate package structure [group('CI/CD')] @@ -1781,6 +1584,15 @@ test-package package: preview-version target="main" package="": nix run --accept-flake-config .#preview-version -- "{{target}}" "{{package}}" +# Run the release flake app with passthrough args (see modules/apps/release/release.{nix,sh}) +# Examples: +# just release --help +# just release info packages/docs +# just release packages/docs --dry-run +[group('CI/CD')] +release *args: + {{nix_cmd}} run --no-warn-dirty .#release -- {{args}} + # Release a package using semantic-release [group('CI/CD')] release-package package dry_run="false": @@ -1910,7 +1722,6 @@ sops-load-agent: #!/usr/bin/env bash set -euo pipefail - # Check if we're on darwin if [[ "$OSTYPE" != "darwin"* ]]; then echo "⚠️ This command is only needed on macOS (darwin)" echo " Linux uses systemd instead of launchd" @@ -1919,28 +1730,23 @@ sops-load-agent: PLIST="$HOME/Library/LaunchAgents/org.nix-community.home.sops-nix.plist" - # Check if plist exists if [ ! -f "$PLIST" ]; then echo "⊘ SOPS plist not found: $PLIST" echo " Run 'just activate' first to create the plist" exit 1 fi - # Check if already loaded if launchctl list | grep -q "org.nix-community.home.sops-nix"; then echo "✓ SOPS agent already loaded" echo " Secrets directory: ~/.config/sops-nix/secrets/" exit 0 fi - # Load the agent echo "Loading SOPS launchd agent..." launchctl load "$PLIST" - # Brief wait for agent to start sleep 1 - # Verify it loaded if launchctl list | grep -q "org.nix-community.home.sops-nix"; then echo "✓ SOPS agent loaded successfully" echo " Secrets directory: ~/.config/sops-nix/secrets/" diff --git a/modules/apps/bootstrap/bootstrap.nix b/modules/apps/bootstrap/bootstrap.nix new file mode 100644 index 000000000..63823f7d5 --- /dev/null +++ b/modules/apps/bootstrap/bootstrap.nix @@ -0,0 +1,36 @@ +# Flake app: re-run the bootstrap flow from a host where nix is already installed +# +# The repo's primary bootstrap entry point is the +# Makefile (`make bootstrap`), which installs nix itself via the NixOS +# community installer and only then installs direnv. This flake app, by +# contrast, can only run once nix is already present (since `nix run` +# requires nix). It exists for reproducibility / scripting on hosts that +# have nix but want to (idempotently) finish the direnv half of bootstrap +# or re-verify that bootstrap has been completed. +# +# Idempotent. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.bootstrap = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "bootstrap"; + runtimeInputs = [ + pkgs.coreutils + pkgs.gnugrep + # `nix` is in runtimeInputs so `nix profile install` works + # from within the hermetic PATH. The host must already have + # a running nix daemon; this app is explicitly not a + # first-contact installer (the Makefile is). + pkgs.nix + ]; + text = builtins.readFile ./bootstrap.sh; + } + ); + }; + }; +} diff --git a/modules/apps/bootstrap/bootstrap.sh b/modules/apps/bootstrap/bootstrap.sh new file mode 100644 index 000000000..3656a5f80 --- /dev/null +++ b/modules/apps/bootstrap/bootstrap.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bootstrap [--help] + +Idempotent post-nix bootstrap: installs direnv via `nix profile install` +if it is missing, then reports the tool versions. Assumes nix is already +installed (that is the precondition of `nix run`). For a clean-host +first-contact install, use `make bootstrap` instead (installs nix first, +then direnv). + +Mirrors the `make bootstrap` target in the repo-root Makefile for the +direnv half of the flow; the nix-installer half is skipped because it +cannot run from inside a nix sandbox. +EOF +} + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac + +printf '=== Bootstrap (nix-present host) ===\n\n' + +# Confirm nix even though running under nix means it must be present. +if command -v nix >/dev/null 2>&1; then + printf '● nix found at %s\n' "$(command -v nix)" + nix --version +else + # Unreachable under `nix run`, but keep the guard for defence in depth. + printf '⊘ nix not found on PATH (unexpected inside a nix sandbox)\n' >&2 + # shellcheck disable=SC2016 # backticks in string are literal output, not command substitution + printf 'Run `make bootstrap` from a nix-free shell to install nix first.\n' >&2 + exit 1 +fi +printf '\n' + +# command -v guard yields cleaner no-op output than relying on nix profile install idempotence. +if command -v direnv >/dev/null 2>&1; then + printf '● direnv already installed at %s\n' "$(command -v direnv)" +else + # shellcheck disable=SC2016 # backticks in string are literal output, not command substitution + printf 'Installing direnv via `nix profile install nixpkgs#direnv`...\n' + nix --accept-flake-config profile install nixpkgs#direnv + printf '● direnv installed\n' +fi +printf '\n' + +printf '=== ● Bootstrap complete ===\n\n' +printf 'Next steps:\n' +# shellcheck disable=SC2016 # backticks in strings are literal output, not command substitution +printf ' 1. Run `nix run .#verify` to audit your installation.\n' +# shellcheck disable=SC2016 +printf ' 2. Run `nix run .#setup-user` once to generate your age key.\n' +# shellcheck disable=SC2016 +printf ' 3. Run `nix develop` to enter the development environment.\n' +printf '\n' +printf 'See https://direnv.net/docs/hook.html to add direnv to your shell.\n' diff --git a/modules/apps/bootstrap/setup-user.nix b/modules/apps/bootstrap/setup-user.nix new file mode 100644 index 000000000..78bff0261 --- /dev/null +++ b/modules/apps/bootstrap/setup-user.nix @@ -0,0 +1,26 @@ +# Flake app: generate the user's age key for sops-nix secrets (first-time user setup; idempotent on re-run). +# +# Idempotent: if ~/.config/sops/age/keys.txt exists, re-prints the public key and exits 0 without regenerating. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.setup-user = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "setup-user"; + runtimeInputs = [ + pkgs.coreutils + # `age` provides age-keygen directly, avoiding a nested + # `nix shell nixpkgs#age` invocation (cleaner dependency + # closure than the Makefile's approach). + pkgs.age + ]; + text = builtins.readFile ./setup-user.sh; + } + ); + }; + }; +} diff --git a/modules/apps/bootstrap/setup-user.sh b/modules/apps/bootstrap/setup-user.sh new file mode 100644 index 000000000..b82ca237d --- /dev/null +++ b/modules/apps/bootstrap/setup-user.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: setup-user [--help] + +Generates an age keypair for sops-nix secrets on the current user at +~/.config/sops/age/keys.txt and prints the public key. If the file +already exists, re-prints the public key and exits 0 WITHOUT +regenerating. Mode 0600 on the private key. + +First-time setup: after running, back up the contents of keys.txt to +Bitwarden as a secure note `age-key-`, and send the public +key to the admin for addition to `.sops.yaml`. +EOF +} + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac + +key_dir="${HOME}/.config/sops/age" +key_file="${key_dir}/keys.txt" + +printf '\n=== Age key setup ===\n\n' + +if [ -f "$key_file" ]; then + printf '⚠ Age key already exists at %s\n' "$key_file" + printf 'To regenerate, manually delete the file first.\n' + printf '\nYour public key is:\n' + if ! age-keygen -y "$key_file" 2>/dev/null; then + printf 'Error reading existing key (is the file corrupted?)\n' >&2 + exit 1 + fi + exit 0 +fi + +mkdir -p "$key_dir" +age-keygen -o "$key_file" +chmod 600 "$key_file" + +printf '\n● Age key generated successfully!\n\n' +printf 'Your public key is:\n' +age-keygen -y "$key_file" + +cat <<'EOF' + +⚠ IMPORTANT: Back up your private key to Bitwarden! + 1. Copy the content of ~/.config/sops/age/keys.txt + 2. Store in Bitwarden as a secure note: `age-key-` + 3. Send your PUBLIC key (shown above) to the admin + +See docs/new-user-host.md for complete setup instructions. +EOF diff --git a/modules/apps/bootstrap/verify.nix b/modules/apps/bootstrap/verify.nix new file mode 100644 index 000000000..74787d071 --- /dev/null +++ b/modules/apps/bootstrap/verify.nix @@ -0,0 +1,32 @@ +# Flake app: verify the host's nix + flakes + direnv + devShell setup. +# +# Chicken-and-egg note: Mirrors `make verify` from the repo-root Makefile. +# The Makefile version is callable from a nix-free shell (it's plain +# make + shell). This flake-app version assumes nix is already installed +# (since `nix run` requires nix); it exists so scripted contexts (CI, +# post-bootstrap sanity checks, buildbot effects) can invoke the audit +# without depending on GNU make being on PATH. +# +# Read-only. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.verify = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "verify"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.gnugrep + pkgs.nix + ]; + text = builtins.readFile ./verify.sh; + } + ); + }; + }; +} diff --git a/modules/apps/bootstrap/verify.sh b/modules/apps/bootstrap/verify.sh new file mode 100644 index 000000000..422a0045b --- /dev/null +++ b/modules/apps/bootstrap/verify.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Mirrors `make verify` minus the devShell build because expensive. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: verify [--help] + +Audits the current host for a working nix + flakes + direnv setup and +validates that the invoking flake parses. Exits 0 on success, 1 if nix +or flakes are missing or the flake fails to parse. Prints a status line +per check. Read-only; does not mutate any system state. + +Equivalent to `make verify` but invokable from a nix-only shell (no +dependency on GNU make). +EOF +} + +case "${1:-}" in + -h|--help) + usage + exit 0 + ;; +esac + +failed=0 + +printf '\n=== Verifying installation ===\n\n' + +printf 'Checking nix installation: ' +if command -v nix >/dev/null 2>&1; then + printf '● nix found at %s\n' "$(command -v nix)" + nix --version +else + printf '⊘ nix not found\n' + # shellcheck disable=SC2016 # backticks in string are literal output, not command substitution + printf ' Run `make install-nix` from a nix-free shell to install nix.\n' + failed=1 +fi +printf '\n' + +printf 'Checking nix flakes support: ' +if nix flake --help >/dev/null 2>&1; then + printf '● flakes enabled\n' +else + printf '⊘ flakes not enabled\n' + failed=1 +fi +printf '\n' + +printf 'Checking direnv installation: ' +if command -v direnv >/dev/null 2>&1; then + printf '● direnv found at %s\n' "$(command -v direnv)" +else + printf '⚠ direnv not found (optional but recommended)\n' + # shellcheck disable=SC2016 # backticks in string are literal output, not command substitution + printf ' Run `nix run .#bootstrap` to install.\n' +fi +printf '\n' + +printf 'Checking flake validity: ' +if nix --accept-flake-config flake metadata . >/dev/null 2>&1; then + printf '● flake is valid\n' +else + printf '⊘ flake has errors\n' + failed=1 +fi +printf '\n' + +# Surface /etc/nix/nix.conf for auditability — parity with make verify. +printf '/etc/nix/nix.conf:\n' +printf '==================\n' +if [ -f /etc/nix/nix.conf ]; then + cat /etc/nix/nix.conf +else + printf '(file not found)\n' +fi +printf '==================\n' + +if [ -f /etc/nix/nix.custom.conf ]; then + printf '\n/etc/nix/nix.custom.conf:\n' + printf '==================\n' + cat /etc/nix/nix.custom.conf + printf '==================\n' +fi +printf '\n' + +if [ "$failed" -eq 0 ]; then + printf '● All verification checks passed!\n\n' + exit 0 +else + printf '⊘ One or more verification checks failed.\n\n' >&2 + exit 1 +fi diff --git a/modules/apps/cluster/k3d-bootstrap-secrets.nix b/modules/apps/cluster/k3d-bootstrap-secrets.nix new file mode 100644 index 000000000..c207a8af5 --- /dev/null +++ b/modules/apps/cluster/k3d-bootstrap-secrets.nix @@ -0,0 +1,24 @@ +# k3d-bootstrap-secrets.nix - Bootstrap sops-age-key into a running k3d cluster. +# +# Idempotent: second invocation leaves the secret byte-identical. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-bootstrap-secrets = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-bootstrap-secrets"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.kubectl + ]; + text = builtins.readFile ./k3d-bootstrap-secrets.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-bootstrap-secrets.sh b/modules/apps/cluster/k3d-bootstrap-secrets.sh new file mode 100644 index 000000000..3489413ff --- /dev/null +++ b/modules/apps/cluster/k3d-bootstrap-secrets.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Idempotent: reapplies cleanly and does not mutate the secret when the key source has not changed. +# +# Env-var contract: +# One of the following MUST be satisfied (narrow exception; env-first): +# SOPS_AGE_KEY (env) single-line AGE-SECRET-KEY-… +# body (CI / effect preamble) +# $HOME/.config/sops/age/keys.txt (file) local dev pathway +# +# This is the ONLY flake app in modules/apps/ that intentionally consumes +# SOPS_AGE_KEY directly. No other effect or app is permitted to expose +# it. Rationale: the k3d bootstrap flow needs an age key INSIDE the +# ephemeral cluster for sops-secrets-operator to decrypt SopsSecret CRs +# at runtime — this is a load-bearing narrow exception. +# +# Caller mechanisms: +# - Local dev: file-branch via $HOME/.config/sops/age/keys.txt +# - GHA env: GHA `env:` block with SOPS_AGE_KEY from repo secrets +# - effect: effect preamble extracts SOPS_AGE_KEY from +# HERCULES_CI_SECRETS_JSON and exports before invoking +# the transitive caller (k3d-integration-ci) +# +# NB: intentionally uses if-else ladder rather than `: "${VAR:?…}"` because +# the "try env, fall back to file" behaviour is the contract shape; a single +# `:?` guard cannot express the env-OR-file dual branch. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-bootstrap-secrets [--help] + +Creates the sops-secrets-operator namespace (if missing) and the +sops-age-key secret containing an age private key used to decrypt +SopsSecret CRs. Idempotent: subsequent invocations leave the secret +byte-identical. Requires kubectl context pointing at the live k3d +cluster. + +Key source (first found): + SOPS_AGE_KEY environment variable (CI) + ~/.config/sops/age/keys.txt file (local dev) +EOF + exit 0 + ;; +esac + +# Validate key source BEFORE any kubectl invocation so the failure surface +# points at the contract (SOPS_AGE_KEY env OR the keys.txt file) rather +# than an opaque kubectl/api error. +if [ -n "${SOPS_AGE_KEY:-}" ]; then + echo "Using SOPS_AGE_KEY from environment variable" + KEYFILE=$(mktemp) + echo "${SOPS_AGE_KEY}" > "$KEYFILE" + trap 'rm -f "$KEYFILE"' EXIT +else + echo "Using SOPS age key from file: ${HOME}/.config/sops/age/keys.txt" + KEYFILE="${HOME}/.config/sops/age/keys.txt" + if [ ! -f "$KEYFILE" ]; then + echo "error: age key file not found: $KEYFILE" >&2 + echo " either set SOPS_AGE_KEY or create the file" >&2 + exit 1 + fi +fi + +kubectl create namespace sops-secrets-operator \ + --dry-run=client -o yaml | kubectl apply -f - + +kubectl create secret generic sops-age-key \ + --namespace=sops-secrets-operator \ + --from-file=age.key="$KEYFILE" \ + --dry-run=client -o yaml | kubectl apply -f - diff --git a/modules/apps/cluster/k3d-configure-dns.nix b/modules/apps/cluster/k3d-configure-dns.nix new file mode 100644 index 000000000..e055d228b --- /dev/null +++ b/modules/apps/cluster/k3d-configure-dns.nix @@ -0,0 +1,28 @@ +# k3d-configure-dns.nix - Patch CoreDNS to forward sslip.io queries to public DNS. +# +# Required because OrbStack's default DNS (192.168.107.1) cannot resolve +# sslip.io wildcards used by the local ArgoCD application routes. +# Idempotent: second invocation exits 0 without patching. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-configure-dns = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-configure-dns"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.gnugrep + pkgs.jq + pkgs.kubectl + ]; + text = builtins.readFile ./k3d-configure-dns.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-configure-dns.sh b/modules/apps/cluster/k3d-configure-dns.sh new file mode 100644 index 000000000..61e599529 --- /dev/null +++ b/modules/apps/cluster/k3d-configure-dns.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Idempotent via grep detection of the existing "sslip.io" block in the Corefile. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-configure-dns [--help] + +Patches the kube-system/coredns ConfigMap to add a + sslip.io:53 { forward . 1.1.1.1 8.8.8.8; cache 30 } +stanza, then rolls the coredns Deployment so the new Corefile takes +effect. Idempotent; re-running on an already-patched cluster is a no-op. + +Requires kubectl context pointing at a running k3d cluster with +Cilium (or another CNI) already Ready. +EOF + exit 0 + ;; +esac + +echo "Waiting for CoreDNS to be running..." +kubectl wait --for=condition=Ready pod -l k8s-app=kube-dns -n kube-system --timeout=120s + +echo "Patching CoreDNS ConfigMap to forward sslip.io to public DNS..." +CURRENT=$(kubectl get configmap coredns -n kube-system -o jsonpath='{.data.Corefile}') +if echo "$CURRENT" | grep -q "sslip.io"; then + echo "CoreDNS already configured for sslip.io forwarding" + exit 0 +fi + +SSLIP_BLOCK=$'sslip.io:53 {\n forward . 1.1.1.1 8.8.8.8\n cache 30\n}\n' +PATCHED="${SSLIP_BLOCK}${CURRENT}" +PATCH_JSON=$(jq -n --arg corefile "$PATCHED" '{"data": {"Corefile": $corefile}}') +kubectl patch configmap coredns -n kube-system --type=merge -p "$PATCH_JSON" + +echo "Restarting CoreDNS deployment..." +kubectl rollout restart deployment coredns -n kube-system + +echo "Waiting for CoreDNS to be ready..." +kubectl rollout status deployment coredns -n kube-system --timeout=120s + +echo "CoreDNS configured for sslip.io forwarding" diff --git a/modules/apps/cluster/k3d-full.nix b/modules/apps/cluster/k3d-full.nix new file mode 100644 index 000000000..5d7a844cb --- /dev/null +++ b/modules/apps/cluster/k3d-full.nix @@ -0,0 +1,29 @@ +# k3d-full.nix - Full local-k3d lifecycle: down -> up -> deploy. +# +# Delegates to the underlying justfile recipes for k3d-down, k3d-up, and +# k3d-deploy. `just` is a runtimeInput because the recipes themselves +# still need k3d/ctlptl/kubectl/etc on PATH; those come from the user's +# dev environment (writeShellApplication prepends runtimeInputs to $PATH +# without stripping it). +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-full = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-full"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.git + pkgs.just + ]; + text = builtins.readFile ./k3d-full.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-full.sh b/modules/apps/cluster/k3d-full.sh new file mode 100644 index 000000000..032e4b04d --- /dev/null +++ b/modules/apps/cluster/k3d-full.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-full [--help] + +Runs, in order: + just k3d-down || true (idempotent teardown) + just k3d-up (ctlptl apply + bootstrap-secrets) + just k3d-deploy (foundation + infrastructure layers) + +The invocation must happen from a directory inside the vanixiets git +worktree (repo root resolution via `git rev-parse --show-toplevel`), +since the underlying just recipes reference +kubernetes/clusters/local-k3d/cluster.yaml by relative path. +EOF + exit 0 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true) +if [[ -n "$repo_root" ]]; then + cd "$repo_root" +fi + +just k3d-down || true +just k3d-up +just k3d-deploy diff --git a/modules/apps/cluster/k3d-integration-ci.nix b/modules/apps/cluster/k3d-integration-ci.nix new file mode 100644 index 000000000..4752885a8 --- /dev/null +++ b/modules/apps/cluster/k3d-integration-ci.nix @@ -0,0 +1,52 @@ +# k3d-integration-ci.nix - CI-variant full integration: file:///manifests + tests. +# +# Template bifurcation (writeShellApplication): PURE READFILE FORM. +# `text = builtins.readFile ./k3d-integration-ci.sh` — the sidecar is +# consumed verbatim, no nix-eval-time string interpolation. This is the +# cluster-domain representative of the pure form and the canonical +# starting point when converting a cluster script. +# +# Choose PURE form when the sidecar needs no nix-eval-time path injection +# (all inputs come from env vars, CLI args, or runtimeInputs). Choose +# INTERPOLATION form (a nix-string text attribute that concatenates an +# eval-time preamble with builtins.readFile of the sidecar) only when +# you must inject a nix-computed store path or derivation outPath into +# the script preamble — for the canonical example see +# `modules/apps/docs/deploy.nix`, which injects DOCS_PAYLOAD +# (config.packages.vanixiets-docs) at eval time. Secret env vars are +# never injected via the nix preamble (per ADR-002 env-var contract); +# the caller provides them through sops exec-env, direnv dotenv, GHA +# env:, or the M4 effect preamble that extracts from +# HERCULES_CI_SECRETS_JSON. +# +# Orchestrates the seven-phase CI integration flow that is currently +# invoked by `.github/workflows/test-cluster.yaml`. Delegates to the +# sibling cluster/docs flake apps (nixidy-build, nixidy-bootstrap, +# k3d-wait-*, k3d-test-coverage) via `just `; those recipes are +# thin `nix run` wrappers after M1. `just` is the single external +# dispatch mechanism, so it is the only orchestration-layer runtimeInput; +# the underlying tools (ctlptl, k3d, kubectl, …) come from the invoking +# dev shell's PATH (writeShellApplication prepends runtimeInputs to $PATH). +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-integration-ci = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-integration-ci"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.git + pkgs.just + pkgs.rsync + ]; + text = builtins.readFile ./k3d-integration-ci.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-integration-ci.sh b/modules/apps/cluster/k3d-integration-ci.sh new file mode 100644 index 000000000..d0da42568 --- /dev/null +++ b/modules/apps/cluster/k3d-integration-ci.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# CI integration driver for the local-k3d cluster, consumed by .github/workflows/test-cluster.yaml's `integration` job. +# +# Env-var contract: +# Transitively required (consumed by k3d-bootstrap-secrets, the leaf): +# SOPS_AGE_KEY age key body for sops-secrets-operator inside the +# ephemeral k3d cluster. Enforcement is deferred to +# the leaf script (k3d-bootstrap-secrets.sh) which +# accepts the env-or-file dual-branch; this wrapper +# does NOT add a top-level `${SOPS_AGE_KEY:?…}` guard +# so that local-dev runs using the file-branch +# ($HOME/.config/sops/age/keys.txt) remain usable. +# Optional (config, defaulted inside this script): +# ARGOCD_REPO_URL defaults to file:///manifests; callers may override +# for remote-repo testing. +# +# Caller mechanisms: +# - Local dev: .envrc dotenv or file-branch ($HOME/.config/sops/...) +# - GHA env: job-level `env:` block populates SOPS_AGE_KEY from +# repo secrets (.github/workflows/test-cluster.yaml) +# - effect: test-cluster effect preamble extracts SOPS_AGE_KEY +# from HERCULES_CI_SECRETS_JSON and exports before +# invoking ${config.apps.k3d-integration-ci.program} +# +# NB: required-env guard via the `:?` idiom lives in the leaf +# k3d-bootstrap-secrets.sh; this file intentionally has no top-level +# `${SOPS_AGE_KEY:?…}` enforcement (the transitive contract is surfaced +# via k3d-bootstrap-secrets.sh's fail-fast behaviour when neither env nor +# file is present). +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-integration-ci [--help] + +Phases: + 1. nixidy-build with ARGOCD_REPO_URL=file:///manifests + 2. Stage /tmp/k3d-manifests as a fresh git repo (cluster volume mount target) + 3. k3d-full (ctlptl create + kluctl deploy) + 4. k3d-wait-ready (foundation + infra Ready) + 5. nixidy-bootstrap (app-of-apps sync via file:///manifests) + 6. k3d-wait-argocd-sync (all Applications Synced + Healthy) + 7. k3d-test-coverage (chainsaw tests + coverage report) + +Must be invoked from a directory inside the vanixiets git worktree. +EOF + exit 0 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +echo "=== Phase 1: Build manifests with local repo URL ===" +# ARGOCD_REPO_URL default applied via `:=` bash parameter expansion so +# callers can override via env for remote-repo testing without editing +# this script. See env-var contract header for the caller mechanisms. +: "${ARGOCD_REPO_URL:=file:///manifests}" +export ARGOCD_REPO_URL +just nixidy-build + +echo "" +echo "=== Phase 2: Prepare local git repo (before cluster for volume mount) ===" +# Ensure writable before cleanup (Nix store copies may be read-only) +chmod -R +w /tmp/k3d-manifests 2>/dev/null || true +rm -rf /tmp/k3d-manifests +mkdir -p /tmp/k3d-manifests +rsync -aL --delete --chmod=Du+w,Fu+w result/ /tmp/k3d-manifests/ +( + cd /tmp/k3d-manifests + git init -b main + git config user.email "ci@localhost" + git config user.name "CI" + git add . + git commit -m "CI manifests" +) + +echo "" +echo "=== Phase 3: Create cluster and deploy via kluctl ===" +just k3d-full + +echo "" +echo "=== Phase 4: Wait for infrastructure ready ===" +just k3d-wait-ready + +echo "" +echo "=== Phase 5: Bootstrap ArgoCD (syncs from file:///manifests) ===" +just nixidy-bootstrap + +echo "" +echo "=== Phase 6: Wait for ArgoCD sync ===" +just k3d-wait-argocd-sync + +echo "" +echo "=== Phase 7: Run integration tests ===" +just k3d-test-coverage + +echo "" +echo "=== CI integration complete ===" diff --git a/modules/apps/cluster/k3d-test-coverage.nix b/modules/apps/cluster/k3d-test-coverage.nix new file mode 100644 index 000000000..3cdfe20ab --- /dev/null +++ b/modules/apps/cluster/k3d-test-coverage.nix @@ -0,0 +1,34 @@ +# k3d-test-coverage.nix - Run chainsaw integration tests and emit coverage report. +# +# Subsumes scripts/k3d-test-coverage.sh (legacy root-level copy kept as +# a thin shim for backward compatibility). The coverage-report logic +# lives in-tree at modules/apps/cluster/k3d-test-coverage.sh. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-test-coverage = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-test-coverage"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.findutils + pkgs.gawk + pkgs.git + pkgs.gnugrep + pkgs.gnused + pkgs.jq + pkgs.kubectl + pkgs.kyverno-chainsaw + pkgs.libxml2 # xmllint + ]; + text = builtins.readFile ./k3d-test-coverage.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-test-coverage.sh b/modules/apps/cluster/k3d-test-coverage.sh new file mode 100644 index 000000000..767757772 --- /dev/null +++ b/modules/apps/cluster/k3d-test-coverage.sh @@ -0,0 +1,498 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-test-coverage [--help] [--raw] [chainsaw args...] + +Run chainsaw integration tests against kubernetes/tests/local-k3d/ and +emit a coverage report categorizing deployed resources as application, +foundation, or system. + +Options: + --help Show this message and exit 0 + --raw Show raw uncategorized coverage output + +Environment: + CI / GITHUB_ACTIONS / NO_COLOR Disable ANSI colors when set +EOF + exit 0 + ;; +esac + +RAW_MODE=0 + +# shellcheck disable=SC2034 # Colors are used via variable expansion +setup_colors() { + if [[ -n "${CI:-}" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -n "${NO_COLOR:-}" ]]; then + RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" + else + RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' + BOLD=$'\e[1m' DIM=$'\e[2m' RESET=$'\e[0m' + fi +} + +run_chainsaw_tests() { + local report_dir="$1" + local test_dir="$2" + shift 2 + + echo "${BOLD}Running chainsaw tests...${RESET}" + echo "" + + if chainsaw test "$test_dir" "$@" \ + --report-format JUNIT-OPERATION \ + --report-path "$report_dir" 2>&1; then + return 0 + else + return 1 + fi +} + +print_test_summary() { + local report_file="$1" + + echo "" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "${BOLD} TEST SUMMARY ${RESET}" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "" + + if [[ ! -f "$report_file" ]]; then + echo " ${YELLOW}Warning: No test report found${RESET}" + return + fi + + local total_ops total_time + total_ops=$(xmllint --xpath 'string(/testsuites/@tests)' "$report_file" 2>/dev/null || echo "0") + total_time=$(xmllint --xpath 'string(/testsuites/@time)' "$report_file" 2>/dev/null || echo "0") + + echo "${BOLD}Test Execution:${RESET}" + echo " Total operations: ${GREEN}${total_ops}${RESET}" + echo " Total time: ${DIM}${total_time}s${RESET}" + echo "" + + echo "${BOLD}By Test Suite:${RESET}" + local suite suite_tests suite_failures suite_time status + for suite in foundation infrastructure local-k3d; do + suite_tests=$(xmllint --xpath "string(//testsuite[@name='$suite']/@tests)" "$report_file" 2>/dev/null || echo "0") + suite_failures=$(xmllint --xpath "string(//testsuite[@name='$suite']/@failures)" "$report_file" 2>/dev/null || echo "0") + suite_time=$(xmllint --xpath "string(//testsuite[@name='$suite']/@time)" "$report_file" 2>/dev/null || echo "0") + + if [[ "$suite_tests" != "0" ]]; then + if [[ "$suite_failures" == "0" ]]; then + status="${GREEN}PASS${RESET}" + else + status="${RED}FAIL${RESET}" + fi + printf " %-20s %s %3s ops ${DIM}%ss${RESET}\n" "$suite" "$status" "$suite_tests" "$suite_time" + fi + done +} + +collect_deployed_resources() { + local -n deployed_ref=$1 + local -n type_counts_ref=$2 + + local line ns name kind key + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ns=$(awk '{print $1}' <<< "$line") + name=$(awk '{print $2}' <<< "$line") + kind=$(awk '{print $3}' <<< "$line") + key="${kind}/${ns}/${name}" + deployed_ref["$key"]=1 + done < <(kubectl get deploy,sts,ds -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,KIND:.kind' --no-headers 2>/dev/null | grep -v '^$') + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ns=$(awk '{print $1}' <<< "$line") + name=$(awk '{print $2}' <<< "$line") + deployed_ref["Gateway/${ns}/${name}"]=1 + done < <(kubectl get gateway -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ns=$(awk '{print $1}' <<< "$line") + name=$(awk '{print $2}' <<< "$line") + deployed_ref["HTTPRoute/${ns}/${name}"]=1 + done < <(kubectl get httproute -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + ns=$(awk '{print $1}' <<< "$line") + name=$(awk '{print $2}' <<< "$line") + deployed_ref["Certificate/${ns}/${name}"]=1 + done < <(kubectl get certificate -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') + + while IFS= read -r line; do + [[ -z "$line" ]] && continue + deployed_ref["ClusterIssuer/-/${line}"]=1 + done < <(kubectl get clusterissuer -o custom-columns='NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') + + for key in "${!deployed_ref[@]}"; do + kind="${key%%/*}" + type_counts_ref["$kind"]=$(( ${type_counts_ref["$kind"]:-0} + 1 )) + done +} + +collect_tested_resources() { + local -n tested_ref=$1 + # shellcheck disable=SC2178 # nameref to associative array + local -n type_counts_ref=$2 + local test_dir="$3" + + local file current_kind current_name current_ns line key + + while IFS= read -r file; do + current_kind="" + current_name="" + current_ns="-" + + while IFS= read -r line; do + if [[ "$line" == "---" ]]; then + if [[ -n "$current_kind" && -n "$current_name" ]]; then + key="${current_kind}/${current_ns}/${current_name}" + tested_ref["$key"]=1 + fi + current_kind="" + current_name="" + current_ns="-" + continue + fi + + if [[ "$line" =~ ^kind:\ *(.+)$ ]]; then + current_kind="${BASH_REMATCH[1]}" + fi + + # First "name:" line is metadata.name (guards against later annotation-name etc). + if [[ "$line" =~ ^[[:space:]]+name:\ *(.+)$ ]]; then + if [[ -z "$current_name" ]]; then + current_name="${BASH_REMATCH[1]}" + fi + fi + + if [[ "$line" =~ ^[[:space:]]+namespace:\ *(.+)$ ]]; then + current_ns="${BASH_REMATCH[1]}" + fi + done < "$file" + + if [[ -n "$current_kind" && -n "$current_name" ]]; then + key="${current_kind}/${current_ns}/${current_name}" + tested_ref["$key"]=1 + fi + done < <(find "$test_dir" -name "*assert*.yaml" -type f) + + for key in "${!tested_ref[@]}"; do + kind="${key%%/*}" + type_counts_ref["$kind"]=$(( ${type_counts_ref["$kind"]:-0} + 1 )) + done +} + +print_resource_table() { + local -n counts_ref=$1 + local total=$2 + + local kind count + for kind in Deployment StatefulSet DaemonSet Gateway HTTPRoute Certificate ClusterIssuer; do + count="${counts_ref[$kind]:-0}" + if [[ "$count" -gt 0 ]]; then + printf " %-15s %3d\n" "$kind" "$count" + fi + done + echo " ${DIM}─────────────────────${RESET}" + printf " %-15s %3d\n" "Total" "$total" +} + +# Categorize a resource as application, foundation, or system. +categorize_resource() { + local key="$1" + local kind="${key%%/*}" + local rest="${key#*/}" + local ns="${rest%%/*}" + local name="${rest#*/}" + + # System components (k3s internals, Cilium internals, auto-generated) + # These are excluded from coverage calculation because they are: + # - Not managed by our nixidy/ArgoCD stack + # - Auto-created by k3s or other controllers + # - Internal components of our foundation layer + case "$key" in + # k3s DNS - managed by k3s, not our stack + Deployment/kube-system/coredns) echo "system"; return ;; + # k3s storage provisioner - managed by k3s + Deployment/kube-system/local-path-provisioner) echo "system"; return ;; + # k3s metrics - managed by k3s + Deployment/kube-system/metrics-server) echo "system"; return ;; + # Cilium internal envoy proxy - managed by Cilium operator + DaemonSet/kube-system/cilium-envoy) echo "system"; return ;; + # Auto-generated by cert-manager gateway-shim from HTTPRoute annotation + # Duplicates our explicit step-ca-tls Certificate + Certificate/gateway-system/test-cert-tls) echo "system"; return ;; + esac + + # k3s servicelb auto-created DaemonSets (svclb-*) + # These are auto-created by k3s for LoadBalancer services + if [[ "$kind" == "DaemonSet" && "$ns" == "kube-system" && "$name" == svclb-* ]]; then + echo "system" + return + fi + + # Foundation resources (CNI layer we deploy but is infrastructure) + case "$key" in + DaemonSet/kube-system/cilium) echo "foundation"; return ;; + Deployment/kube-system/cilium-operator) echo "foundation"; return ;; + esac + + # Application resources (nixidy/ArgoCD-managed: argocd, cert-manager, + # sops-secrets-operator, step-ca, gateway-system, Gateway API). + echo "application" +} + +get_system_description() { + local key="$1" + + case "$key" in + Deployment/kube-system/coredns) echo "k3s DNS" ;; + Deployment/kube-system/local-path-provisioner) echo "k3s storage" ;; + Deployment/kube-system/metrics-server) echo "k3s metrics" ;; + DaemonSet/kube-system/cilium-envoy) echo "Cilium internal" ;; + Certificate/gateway-system/test-cert-tls) echo "gateway-shim duplicate" ;; + DaemonSet/kube-system/svclb-*) echo "k3s servicelb auto-created" ;; + *) echo "system component" ;; + esac +} + +print_coverage_report() { + local test_dir="$1" + + echo "" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "${BOLD} RESOURCE COVERAGE ${RESET}" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "" + + declare -A deployed_resources + # shellcheck disable=SC2034 # passed to function via nameref + declare -A deployed_type_counts + declare -A tested_resources + # shellcheck disable=SC2034 # passed to function via nameref + declare -A tested_type_counts + + collect_deployed_resources deployed_resources deployed_type_counts + collect_tested_resources tested_resources tested_type_counts "$test_dir" + + local deployed_count=${#deployed_resources[@]} + local tested_count=${#tested_resources[@]} + + echo "${BOLD}Deployed Resources:${RESET}" + print_resource_table deployed_type_counts "$deployed_count" + + echo "" + echo "${BOLD}Tested Resources:${RESET}" + print_resource_table tested_type_counts "$tested_count" + + echo "" + echo "${BOLD}Coverage Analysis:${RESET}" + + local matched=0 + local untested=() + local key category + + local app_total=0 app_tested=0 + local foundation_total=0 foundation_tested=0 + local system_total=0 system_tested=0 + local system_resources=() + + for key in "${!deployed_resources[@]}"; do + category=$(categorize_resource "$key") + + case "$category" in + application) + (( app_total++ )) || true + if [[ -n "${tested_resources[$key]:-}" ]]; then + (( app_tested++ )) || true + (( matched++ )) || true + else + untested+=("$key") + fi + ;; + foundation) + (( foundation_total++ )) || true + if [[ -n "${tested_resources[$key]:-}" ]]; then + (( foundation_tested++ )) || true + (( matched++ )) || true + else + untested+=("$key") + fi + ;; + system) + (( system_total++ )) || true + system_resources+=("$key") + if [[ -n "${tested_resources[$key]:-}" ]]; then + (( system_tested++ )) || true + (( matched++ )) || true + fi + ;; + esac + done + + local raw_coverage=0 + if [[ $deployed_count -gt 0 ]]; then + raw_coverage=$(( matched * 100 / deployed_count )) + fi + + # Excluding system components. + local managed_total=$(( app_total + foundation_total )) + local managed_tested=$(( app_tested + foundation_tested )) + local managed_coverage=0 + if [[ $managed_total -gt 0 ]]; then + managed_coverage=$(( managed_tested * 100 / managed_total )) + fi + + if [[ $RAW_MODE -eq 1 ]]; then + local cov_color="$RED" + if [[ $raw_coverage -ge 80 ]]; then + cov_color="$GREEN" + elif [[ $raw_coverage -ge 50 ]]; then + cov_color="$YELLOW" + fi + + echo "" + echo " Resource instance coverage: ${cov_color}${BOLD}${raw_coverage}%${RESET} (${matched}/${deployed_count})" + echo "" + + if [[ ${#untested[@]} -gt 0 ]] || [[ ${#system_resources[@]} -gt 0 ]]; then + echo "${BOLD}Untested Resources:${RESET}" + local rest ns name + local all_untested=() + for key in "${untested[@]}"; do + all_untested+=("$key") + done + for key in "${system_resources[@]}"; do + if [[ -z "${tested_resources[$key]:-}" ]]; then + all_untested+=("$key") + fi + done + for key in "${all_untested[@]}"; do + kind="${key%%/*}" + rest="${key#*/}" + ns="${rest%%/*}" + name="${rest#*/}" + if [[ "$ns" == "-" ]]; then + printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s\n" "$kind" "$name" + else + printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET}\n" "$kind" "$name" "$ns" + fi + done | sort + else + echo " ${GREEN}All deployed resources have test coverage${RESET}" + fi + else + echo "" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "${BOLD} COVERAGE BY CATEGORY ${RESET}" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" + echo "" + + # Application coverage + local app_cov_pct=0 + if [[ $app_total -gt 0 ]]; then + app_cov_pct=$(( app_tested * 100 / app_total )) + fi + local app_color="$RED" + [[ $app_cov_pct -ge 80 ]] && app_color="$GREEN" + [[ $app_cov_pct -ge 50 && $app_cov_pct -lt 80 ]] && app_color="$YELLOW" + printf " Application Resources: ${app_color}%2d/%2d (%3d%%)${RESET}\n" "$app_tested" "$app_total" "$app_cov_pct" + + # Foundation coverage + local fnd_cov_pct=0 + if [[ $foundation_total -gt 0 ]]; then + fnd_cov_pct=$(( foundation_tested * 100 / foundation_total )) + fi + local fnd_color="$RED" + [[ $fnd_cov_pct -ge 80 ]] && fnd_color="$GREEN" + [[ $fnd_cov_pct -ge 50 && $fnd_cov_pct -lt 80 ]] && fnd_color="$YELLOW" + printf " Foundation Resources: ${fnd_color}%2d/%2d (%3d%%)${RESET}\n" "$foundation_tested" "$foundation_total" "$fnd_cov_pct" + + echo " ${DIM}─────────────────────────────────────────────────────────────────${RESET}" + + # Managed total + local mgd_color="$RED" + [[ $managed_coverage -ge 80 ]] && mgd_color="$GREEN" + [[ $managed_coverage -ge 50 && $managed_coverage -lt 80 ]] && mgd_color="$YELLOW" + printf " ${BOLD}Managed Resources Total: ${mgd_color}%2d/%2d (%3d%%)${RESET}\n" "$managed_tested" "$managed_total" "$managed_coverage" + + if [[ ${#untested[@]} -gt 0 ]]; then + echo "" + echo "${BOLD}Untested Managed Resources:${RESET}" + local rest ns name + for key in "${untested[@]}"; do + kind="${key%%/*}" + rest="${key#*/}" + ns="${rest%%/*}" + name="${rest#*/}" + if [[ "$ns" == "-" ]]; then + printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s\n" "$kind" "$name" + else + printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET}\n" "$kind" "$name" "$ns" + fi + done | sort + fi + + echo "" + echo "${BOLD}System Components (excluded from coverage):${RESET}" + local rest ns name desc + for key in "${system_resources[@]}"; do + kind="${key%%/*}" + rest="${key#*/}" + ns="${rest%%/*}" + name="${rest#*/}" + desc=$(get_system_description "$key") + printf " ${DIM}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET} - ${DIM}%s${RESET}\n" "$kind" "$name" "$ns" "$desc" + done | sort + + echo "" + printf " ${DIM}Raw Resource Count: %2d/%2d (%3d%%)${RESET}\n" "$matched" "$deployed_count" "$raw_coverage" + fi + + echo "" + echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" +} + +main() { + setup_colors + + local args=() + for arg in "$@"; do + if [[ "$arg" == "--raw" ]]; then + RAW_MODE=1 + else + args+=("$arg") + fi + done + + # Resolve test directory relative to the invoking worktree so the + # flake app is location-independent. + local repo_root + repo_root=$(git rev-parse --show-toplevel) + local test_dir="${repo_root}/kubernetes/tests/local-k3d" + + local report_dir + report_dir=$(mktemp -d) + trap 'rm -rf "$report_dir"' EXIT + + local test_failed=0 + if ! run_chainsaw_tests "$report_dir" "$test_dir" "${args[@]}"; then + test_failed=1 + fi + + print_test_summary "$report_dir/chainsaw-report.xml" + print_coverage_report "$test_dir" + + exit $test_failed +} + +main "$@" diff --git a/modules/apps/cluster/k3d-wait-argocd-sync.nix b/modules/apps/cluster/k3d-wait-argocd-sync.nix new file mode 100644 index 000000000..85ba4691e --- /dev/null +++ b/modules/apps/cluster/k3d-wait-argocd-sync.nix @@ -0,0 +1,26 @@ +# k3d-wait-argocd-sync.nix - Wait for all ArgoCD Applications to reach Synced + Healthy. +# +# Matches the Phase-4 post-bootstrap gating from the justfile +# `k3d-wait-argocd-sync` recipe. The expected-apps list mirrors the +# nixidy sync-wave declarations and is the source of truth at this layer. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-wait-argocd-sync = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-wait-argocd-sync"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.kubectl + ]; + text = builtins.readFile ./k3d-wait-argocd-sync.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-wait-argocd-sync.sh b/modules/apps/cluster/k3d-wait-argocd-sync.sh new file mode 100644 index 000000000..1c25bbdc9 --- /dev/null +++ b/modules/apps/cluster/k3d-wait-argocd-sync.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Block until every ArgoCD Application is Healthy and Synced, then verify the root Gateway is Programmed by Cilium. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-wait-argocd-sync [--help] + +Waits for the app-of-apps-managed ArgoCD Applications to come online in +the cluster, then gates on each becoming Healthy and Synced, and finally +waits for the main-gateway Gateway to be Programmed by Cilium. + +Sync waves (from nixidy local-k3d env): + Wave -1 (adoption): cilium, argocd, sops-secrets-operator, step-ca + Wave 0: cert-manager + Wave 1-2: cluster-issuer, gateway, gateway-api + Wave 3: argocd-route +EOF + exit 0 + ;; +esac + +echo "=== Waiting for ArgoCD Applications ===" +echo "Applications managed by nixidy sync waves:" +echo " Wave -1 (adoption): cilium, argocd, sops-secrets-operator, step-ca" +echo " Wave 0: cert-manager" +echo " Wave 1-2: cluster-issuer, gateway, gateway-api" +echo " Wave 3: argocd-route" +echo "" + +# All expected applications (app-of-apps creates these asynchronously) +EXPECTED_APPS=( + apps + argocd + argocd-route + cert-manager + cilium + cluster-issuer + gateway + gateway-api + sops-secrets-operator + step-ca +) + +echo "Waiting for all ${#EXPECTED_APPS[@]} applications to exist..." +for app in "${EXPECTED_APPS[@]}"; do + echo -n " Waiting for $app... " + # kubectl wait fails immediately if resource doesn't exist, so poll instead + timeout 300 bash -c "until kubectl get application/$app -n argocd &>/dev/null; do sleep 2; done" + echo "exists" +done + +echo "" +echo "Listing applications..." +kubectl get applications -n argocd -o wide || true +echo "" + +echo "Waiting for all applications to be Healthy..." +for app in "${EXPECTED_APPS[@]}"; do + echo -n " Waiting for $app to be Healthy... " + kubectl wait --for=jsonpath='{.status.health.status}'=Healthy application/"$app" -n argocd --timeout=600s >/dev/null + echo "done" +done + +echo "" +echo "Waiting for all applications to be Synced..." +for app in "${EXPECTED_APPS[@]}"; do + echo -n " Waiting for $app to be Synced... " + kubectl wait --for=jsonpath='{.status.sync.status}'=Synced application/"$app" -n argocd --timeout=300s >/dev/null + echo "done" +done + +echo "" +echo "=== Waiting for Gateway to be programmed ===" +# ArgoCD reports Healthy before Cilium fully programs the Gateway +# Wait for the actual Gateway condition, not just ArgoCD's view +kubectl wait --for=condition=Programmed gateway/main-gateway -n gateway-system --timeout=300s + +echo "" +echo "=== All ArgoCD applications synced and healthy ===" +kubectl get applications -n argocd -o wide diff --git a/modules/apps/cluster/k3d-wait-ready.nix b/modules/apps/cluster/k3d-wait-ready.nix new file mode 100644 index 000000000..23ef3542b --- /dev/null +++ b/modules/apps/cluster/k3d-wait-ready.nix @@ -0,0 +1,25 @@ +# k3d-wait-ready.nix - Block until kluctl-deployed foundation + infra pods are Ready. +# +# Mirrors the Phase 3 post-deploy gating that sat in the justfile +# `k3d-wait-ready` recipe. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.k3d-wait-ready = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "k3d-wait-ready"; + runtimeInputs = [ + pkgs.bash + pkgs.coreutils + pkgs.kubectl + ]; + text = builtins.readFile ./k3d-wait-ready.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/k3d-wait-ready.sh b/modules/apps/cluster/k3d-wait-ready.sh new file mode 100644 index 000000000..9ff42e293 --- /dev/null +++ b/modules/apps/cluster/k3d-wait-ready.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: k3d-wait-ready [--help] + +Blocks until all Phase-3 (foundation + infrastructure) pods are Ready in +the local-k3d cluster, in the order: + + Foundation: cilium-agent, cilium-operator (kube-system) + Infrastructure: argocd deployments, argocd-app-ctrl (argocd) + step-ca statefulset pod (step-ca) + sops-secrets-operator deployments (sops-secrets-operator) + +Each kubectl-wait carries a 300s timeout. Requires kubectl context +pointing at a live k3d cluster with all manifests already applied. +EOF + exit 0 + ;; +esac + +echo "=== Waiting for Foundation (CNI) ===" +echo "Waiting for Cilium Agent..." +kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=cilium-agent -n kube-system --timeout=300s + +echo "Waiting for Cilium Operator..." +kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=cilium-operator -n kube-system --timeout=300s + +echo "" +echo "=== Waiting for Infrastructure ===" +echo "Waiting for ArgoCD deployments..." +kubectl wait --for=condition=Available deployment --all -n argocd --timeout=300s + +echo "Waiting for ArgoCD Application Controller..." +kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=argocd-application-controller -n argocd --timeout=300s + +echo "Waiting for step-ca..." +# Use StatefulSet pod label to exclude Helm test-connection pod (which always fails) +kubectl wait --for=condition=Ready pod -l statefulset.kubernetes.io/pod-name=step-ca-step-certificates-0 -n step-ca --timeout=300s + +echo "Waiting for sops-secrets-operator..." +kubectl wait --for=condition=Available deployment --all -n sops-secrets-operator --timeout=300s + +echo "" +echo "=== All foundation and infrastructure pods ready ===" diff --git a/modules/apps/cluster/list-packages-json.nix b/modules/apps/cluster/list-packages-json.nix new file mode 100644 index 000000000..84c488b82 --- /dev/null +++ b/modules/apps/cluster/list-packages-json.nix @@ -0,0 +1,25 @@ +# list-packages-json.nix - Emit a JSON matrix of workspace packages. +# +# Enumerates packages// directories containing a package.json +# and emits a JSON array of {name, path} entries consumed by the +# preview-release-version matrix step in cd.yaml's set-variables job. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.list-packages-json = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "list-packages-json"; + runtimeInputs = [ + pkgs.coreutils + pkgs.git + ]; + text = builtins.readFile ./list-packages-json.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/list-packages-json.sh b/modules/apps/cluster/list-packages-json.sh new file mode 100644 index 000000000..e80cb4e27 --- /dev/null +++ b/modules/apps/cluster/list-packages-json.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Resolves repo root via git rev-parse --show-toplevel. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: list-packages-json [--help] + +Emit a JSON array of {"name": "", "path": "packages/"} for every +packages// directory containing a package.json. Consumed by the +preview-release-version CI matrix in cd.yaml (set-variables job). + +No positional arguments; must run inside a git worktree rooted at the +vanixiets repo (or subdirectory thereof). +EOF + exit 0 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root/packages" + +packages=() +for dir in */; do + pkg_name="${dir%/}" + if [ -f "${dir}package.json" ]; then + packages+=("{\"name\":\"$pkg_name\",\"path\":\"packages/$pkg_name\"}") + fi +done + +# Emit a JSON array; empty case still produces a valid "[]". +( + IFS=, + echo "[${packages[*]}]" +) diff --git a/modules/apps/cluster/nixidy-bootstrap.nix b/modules/apps/cluster/nixidy-bootstrap.nix new file mode 100644 index 000000000..3674a913c --- /dev/null +++ b/modules/apps/cluster/nixidy-bootstrap.nix @@ -0,0 +1,27 @@ +# nixidy-bootstrap.nix - Apply the local-k3d app-of-apps bootstrap Application CR. +{ ... }: +{ + perSystem = + { + pkgs, + lib, + config, + ... + }: + { + apps.nixidy-bootstrap = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nixidy-bootstrap"; + runtimeInputs = [ + pkgs.coreutils + pkgs.kubectl + config.packages.nixidy + ]; + text = builtins.readFile ./nixidy-bootstrap.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/nixidy-bootstrap.sh b/modules/apps/cluster/nixidy-bootstrap.sh new file mode 100644 index 000000000..1b6eafb85 --- /dev/null +++ b/modules/apps/cluster/nixidy-bootstrap.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: nixidy-bootstrap [--help] + +Equivalent to: + nixidy bootstrap .#local-k3d | kubectl apply -f - + +Transitions Phase 3 (kluctl-driven) infrastructure to Phase 4 (ArgoCD +app-of-apps). ArgoCD must already be Available before invoking, and it +must have credentials to access the local-k3d manifest repo referenced +by the rendered Application CR. +EOF + exit 0 + ;; +esac + +nixidy bootstrap .#local-k3d | kubectl apply -f - diff --git a/modules/apps/cluster/nixidy-build.nix b/modules/apps/cluster/nixidy-build.nix new file mode 100644 index 000000000..b7e823bec --- /dev/null +++ b/modules/apps/cluster/nixidy-build.nix @@ -0,0 +1,31 @@ +# nixidy-build.nix - Render nixidy manifests for local-k3d to ./result. +# +# The nixidy CLI is exposed via config.packages.nixidy (set in +# modules/nixidy.nix) and added to runtimeInputs; the flake-app +# invocation resolves the env at `.#local-k3d` using the current +# system's nixidyEnvs..local-k3d output. +{ ... }: +{ + perSystem = + { + pkgs, + lib, + config, + ... + }: + { + apps.nixidy-build = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nixidy-build"; + runtimeInputs = [ + pkgs.coreutils + config.packages.nixidy + ]; + text = builtins.readFile ./nixidy-build.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/nixidy-build.sh b/modules/apps/cluster/nixidy-build.sh new file mode 100644 index 000000000..9ad70b232 --- /dev/null +++ b/modules/apps/cluster/nixidy-build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Consumers: CI effects + justfile wrappers. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: nixidy-build [--help] + +Invokes `nixidy build .#local-k3d`, producing a ./result/ symlink at the +working directory that materializes the rendered manifest tree. +ARGOCD_REPO_URL may be set in the environment to override the default +remote repo URL baked into the rendered Application resources (see +modules/nixidy.nix and the ARGOCD_REPO_URL env hook in kubernetes/nixidy). +EOF + exit 0 + ;; +esac + +exec nixidy build .#local-k3d diff --git a/modules/apps/cluster/nixidy-push.nix b/modules/apps/cluster/nixidy-push.nix new file mode 100644 index 000000000..8b2c9ee8c --- /dev/null +++ b/modules/apps/cluster/nixidy-push.nix @@ -0,0 +1,22 @@ +# nixidy-push.nix - Rsync rendered manifests to the local-k3d private repo. +{ ... }: +{ + perSystem = + { pkgs, lib, ... }: + { + apps.nixidy-push = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nixidy-push"; + runtimeInputs = [ + pkgs.coreutils + pkgs.git + pkgs.rsync + ]; + text = builtins.readFile ./nixidy-push.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/nixidy-push.sh b/modules/apps/cluster/nixidy-push.sh new file mode 100644 index 000000000..593c4e16d --- /dev/null +++ b/modules/apps/cluster/nixidy-push.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: nixidy-push [--help] + +Prerequisites: + - `nixidy-build` has run in the current directory, producing ./result + - The LOCAL_K3D_REPO directory exists and has a configured git remote + +rsync copies result/ → $LOCAL_K3D_REPO/ with --delete, dereferencing +nix-store symlinks and normalizing permissions. Exits 0 cleanly when +there is nothing to push (no changes detected). + +Environment: + LOCAL_K3D_REPO target repo path + (default: $HOME/projects/nix-workspace/local-k3d) +EOF + exit 0 + ;; +esac + +LOCAL_K3D_REPO="${LOCAL_K3D_REPO:-$HOME/projects/nix-workspace/local-k3d}" + +if [[ ! -d "result" ]]; then + echo "Error: result/ directory not found. Run 'just nixidy-build' first." >&2 + exit 1 +fi + +if [[ ! -d "$LOCAL_K3D_REPO" ]]; then + echo "Error: local-k3d repo not found at $LOCAL_K3D_REPO" >&2 + echo "Clone it with: git clone git@github.com:cameronraysmith/local-k3d.git $LOCAL_K3D_REPO" >&2 + exit 1 +fi + +echo "Syncing rendered manifests to $LOCAL_K3D_REPO..." +# -L dereferences symlinks (nix store paths) to copy actual content +# --checksum compares by content hash (Nix store files have epoch timestamps) +# --chmod fixes read-only permissions from nix store +rsync -aL --delete --checksum --chmod=Du+w,Fu+w --exclude='.git' result/ "$LOCAL_K3D_REPO/" + +echo "Committing and pushing to local-k3d repo..." +cd "$LOCAL_K3D_REPO" +git add -A +if git diff --cached --quiet; then + echo "No changes to push." +else + git commit -m "chore: update rendered manifests from vanixiets" + git push + echo "Manifests pushed to local-k3d repo." +fi diff --git a/modules/apps/cluster/nixidy-sync.nix b/modules/apps/cluster/nixidy-sync.nix new file mode 100644 index 000000000..ba2b92cb0 --- /dev/null +++ b/modules/apps/cluster/nixidy-sync.nix @@ -0,0 +1,56 @@ +# nixidy-sync.nix - Compose nixidy-build then nixidy-push. +# +# Composes nixidy-build and nixidy-push by invoking them sequentially +# via their published bin names (both exposed as runtimeInputs). Running +# the sidecars directly—rather than going through `nix run .#...`—means +# the sync app is self-contained and does not need `nix` on PATH. +{ ... }: +{ + perSystem = + { + pkgs, + lib, + config, + ... + }: + let + # writeShellApplication closures for the two composed apps. These are + # already referenced by apps.nixidy-build / apps.nixidy-push via + # lib.getExe; re-declaring the derivations here lets nixidy-sync + # place both on its own PATH through runtimeInputs, avoiding a + # dependency on `nix` or `just` being present at runtime. + nixidyBuild = pkgs.writeShellApplication { + name = "nixidy-build"; + runtimeInputs = [ + pkgs.coreutils + config.packages.nixidy + ]; + text = builtins.readFile ./nixidy-build.sh; + }; + nixidyPush = pkgs.writeShellApplication { + name = "nixidy-push"; + runtimeInputs = [ + pkgs.coreutils + pkgs.git + pkgs.rsync + ]; + text = builtins.readFile ./nixidy-push.sh; + }; + in + { + apps.nixidy-sync = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "nixidy-sync"; + runtimeInputs = [ + pkgs.coreutils + nixidyBuild + nixidyPush + ]; + text = builtins.readFile ./nixidy-sync.sh; + } + ); + }; + }; +} diff --git a/modules/apps/cluster/nixidy-sync.sh b/modules/apps/cluster/nixidy-sync.sh new file mode 100644 index 000000000..9b5551b52 --- /dev/null +++ b/modules/apps/cluster/nixidy-sync.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'EOF' +Usage: nixidy-sync [--help] + +Runs `nixidy-build` then `nixidy-push` in-process (no just/nix run +indirection). Requires the same preconditions as the two sub-apps: + + - A configured LOCAL_K3D_REPO directory with a git remote + - A current directory writable to receive ./result (nixidy-build) + - An ARGOCD_REPO_URL env override when building for file:/// manifests + +See `nixidy-build --help` and `nixidy-push --help` for details. +EOF + exit 0 + ;; +esac + +nixidy-build +nixidy-push diff --git a/modules/apps/docs/deploy.nix b/modules/apps/docs/deploy.nix index d4071e7f2..ae5af8629 100644 --- a/modules/apps/docs/deploy.nix +++ b/modules/apps/docs/deploy.nix @@ -3,10 +3,17 @@ # nix run .#deploy-docs -- preview # nix run .#deploy-docs -- production # -# Consumes the nix-built CF Worker payload from config.packages.vanixiets-docs -# ($out/{dist/,.wrangler/,wrangler.jsonc}) and dispatches to wrangler via -# sops exec-env for declarative Cloudflare credential access. -{ inputs, ... }: +# Why: consumes the nix-built CF Worker payload from +# config.packages.vanixiets-docs (DOCS_PAYLOAD). +# +# Template bifurcation (writeShellApplication): INTERPOLATION FORM. +# `text` is a nix string that injects one eval-time-computed path +# (DOCS_PAYLOAD via config.packages.vanixiets-docs) into the script preamble +# before the readFile'd sidecar body. Contrast with `release.nix` and +# `preview-version.nix`, which use the pure +# `text = builtins.readFile ./.sh` form because they have no +# nix-eval-time path injection requirement (they rely on runtimeEnv only). +{ ... }: { perSystem = { @@ -21,20 +28,29 @@ program = lib.getExe ( pkgs.writeShellApplication { name = "deploy-docs"; + # Secrets flow via inherited env (never via `sops exec-env` + # inside the script), so pkgs.sops / pkgs.age are not required + # runtime inputs. + # + # sed/awk/grep/find are explicitly declared because the + # hercules-ci-effects bwrap sandbox PATH does not include them + # by default. Required for the writeShellApplication invariant + # that PATH equals runtimeInputs at runtime. runtimeInputs = [ pkgs.nodejs_24 - pkgs.sops - pkgs.age pkgs.jq pkgs.coreutils pkgs.git + pkgs.gnugrep + pkgs.gnused + pkgs.gawk + pkgs.findutils ]; runtimeEnv = { DOCS_NODE_MODULES = "${config.packages.vanixiets-docs-deps}/packages/docs/node_modules"; }; text = '' export DOCS_PAYLOAD=${lib.escapeShellArg config.packages.vanixiets-docs} - export SOPS_SECRETS_FILE=${lib.escapeShellArg "${inputs.self}/secrets/shared.yaml"} ${builtins.readFile ./deploy.sh} ''; } diff --git a/modules/apps/docs/deploy.sh b/modules/apps/docs/deploy.sh index b035210d8..89d6d9199 100644 --- a/modules/apps/docs/deploy.sh +++ b/modules/apps/docs/deploy.sh @@ -1,60 +1,148 @@ #!/usr/bin/env bash # shellcheck shell=bash # Docs deployment dispatcher invoked via `nix run .#deploy-docs`. +# See `usage()` for caller-facing usage; this header documents the +# env-var contract only. # -# Environment inputs (set by deploy.nix): -# DOCS_PAYLOAD absolute path to the vanixiets-docs derivation output -# ({dist/,.wrangler/,wrangler.jsonc} layout) -# SOPS_SECRETS_FILE absolute path to secrets/shared.yaml under $inputs.self -# -# Usage: -# deploy-docs preview -# deploy-docs production +# Required (secret, caller-provided from the closed 4-key effects bundle — +# modules/effects/vanixiets/secrets.nix): +# CLOUDFLARE_API_TOKEN wrangler auth token (CONSUMED). +# CLOUDFLARE_ACCOUNT_ID Cloudflare account id (CONSUMED; +# account-scoped ops require this). +# GITHUB_TOKEN not consumed here; bundle homogeneity +# (consumed by release.sh). +# SOPS_AGE_KEY not consumed here; bundle homogeneity +# (consumed by k3d-bootstrap-secrets.sh). +# Required (config, injected by deploy.nix): +# DOCS_PAYLOAD vanixiets-docs derivation outPath +# ($out/{dist/, .wrangler/, wrangler.jsonc}). +# DOCS_NODE_MODULES vanixiets-docs-deps node_modules tree. +# Optional (env-first with git-fallback): every GIT_* consumer is +# `${GIT_X:-$(git … 2>/dev/null || true)}` so the script runs both +# inside the buildbot-effects bwrap sandbox (no .git bind-mounted; env +# pre-populated by the effect preamble) and from a live worktree (env +# unset; git fallback resolves locally): +# GIT_REV, GIT_REV_SHORT, GIT_REV_SHORT12, GIT_BRANCH, +# GIT_COMMIT_MSG, GIT_WORKTREE_STATUS. +# Optional (env-first with bash-builtin / shelled-fallback): the bwrap +# sandbox lacks `hostname`/`whoami` on PATH, so DEPLOY_HOST falls back to +# `${HOSTNAME%%.*}` (bash builtin populated from gethostname(2)) and +# DEPLOY_DEPLOYER falls back to GITHUB_ACTOR → `whoami 2>/dev/null` +# → "unknown". +# Optional (caller debugging / overrides): +# WRANGLER, DEPLOY_DOCS_DEBUG, GITHUB_ACTIONS / GITHUB_ACTOR / +# GITHUB_WORKFLOW (when GITHUB_ACTIONS is set, the production deploy +# message uses GITHUB_WORKFLOW (default "CI") as deploy context +# instead of DEPLOY_HOST). + set -euo pipefail +usage() { + cat <<'EOF' +usage: deploy-docs preview + deploy-docs production + deploy-docs --help + +Deploy the nix-built vanixiets-docs payload to Cloudflare Workers. + +Subcommands: + preview Upload a Cloudflare Workers preview version tagged with + the current HEAD short SHA, aliased at b-. + defaults to `git branch --show-current`; explicit + value required when HEAD is detached. + production Promote the existing preview version matching the current + HEAD short SHA to 100% production traffic, or fall back + to a direct deploy of the nix-built payload when no + matching preview exists. + +Flags: + --help, -h Print this usage and exit 0. + +Environment contract (see top-of-file header for full details): + Required (secret, caller-provided from the closed 4-key effects bundle): + CLOUDFLARE_API_TOKEN wrangler auth token (CONSUMED) + CLOUDFLARE_ACCOUNT_ID Cloudflare account id (CONSUMED; account-scoped ops) + GITHUB_TOKEN bundle homogeneity (not consumed by deploy.sh) + SOPS_AGE_KEY bundle homogeneity (not consumed by deploy.sh) + Required (config, injected by deploy.nix): + DOCS_PAYLOAD path to the vanixiets-docs derivation output + DOCS_NODE_MODULES path to vanixiets-docs-deps node_modules tree + Optional (env-first with shelled-fallback): + GIT_REV, GIT_REV_SHORT, GIT_REV_SHORT12, GIT_BRANCH, + GIT_COMMIT_MSG, GIT_WORKTREE_STATUS + git metadata; supplied by effect preamble when no + .git is reachable; otherwise resolved via `git ...`. + DEPLOY_HOST short hostname; fallback `${HOSTNAME%%.*}` (bash + builtin, no external binary). + DEPLOY_DEPLOYER actor identity; fallback chain GITHUB_ACTOR → + `whoami 2>/dev/null` → "unknown". + Optional (caller debugging / overrides): + WRANGLER, DEPLOY_DOCS_DEBUG + GITHUB_ACTIONS / GITHUB_ACTOR / GITHUB_WORKFLOW + When GITHUB_ACTIONS is set, the production deploy + message uses the GitHub Actions context (workflow + name) instead of DEPLOY_HOST. + +Examples: + nix run .#deploy-docs -- preview my-feature-branch + nix run .#deploy-docs -- production +EOF +} + mode="${1:-}" +case "$mode" in + -h | --help) + usage + exit 0 + ;; +esac + if [[ -z "$mode" ]]; then echo "error: missing subcommand" >&2 echo "usage: deploy-docs preview | deploy-docs production" >&2 + echo "(run with --help for full usage and env-var contract)" >&2 exit 2 fi shift -if [[ -z "${DOCS_PAYLOAD:-}" ]]; then - echo "error: DOCS_PAYLOAD not set; deploy.nix must pass the nix-built payload" >&2 - exit 1 -fi -if [[ ! -d "$DOCS_PAYLOAD" ]]; then - echo "error: DOCS_PAYLOAD=$DOCS_PAYLOAD is not a directory" >&2 - exit 1 -fi -if [[ -z "${SOPS_SECRETS_FILE:-}" ]]; then - echo "error: SOPS_SECRETS_FILE not set; deploy.nix must interpolate secrets path" >&2 - exit 1 -fi -if [[ ! -f "$SOPS_SECRETS_FILE" ]]; then - echo "error: SOPS_SECRETS_FILE=$SOPS_SECRETS_FILE does not exist" >&2 - exit 1 -fi -if [[ -z "${DOCS_NODE_MODULES:-}" ]]; then - echo "error: DOCS_NODE_MODULES not set; deploy.nix must expose vanixiets-docs-deps" >&2 - exit 1 -fi +# Env-var contract guards: fail fast before any wrangler / filesystem work. +: "${DOCS_PAYLOAD:?DOCS_PAYLOAD not set; deploy.nix must pass the nix-built payload}" +[[ -d "$DOCS_PAYLOAD" ]] || { echo "error: DOCS_PAYLOAD=$DOCS_PAYLOAD is not a directory" >&2; exit 1; } +: "${DOCS_NODE_MODULES:?DOCS_NODE_MODULES not set; deploy.nix must expose vanixiets-docs-deps via runtimeEnv}" +: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required (see deploy.sh header for caller mechanisms: effect preamble, direnv, caller-side sops wrapper, or GHA env)}" +: "${CLOUDFLARE_ACCOUNT_ID:?CLOUDFLARE_ACCOUNT_ID is required (see deploy.sh header for caller mechanisms: effect preamble, direnv, caller-side sops wrapper, or GHA env)}" # Hermetic wrangler via bun-managed node_modules (vanixiets-docs-deps derivation). -# Must be exported so sops exec-env subshells inherit it for single-quoted command strings. -export WRANGLER="$DOCS_NODE_MODULES/.bin/wrangler" +# The `${WRANGLER:-...}` fallback allows test harnesses (e.g. the no-op wrangler stub +# used to exercise the post-condition error paths) to override the hermetic +# binary without rewriting this script. +export WRANGLER="${WRANGLER:-$DOCS_NODE_MODULES/.bin/wrangler}" + +# Invoke wrangler via real node, not the .bin/wrangler shebang: +# bun's .bin wrappers point at bun-with-fake-node/bin/node (bun in node- +# compat mode), but bun's fetch() on linux-x64 silently hangs on keep- +# alive connection reuse to api.cloudflare.com — wrangler `versions +# upload` / `versions deploy` exit 0 with no Worker Version ID produced +# and no error. Prefixing `node` forces real-node (undici) runtime. +# Matches pkgs/by-name/vanixiets-docs/package.nix:141 (astro) and :248 +# (playwright) precedent for tools with known bun incompatibilities. +# Empirical: diagnosed 2026-04-22 via magnetite linux-x64 reproducer; +# same machine + wrangler runs fine under real node, hangs under bun. -# Resolve repo root so git metadata commands work independently of callsite. -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" +# Wrangler is invoked with absolute `--config "$WRANGLER_CONFIG"`, so no +# `cd` into the worktree is required (and would fail inside the bwrap +# sandbox, which does not bind-mount the working tree). -# Materialise a writable copy of the nix payload. wrangler reads -# .wrangler/deploy/config.json whose configPath ("../../dist/server/wrangler.json") -# resolves against the config file's location, and wrangler may write state to -# .wrangler/ during deploy — both require a writable tree outside /nix/store. +# Materialise a writable copy of the nix payload: wrangler reads +# .wrangler/deploy/config.json (configPath resolves against the config +# file's location) and may write state to .wrangler/ during deploy. tmpdir=$(mktemp -d -t deploy-docs.XXXXXX) -trap 'rm -rf "$tmpdir"' EXIT +if [[ -n "${DEPLOY_DOCS_DEBUG:-}" ]]; then + echo "[deploy-docs] DEBUG: preserving tmpdir at $tmpdir" >&2 + trap 'echo "[deploy-docs] DEBUG: tmpdir preserved at '\''$tmpdir'\''" >&2' EXIT +else + trap 'rm -rf "$tmpdir"' EXIT +fi cp -R "$DOCS_PAYLOAD"/. "$tmpdir/" chmod -R u+w "$tmpdir" @@ -66,20 +154,26 @@ chmod -R u+w "$tmpdir" # present in the source wrangler.jsonc. wrangler_config="$tmpdir/dist/server/wrangler.json" -# Commit metadata shared by preview and production subcommands. -commit_sha=$(git rev-parse HEAD) -commit_tag=$(git rev-parse --short=12 HEAD) -commit_short=$(git rev-parse --short HEAD) -current_branch=$(git branch --show-current || true) +# Commit metadata: env-first with errexit-tolerant git fallback so a +# missing .git (bwrap sandbox) surfaces as empty strings rather than +# aborting; the env-first path supplies authoritative values in that case. +commit_sha="${GIT_REV:-$(git rev-parse HEAD 2>/dev/null || true)}" +commit_tag="${GIT_REV_SHORT12:-$(git rev-parse --short=12 HEAD 2>/dev/null || true)}" +commit_short="${GIT_REV_SHORT:-$(git rev-parse --short HEAD 2>/dev/null || true)}" +current_branch="${GIT_BRANCH:-$(git branch --show-current 2>/dev/null || true)}" + +# Resolve deployer / deploy_host with env-first / bash-builtin / +# shelled-fallback. Bash builtin `$HOSTNAME` is populated from +# gethostname(2) at shell startup, so `${HOSTNAME%%.*}` mimics +# `hostname -s` without shelling out — required because the bwrap +# sandbox lacks `hostname` on PATH. +deploy_host="${DEPLOY_HOST:-${HOSTNAME%%.*}}" +deployer="${DEPLOY_DEPLOYER:-${GITHUB_ACTOR:-$(whoami 2>/dev/null || echo unknown)}}" -# Compose deploy message (prefer GitHub Actions context, fall back to local). if [[ -n "${GITHUB_ACTIONS:-}" ]]; then - deployer="${GITHUB_ACTOR:-github-actions}" deploy_context="${GITHUB_WORKFLOW:-CI}" deploy_msg="Deployed by ${deployer} from ${current_branch} via ${deploy_context}" else - deployer=$(whoami) - deploy_host=$(hostname -s) deploy_msg="Deployed by ${deployer} from ${current_branch} on ${deploy_host}" fi @@ -92,16 +186,25 @@ case "$mode" in exit 2 fi - # Sanitize branch name for Cloudflare alias (valid subdomain component): - # replace / with -, collapse runs, strip leading/trailing -, cap at 40 chars. safe_branch=$(echo "$branch" \ | tr '/' '-' \ | tr -c 'a-zA-Z0-9-' '-' \ | sed 's/--*/-/g; s/^-//; s/-$//' \ | cut -c1-40) - commit_msg=$(git log -1 --pretty=format:'%s') - git_status=$(git diff-index --quiet HEAD -- && echo "clean" || echo "dirty") + # Env-first / errexit-tolerant git fallback so a missing .git leaves + # commit_msg empty; the effect preamble supplies authoritative values. + commit_msg="${GIT_COMMIT_MSG:-$(git log -1 --pretty=format:'%s' 2>/dev/null || true)}" + if [[ -n "${GIT_WORKTREE_STATUS:-}" ]]; then + git_status="$GIT_WORKTREE_STATUS" + elif git diff-index --quiet HEAD -- 2>/dev/null; then + git_status="clean" + else + # Non-zero from `git diff-index` covers both "dirty worktree" and + # "not a git repository" — collapse both to "dirty" so downstream + # version_message is always well-formed. + git_status="dirty" + fi version_message="[${branch}] ${commit_msg} (${commit_tag}, ${git_status})" echo "Deploying preview for branch: ${branch}" @@ -117,16 +220,171 @@ case "$mode" in export SAFE_BRANCH="$safe_branch" export WRANGLER_CONFIG="$wrangler_config" - # shellcheck disable=SC2016 # single-quoted $VARs are intentional; expanded by sops-wrapped subshell - sops exec-env "$SOPS_SECRETS_FILE" ' - "$WRANGLER" --config "$WRANGLER_CONFIG" versions upload \ + # Capture wrangler's machine-readable NDJSON event log via + # WRANGLER_OUTPUT_FILE_PATH (supported by wrangler >= 3.x; confirmed on + # 4.84.1 by grepping `WRANGLER_OUTPUT_FILE_PATH` + `type: "version-upload"` + # in packages/docs/node_modules/wrangler/wrangler-dist/cli.js). The + # previous revision of this script used `--json` on `wrangler versions + # upload`, but wrangler 4.84.x does NOT accept `--json` on that subcommand + # (GHA re-run against cd-via-effects @ 6ce9fca2 exited 1 with "Unknown + # argument: json"); `--json` is only supported on the `versions list` and + # `deployments list` subcommands. The NDJSON stream is emitted to the file + # named by WRANGLER_OUTPUT_FILE_PATH; each line is a JSON object with a + # `type` discriminator. For `versions upload` we look for the + # `version-upload` event, which carries `version_id`, `worker_tag`, + # `preview_url`, and `preview_alias_url`. + # + # Three post-conditions enforce the no-silent-success invariant: + # (a) the NDJSON event log contains a `type == "version-upload"` entry + # with a non-empty `version_id` (primary authoritative source) + # (b) `wrangler versions list --json` contains an entry whose + # annotations["workers/tag"] matches $commit_tag (server-side + # persistence cross-check) + # (c) only then is the user-visible success block echoed, including the + # authoritative Worker Version ID parsed from (a). + wrangler_upload_ndjson="$tmpdir/wrangler-versions-upload.ndjson" + wrangler_upload_stdout="$tmpdir/wrangler-versions-upload.stdout" + wrangler_upload_stderr="$tmpdir/wrangler-versions-upload.stderr" + : > "$wrangler_upload_ndjson" + : > "$wrangler_upload_stdout" + : > "$wrangler_upload_stderr" + export WRANGLER_OUTPUT_FILE_PATH="$wrangler_upload_ndjson" + # Note: WRANGLER_LOG=debug was observed to deterministically terminate + # wrangler 4.84.1 mid-fetch (process exits 0 after POST + # /assets-upload-session request, before response; on GHA similar early + # termination at GET /workers/services/). Upload then never + # completes. Do NOT re-enable without gating it to a retry-only code + # path. Wrangler's internal log file at ~/.wrangler/logs/wrangler-*.log + # is written at default level regardless and is captured on failure. + + # Tee stdout so we both display wrangler output live AND parse it as a + # fallback version_id source when the NDJSON event stream from + # WRANGLER_OUTPUT_FILE_PATH doesn't produce the expected + # `type:"version-upload"` event. Retained as defense-in-depth against + # future wrangler silent-success regressions. + printf '>> wrangler upload command: node %s --config %s versions upload --preview-alias %s --tag %s --message %q\n' \ + "$WRANGLER" "$WRANGLER_CONFIG" "b-${SAFE_BRANCH}" "$VERSION_TAG" "$VERSION_MESSAGE" >&2 + + set +e + node "$WRANGLER" --config "$WRANGLER_CONFIG" versions upload \ --preview-alias "b-${SAFE_BRANCH}" \ --tag "$VERSION_TAG" \ - --message "$VERSION_MESSAGE" - ' + --message "$VERSION_MESSAGE" \ + > >(tee "$wrangler_upload_stdout") \ + 2> >(tee "$wrangler_upload_stderr" >&2) + wrangler_upload_rc=$? + set -e + + unset WRANGLER_OUTPUT_FILE_PATH + + # Post-condition (a): extract a non-empty Worker Version ID. + # Primary: NDJSON `version-upload` event. Fallback: stdout line + # `Worker Version ID: `. (b) cross-checks server-side persistence. + version_id="" + if [[ -s "$wrangler_upload_ndjson" ]]; then + version_id=$( + jq -rs ' + map(select(type == "object" and (.type // "") == "version-upload")) + | .[0].version_id // empty + ' "$wrangler_upload_ndjson" 2>/dev/null || true + ) + fi + if [[ -z "$version_id" ]]; then + version_id=$( + grep -oE 'Worker Version ID: [a-f0-9-]+' "$wrangler_upload_stdout" 2>/dev/null \ + | awk '{print $NF}' \ + | head -1 || true + ) + fi + if [[ -z "$version_id" ]]; then + # Relax errexit for the entire diagnostic dump block. grep/sed/cat/head + # failures here (missing stdout match, empty NDJSON, nonexistent log + # file) must not abort before every dump section fires — the script's + # fail contract is satisfied by the explicit `exit 1` at the end of + # this block, not by intermediate pipeline exit codes. + set +e + echo "" >&2 + echo "error: wrangler exited 0 but produced no Worker Version ID" >&2 + echo " post-condition (a) failed: neither WRANGLER_OUTPUT_FILE_PATH NDJSON" >&2 + echo " event log nor wrangler stdout contained" >&2 + echo " a recognizable Worker Version ID" >&2 + echo " wrangler exit code: $wrangler_upload_rc" >&2 + echo " raw wrangler event log: $wrangler_upload_ndjson" >&2 + echo " raw wrangler stdout: $wrangler_upload_stdout" >&2 + echo " raw wrangler stderr: $wrangler_upload_stderr" >&2 + echo " hints:" >&2 + echo " - confirm CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are exported by" >&2 + echo " the caller (see deploy.sh env-var contract header for caller mechanisms)" >&2 + echo " - if linux-x64 regression, confirm wrangler invoked under real node and" >&2 + echo " not bun-fake-node (see deploy.sh node invocation rationale)" >&2 + echo " - inspect the wrangler internal log dumped below / raw NDJSON and stdout paths above for any output" >&2 + echo "" >&2 + # Locate wrangler's internal log file by glob + newest mtime across + # platform-specific candidate locations. The log file contains full + # HTTP request/response bodies and any internal stack traces — most + # informative diagnostic source when NDJSON/stdout/stderr are empty. + wrangler_log_path="" + for candidate_dir in "$HOME/.wrangler/logs" "$HOME/.config/.wrangler/logs"; do + if [[ -d "$candidate_dir" ]]; then + # Filename `wrangler-YYYY-MM-DD_HH-MM-SS_mmm.log` is + # zero-padded and lex-sortable, so `sort | tail -1` picks newest. + newest=$(find "$candidate_dir" -maxdepth 1 -type f -name 'wrangler-*.log' 2>/dev/null | sort | tail -1 || true) + if [[ -n "$newest" ]]; then + wrangler_log_path="$newest" + break + fi + fi + done + if [[ -n "$wrangler_log_path" && -f "$wrangler_log_path" ]]; then + echo "--- begin wrangler internal log ($wrangler_log_path) ---" >&2 + cat "$wrangler_log_path" >&2 || true + echo "--- end wrangler internal log ---" >&2 + else + echo "wrangler internal log: no file found under \$HOME/.wrangler/logs or \$HOME/.config/.wrangler/logs" >&2 + fi + echo "--- begin raw wrangler NDJSON ($wrangler_upload_ndjson) ---" >&2 + cat "$wrangler_upload_ndjson" >&2 || true + echo "--- end raw wrangler NDJSON ---" >&2 + echo "--- begin raw wrangler stdout ($wrangler_upload_stdout) ---" >&2 + cat "$wrangler_upload_stdout" >&2 || true + echo "--- end raw wrangler stdout ---" >&2 + echo "--- begin raw wrangler stderr ($wrangler_upload_stderr) ---" >&2 + cat "$wrangler_upload_stderr" >&2 || true + echo "--- end raw wrangler stderr ---" >&2 + set -e + exit 1 + fi + + # Post-condition (b): cross-check via versions list that the upload landed + # server-side with the expected commit tag annotation. The `| cat` pipe + # ensures wrangler's stdout is delivered through a pipe-shaped fd before + # being redirected to disk (observed empirically: `wrangler ... --json > + # file` intermittently produces zero bytes whereas `wrangler ... --json | + # cat > file` reliably produces the full JSON output, which suggests + # wrangler inspects stdout before emitting when the fd points directly at + # a file). + wrangler_list_json="$tmpdir/wrangler-versions-list.json" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" versions list --json \ + | cat > "$wrangler_list_json" + + matched_count=$(jq --arg tag "$commit_tag" \ + '[.[] | select(.annotations["workers/tag"] == $tag)] | length' \ + "$wrangler_list_json" 2>/dev/null || echo 0) + if [[ "$matched_count" -lt 1 ]]; then + echo "" >&2 + echo "error: uploaded version with tag ${commit_tag} not found in versions list" >&2 + echo " post-condition (b) failed: wrangler versions list returned no entries" >&2 + echo " with annotations[\"workers/tag\"] == ${commit_tag}" >&2 + echo " raw versions list output: $wrangler_list_json" >&2 + echo " hint: wrangler reported a version_id locally but the Cloudflare API did" >&2 + echo " not persist it; inspect the raw versions list for surrounding entries" >&2 + exit 1 + fi echo "" echo "Version uploaded successfully" + echo " Worker Version ID: ${version_id}" echo " Tag: ${commit_tag}" echo " Full SHA: ${commit_sha}" echo " Message: ${version_message}" @@ -144,11 +402,17 @@ case "$mode" in export WRANGLER_CONFIG="$wrangler_config" # Query for an existing version uploaded from this commit (via preview). - # shellcheck disable=SC2016 # single-quoted $VARs are intentional; expanded by sops-wrapped subshell - existing_version=$(sops exec-env "$SOPS_SECRETS_FILE" ' - "$WRANGLER" --config "$WRANGLER_CONFIG" versions list --json - ' | jq -r --arg tag "$commit_tag" \ - '.[] | select(.annotations["workers/tag"] == $tag) | .id' | head -1) + # Capture versions list to a tempfile so post-condition verification can + # reuse it. `| cat >` routes through a pipe-shaped fd — see the preview + # subcommand's equivalent comment for the empirical rationale. + wrangler_list_json="$tmpdir/wrangler-versions-list.json" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" versions list --json \ + | cat > "$wrangler_list_json" + + existing_version=$(jq -r --arg tag "$commit_tag" \ + '.[] | select(.annotations["workers/tag"] == $tag) | .id' \ + "$wrangler_list_json" 2>/dev/null | head -1 || true) if [[ -n "$existing_version" ]]; then echo "found existing version: ${existing_version}" @@ -157,26 +421,95 @@ case "$mode" in echo "" export DEPLOYMENT_MESSAGE="$deploy_msg" + export EXISTING_VERSION="$existing_version" - # shellcheck disable=SC2016 # single-quoted $VARs are intentional; expanded by sops-wrapped subshell - if sops exec-env "$SOPS_SECRETS_FILE" ' - "$WRANGLER" --config "$WRANGLER_CONFIG" versions deploy \ - "'"$existing_version"'@100%" \ + # Post-condition verification mirrors the preview path: capture + # wrangler's NDJSON event log via WRANGLER_OUTPUT_FILE_PATH, assert a + # non-empty deployment_id on the `version-deploy` event, then cross-check + # via `wrangler deployments list --json` before declaring success. Like + # `versions upload`, `versions deploy` does NOT accept `--json` on + # wrangler 4.84.x — the event log is the authoritative machine-readable + # output channel. Detects wrangler's silent-exit failure mode when the + # CI-detection branch exits 0 without performing the promotion. + deploy_ndjson="$tmpdir/wrangler-versions-deploy.ndjson" + deploy_stdout="$tmpdir/wrangler-versions-deploy.stdout" + : > "$deploy_ndjson" + : > "$deploy_stdout" + export WRANGLER_OUTPUT_FILE_PATH="$deploy_ndjson" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" versions deploy \ + "${EXISTING_VERSION}@100%" \ --yes \ - --message "$DEPLOYMENT_MESSAGE" - '; then - echo "" - echo "successfully promoted version ${existing_version} to production" - echo " tag: ${commit_tag}" - echo " full SHA: ${commit_sha}" - echo " deployed by: ${deploy_msg}" - echo " production URL: https://infra.cameronraysmith.net" - else - echo "" - echo "error: failed to promote version ${existing_version}" >&2 - echo " deployment was cancelled or failed" >&2 + --message "$DEPLOYMENT_MESSAGE" \ + | tee "$deploy_stdout" + + unset WRANGLER_OUTPUT_FILE_PATH + + deployment_id="" + if [[ -s "$deploy_ndjson" ]]; then + deployment_id=$( + jq -rs ' + map(select(type == "object" and (.type // "") == "version-deploy")) + | .[0].deployment_id // empty + ' "$deploy_ndjson" 2>/dev/null || true + ) + fi + if [[ -z "$deployment_id" ]]; then + # stdout fallback: match `Deployment ID: ` / `deployment_id: `. + deployment_id=$( + grep -oiE '(Deployment ID|deployment_id)[[:space:]]*:[[:space:]]*[a-f0-9-]+' \ + "$deploy_stdout" 2>/dev/null \ + | awk '{print $NF}' \ + | head -1 || true + ) + fi + if [[ -z "$deployment_id" ]]; then + echo "" >&2 + echo "error: wrangler exited 0 but produced no Deployment ID" >&2 + echo " post-condition (a) failed: neither WRANGLER_OUTPUT_FILE_PATH NDJSON" >&2 + echo " event log nor wrangler stdout contained" >&2 + echo " a recognizable Deployment ID" >&2 + echo " raw wrangler event log: $deploy_ndjson" >&2 + echo " raw wrangler stdout: $deploy_stdout" >&2 + echo " hints:" >&2 + echo " - confirm CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are exported by" >&2 + echo " the caller (see deploy.sh env-var contract header for caller mechanisms)" >&2 + echo " - if linux-x64 regression, confirm wrangler invoked under real node and" >&2 + echo " not bun-fake-node (see deploy.sh node invocation rationale)" >&2 + echo " - inspect the wrangler internal log dumped below / raw capture paths above for any output" >&2 + exit 1 + fi + + deployments_list_json="$tmpdir/wrangler-deployments-list.json" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" deployments list --json \ + | cat > "$deployments_list_json" + + found_count=$(jq --arg did "$deployment_id" --arg vid "$existing_version" \ + '[.[] | select( + .id == $did + or .deployment_id == $did + or ((.versions // []) | map(.version_id // .id // "") | index($vid) != null) + )] | length' \ + "$deployments_list_json" 2>/dev/null || echo 0) + if [[ "$found_count" -lt 1 ]]; then + echo "" >&2 + echo "error: deployment ${deployment_id} (version ${existing_version}) not found in deployments list" >&2 + echo " post-condition (b) failed: wrangler deployments list returned no" >&2 + echo " entries matching the just-deployed id/version" >&2 + echo " raw deployments list output: $deployments_list_json" >&2 + echo " hint: wrangler reported a deployment locally but the Cloudflare API did" >&2 + echo " not persist it; inspect the raw deployments list for surrounding entries" >&2 exit 1 fi + + echo "" + echo "successfully promoted version ${existing_version} to production" + echo " Deployment ID: ${deployment_id}" + echo " tag: ${commit_tag}" + echo " full SHA: ${commit_sha}" + echo " deployed by: ${deploy_msg}" + echo " production URL: https://infra.cameronraysmith.net" else echo "warning: no existing version found with tag: ${commit_tag}" echo " this should only happen if:" @@ -189,24 +522,98 @@ case "$mode" in export DEPLOYMENT_MESSAGE="$deploy_msg" - # shellcheck disable=SC2016 # single-quoted $VARs are intentional; expanded by sops-wrapped subshell - if sops exec-env "$SOPS_SECRETS_FILE" ' - "$WRANGLER" --config "$WRANGLER_CONFIG" deploy --message "$DEPLOYMENT_MESSAGE" - '; then - echo "" - echo "deployed nix-built payload directly to production" - echo " warning: this version was not tested in preview first" - else - echo "" - echo "error: failed to deploy" >&2 + # Fallback direct-deploy: same post-condition pattern, but the + # NDJSON event is `type == "deploy"` carrying `version_id` (no + # deployment_id field on this event type). `wrangler deploy` does + # NOT accept `--json` on wrangler 4.84.x; WRANGLER_OUTPUT_FILE_PATH + # is the authoritative machine-readable channel. + deploy_ndjson="$tmpdir/wrangler-deploy.ndjson" + deploy_stdout="$tmpdir/wrangler-deploy.stdout" + : > "$deploy_ndjson" + : > "$deploy_stdout" + export WRANGLER_OUTPUT_FILE_PATH="$deploy_ndjson" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" deploy \ + --message "$DEPLOYMENT_MESSAGE" \ + | tee "$deploy_stdout" + + unset WRANGLER_OUTPUT_FILE_PATH + + deploy_version_id="" + if [[ -s "$deploy_ndjson" ]]; then + deploy_version_id=$( + jq -rs ' + map(select(type == "object" and (.type // "") == "deploy")) + | .[0].version_id // empty + ' "$deploy_ndjson" 2>/dev/null || true + ) + fi + if [[ -z "$deploy_version_id" ]]; then + deploy_version_id=$( + grep -oiE '(Current Version ID|Worker Version ID|version_id)[[:space:]]*:[[:space:]]*[a-f0-9-]+' \ + "$deploy_stdout" 2>/dev/null \ + | awk '{print $NF}' \ + | head -1 || true + ) + fi + if [[ -z "$deploy_version_id" ]]; then + echo "" >&2 + echo "error: wrangler exited 0 but produced no Deployment Version ID (fallback direct deploy)" >&2 + echo " post-condition (a) failed: neither WRANGLER_OUTPUT_FILE_PATH NDJSON" >&2 + echo " event log nor wrangler stdout contained" >&2 + echo " a recognizable version_id" >&2 + echo " raw wrangler event log: $deploy_ndjson" >&2 + echo " raw wrangler stdout: $deploy_stdout" >&2 + echo " hints:" >&2 + echo " - confirm CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are exported by" >&2 + echo " the caller (see deploy.sh env-var contract header for caller mechanisms)" >&2 + echo " - if linux-x64 regression, confirm wrangler invoked under real node and" >&2 + echo " not bun-fake-node (see deploy.sh node invocation rationale)" >&2 + echo " - inspect the wrangler internal log dumped below / raw capture paths above for any output" >&2 + exit 1 + fi + # Reuse deployment_id slot below (it now holds the just-deployed version_id + # since `wrangler deploy` emits no server-assigned deployment id directly). + deployment_id="$deploy_version_id" + + deployments_list_json="$tmpdir/wrangler-deployments-list.json" + + node "$WRANGLER" --config "$WRANGLER_CONFIG" deployments list --json \ + | cat > "$deployments_list_json" + + found_count=$(jq --arg vid "$deploy_version_id" \ + '[.[] | select( + .id == $vid + or .deployment_id == $vid + or ((.versions // []) | map(.version_id // .id // "") | index($vid) != null) + )] | length' \ + "$deployments_list_json" 2>/dev/null || echo 0) + if [[ "$found_count" -lt 1 ]]; then + echo "" >&2 + echo "error: deployment for version ${deploy_version_id} not found in deployments list (fallback direct deploy)" >&2 + echo " post-condition (b) failed: wrangler deployments list returned no" >&2 + echo " entries matching the just-deployed version_id" >&2 + echo " raw deployments list output: $deployments_list_json" >&2 + echo " hint: wrangler reported a deployment locally but the Cloudflare API did" >&2 + echo " not persist it; inspect the raw deployments list for surrounding entries" >&2 exit 1 fi + + echo "" + echo "deployed nix-built payload directly to production" + echo " Deployment Version ID: ${deployment_id}" + echo " tag: ${commit_tag}" + echo " full SHA: ${commit_sha}" + echo " deployed by: ${deploy_msg}" + echo " production URL: https://infra.cameronraysmith.net" + echo " warning: this version was not tested in preview first" fi ;; *) echo "error: unknown subcommand '$mode'" >&2 echo "usage: deploy-docs preview | deploy-docs production" >&2 + echo "(run with --help for full usage and env-var contract)" >&2 exit 2 ;; esac diff --git a/modules/apps/docs/preview-version.nix b/modules/apps/docs/preview-version.nix index c6529b9a2..2f2a8bc51 100644 --- a/modules/apps/docs/preview-version.nix +++ b/modules/apps/docs/preview-version.nix @@ -1,14 +1,19 @@ -# Flake app: preview the semantic-release version that would be published after -# merging the current branch into a target branch. +# Flake app: preview the semantic-release version that would be published +# after merging the current branch into a target branch. # -# Usage: # nix run .#preview-version # root package on main # nix run .#preview-version -- main packages/docs # monorepo package preview # # Hermetic: semantic-release and its plugins are provided by the -# vanixiets-docs-deps derivation (linked into the worktree at runtime); the app -# is self-contained and does not depend on a prior `bun install` or on -# pkgs.semantic-release. +# vanixiets-docs-deps derivation linked into the worktree at runtime; no +# prior `bun install` or pkgs.semantic-release dependency. +# +# Template bifurcation (writeShellApplication): PURE READFILE FORM. +# `text = builtins.readFile ./preview-version.sh` — the sidecar is consumed +# verbatim, no nix-eval-time string interpolation. The only nix-injected +# value is DOCS_NODE_MODULES, exposed via `runtimeEnv` at invocation time. +# Contrast with `deploy.nix`, which uses the interpolation form because it +# must inject the DOCS_PAYLOAD store path into the script preamble. { ... }: { perSystem = diff --git a/modules/apps/docs/preview-version.sh b/modules/apps/docs/preview-version.sh index 2ed6047cc..aa7afde84 100644 --- a/modules/apps/docs/preview-version.sh +++ b/modules/apps/docs/preview-version.sh @@ -1,21 +1,65 @@ -# preview-version.sh - Preview semantic-release version after merging to target branch +#!/usr/bin/env bash +# shellcheck shell=bash +# preview-version.sh - Preview semantic-release version after merging to +# target branch. See `usage()` for caller-facing usage. # -# Usage: -# nix run .#preview-version -- [target-branch] [package-path] +# Env-var contract: +# Required (config, injected by preview-version.nix runtimeEnv): +# DOCS_NODE_MODULES vanixiets-docs-deps node_modules tree (hosts +# node_modules/.bin/semantic-release). +# Optional (caller-provided): +# CURRENT_BRANCH bookmark/branch name to attach HEAD to when +# invoked from jj-colocated detached HEAD. # -# Examples: -# nix run .#preview-version # Preview root version on main -# nix run .#preview-version -- main packages/docs # Preview docs package version on main -# nix run .#preview-version -- beta packages/docs # Preview docs version on beta -# -# This script simulates merging the current branch into the target branch and -# runs semantic-release in dry-run mode to preview what version would be released. -# -# Hermetic: DOCS_NODE_MODULES (set by preview-version.nix) points to a read-only -# node_modules tree produced by the vanixiets-docs-deps derivation. This script -# links it into the worktree's package directory and invokes semantic-release -# directly via node_modules/.bin, bypassing any need for bun or a prior -# `bun install`. +# No secret env vars required: semantic-release runs --dry-run with +# @semantic-release/github filtered out of the plugin list. + +set -euo pipefail + +: "${DOCS_NODE_MODULES:?DOCS_NODE_MODULES not set; preview-version.nix must expose vanixiets-docs-deps via runtimeEnv}" + +usage() { + cat <<'EOF' +usage: preview-version [target-branch] [package-path] + preview-version --help + +Preview the semantic-release version that would be published after merging +the current branch into . Simulates the merge via +`git merge-tree --write-tree`, runs semantic-release in --dry-run / --no-ci +mode against a temporary worktree, and prints the next version (or a +no-bump / unsupported-branch notice). + +Positional arguments: + target-branch Release branch to simulate merging into (default: main). + package-path Monorepo package directory relative to the repo root + (e.g., packages/docs). Defaults to the root package. + +Flags: + --help, -h Print this usage and exit 0. + +Environment: + DOCS_NODE_MODULES (required) Absolute path to the vanixiets-docs-deps + node_modules tree, provided by preview-version.nix. + Symlinked into the temporary worktree so + semantic-release and its plugins are resolvable. + CURRENT_BRANCH (optional) Bookmark/branch name to attach HEAD to when + invoked from a jj-colocated detached-HEAD setup. When + set while HEAD is detached, the script checks out the + branch for the run and restores detached state on exit. + +Examples: + nix run .#preview-version # root package on main + nix run .#preview-version -- main packages/docs # docs package on main + nix run .#preview-version -- beta packages/docs # docs package on beta +EOF +} + +case "${1:-}" in + -h | --help) + usage + exit 0 + ;; +esac # Configuration TARGET_BRANCH="${1:-main}" @@ -23,15 +67,23 @@ PACKAGE_PATH="${2:-}" REPO_ROOT=$(git rev-parse --show-toplevel) WORKTREE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/semantic-release-preview.XXXXXX") -# Save original target branch HEAD for restoration ORIGINAL_TARGET_HEAD="" ORIGINAL_REMOTE_HEAD="" -# Track which node_modules symlink(s) we created so cleanup can remove them. +# Track node_modules symlink(s) we created for cleanup. WORKTREE_NODE_MODULES_LINK="" LOCAL_NODE_MODULES_LINK="" -# Colors for output +# Local bare clone used to redirect semantic-release verifyAuth's +# `git push --dry-run HEAD:` away from the GitHub remote +# (which can short-circuit semantic-release on branch-protection rejection +# or token-permission mismatch). Populated AFTER `git update-ref` so the +# bare's refs/heads/ captures TEMP_COMMIT, allowing the +# dry-run push to be a no-op fast-forward against a quiescent file:// +# remote with no auth and no protection. +PREVIEW_BARE_DIR="" +PREVIEW_BARE="" + RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' @@ -51,11 +103,10 @@ NC='\033[0m' # No Color ORIGINAL_HEAD_SHA="" WE_ATTACHED_HEAD=0 if [ -n "${CURRENT_BRANCH:-}" ]; then - # Env-var override path. DETECTED_BRANCH=$(git branch --show-current) if [ -z "$DETECTED_BRANCH" ]; then - # HEAD is detached; attach to the provided branch so git operations that - # rely on an attached HEAD work, and remember how to restore detached state. + # HEAD detached: attach to the provided branch and remember how to + # restore detached state on cleanup. ORIGINAL_HEAD_SHA=$(git rev-parse --verify HEAD) echo -e "${BLUE}CURRENT_BRANCH=${CURRENT_BRANCH} override; attaching HEAD for duration of preview${NC}" >&2 if ! git checkout --quiet "$CURRENT_BRANCH"; then @@ -64,8 +115,7 @@ if [ -n "${CURRENT_BRANCH:-}" ]; then fi WE_ATTACHED_HEAD=1 fi - # If HEAD was already attached, we honor CURRENT_BRANCH as-is without - # performing any checkout dance (per task spec). + # HEAD already attached: honour CURRENT_BRANCH as-is, no checkout. else CURRENT_BRANCH=$(git branch --show-current) if [ -z "$CURRENT_BRANCH" ]; then @@ -78,13 +128,11 @@ else fi fi -# Cleanup function (invoked via `trap cleanup EXIT INT TERM` below) # shellcheck disable=SC2329 cleanup() { local exit_code=$? - # Remove any node_modules symlinks we created. Only unlink if still a symlink - # (guards against manual replacement mid-run). + # Only unlink if still a symlink (guards against manual replacement). if [ -n "$WORKTREE_NODE_MODULES_LINK" ] && [ -L "$WORKTREE_NODE_MODULES_LINK" ]; then rm -f "$WORKTREE_NODE_MODULES_LINK" fi @@ -92,25 +140,26 @@ cleanup() { rm -f "$LOCAL_NODE_MODULES_LINK" fi - # Always restore target branch to original state if we modified it if [ -n "$ORIGINAL_TARGET_HEAD" ]; then echo -e "\n${BLUE}restoring ${TARGET_BRANCH} to original state...${NC}" git update-ref "refs/heads/$TARGET_BRANCH" "$ORIGINAL_TARGET_HEAD" 2>/dev/null || true fi - # Always restore remote-tracking branch to original state if we modified it if [ -n "$ORIGINAL_REMOTE_HEAD" ]; then git update-ref "refs/remotes/origin/$TARGET_BRANCH" "$ORIGINAL_REMOTE_HEAD" 2>/dev/null || true fi - # Clean up worktree if [ -d "$WORKTREE_DIR" ]; then echo -e "${BLUE}cleaning up worktree...${NC}" git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || true - # Prune any stale worktree references git worktree prune 2>/dev/null || true fi + # Clean up the local bare clone used for verifyAuth redirection. + if [ -n "$PREVIEW_BARE_DIR" ] && [ -d "$PREVIEW_BARE_DIR" ]; then + rm -rf "$PREVIEW_BARE_DIR" + fi + # Restore detached HEAD if we attached it via the CURRENT_BRANCH override path. # Gated on WE_ATTACHED_HEAD so this is a no-op in the normal attached-HEAD flow. # Must cd to REPO_ROOT first because cleanup may be triggered while cwd is @@ -125,9 +174,9 @@ cleanup() { trap cleanup EXIT INT TERM -# link_docs_node_modules : symlink DOCS_NODE_MODULES into the given -# directory's node_modules slot, guarding against clobbering a real install. -# Echoes the resulting symlink path so callers can record it for cleanup. +# link_docs_node_modules : symlink DOCS_NODE_MODULES into the +# directory's node_modules slot, refusing to overwrite a real install. +# Echoes the symlink path for cleanup tracking. link_docs_node_modules() { local target_dir="$1" local slot="$target_dir/node_modules" @@ -139,7 +188,6 @@ link_docs_node_modules() { echo "$slot" } -# Validation if [ "$CURRENT_BRANCH" == "$TARGET_BRANCH" ]; then echo -e "${YELLOW}already on target branch ${TARGET_BRANCH}${NC}" echo -e "${YELLOW}running test-release instead of preview${NC}\n" @@ -152,7 +200,6 @@ if [ "$CURRENT_BRANCH" == "$TARGET_BRANCH" ]; then exec node ./node_modules/.bin/semantic-release --dry-run --no-ci fi -# Display what we're doing echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" echo -e "${BLUE}semantic-release version preview${NC}" echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" @@ -165,26 +212,26 @@ else fi echo -e "${BLUE}───────────────────────────────────────────────────────────────${NC}\n" -# Verify target branch exists if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then echo -e "${RED}error: target branch '${TARGET_BRANCH}' does not exist${NC}" >&2 exit 1 fi -# Save original target branch HEAD before any modifications ORIGINAL_TARGET_HEAD=$(git rev-parse "$TARGET_BRANCH") - -# Save original remote-tracking branch HEAD before any modifications ORIGINAL_REMOTE_HEAD=$(git rev-parse "origin/$TARGET_BRANCH" 2>/dev/null || echo "") -# Create merge tree to test if merge is possible echo -e "${BLUE}simulating merge of ${CURRENT_BRANCH} → ${TARGET_BRANCH}...${NC}" -# Perform merge-tree operation to test if merge is possible -MERGE_OUTPUT=$(git merge-tree --write-tree "$TARGET_BRANCH" "$CURRENT_BRANCH" 2>&1) -MERGE_EXIT=$? - -if [ $MERGE_EXIT -ne 0 ]; then +# Perform merge-tree operation to test if merge is possible. +# +# The `if ! MERGE_OUTPUT=$(...)` form is deliberate: under `set -e`, a +# failing command substitution in a bare assignment (`VAR=$(cmd)`) does +# NOT cause the script to exit in every bash version/mode and does not +# propagate `$?` reliably when combined with `inherit_errexit` — the prior +# pattern of `VAR=$(cmd); RC=$?` was fragile. Guard the assignment with +# `if !` so merge-conflict detection is explicit and independent of +# errexit semantics. +if ! MERGE_OUTPUT=$(git merge-tree --write-tree "$TARGET_BRANCH" "$CURRENT_BRANCH" 2>&1); then echo -e "${RED}error: merge conflicts detected${NC}" >&2 echo -e "${YELLOW}please resolve conflicts in your branch before previewing${NC}" >&2 echo -e "\n${YELLOW}conflict details:${NC}" >&2 @@ -192,7 +239,6 @@ if [ $MERGE_EXIT -ne 0 ]; then exit 1 fi -# Extract tree hash from merge-tree output (first line) MERGE_TREE=$(echo "$MERGE_OUTPUT" | head -1) if [ -z "$MERGE_TREE" ]; then @@ -200,7 +246,6 @@ if [ -z "$MERGE_TREE" ]; then exit 1 fi -# Create temporary merge commit echo -e "${BLUE}creating temporary merge commit...${NC}" TEMP_COMMIT=$(git commit-tree -p "$TARGET_BRANCH" -p "$CURRENT_BRANCH" \ -m "Temporary merge for semantic-release preview" "$MERGE_TREE") @@ -210,24 +255,41 @@ if [ -z "$TEMP_COMMIT" ]; then exit 1 fi -# Temporarily update target branch to point to merge commit -# This allows semantic-release to analyze the correct commit history -# The cleanup function will ALWAYS restore the original branch HEAD +# Temporarily point target branch at the merge commit so semantic-release +# analyzes the correct history; cleanup always restores the original HEAD. echo -e "${BLUE}temporarily updating ${TARGET_BRANCH} ref for analysis...${NC}" git update-ref "refs/heads/$TARGET_BRANCH" "$TEMP_COMMIT" -# Also update remote-tracking branch to match (so semantic-release sees them as synchronized) +# Mirror onto remote-tracking so semantic-release sees them synchronized. git update-ref "refs/remotes/origin/$TARGET_BRANCH" "$TEMP_COMMIT" -# Create worktree at target branch (now pointing to merge commit) +# Capture the post-update-ref state into a local bare clone so +# semantic-release's `verifyAuth` runs `git push --dry-run +# HEAD:` against a quiescent file:// remote instead of the +# GitHub origin. +# +# The bare must be cloned from $REPO_ROOT (cwd's local refs at clone time +# include the just-updated refs/heads/ = TEMP_COMMIT). Cloning +# from $REPO_ROOT — not WORKTREE_DIR which has not been created yet — is what +# makes verifyAuth's push a trivial no-op fast-forward. +# +# Without this redirect, semantic-release v25.0.3's `lib/git.js:205-211` +# performs a real network round-trip to GitHub, which short-circuits the run +# whenever branch protection or token-permission mismatches reject the +# dry-run push (then `lib/git.js:282-290` strict-=== compare against +# TEMP_COMMIT can never succeed and `index.js:84-100` bails with "behind +# the remote one"), preventing analyzeCommits from ever firing. +echo -e "${BLUE}creating local bare clone for semantic-release repository-url override...${NC}" +PREVIEW_BARE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/preview-bare.XXXXXX") +PREVIEW_BARE="$PREVIEW_BARE_DIR/preview.git" +git clone --quiet --bare "$REPO_ROOT" "$PREVIEW_BARE" + echo -e "${BLUE}creating temporary worktree at ${TARGET_BRANCH}...${NC}" git worktree add --quiet "$WORKTREE_DIR" "$TARGET_BRANCH" -# Navigate to worktree cd "$WORKTREE_DIR" -# Link the hermetic vanixiets-docs-deps tree into the worktree's package dir. -# (bun install is no longer required here.) +# Link hermetic vanixiets-docs-deps into the worktree's package dir. if [ -n "$PACKAGE_PATH" ]; then if [ ! -d "$PACKAGE_PATH" ]; then echo -e "${RED}error: package path '${PACKAGE_PATH}' does not exist${NC}" >&2 @@ -239,23 +301,23 @@ else WORKTREE_NODE_MODULES_LINK=$(link_docs_node_modules "$WORKTREE_DIR") fi -# Run semantic-release in dry-run mode echo -e "\n${BLUE}running semantic-release analysis...${NC}\n" -# Capture output and parse version -# Exclude @semantic-release/github to avoid GitHub token requirement for preview -# This is safe because dry-run skips publish/success/fail steps anyway +# Exclude @semantic-release/github to avoid GitHub token requirement for +# preview; safe because dry-run skips publish/success/fail steps anyway. PLUGINS="@semantic-release/commit-analyzer,@semantic-release/release-notes-generator" -OUTPUT=$(GITHUB_REF="refs/heads/$TARGET_BRANCH" node ./node_modules/.bin/semantic-release --dry-run --no-ci --branches "$TARGET_BRANCH" --plugins "$PLUGINS" 2>&1 || true) +# Forensic banner: confirms the verifyAuth-redirect bare clone is engaged +# in production logs (parallels RELEASE-CLONE-PR-HEAD / -DISPATCH). +echo "RELEASE-PREVIEW-BARE: $PREVIEW_BARE" + +OUTPUT=$(GITHUB_REF="refs/heads/$TARGET_BRANCH" node ./node_modules/.bin/semantic-release --dry-run --no-ci --repository-url "file://$PREVIEW_BARE" --branches "$TARGET_BRANCH" --plugins "$PLUGINS" 2>&1 || true) -# Display semantic-release summary (filter out verbose plugin repetition) echo "$OUTPUT" | grep -v "^$" | grep -vE "(No more plugins|does not provide step)" | \ grep -E "(semantic-release|Running|analyzing|Found.*commits|release version|Release note|Features|Bug Fixes|Breaking Changes|Published|\*\s)" || true echo -e "\n${BLUE}═══════════════════════════════════════════════════════════════${NC}" -# Extract and display the next version if echo "$OUTPUT" | grep -q "There are no relevant changes"; then echo -e "${YELLOW}no version bump required${NC}" echo -e "no semantic commits found since last release" @@ -276,6 +338,5 @@ fi echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}\n" -# Preview completed successfully - exit 0 regardless of whether a version bump is pending. -# "No version bump required" is a valid outcome, not an error. +# "No version bump required" is a valid outcome, not an error: exit 0. exit 0 diff --git a/modules/apps/docs/release.nix b/modules/apps/docs/release.nix deleted file mode 100644 index d38bf9592..000000000 --- a/modules/apps/docs/release.nix +++ /dev/null @@ -1,40 +0,0 @@ -# release.nix - Production semantic-release wrapper as a flake app. -# -# Usage: -# nix run .#release -- -# nix run .#release -- packages/docs -# -# Hermetic: semantic-release and all plugins are provided by the -# vanixiets-docs-deps derivation and linked into the package directory at runtime. -# Callers do not need to run `bun install`. -# -# Expected caller environment (not loaded from sops; CI-only): -# GITHUB_TOKEN - GitHub authentication for the @semantic-release/github plugin -{ ... }: -{ - perSystem = - { - pkgs, - lib, - config, - ... - }: - { - apps.release = { - type = "app"; - program = lib.getExe ( - pkgs.writeShellApplication { - name = "release"; - runtimeInputs = [ - pkgs.nodejs-slim - pkgs.git - ]; - runtimeEnv = { - DOCS_NODE_MODULES = "${config.packages.vanixiets-docs-deps}/packages/docs/node_modules"; - }; - text = builtins.readFile ./release.sh; - } - ); - }; - }; -} diff --git a/modules/apps/docs/release.sh b/modules/apps/docs/release.sh deleted file mode 100644 index 8523a38a4..000000000 --- a/modules/apps/docs/release.sh +++ /dev/null @@ -1,45 +0,0 @@ -# release.sh - Production semantic-release runner for a monorepo package. -# -# Usage: -# nix run .#release -- [extra semantic-release args...] -# -# Examples: -# nix run .#release -- packages/docs -# -# Hermetic: DOCS_NODE_MODULES (set by release.nix) points to a read-only -# node_modules tree produced by the vanixiets-docs-deps derivation. This script -# links it into the target package directory and invokes semantic-release -# directly via node_modules/.bin, bypassing any need for bun or a prior -# `bun install`. -# -# Required environment (pass through from caller; CI-only): -# GITHUB_TOKEN - required by @semantic-release/github to publish tags/releases. - -package_path="${1:?usage: release [extra semantic-release args...]}" -shift - -repo_root="$(git rev-parse --show-toplevel)" -cd "$repo_root" - -if [ ! -d "$package_path" ]; then - printf 'error: package path %q does not exist relative to %s\n' \ - "$package_path" "$repo_root" >&2 - exit 1 -fi - -cd "$package_path" - -# Guard against clobbering a real local node_modules from a developer's -# bun install; only proceed if the slot is empty or already our symlink. -if [[ -e node_modules && ! -L node_modules ]]; then - echo "error: $package_path/node_modules exists and is not a symlink; refusing to overwrite a local bun install" >&2 - exit 1 -fi -trap 'rm -f "$PWD/node_modules"' EXIT -ln -snf "$DOCS_NODE_MODULES" node_modules - -# This is a real release path: semantic-release will create a tag and publish -# a GitHub release when invoked. Use `preview-version` (dry-run) or -# `test-release` for previewing. -echo "running production semantic-release in ${package_path}..." -exec node ./node_modules/.bin/semantic-release "$@" diff --git a/modules/apps/release/release.nix b/modules/apps/release/release.nix new file mode 100644 index 000000000..f76c46778 --- /dev/null +++ b/modules/apps/release/release.nix @@ -0,0 +1,69 @@ +# release.nix - Production semantic-release wrapper as a flake app. +# +# nix run .#release -- +# nix run .#release -- --dry-run +# nix run .#release -- info +# nix run .#release -- --help +# +# Configures git, invokes semantic-release against the target monorepo +# package, filters `@semantic-release/github` out of the plugin list when +# `--dry-run` is set (so GITHUB_TOKEN is not required for previews), and +# provides an `info` subcommand emitting release info as JSON. +# +# Hermetic: semantic-release and all plugins are provided by the +# vanixiets-docs-deps derivation and linked into the package directory at +# runtime. Callers do not need to run `bun install`. +# +# Expected caller environment (CI-only): +# GITHUB_TOKEN, CI, RELEASE_REPO_ROOT, +# GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL, +# GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL. +# +# Template bifurcation (writeShellApplication): PURE READFILE FORM. +# `text = builtins.readFile ./release.sh` — the sidecar is consumed verbatim, +# no nix-eval-time string interpolation. The only nix-injected value is +# DOCS_NODE_MODULES, which is provided via `runtimeEnv` (an env var set by +# the writeShellApplication wrapper at invocation time), not via `text` +# interpolation. Contrast with `deploy.nix` (modules/apps/docs/), which uses +# the interpolation form because it must inject derivation outPaths into +# the script preamble. +{ ... }: +{ + perSystem = + { + pkgs, + lib, + config, + ... + }: + { + apps.release = { + type = "app"; + program = lib.getExe ( + pkgs.writeShellApplication { + name = "release"; + runtimeInputs = [ + pkgs.nodejs-slim + pkgs.git + pkgs.jq + pkgs.gnugrep + pkgs.coreutils + # Explicitly declare every host-PATH binary that release.sh OR + # any transitive semantic-release plugin / node_modules helper + # might shell out to. The buildbot-effects bwrap sandbox + # provides only /nix/store ro-bind + writeShellApplication + # runtimeInputs PATH (no host PATH binaries); a missing input + # surfaces only at runtime as `command not found`. + pkgs.gnused + pkgs.gawk + pkgs.findutils + ]; + runtimeEnv = { + DOCS_NODE_MODULES = "${config.packages.vanixiets-docs-deps}/packages/docs/node_modules"; + }; + text = builtins.readFile ./release.sh; + } + ); + }; + }; +} diff --git a/modules/apps/release/release.sh b/modules/apps/release/release.sh new file mode 100644 index 000000000..448ff3de7 --- /dev/null +++ b/modules/apps/release/release.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# release.sh - Production semantic-release runner for a monorepo package. +# See `usage()` for caller-facing usage; this header documents the env-var +# contract only. +# +# Required (secret, production path only — not --dry-run): +# GITHUB_TOKEN @semantic-release/github auth for tag push and +# release publish. Filtered-out plugin list under +# --dry-run means no token is consulted in that mode. +# Required (config, injected by release.nix runtimeEnv): +# DOCS_NODE_MODULES vanixiets-docs-deps node_modules tree hosting +# node_modules/.bin/semantic-release. +# Optional (CI-mode signalling; required by env-ci on the effect path): +# CI "true" tells semantic-release / env-ci that the +# run is non-interactive CI. Required in the +# buildbot-effects bwrap sandbox (not a recognised +# CI provider; semantic-release would otherwise +# abort `running on a CI environment is required`). +# Optional (repo-root resolution; env-first with errexit-tolerant fallback): +# RELEASE_REPO_ROOT absolute path to the working tree's repo root. +# Required in the bwrap sandbox (no .git bind-mount; +# `git rev-parse --show-toplevel` would fail). +# Fallback: git rev-parse --show-toplevel || pwd. +# Optional (git identity; env-first, NO .git/config writes — bwrap mounts +# /nix/store ro-bind, so `git config user.email …` would fail to lock +# .git/config). git honours these natively without any config write. +# Defaults applied by the effect preamble: +# GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL (semantic-release@vanixiets.local) +# GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL (semantic-release@vanixiets.local) + +set -euo pipefail + +: "${DOCS_NODE_MODULES:?DOCS_NODE_MODULES not set; release.nix must expose vanixiets-docs-deps via runtimeEnv}" + +usage() { + cat <<'EOF' +usage: release [--dry-run] [-- extra semantic-release args] + release info [] + release --help + +Run semantic-release against a monorepo package, or extract release info. + +Subcommands: + (default) Run semantic-release for . + info Emit release info JSON (version, tag, released) from latest + git tag matching the package. + +Flags: + --dry-run Dry-run (skips @semantic-release/github; no GITHUB_TOKEN needed). + --help Print this usage and exit. + +Environment: + GITHUB_TOKEN, DOCS_NODE_MODULES, RELEASE_REPO_ROOT, CI, + GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME, GIT_COMMITTER_EMAIL + (see release.sh header for details). +EOF +} + +emit_release_info() { + local package_path="${1:-}" + local latest_tag="" + local version="" + + if [ -n "$package_path" ]; then + # Monorepo tag convention (semantic-release-monorepo): -vX.Y.Z + local package_name + package_name=$(basename "$package_path") + latest_tag=$(git tag --list "${package_name}-v*" --sort=-v:refname 2>/dev/null | head -1 || true) + else + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || true) + fi + + if [ -n "$latest_tag" ]; then + version=$(printf '%s\n' "$latest_tag" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?' \ + | head -1 || true) + if [ -z "$version" ]; then + version="unknown" + fi + jq -cn \ + --arg v "$version" \ + --arg t "$latest_tag" \ + '{version: $v, tag: $t, released: true}' + else + jq -cn '{version: "unknown", tag: "", released: false}' + fi +} + +if [ $# -eq 0 ]; then + usage >&2 + exit 2 +fi + +case "$1" in + -h|--help) + usage + exit 0 + ;; + info) + shift + emit_release_info "${1:-}" + exit 0 + ;; +esac + +dry_run=0 +package_path="" +extra_args=() + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + extra_args+=("$@") + break + ;; + -*) + extra_args+=("$1") + shift + ;; + *) + if [ -z "$package_path" ]; then + package_path="$1" + else + extra_args+=("$1") + fi + shift + ;; + esac +done + +if [ -z "$package_path" ]; then + echo "error: missing required " >&2 + usage >&2 + exit 2 +fi + +# Repo-root resolution: env-first, then error-tolerant git fallback, then +# pwd. Required because the buildbot-effects bwrap sandbox does not bind- +# mount the working tree's .git, so `git rev-parse --show-toplevel` would +# fail with `fatal: not a git repository` (exit 128) and abort the script. +# The effect preamble sets RELEASE_REPO_ROOT="$PWD" so this branch resolves +# without invoking git. Local-shell callers leave RELEASE_REPO_ROOT unset, +# exercising the git fallback against the live worktree. +repo_root="${RELEASE_REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +cd "$repo_root" + +if [ ! -d "$package_path" ]; then + printf 'error: package path %q does not exist relative to %s\n' \ + "$package_path" "$repo_root" >&2 + exit 1 +fi + +# Git identity: exported via GIT_AUTHOR_* / GIT_COMMITTER_* env vars rather +# than written to .git/config. Required because the buildbot-effects bwrap +# sandbox renders .git read-only (mounts /nix/store ro-bind only) and +# `git config user.email "…"` would fail with `error: could not lock config +# file .git/config`. git honours these env vars natively without any config +# write. Each export uses parameter-expansion default chaining so a pre-set +# value (effect preamble or caller env) is preserved unchanged. +export GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-semantic-release}" +export GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-semantic-release@vanixiets.local}" +export GIT_COMMITTER_NAME="${GIT_COMMITTER_NAME:-semantic-release}" +export GIT_COMMITTER_EMAIL="${GIT_COMMITTER_EMAIL:-semantic-release@vanixiets.local}" + +cd "$package_path" + +# Production-path contract guard: fail fast on missing GITHUB_TOKEN +# BEFORE any node_modules mutation so the error points at the contract +# rather than at an opaque state-mutation side effect. Gated on dry_run. +if [ "$dry_run" -ne 1 ]; then + : "${GITHUB_TOKEN:?GITHUB_TOKEN is required for production semantic-release (see release.sh header for caller mechanisms; not needed for --dry-run)}" +fi + +# Guard node_modules slot against clobbering a developer's real install. +# Production (non-dry-run): strict — refuse to overwrite a real node_modules +# directory. Only an empty slot or a pre-existing symlink is safe to clobber. +# Dry-run: proceed safely via two strategies that NEVER mutate the +# developer's real install in place: +# (b) reuse the existing node_modules directly if it already contains a +# usable semantic-release binary (common when the dev ran `bun install` +# to completion), or +# (a) move the existing node_modules aside to a tempdir, symlink +# DOCS_NODE_MODULES in its place for the duration of the run, and +# atomically restore the original on EXIT (including on error/SIGINT). +nm_exists_real=0 +if [[ -e node_modules && ! -L node_modules ]]; then + nm_exists_real=1 +fi + +if [ "$nm_exists_real" -eq 1 ] && [ "$dry_run" -ne 1 ]; then + echo "error: $package_path/node_modules exists and is not a symlink; refusing to overwrite a local bun install" >&2 + exit 1 +fi + +if [ "$nm_exists_real" -eq 1 ] && [ -x node_modules/.bin/semantic-release ]; then + # Dry-run strategy (b): reuse existing node_modules in place. + echo "dry-run: reusing existing node_modules (.bin/semantic-release present)" >&2 +elif [ "$nm_exists_real" -eq 1 ]; then + # Dry-run strategy (a): move existing node_modules aside, symlink for the + # duration of the run, restore atomically on exit. + backup_dir="$(mktemp -d)" + echo "dry-run: moving existing node_modules to ${backup_dir} (restored on exit)" >&2 + mv node_modules "${backup_dir}/node_modules" + # shellcheck disable=SC2064 + trap "rm -f '${PWD}/node_modules'; mv '${backup_dir}/node_modules' '${PWD}/node_modules' 2>/dev/null || true; rmdir '${backup_dir}' 2>/dev/null || true" EXIT + ln -snf "$DOCS_NODE_MODULES" node_modules +else + # Slot is empty or already a symlink — safe to (re)link. + trap 'rm -f "$PWD/node_modules"' EXIT + ln -snf "$DOCS_NODE_MODULES" node_modules +fi + +if [ "$dry_run" -eq 1 ]; then + # Filter @semantic-release/github so GITHUB_TOKEN is not required for + # preview; safe under --dry-run (prepare/publish steps are no-ops). + # Mirrors preview-version.sh plus changelog + major-tag plugins from + # the package.json "release" block. + plugins="@semantic-release/commit-analyzer,@semantic-release/release-notes-generator,@semantic-release/changelog,semantic-release-major-tag" + echo "running semantic-release (dry-run, no GitHub plugin) in ${package_path}..." + node ./node_modules/.bin/semantic-release \ + --dry-run \ + --no-ci \ + --plugins "$plugins" \ + "${extra_args[@]}" +else + # GITHUB_TOKEN is enforced via the early :? guard above (placed before + # node_modules setup so failure modes are contract-first). + echo "running production semantic-release in ${package_path}..." + node ./node_modules/.bin/semantic-release "${extra_args[@]}" +fi diff --git a/modules/checks/nix-unit.nix b/modules/checks/nix-unit.nix index da93abf7c..c7195d03e 100644 --- a/modules/checks/nix-unit.nix +++ b/modules/checks/nix-unit.nix @@ -28,15 +28,13 @@ nuenv llm-agents catppuccin + hercules-ci-effects ; inherit self; }; nix-unit.tests = { - # Metadata Test - # TC-001: Flake Structure Smoke Test - # Validates packages have required metadata (if packages exist) testMetadataFlakeOutputsExist = { expr = (builtins.hasAttr "nixosConfigurations" self) @@ -45,10 +43,7 @@ expected = true; }; - # Regression Tests - # TC-002: Terraform Module Exports Exist - # Validates that terranix module exports exist in the flake namespace testRegressionTerraformModulesExist = { expr = (builtins.hasAttr "base" self.modules.terranix) @@ -57,8 +52,7 @@ }; # TC-003: NixOS Closure Equivalence - # Validates that machine configs exist and can be referenced - # Note: Full config evaluation requires network access, so we just test existence + # Full config evaluation requires network access, so we just test existence. testRegressionNixosConfigExists = { expr = builtins.hasAttr "electrum" self.nixosConfigurations @@ -66,10 +60,7 @@ expected = true; }; - # Invariant Tests - # TC-004: Clan Inventory Structure - # Validates inventory has required fields testInvariantClanInventoryMachines = { expr = builtins.sort builtins.lessThan (builtins.attrNames self.clan.inventory.machines); expected = [ @@ -86,7 +77,6 @@ }; # TC-005: NixOS Configs Exist - # Validates all expected configs present testInvariantNixosConfigurationsExist = { expr = builtins.sort builtins.lessThan (builtins.attrNames self.nixosConfigurations); expected = [ @@ -99,7 +89,6 @@ }; # TC-006: Darwin Configs Exist - # Validates darwin configurations are created testInvariantDarwinConfigurationsExist = { expr = builtins.sort builtins.lessThan (builtins.attrNames self.darwinConfigurations); expected = [ @@ -111,7 +100,6 @@ }; # TC-007: Home Configs Exist - # Validates standalone home configurations are created testInvariantHomeConfigurationsExist = { expr = builtins.sort builtins.lessThan (builtins.attrNames self.homeConfigurations.x86_64-linux); expected = [ @@ -120,10 +108,7 @@ ]; }; - # Feature Tests - # TC-008: Module Discovery - # Validates import-tree discovers nixos modules testFeatureModuleDiscovery = { expr = (builtins.hasAttr "base" self.modules.nixos) @@ -132,7 +117,6 @@ }; # TC-009: Darwin Module Discovery - # Validates import-tree discovers darwin modules testFeatureDarwinModuleDiscovery = { expr = (builtins.hasAttr "base" self.modules.darwin) @@ -142,8 +126,7 @@ }; # TC-010: Namespace Exports - # Validates modules export to correct namespaces as valid module definitions - # NixOS module system accepts both attrsets and functions as modules + # NixOS module system accepts both attrsets and functions as modules. testFeatureNamespaceExports = { expr = let @@ -153,18 +136,14 @@ expected = true; }; - # Type-Safety Tests - # TC-011: SpecialArgs Propagation - # Validates inputs available in all machines via specialArgs testTypeSafetySpecialargsPropagation = { expr = builtins.hasAttr "inputs" self.clan.specialArgs; expected = true; }; # TC-012: Required NixOS Options - # Validates all configs have config attribute - # full option evaluation requires network access + # Full option evaluation requires network access, so we just test existence. testTypeSafetyNixosConfigStructure = { expr = builtins.all (name: builtins.hasAttr "config" self.nixosConfigurations.${name}) ( builtins.attrNames self.nixosConfigurations @@ -172,10 +151,8 @@ expected = true; }; - # Architectural Invariant Tests - # TC-013: Namespace Merging - # Validates files in same module directory auto-merge into single namespace + # Files in the same module directory auto-merge into a single namespace. testInvariantNamespaceMerging = { expr = (builtins.hasAttr "ai" self.modules.homeManager) @@ -185,7 +162,6 @@ }; # TC-014: Clan Module Integration - # Validates clan machines have corresponding flake module exports testInvariantClanModuleIntegration = { expr = let @@ -209,7 +185,6 @@ }; # TC-015: Import-Tree Completeness - # Validates import-tree discovers key modules from each namespace testFeatureImportTreeCompleteness = { expr = (builtins.hasAttr "base" self.modules.darwin) @@ -220,7 +195,6 @@ }; # TC-016: Crossplatform Home Modules - # Validates home-manager aggregates available for both darwin and linux contexts testInvariantCrossplatformHomeModules = { expr = (builtins.hasAttr "x86_64-linux" self.homeConfigurations) diff --git a/modules/devshells/default.nix b/modules/devshells/default.nix index e96b48811..58ea0ac79 100644 --- a/modules/devshells/default.nix +++ b/modules/devshells/default.nix @@ -1,6 +1,7 @@ { perSystem = { + lib, pkgs, inputs', config, @@ -59,13 +60,18 @@ pkgs.bun inputs'.bun2nix.packages.default pkgs.nodejs_24 # semantic-release >= 24.10.0 - pkgs.fuc # (rm/cp)z + pkgs.fuc pkgs.rip2 # Language detection pkgs.github-linguist # Document typesetting pkgs.typstWithPackages pkgs.svgo + ] + # buildbot-effects CLI for local dispatch of hercules-ci-effects + # (see buildbot-nix/docs/EFFECTS.md). Linux-only: depends on bwrap. + ++ lib.optionals pkgs.stdenv.isLinux [ + inputs'.buildbot-nix.packages.buildbot-effects ]; passthru.meta.description = "Development environment with clan CLI and build tools"; diff --git a/modules/effects/flake-module.nix b/modules/effects/flake-module.nix new file mode 100644 index 000000000..856e8d474 --- /dev/null +++ b/modules/effects/flake-module.nix @@ -0,0 +1,6 @@ +{ inputs, ... }: +{ + imports = [ + inputs.hercules-ci-effects.flakeModule + ]; +} diff --git a/modules/effects/vanixiets/herculesCI/deploy-docs.nix b/modules/effects/vanixiets/herculesCI/deploy-docs.nix new file mode 100644 index 000000000..6ad423b7e --- /dev/null +++ b/modules/effects/vanixiets/herculesCI/deploy-docs.nix @@ -0,0 +1,114 @@ +# herculesCI effect: docs deployment branch-dispatcher (preview vs promote). +{ + config, + inputs, + lib, + withSystem, + ... +}: +{ + herculesCI = + herculesCI: + let + # Nullable: null on tag pushes (no branch). + branch = herculesCI.config.repo.branch; + shortRev = herculesCI.config.repo.shortRev; + rev = herculesCI.config.repo.rev; + + isMain = branch == "main"; + in + { + onPush.default.outputs.effects.deploy-docs = withSystem "x86_64-linux" ( + { config, pkgs, ... }: + let + hci-effects = inputs.hercules-ci-effects.lib.withPkgs pkgs; + + deployDocsProgram = config.apps.deploy-docs.program; + + actionBanner = if isMain then "promote" else "preview-upload"; + + previewBranchArg = if branch != null && branch != "" then branch else shortRev; + in + hci-effects.mkEffect { + name = "deploy-docs"; + + effectScript = '' + set -euo pipefail + + echo "=== effects.deploy-docs (docs deployment dispatcher) ===" + echo "branch: ${lib.escapeShellArg (toString branch)}" + echo "rev: ${lib.escapeShellArg (toString rev)}" + echo "shortRev: ${lib.escapeShellArg (toString shortRev)}" + echo "isMain: ${if isMain then "true" else "false"}" + + echo "DEPLOY-DOCS-ACTION: ${actionBanner}" + + export CLOUDFLARE_API_TOKEN="$(jq -r '.CLOUDFLARE_API_TOKEN.data.value' "$HERCULES_CI_SECRETS_JSON")" + export CLOUDFLARE_ACCOUNT_ID="$(jq -r '.CLOUDFLARE_ACCOUNT_ID.data.value' "$HERCULES_CI_SECRETS_JSON")" + + export GIT_REV=${lib.escapeShellArg (toString rev)} + export GIT_REV_SHORT=${lib.escapeShellArg (toString shortRev)} + export GIT_REV_SHORT12=${lib.escapeShellArg (builtins.substring 0 12 (toString rev))} + export GIT_BRANCH=${lib.escapeShellArg (if branch == null then "" else toString branch)} + export GIT_COMMIT_MSG=${lib.escapeShellArg "effect deploy from rev ${toString shortRev}"} + export GIT_WORKTREE_STATUS=clean + + # Why: whoami/hostname not on bwrap PATH; supply hard-coded values. + export DEPLOY_DEPLOYER=hercules-ci-effects + export DEPLOY_HOST=magnetite + + if [ -z "''${CLOUDFLARE_API_TOKEN:-}" ] || [ "$CLOUDFLARE_API_TOKEN" = "null" ]; then + echo "error: CLOUDFLARE_API_TOKEN missing from \$HERCULES_CI_SECRETS_JSON" >&2 + exit 1 + fi + if [ -z "''${CLOUDFLARE_ACCOUNT_ID:-}" ] || [ "$CLOUDFLARE_ACCOUNT_ID" = "null" ]; then + echo "error: CLOUDFLARE_ACCOUNT_ID missing from \$HERCULES_CI_SECRETS_JSON" >&2 + exit 1 + fi + + # Why: bwrap sandbox does not bind working tree; .# cannot resolve. Use eval-time /nix/store path. + DEPLOY_DOCS=${deployDocsProgram} + + ${ + if isMain then + '' + # release.sh's production subcommand re-emits "falling back to direct deploy" on the fresh-deploy fallback; the dispatcher grep below depends on that exact substring. + deploy_log="$(mktemp -t deploy-docs-prod.XXXXXX.log)" + set +e + "$DEPLOY_DOCS" production 2>&1 | tee "$deploy_log" + deploy_rc=''${PIPESTATUS[0]} + set -e + if grep -q "falling back to direct deploy" "$deploy_log"; then + echo "DEPLOY-DOCS-ACTION: fresh-deploy-and-promote" + fi + if [ "$deploy_rc" -ne 0 ]; then + echo "error: deploy-docs production exited $deploy_rc" >&2 + exit "$deploy_rc" + fi + '' + else + '' + preview_log="$(mktemp -t deploy-docs-preview.XXXXXX.log)" + set +e + "$DEPLOY_DOCS" preview ${lib.escapeShellArg previewBranchArg} 2>&1 | tee "$preview_log" + upload_rc=''${PIPESTATUS[0]} + set -e + preview_url="$(grep -oE 'Preview URL: https://[^[:space:]]+' "$preview_log" | head -1 | awk '{print $3}' || true)" + if [ -n "$preview_url" ]; then + echo "DEPLOY-DOCS-PREVIEW-URL: $preview_url" + else + echo "warning: could not parse preview URL from deploy.sh output" >&2 + fi + if [ "$upload_rc" -ne 0 ]; then + echo "error: deploy-docs preview exited $upload_rc" >&2 + exit "$upload_rc" + fi + '' + } + + echo "=== deploy-docs effect complete (exit 0) ===" + ''; + } + ); + }; +} diff --git a/modules/effects/vanixiets/herculesCI/release-packages.nix b/modules/effects/vanixiets/herculesCI/release-packages.nix new file mode 100644 index 000000000..60837fb5e --- /dev/null +++ b/modules/effects/vanixiets/herculesCI/release-packages.nix @@ -0,0 +1,236 @@ +# herculesCI effect: semantic-release per-package dispatcher (dry-run vs release). +{ + config, + inputs, + lib, + withSystem, + ... +}: +{ + herculesCI = + herculesCI: + let + # Nullable: null on tag pushes (no branch). + branch = herculesCI.config.repo.branch; + shortRev = herculesCI.config.repo.shortRev; + rev = herculesCI.config.repo.rev; + + isMain = branch == "main"; + + # builtins.match returns null on no-match, list of captures on success; null-guard keeps the eval pure for non-PR pushes. + prMergeMatch = if branch == null then null else builtins.match "^refs/pull/([0-9]+)/merge$" branch; + isPrMerge = prMergeMatch != null; + prNumber = if isPrMerge then builtins.head prMergeMatch else null; + + actionBanner = if isMain then "release" else "dry-run"; + + # mkReleasePackagesEffect: shared effect body parameterised on dryRun. + # + # dryRun = false (production): + # isMain → call release.sh (publish path). + # non-main → call preview-version.sh (rehearsal path that filters + # @semantic-release/github and replaces it with a local bare clone). + # + # dryRun = true (rehearsal attribute): + # ALWAYS calls release.sh with `-- --dry-run` so the production + # plugin set (including @semantic-release/github) is exercised end + # to end, but semantic-release's prepare/publish/success steps are + # no-ops. Stale-rev guard is bypassed because the rehearsal is + # invoked manually with `--branch main` against an arbitrary rev, + # so HEAD will not equal origin/main by design. + mkReleasePackagesEffect = + { dryRun }: + withSystem "x86_64-linux" ( + { config, pkgs, ... }: + let + hci-effects = inputs.hercules-ci-effects.lib.withPkgs pkgs; + + listPackagesProgram = config.apps.list-packages-json.program; + releaseProgram = config.apps.release.program; + previewVersionProgram = config.apps.preview-version.program; + + effectName = if dryRun then "release-packages-dry-run" else "release-packages"; + + actionLabel = + if dryRun then "rehearsal (production plugins, semantic-release --dry-run)" else actionBanner; + + dispatchLine = + if dryRun then + ''"$RELEASE" "$pkg_path" -- --dry-run'' + else if isMain then + ''"$RELEASE" "$pkg_path"'' + else + ''"$PREVIEW" main "$pkg_path"''; + + # Distinct dispatch marker so rehearsal logs are not mistaken for + # production runs in CI output. Under dryRun=false the marker is + # empty (suppresses the echo line) for byte-for-byte parity with + # the pre-refactor effect. + dispatchMarkerLine = if dryRun then ''echo "RELEASE-PACKAGE-DRY-RUN-DISPATCH: $pkg_path"'' else ""; + + # Stale-rev guard bypassed under dryRun: the rehearsal attribute + # is intentionally invoked against non-main revs while declaring + # `--branch main`, so HEAD will not equal origin/main. + staleRevGuard = + if dryRun then + "" + else + '' + if [ -n "$GIT_BRANCH" ]; then + git -C "$clone_dir" fetch origin "$GIT_BRANCH" + head_rev="$(git -C "$clone_dir" rev-parse HEAD)" + remote_rev="$(git -C "$clone_dir" rev-parse "origin/$GIT_BRANCH")" + if [ "$head_rev" != "$remote_rev" ]; then + echo "RELEASE-CLONE-STALE: expected $head_rev remote $remote_rev" >&2 + exit 1 + fi + fi + ''; + in + hci-effects.mkEffect { + name = effectName; + + # Why: mkEffect's defaultInputs do not include git; clone preamble below requires it. + inputs = [ pkgs.git ]; + + effectScript = '' + set -euo pipefail + + echo "=== effects.${effectName} (semantic-release per-package dispatcher) ===" + echo "branch: ${lib.escapeShellArg (toString branch)}" + echo "rev: ${lib.escapeShellArg (toString rev)}" + echo "shortRev: ${lib.escapeShellArg (toString shortRev)}" + echo "isMain: ${if isMain then "true" else "false"}" + + echo "RELEASE-PACKAGES-ACTION: ${actionLabel}" + + export GITHUB_TOKEN="$(jq -r '.GITHUB_TOKEN.data.value' "$HERCULES_CI_SECRETS_JSON")" + + if [ -z "''${GITHUB_TOKEN:-}" ] || [ "$GITHUB_TOKEN" = "null" ]; then + echo "error: GITHUB_TOKEN missing from \$HERCULES_CI_SECRETS_JSON" >&2 + exit 1 + fi + + # Why: do not use config.repo.remoteHttpUrl — buildbot-nix bakes + # the App installation token into it; would leak via banner echo. + clone_url="https://github.com/cameronraysmith/vanixiets.git" + + clone_dir="$(mktemp -d -t release-packages-clone.XXXXXX)" + + trap 'rm -rf "$clone_dir"' EXIT + + GIT_REV=${lib.escapeShellArg (toString rev)} + GIT_BRANCH=${lib.escapeShellArg (if branch == null then "" else toString branch)} + ${ + if isPrMerge then + '' + # GitHub's refs/pull//merge is a synthetic test-merge + # ref recomputed on base advance, head update, or + # merge-test scheduler fire; the T0 buildbot-eval SHA + # drifts from T1 runtime content. refs/pull//head + # is the dev-pushed source-branch tip, stable until + # the next dev push. + git clone "$clone_url" "$clone_dir" + git -C "$clone_dir" fetch --tags origin + + # `git fetch origin refs/pull//head` alone updates + # FETCH_HEAD but does NOT auto-create the remote-tracking + # ref; the explicit `+ref:remote-tracking-ref` mapping + # closes that gap (idiom from buildbot-nix + # buildbot_nix/buildbot_nix/nix_eval.py:GitLocalPrMerge). + git -C "$clone_dir" fetch origin \ + "+refs/pull/${toString prNumber}/head:refs/remotes/origin/pr-${toString prNumber}-head" + head_sha="$(git -C "$clone_dir" rev-parse origin/pr-${toString prNumber}-head)" + + echo "RELEASE-CLONE-PR-HEAD: ${toString prNumber} $head_sha" + echo "RELEASE-CLONE-PR-DISPATCH: ${toString prNumber} buildbot-rev=$GIT_REV head=$head_sha" + + echo "RELEASE-CLONE-START: $clone_url $GIT_REV $GIT_BRANCH" + + git -C "$clone_dir" checkout -B "pr-${toString prNumber}-head" "$head_sha" + echo "RELEASE-CLONE-CHECKOUT: $head_sha" + + # Trivially true post-fetch unless force-push race lost the head ref; set -e propagates abort. + git -C "$clone_dir" rev-parse --verify origin/pr-${toString prNumber}-head >/dev/null + '' + else + '' + echo "RELEASE-CLONE-START: $clone_url $GIT_REV $GIT_BRANCH" + + git clone "$clone_url" "$clone_dir" + git -C "$clone_dir" fetch --tags origin + + if [ -n "$GIT_BRANCH" ]; then + checkout_branch="$GIT_BRANCH" + else + checkout_branch="release-packages-detached" + fi + git -C "$clone_dir" checkout -B "$checkout_branch" "$GIT_REV" + echo "RELEASE-CLONE-CHECKOUT: $GIT_REV" + + ${staleRevGuard} + '' + } + + echo "RELEASE-CLONE-READY: $clone_dir" + + # semantic-release's get-git-auth-url.js treats GIT_CREDENTIALS as user:password and constructs the authenticated URL in-process. The vanixiets-effects-secrets PAT (Read+Write) is the canonical authority — the buildbot-nix App installation token (Read-only) is NOT reused for release mutation. + export GIT_CREDENTIALS="x-access-token:''${GITHUB_TOKEN}" + + # CI=true bypasses semantic-release's env-ci abort. GIT_AUTHOR/COMMITTER are honoured natively without writing .git/config (which the bwrap /nix/store ro-bind would block). + export CI=true + export GIT_BRANCH + export RELEASE_REPO_ROOT="$clone_dir" + export GIT_AUTHOR_NAME=semantic-release + export GIT_AUTHOR_EMAIL=semantic-release@vanixiets.local + export GIT_COMMITTER_NAME=semantic-release + export GIT_COMMITTER_EMAIL=semantic-release@vanixiets.local + + # Why: bwrap sandbox does not bind working tree; .# cannot resolve. Use eval-time /nix/store paths. + LIST_PACKAGES=${listPackagesProgram} + RELEASE=${releaseProgram} + PREVIEW=${previewVersionProgram} + + # list-packages-json calls `git rev-parse --show-toplevel` + # which must resolve to $clone_dir (the only real git tree). + cd "$clone_dir" + + packages_json="$("$LIST_PACKAGES")" + echo "packages discovered: $packages_json" + + failed_packages=() + + while IFS= read -r pkg_path; do + [ -z "$pkg_path" ] && continue + + echo "RELEASE-PACKAGE-ITERATION: $pkg_path" + ${dispatchMarkerLine} + + # CLI grammars differ — release [-- extra-args] vs preview-version [target-branch] [pkg-path] — so a single shared dispatch line cannot work. + set +e + ${dispatchLine} + rc=$? + set -e + + if [ "$rc" -eq 0 ]; then + echo "RELEASE-PACKAGE-OK: $pkg_path" + else + echo "RELEASE-PACKAGE-FAILURE: $pkg_path (exit $rc)" + failed_packages+=("$pkg_path") + fi + done < <(printf '%s\n' "$packages_json" | jq -r '.[].path') + + if [ "''${#failed_packages[@]}" -gt 0 ]; then + echo "error: ''${#failed_packages[@]} package(s) failed: ''${failed_packages[*]}" >&2 + exit 1 + fi + + echo "=== ${effectName} effect complete (exit 0) ===" + ''; + } + ); + in + { + onPush.default.outputs.effects.release-packages = mkReleasePackagesEffect { dryRun = false; }; + }; +} diff --git a/modules/effects/vanixiets/secrets.nix b/modules/effects/vanixiets/secrets.nix new file mode 100644 index 000000000..baf5aa6bf --- /dev/null +++ b/modules/effects/vanixiets/secrets.nix @@ -0,0 +1,121 @@ +# Per-repo effects-secrets generator for github:cameronraysmith/vanixiets. +{ + config, + inputs, + ... +}: +{ + flake.modules.nixos.effects-vanixiets-secrets = + { + config, + pkgs, + lib, + ... + }: + { + clan.core.vars.generators.vanixiets-effects-secrets = { + files.secrets = { + secret = true; + owner = "buildbot"; + }; + + prompts.cloudflare-api-token = { + description = '' + Cloudflare API token (scope: Workers/Pages:Edit + relevant zone/R2 scopes). + Single token shared across preview and production effects. + ''; + type = "hidden"; + persist = true; + display = { + group = "vanixiets effects"; + label = "CLOUDFLARE_API_TOKEN"; + helperText = '' + Pasted once at first generate; Enter to keep existing on subsequent + `clan vars generate --regenerate` invocations. + ''; + }; + }; + + prompts.cloudflare-account-id = { + description = '' + Cloudflare account ID paired with CLOUDFLARE_API_TOKEN above. + Required by wrangler for Pages/Workers deploys. Not secret in + the cryptographic sense, but captured via the same generator + to keep the 4-env-var contract homogeneous and avoid a + parallel non-secret distribution channel. + ''; + type = "line"; + persist = true; + display = { + group = "vanixiets effects"; + label = "CLOUDFLARE_ACCOUNT_ID"; + helperText = '' + Single-line account id (32 hex chars). Enter to keep existing + on subsequent `clan vars generate --regenerate` invocations. + ''; + }; + }; + + prompts.github-token = { + description = '' + GitHub fine-grained Personal Access Token for effect scripts that + interact with the forge API (release creation, label edits, etc.). + Scope to the minimum repositories required by the effect bundle. + ''; + type = "hidden"; + persist = true; + display = { + group = "vanixiets effects"; + label = "GITHUB_TOKEN"; + helperText = '' + Fine-grained PAT, not a classic PAT. Expires per your GitHub + account default (rotate before expiry). + ''; + }; + }; + + prompts.sops-age-key = { + description = '' + Age private key used by sops-secrets-operator bootstrap effects + running inside the ephemeral test-cluster spawned during CI. + Corresponds to the age recipient recorded in .sops.yaml. + ''; + type = "hidden"; + persist = true; + display = { + group = "vanixiets effects"; + label = "SOPS_AGE_KEY"; + helperText = '' + AGE-SECRET-KEY-… literal (single line). Not the path to a key + file; paste the key body itself. + ''; + }; + }; + + # Raw prompts kept in repo (encrypted-at-rest) for the "Enter to keep" rotation UX, but only the composed secrets file deploys to magnetite. + files.cloudflare-api-token.deploy = false; + files.cloudflare-account-id.deploy = false; + files.github-token.deploy = false; + files.sops-age-key.deploy = false; + + runtimeInputs = [ pkgs.jq ]; + + script = '' + jq -n \ + --arg cloudflare_api_token "$(cat "$prompts/cloudflare-api-token")" \ + --arg cloudflare_account_id "$(cat "$prompts/cloudflare-account-id")" \ + --arg github_token "$(cat "$prompts/github-token")" \ + --arg sops_age_key "$(cat "$prompts/sops-age-key")" \ + '{ + CLOUDFLARE_API_TOKEN: { data: { value: $cloudflare_api_token } }, + CLOUDFLARE_ACCOUNT_ID: { data: { value: $cloudflare_account_id } }, + GITHUB_TOKEN: { data: { value: $github_token } }, + SOPS_AGE_KEY: { data: { value: $sops_age_key } } + }' > "$out/secrets" + ''; + }; + + services.buildbot-nix.master.effects.perRepoSecretFiles."github:cameronraysmith/vanixiets" = + config.clan.core.vars.generators.vanixiets-effects-secrets.files.secrets.path; + }; +} diff --git a/modules/home/tools/commands/_dev-tools.nix b/modules/home/tools/commands/_dev-tools.nix index ef39e543b..df90248e7 100644 --- a/modules/home/tools/commands/_dev-tools.nix +++ b/modules/home/tools/commands/_dev-tools.nix @@ -268,117 +268,16 @@ # On Darwin, uses /usr/bin/ssh (Apple-signed, Keychain-integrated agent) # rather than nixpkgs openssh, matching the ntfy-send precedent for # reaching ZeroTier hosts from macOS. + # Template bifurcation (writeShellApplication): INTERPOLATION FORM. + # Body lives in the sibling ./buildbot-logs.sh (directly executable for + # local debugging). The `text` preamble injects BUILDBOT_SSH_BIN — the + # eval-time-resolved ssh path — so darwin uses /usr/bin/ssh while linux + # uses PATH ssh. Standalone invocation (./buildbot-logs.sh) falls back + # to `ssh` on PATH via the BUILDBOT_SSH_BIN default in the sidecar. buildbot-logs = { text = '' - case "''${1:-}" in - -h|--help) - cat <<'HELP' - Fetch all step logs for a buildbot-nix build from the magnetite CI host - - Usage: buildbot-logs BUILDER_ID BUILD_ID - - Retrieves every non-hidden step's log (stdio plus any named logs such as - Evaluation Warnings) from the buildbot-nix master via ssh to magnetite.zt - on the ZeroTier mesh, and concatenates results to stdout with clear - step/log section headers. Intended to be redirected to a local file for - offline search, mirroring the 'gh run download -> unzip -> grep' pattern - used for GitHub Actions logs: - - buildbot-logs 48 30 > logs/buildbot-48-30.log - rg "error:" logs/buildbot-48-30.log - - Arguments: - BUILDER_ID Numeric builder id (e.g. 48 for the nix-eval builder of - cameronraysmith/vanixiets) - BUILD_ID Build number within that builder - - Environment: - BUILDBOT_SSH_HOST Override ssh target (default: magnetite.zt). - Accepts user@host form, e.g. root@magnetite.zt. - Leave unset to rely on local ~/.ssh/config. - BUILDBOT_INCLUDE_HIDDEN Set to 1 to include steps marked hidden in - buildbot (default: skip hidden steps). - - Mapping a PR check row to BUILDER_ID/BUILD_ID: - gh pr checks --json name,link \ - | jq -r '.[] | select(.name=="buildbot/nix-build") | .link' - # URL shape: /#/builders//builds/ - # (Both buildbot/nix-build and buildbot/nix-eval share this URL — the - # parent nix-eval build contains both phases' logs as separate steps.) - - Privacy: captured logs may include build output, worker names, store - paths, and buildbot-masked token references (e.g. ). - Review before sharing publicly. - HELP - exit 0 - ;; - esac - - if [ "$#" -lt 2 ]; then - echo "Error: BUILDER_ID and BUILD_ID required" >&2 - echo "Try 'buildbot-logs --help' for more information." >&2 - exit 2 - fi - - builder="$1" - build="$2" - host="''${BUILDBOT_SSH_HOST:-magnetite.zt}" - include_hidden="''${BUILDBOT_INCLUDE_HIDDEN:-0}" - - case "$builder$build" in - *[!0-9]*|"") - echo "Error: BUILDER_ID and BUILD_ID must be positive integers" >&2 - exit 2 - ;; - esac - - echo "Fetching logs for build $builder/$build from $host..." >&2 - - ${if pkgs.stdenv.isDarwin then "/usr/bin/ssh" else "ssh"} -T "$host" \ - "BUILDER=$builder BUILD=$build INCLUDE_HIDDEN=$include_hidden bash -s" \ - <<'REMOTE_SCRIPT' - set -euo pipefail - - API=http://127.0.0.1:8010/api/v2 - PW=$(sudo -n bash -c 'cat /run/secrets.d/*/vars/buildbot-http-basic-auth-password/secret' 2>/dev/null) || { - echo "Error: failed to read buildbot http basic auth password on $(hostname)" >&2 - exit 3 - } - - api() { curl -fsS -u "buildbot:$PW" "$API/$1"; } - - echo "=== BUILD $BUILDER/$BUILD ===" - api "builders/$BUILDER/builds/$BUILD" | jq '.builds[0]' || { - echo "Error: build $BUILDER/$BUILD not found or API unreachable" >&2 - exit 4 - } - echo - - echo "=== STEPS ===" - steps_json=$(api "builders/$BUILDER/builds/$BUILD/steps") - echo "$steps_json" | jq -r '.steps[] - | "\(.number)\t\(.name)\t\(.state_string // "")\tstepid=\(.stepid)\thidden=\(.hidden // false)"' - echo - - echo "$steps_json" | jq -c '.steps[]' | while read -r step; do - number=$(echo "$step" | jq -r '.number') - name=$(echo "$step" | jq -r '.name') - stepid=$(echo "$step" | jq -r '.stepid') - hidden=$(echo "$step" | jq -r '.hidden // false') - if [ "$hidden" = "true" ] && [ "$INCLUDE_HIDDEN" != "1" ]; then continue; fi - logs_json=$(api "steps/$stepid/logs" || echo '{"logs":[]}') - echo "$logs_json" | jq -c '.logs[]?' | while read -r log; do - logid=$(echo "$log" | jq -r '.logid') - logname=$(echo "$log" | jq -r '.name') - num_lines=$(echo "$log" | jq -r '.num_lines // 0') - echo "=== STEP $number: $name / LOG: $logname ($num_lines lines) ===" - api "logs/$logid/raw" || echo "(log fetch failed)" - echo - done - done - REMOTE_SCRIPT - - echo "Done." >&2 + export BUILDBOT_SSH_BIN=${if pkgs.stdenv.isDarwin then "/usr/bin/ssh" else "ssh"} + ${builtins.readFile ./buildbot-logs.sh} ''; }; diff --git a/modules/home/tools/commands/buildbot-logs.sh b/modules/home/tools/commands/buildbot-logs.sh new file mode 100755 index 000000000..343e36ada --- /dev/null +++ b/modules/home/tools/commands/buildbot-logs.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# Fetch buildbot-nix step logs (and triggered effect builds) from magnetite. +# +# Wrapped form: invoked as `buildbot-logs ` after activation +# via writeShellApplication in modules/home/tools/commands/_dev-tools.nix. +# +# Direct execution: `./modules/home/tools/commands/buildbot-logs.sh 48 154` +# requires `ssh` on PATH; curl/jq/sudo run inside the SSH heredoc on +# magnetite, so they are not needed locally. +# +# The wrapper injects BUILDBOT_SSH_BIN (eval-time path to ssh) via the +# nix-string preamble; standalone invocation falls back to `ssh` on PATH. +set -euo pipefail + +case "${1:-}" in + -h|--help) + cat <<'HELP' +Fetch all step logs for a buildbot-nix build from the magnetite CI host + +Usage: buildbot-logs BUILDER_ID BUILD_ID + +Retrieves every non-hidden step's log (stdio plus any named logs such as +Evaluation Warnings) from the buildbot-nix master via ssh to magnetite.zt +on the ZeroTier mesh, and concatenates results to stdout with clear +step/log section headers. Intended to be redirected to a local file for +offline search, mirroring the 'gh run download -> unzip -> grep' pattern +used for GitHub Actions logs: + + buildbot-logs 48 30 > logs/buildbot-48-30.log + rg "error:" logs/buildbot-48-30.log + +Arguments: + BUILDER_ID Numeric builder id (e.g. 48 for the nix-eval builder of + cameronraysmith/vanixiets) + BUILD_ID Build number within that builder + +Environment: + BUILDBOT_SSH_HOST Override ssh target (default: magnetite.zt). + Accepts user@host form, e.g. root@magnetite.zt. + Leave unset to rely on local ~/.ssh/config. + BUILDBOT_INCLUDE_HIDDEN Set to 1 to include steps marked hidden in + buildbot (default: skip hidden steps). + +Mapping a PR check row to BUILDER_ID/BUILD_ID: + gh pr checks --json name,link \ + | jq -r '.[] | select(.name=="buildbot/nix-build") | .link' + # URL shape: /#/builders//builds/ + # (Both buildbot/nix-build and buildbot/nix-eval share this URL — the + # parent nix-eval build contains both phases' logs as separate steps.) + +Privacy: captured logs may include build output, worker names, store +paths, and buildbot-masked token references (e.g. ). +Review before sharing publicly. +HELP + exit 0 + ;; +esac + +if [ "$#" -lt 2 ]; then + echo "Error: BUILDER_ID and BUILD_ID required" >&2 + echo "Try 'buildbot-logs --help' for more information." >&2 + exit 2 +fi + +builder="$1" +build="$2" +host="${BUILDBOT_SSH_HOST:-magnetite.zt}" +include_hidden="${BUILDBOT_INCLUDE_HIDDEN:-0}" +ssh_bin="${BUILDBOT_SSH_BIN:-ssh}" + +case "$builder$build" in + *[!0-9]*|"") + echo "Error: BUILDER_ID and BUILD_ID must be positive integers" >&2 + exit 2 + ;; +esac + +echo "Fetching logs for build $builder/$build from $host..." >&2 + +"$ssh_bin" -T "$host" \ + "BUILDER=$builder BUILD=$build INCLUDE_HIDDEN=$include_hidden bash -s" \ +<<'REMOTE_SCRIPT' +set -euo pipefail + +API=http://127.0.0.1:8010/api/v2 +PW=$(sudo -n bash -c 'cat /run/secrets.d/*/vars/buildbot-http-basic-auth-password/secret' 2>/dev/null) || { + echo "Error: failed to read buildbot http basic auth password on $(hostname)" >&2 + exit 3 +} + +api() { curl -fsS -u "buildbot:$PW" "$API/$1"; } + +dump_build_steps_and_logs() { + local b="$1" n="$2" + local steps_json + echo "=== STEPS ===" + steps_json=$(api "builders/$b/builds/$n/steps") + echo "$steps_json" | jq -r '.steps[] + | "\(.number)\t\(.name)\t\(.state_string // "")\tstepid=\(.stepid)\thidden=\(.hidden // false)"' + echo + + echo "$steps_json" | jq -c '.steps[]' | while read -r step; do + number=$(echo "$step" | jq -r '.number') + name=$(echo "$step" | jq -r '.name') + stepid=$(echo "$step" | jq -r '.stepid') + hidden=$(echo "$step" | jq -r '.hidden // false') + if [ "$hidden" = "true" ] && [ "$INCLUDE_HIDDEN" != "1" ]; then continue; fi + logs_json=$(api "steps/$stepid/logs" || echo '{"logs":[]}') + echo "$logs_json" | jq -c '.logs[]?' | while read -r log; do + logid=$(echo "$log" | jq -r '.logid') + logname=$(echo "$log" | jq -r '.name') + num_lines=$(echo "$log" | jq -r '.num_lines // 0') + echo "=== STEP $number: $name / LOG: $logname ($num_lines lines) ===" + api "logs/$logid/raw" || echo "(log fetch failed)" + echo + done + done +} + +echo "=== BUILD $BUILDER/$BUILD ===" +parent_json=$(api "builders/$BUILDER/builds/$BUILD") || { + echo "Error: build $BUILDER/$BUILD not found or API unreachable" >&2 + exit 4 +} +echo "$parent_json" | jq '.builds[0]' +echo + +dump_build_steps_and_logs "$BUILDER" "$BUILD" + +parent_buildid=$(echo "$parent_json" | jq -r '.builds[0].buildid // empty') +if [ -n "$parent_buildid" ]; then + triggered_json=$(api "builds/$parent_buildid/triggered_builds" || echo '{"builds":[]}') + echo "$triggered_json" | jq -c '.builds[]?' | while read -r child; do + cb_builder=$(echo "$child" | jq -r '.builderid') + cb_number=$(echo "$child" | jq -r '.number') + cb_buildid=$(echo "$child" | jq -r '.buildid') + effect_name=$(api "builds/$cb_buildid/properties" \ + | jq -r '.properties[0]."virtual_builder_name"[0] // ""' 2>/dev/null || echo "") + echo "=== CHILD BUILD $cb_builder/$cb_number ($effect_name) ===" + dump_build_steps_and_logs "$cb_builder" "$cb_number" + done +fi +REMOTE_SCRIPT + +echo "Done." >&2 diff --git a/modules/machines/nixos/magnetite/default.nix b/modules/machines/nixos/magnetite/default.nix index 05d3d52ed..7c1a926c2 100644 --- a/modules/machines/nixos/magnetite/default.nix +++ b/modules/machines/nixos/magnetite/default.nix @@ -4,12 +4,10 @@ ... }: let - # Capture outer config for use in imports flakeModules = config.flake.modules.nixos; flakeModulesHome = config.flake.modules.homeManager; in { - # Export host module to flake namespace flake.modules.nixos."machines/nixos/magnetite" = { config, @@ -35,54 +33,43 @@ in buildbot gitea gitea-actions-runner + docker + effects-vanixiets-secrets ]); # Make flake available to all modules (required by ssh-known-hosts) _module.args.flake = inputs.self; - # System platform nixpkgs.hostPlatform = "x86_64-linux"; - # Allow unfree packages for nixosConfigurations (clan CLI path) - # perSystem.legacyPackages only affects clanInternals.machines (nom build path) + # Required for clan CLI path; perSystem.legacyPackages only affects the nom build path. nixpkgs.config.allowUnfree = true; - # Use flake.overlays.default (drupol pattern) - # All 5 overlay layers + pkgs-by-name packages exported from modules/nixpkgs.nix + # Overlays exported from modules/nixpkgs.nix (drupol pattern). nixpkgs.overlays = [ inputs.self.overlays.default ]; # ZFS device node path - more stable for cloud VMs boot.zfs.devNodes = "/dev/disk/by-path"; - # Disko disk configuration extracted to disko.nix - # Auto-merged via import-tree - # Bootloader: GRUB BIOS mode (CX53 has legacy BIOS only, not UEFI) # srvos hardware-hetzner-cloud handles GRUB BIOS configuration - # Hostname configuration networking.hostName = "magnetite"; networking.search = [ ]; - # Override state version for new deployment system.stateVersion = "25.05"; - # User configuration managed via clan inventory users service - # See: modules/clan/inventory/services/users/cameron.nix + # User configuration managed via clan inventory users service (modules/clan/inventory/services/users/cameron.nix). - # Allow wheel group sudo without password security.sudo.wheelNeedsPassword = false; - # ACME TLS certificate configuration for public-facing services security.acme = { acceptTerms = true; defaults.email = "cameron@scientistexperience.net"; }; - # Networking configuration - # srvos hardware-hetzner-cloud sets useNetworkd=true and useDHCP=false - # Configure primary interface with DHCP + # srvos hardware-hetzner-cloud sets useNetworkd=true and useDHCP=false; configure primary interface explicitly. systemd.network.networks."10-uplink" = { matchConfig.Name = "en*"; networkConfig = { @@ -96,7 +83,6 @@ in # Firewall configuration: dual-zone (public + ZeroTier) networking.firewall = { enable = true; - # Public-facing ports only allowedTCPPorts = [ 22 80 @@ -108,7 +94,6 @@ in }; }; - # SSH daemon configuration # Increase MaxAuthTries to accommodate agent forwarding with many keys # Default is 6, but Bitwarden SSH agent may have 10+ keys loaded services.openssh.settings.MaxAuthTries = 20; @@ -123,8 +108,7 @@ in # Bridge NixOS-level sops to home-manager for user secret key delivery hm-sops-bridge.users.cameron.sopsIdentity = "crs58"; - # cameron home-manager module imports - # Infrastructure settings (useGlobalPkgs, extraSpecialArgs, etc.) provided by cameron inventory service + # cameron home-manager imports; infrastructure settings provided by the cameron inventory service. home-manager.users.cameron = { imports = [ flakeModulesHome."users/crs58" diff --git a/modules/machines/nixos/magnetite/disko.nix b/modules/machines/nixos/magnetite/disko.nix index 04229fdbf..785841366 100644 --- a/modules/machines/nixos/magnetite/disko.nix +++ b/modules/machines/nixos/magnetite/disko.nix @@ -2,7 +2,6 @@ { ... }: { flake.modules.nixos."machines/nixos/magnetite" = { - # Disko disk configuration for BIOS boot disko.devices = { disk.main = { type = "disk"; @@ -15,7 +14,6 @@ size = "1M"; type = "EF02"; }; - # Boot partition for GRUB grub = { size = "1G"; content = { @@ -24,7 +22,6 @@ mountpoint = "/boot"; }; }; - # ZFS partition zfs = { size = "100%"; content = { @@ -66,6 +63,15 @@ options.mountpoint = "/var/lib/containers"; mountpoint = "/var/lib/containers"; }; + # Dedicated dataset for docker graphroot to use the native ZFS + # storage driver (docker's overlay2 does not layer cleanly on ZFS, + # and /var/lib/docker must be its own dataset for the zfs driver). + # Coexists with zroot/root/podman; disjoint mountpoints. + "root/docker" = { + type = "zfs_fs"; + options.mountpoint = "/var/lib/docker"; + mountpoint = "/var/lib/docker"; + }; }; }; }; diff --git a/modules/nixos/buildbot.nix b/modules/nixos/buildbot.nix index d7edc9e70..9f8ffc540 100644 --- a/modules/nixos/buildbot.nix +++ b/modules/nixos/buildbot.nix @@ -1,18 +1,17 @@ -# buildbot-nix CI service for magnetite +# buildbot-nix CI service for magnetite. # -# Provides clan vars generators for buildbot credentials and configures -# the buildbot-nix master with GitHub and Gitea forge backends in fullyPrivate -# access mode (oauth2-proxy gates all UI access via GitHub OAuth). -# Generators define the credential slots; values are populated via: -# - buildbot-github-app-secret-key: manual `clan vars set` (PEM key from GitHub App) -# - buildbot-github-oauth-secret: manual `clan vars set` (OAuth client secret from GitHub App) +# Credential generator catalog (slots; values populated as marked): +# - buildbot-github-app-secret-key: manual `clan vars set` (GitHub App PEM key) +# - buildbot-github-oauth-secret: manual `clan vars set` (OAuth client secret) # - buildbot-github-webhook-secret: auto-generated # - buildbot-worker: auto-generated (worker password + workers.json) # - buildbot-oauth2-cookie-secret: auto-generated (oauth2-proxy cookie encryption) # - buildbot-http-basic-auth-password: auto-generated (oauth2-proxy to buildbot internal auth) -# Gitea-specific credentials are declared in gitea.nix: +# Gitea-specific credentials live in gitea.nix: # - buildbot-gitea-token: manual `clan vars set` (API token with write:repository, write:user) # - buildbot-gitea-webhook-secret: auto-generated +# Per-repo effects secrets for github:cameronraysmith/vanixiets are wired in +# modules/effects/vanixiets/secrets.nix (flake module `effects-vanixiets-secrets`). { config, inputs, @@ -49,7 +48,6 @@ ''; }; - # GitHub webhook secret (auto-generated) clan.core.vars.generators.buildbot-github-webhook-secret = { files."secret" = { owner = "buildbot"; @@ -71,7 +69,7 @@ ''; }; - # HTTP basic auth password for oauth2-proxy to buildbot internal communication (auto-generated) + # HTTP basic auth password for oauth2-proxy to buildbot internal communication. clan.core.vars.generators.buildbot-http-basic-auth-password = { files."secret" = { owner = "buildbot"; @@ -103,7 +101,6 @@ ''; }; - # Buildbot master with GitHub forge services.buildbot-nix.master = { enable = true; domain = "buildbot.scientistexperience.net"; @@ -150,8 +147,7 @@ topic = "build-with-buildbot"; }; - # Conservative eval sizing for CX53 (8 vCPU, 16 GB RAM) - # 4 workers * 2048 MB = 8 GB max, leaving headroom for niks3 + PostgreSQL + nginx + # evalWorkerCount × evalMaxMemorySize = 8 GB peak; headroom for niks3 + PostgreSQL + nginx on 32 GB CX53. evalWorkerCount = 4; evalMaxMemorySize = 2048; diff --git a/modules/nixos/docker.nix b/modules/nixos/docker.nix new file mode 100644 index 000000000..f1da3810a --- /dev/null +++ b/modules/nixos/docker.nix @@ -0,0 +1,27 @@ +# Docker runtime for magnetite, additive to the existing podman stack used by +# gitea-actions-runner. Required by the test-cluster effect, which drives k3d +# via ctlptl invoking the `docker` binary directly (no podman support). +# +# Storage: docker's native ZFS storage driver (overlay2 does not layer cleanly +# on ZFS); requires /var/lib/docker to be its own ZFS dataset, declared as +# zroot/root/docker in modules/machines/nixos/magnetite/disko.nix. +# +# buildbot-worker joins the docker group so effects can reach the docker +# socket without sudo. +{ + ... +}: +{ + flake.modules.nixos.docker = + { ... }: + { + virtualisation.docker = { + enable = true; + # Native ZFS storage driver; requires /var/lib/docker to be its own ZFS dataset (disko.nix zroot/root/docker). + storageDriver = "zfs"; + }; + + # Grant buildbot-worker docker socket access so effects can drive the daemon without sudo. + users.users.buildbot-worker.extraGroups = [ "docker" ]; + }; +} diff --git a/package.json b/package.json index 16ec893a2..a8cb86fd6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "reinstall": "rm -rf node_modules packages/docs/node_modules && bun install", "test-release": "semantic-release --dry-run --no-ci", - "preview-version": "./scripts/preview-version.sh" + "preview-version": "nix run --accept-flake-config --no-warn-dirty .#preview-version --" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", diff --git a/packages/docs/README.md b/packages/docs/README.md index 8958953f3..cffec2213 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -57,12 +57,12 @@ packages/docs/ # Start dev server just dev # or -bun run --filter '@typescript-nix-template/docs' dev +bun run --filter '@vanixiets/docs' dev # Build just build # or -bun run --filter '@typescript-nix-template/docs' build +bun run --filter '@vanixiets/docs' build ``` ### From package directory diff --git a/scripts/k3d-test-coverage.sh b/scripts/k3d-test-coverage.sh index 999dbceae..83e1bfb37 100755 --- a/scripts/k3d-test-coverage.sh +++ b/scripts/k3d-test-coverage.sh @@ -1,511 +1,13 @@ #!/usr/bin/env bash -# Run chainsaw tests with coverage report showing tested vs deployed resources -# -# Usage: ./scripts/k3d-test-coverage.sh [--raw] [chainsaw args...] -# -# Options: -# --raw Show raw uncategorized output (original format) -# -# Environment: -# CI, GITHUB_ACTIONS, NO_COLOR - Disable colors when set -# -# Exit codes: -# 0 - All tests passed -# 1 - Tests failed or error - +# shellcheck shell=bash +# Backward-compat shim — the authoritative implementation now lives at +# modules/apps/cluster/k3d-test-coverage.{nix,sh} (flake app +# `k3d-test-coverage`). This shim is preserved so that out-of-tree +# consumers pinning the legacy `scripts/k3d-test-coverage.sh` path (e.g., +# the `hash-sources` entry in `.github/workflows/test-cluster.yaml`) +# continue to work during the M1→M5 transition. Once M5 drops the legacy +# path from workflow hash sources, this file may be deleted outright. set -euo pipefail - -# Global flag for raw output mode -RAW_MODE=0 - -# shellcheck disable=SC2034 # Colors are used via variable expansion -setup_colors() { - if [[ -n "${CI:-}" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -n "${NO_COLOR:-}" ]]; then - RED="" GREEN="" YELLOW="" BOLD="" DIM="" RESET="" - else - RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' - BOLD=$'\e[1m' DIM=$'\e[2m' RESET=$'\e[0m' - fi -} - -run_chainsaw_tests() { - local report_dir="$1" - shift - - echo "${BOLD}Running chainsaw tests...${RESET}" - echo "" - - if chainsaw test kubernetes/tests/local-k3d/ "$@" \ - --report-format JUNIT-OPERATION \ - --report-path "$report_dir" 2>&1; then - return 0 - else - return 1 - fi -} - -print_test_summary() { - local report_file="$1" - - echo "" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "${BOLD} TEST SUMMARY ${RESET}" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "" - - if [[ ! -f "$report_file" ]]; then - echo " ${YELLOW}Warning: No test report found${RESET}" - return - fi - - local total_ops total_time - total_ops=$(xmllint --xpath 'string(/testsuites/@tests)' "$report_file" 2>/dev/null || echo "0") - total_time=$(xmllint --xpath 'string(/testsuites/@time)' "$report_file" 2>/dev/null || echo "0") - - echo "${BOLD}Test Execution:${RESET}" - echo " Total operations: ${GREEN}${total_ops}${RESET}" - echo " Total time: ${DIM}${total_time}s${RESET}" - echo "" - - echo "${BOLD}By Test Suite:${RESET}" - local suite suite_tests suite_failures suite_time status - for suite in foundation infrastructure local-k3d; do - suite_tests=$(xmllint --xpath "string(//testsuite[@name='$suite']/@tests)" "$report_file" 2>/dev/null || echo "0") - suite_failures=$(xmllint --xpath "string(//testsuite[@name='$suite']/@failures)" "$report_file" 2>/dev/null || echo "0") - suite_time=$(xmllint --xpath "string(//testsuite[@name='$suite']/@time)" "$report_file" 2>/dev/null || echo "0") - - if [[ "$suite_tests" != "0" ]]; then - if [[ "$suite_failures" == "0" ]]; then - status="${GREEN}PASS${RESET}" - else - status="${RED}FAIL${RESET}" - fi - printf " %-20s %s %3s ops ${DIM}%ss${RESET}\n" "$suite" "$status" "$suite_tests" "$suite_time" - fi - done -} - -collect_deployed_resources() { - local -n deployed_ref=$1 - local -n type_counts_ref=$2 - - # Workloads (Deployment, StatefulSet, DaemonSet) - local line ns name kind key - while IFS= read -r line; do - [[ -z "$line" ]] && continue - ns=$(awk '{print $1}' <<< "$line") - name=$(awk '{print $2}' <<< "$line") - kind=$(awk '{print $3}' <<< "$line") - key="${kind}/${ns}/${name}" - deployed_ref["$key"]=1 - done < <(kubectl get deploy,sts,ds -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,KIND:.kind' --no-headers 2>/dev/null | grep -v '^$') - - # Gateway API resources - while IFS= read -r line; do - [[ -z "$line" ]] && continue - ns=$(awk '{print $1}' <<< "$line") - name=$(awk '{print $2}' <<< "$line") - deployed_ref["Gateway/${ns}/${name}"]=1 - done < <(kubectl get gateway -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') - - while IFS= read -r line; do - [[ -z "$line" ]] && continue - ns=$(awk '{print $1}' <<< "$line") - name=$(awk '{print $2}' <<< "$line") - deployed_ref["HTTPRoute/${ns}/${name}"]=1 - done < <(kubectl get httproute -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') - - # Certificates - while IFS= read -r line; do - [[ -z "$line" ]] && continue - ns=$(awk '{print $1}' <<< "$line") - name=$(awk '{print $2}' <<< "$line") - deployed_ref["Certificate/${ns}/${name}"]=1 - done < <(kubectl get certificate -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') - - # ClusterIssuers (cluster-scoped) - while IFS= read -r line; do - [[ -z "$line" ]] && continue - deployed_ref["ClusterIssuer/-/${line}"]=1 - done < <(kubectl get clusterissuer -o custom-columns='NAME:.metadata.name' --no-headers 2>/dev/null | grep -v '^$') - - # Count by type - for key in "${!deployed_ref[@]}"; do - kind="${key%%/*}" - type_counts_ref["$kind"]=$(( ${type_counts_ref["$kind"]:-0} + 1 )) - done -} - -collect_tested_resources() { - local -n tested_ref=$1 - # shellcheck disable=SC2178 # nameref to associative array - local -n type_counts_ref=$2 - local test_dir="$3" - - local file current_kind current_name current_ns line key - - while IFS= read -r file; do - current_kind="" - current_name="" - current_ns="-" - - while IFS= read -r line; do - # New document resets state - if [[ "$line" == "---" ]]; then - if [[ -n "$current_kind" && -n "$current_name" ]]; then - key="${current_kind}/${current_ns}/${current_name}" - tested_ref["$key"]=1 - fi - current_kind="" - current_name="" - current_ns="-" - continue - fi - - # Extract kind - if [[ "$line" =~ ^kind:\ *(.+)$ ]]; then - current_kind="${BASH_REMATCH[1]}" - fi - - # Extract name (first name field is metadata.name) - if [[ "$line" =~ ^[[:space:]]+name:\ *(.+)$ ]]; then - if [[ -z "$current_name" ]]; then - current_name="${BASH_REMATCH[1]}" - fi - fi - - # Extract namespace - if [[ "$line" =~ ^[[:space:]]+namespace:\ *(.+)$ ]]; then - current_ns="${BASH_REMATCH[1]}" - fi - done < "$file" - - # Last resource in file - if [[ -n "$current_kind" && -n "$current_name" ]]; then - key="${current_kind}/${current_ns}/${current_name}" - tested_ref["$key"]=1 - fi - done < <(find "$test_dir" -name "*assert*.yaml" -type f) - - # Count by type - for key in "${!tested_ref[@]}"; do - kind="${key%%/*}" - type_counts_ref["$kind"]=$(( ${type_counts_ref["$kind"]:-0} + 1 )) - done -} - -print_resource_table() { - local -n counts_ref=$1 - local total=$2 - - local kind count - for kind in Deployment StatefulSet DaemonSet Gateway HTTPRoute Certificate ClusterIssuer; do - count="${counts_ref[$kind]:-0}" - if [[ "$count" -gt 0 ]]; then - printf " %-15s %3d\n" "$kind" "$count" - fi - done - echo " ${DIM}─────────────────────${RESET}" - printf " %-15s %3d\n" "Total" "$total" -} - -# Categorize a resource as application, foundation, or system -# Returns: "application", "foundation", or "system" -categorize_resource() { - local key="$1" - local kind="${key%%/*}" - local rest="${key#*/}" - local ns="${rest%%/*}" - local name="${rest#*/}" - - # System components (k3s internals, Cilium internals, auto-generated) - # These are excluded from coverage calculation because they are: - # - Not managed by our nixidy/ArgoCD stack - # - Auto-created by k3s or other controllers - # - Internal components of our foundation layer - case "$key" in - # k3s DNS - managed by k3s, not our stack - Deployment/kube-system/coredns) echo "system"; return ;; - # k3s storage provisioner - managed by k3s - Deployment/kube-system/local-path-provisioner) echo "system"; return ;; - # k3s metrics - managed by k3s - Deployment/kube-system/metrics-server) echo "system"; return ;; - # Cilium internal envoy proxy - managed by Cilium operator - DaemonSet/kube-system/cilium-envoy) echo "system"; return ;; - # Auto-generated by cert-manager gateway-shim from HTTPRoute annotation - # Duplicates our explicit step-ca-tls Certificate - Certificate/gateway-system/test-cert-tls) echo "system"; return ;; - esac - - # k3s servicelb auto-created DaemonSets (svclb-*) - # These are auto-created by k3s for LoadBalancer services - if [[ "$kind" == "DaemonSet" && "$ns" == "kube-system" && "$name" == svclb-* ]]; then - echo "system" - return - fi - - # Foundation resources (CNI layer we deploy but is infrastructure) - case "$key" in - DaemonSet/kube-system/cilium) echo "foundation"; return ;; - Deployment/kube-system/cilium-operator) echo "foundation"; return ;; - esac - - # Application resources (our nixidy/ArgoCD managed stack) - # Includes: argocd, cert-manager, sops-secrets-operator, step-ca, - # gateway-system, plus Gateway API resources - echo "application" -} - -# Get human-readable description for system components -get_system_description() { - local key="$1" - local kind="${key%%/*}" - local rest="${key#*/}" - local ns="${rest%%/*}" - local name="${rest#*/}" - - case "$key" in - Deployment/kube-system/coredns) echo "k3s DNS" ;; - Deployment/kube-system/local-path-provisioner) echo "k3s storage" ;; - Deployment/kube-system/metrics-server) echo "k3s metrics" ;; - DaemonSet/kube-system/cilium-envoy) echo "Cilium internal" ;; - Certificate/gateway-system/test-cert-tls) echo "gateway-shim duplicate" ;; - DaemonSet/kube-system/svclb-*) echo "k3s servicelb auto-created" ;; - *) echo "system component" ;; - esac -} - -print_coverage_report() { - local test_dir="$1" - - echo "" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "${BOLD} RESOURCE COVERAGE ${RESET}" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "" - - declare -A deployed_resources - # shellcheck disable=SC2034 # passed to function via nameref - declare -A deployed_type_counts - declare -A tested_resources - # shellcheck disable=SC2034 # passed to function via nameref - declare -A tested_type_counts - - collect_deployed_resources deployed_resources deployed_type_counts - collect_tested_resources tested_resources tested_type_counts "$test_dir" - - local deployed_count=${#deployed_resources[@]} - local tested_count=${#tested_resources[@]} - - echo "${BOLD}Deployed Resources:${RESET}" - print_resource_table deployed_type_counts "$deployed_count" - - echo "" - echo "${BOLD}Tested Resources:${RESET}" - print_resource_table tested_type_counts "$tested_count" - - echo "" - echo "${BOLD}Coverage Analysis:${RESET}" - - # Categorize resources and calculate coverage - local matched=0 - local untested=() - local key category - - # Categorized counts - local app_total=0 app_tested=0 - local foundation_total=0 foundation_tested=0 - local system_total=0 system_tested=0 - local system_resources=() - - for key in "${!deployed_resources[@]}"; do - category=$(categorize_resource "$key") - - case "$category" in - application) - (( app_total++ )) || true - if [[ -n "${tested_resources[$key]:-}" ]]; then - (( app_tested++ )) || true - (( matched++ )) || true - else - untested+=("$key") - fi - ;; - foundation) - (( foundation_total++ )) || true - if [[ -n "${tested_resources[$key]:-}" ]]; then - (( foundation_tested++ )) || true - (( matched++ )) || true - else - untested+=("$key") - fi - ;; - system) - (( system_total++ )) || true - system_resources+=("$key") - if [[ -n "${tested_resources[$key]:-}" ]]; then - (( system_tested++ )) || true - (( matched++ )) || true - fi - ;; - esac - done - - # Calculate raw coverage (all resources) - local raw_coverage=0 - if [[ $deployed_count -gt 0 ]]; then - raw_coverage=$(( matched * 100 / deployed_count )) - fi - - # Calculate managed coverage (excluding system components) - local managed_total=$(( app_total + foundation_total )) - local managed_tested=$(( app_tested + foundation_tested )) - local managed_coverage=0 - if [[ $managed_total -gt 0 ]]; then - managed_coverage=$(( managed_tested * 100 / managed_total )) - fi - - if [[ $RAW_MODE -eq 1 ]]; then - # Original raw output format - local cov_color="$RED" - if [[ $raw_coverage -ge 80 ]]; then - cov_color="$GREEN" - elif [[ $raw_coverage -ge 50 ]]; then - cov_color="$YELLOW" - fi - - echo "" - echo " Resource instance coverage: ${cov_color}${BOLD}${raw_coverage}%${RESET} (${matched}/${deployed_count})" - echo "" - - if [[ ${#untested[@]} -gt 0 ]] || [[ ${#system_resources[@]} -gt 0 ]]; then - echo "${BOLD}Untested Resources:${RESET}" - local rest ns name - # Combine untested managed resources with untested system resources - local all_untested=() - for key in "${untested[@]}"; do - all_untested+=("$key") - done - for key in "${system_resources[@]}"; do - if [[ -z "${tested_resources[$key]:-}" ]]; then - all_untested+=("$key") - fi - done - for key in "${all_untested[@]}"; do - kind="${key%%/*}" - rest="${key#*/}" - ns="${rest%%/*}" - name="${rest#*/}" - if [[ "$ns" == "-" ]]; then - printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s\n" "$kind" "$name" - else - printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET}\n" "$kind" "$name" "$ns" - fi - done | sort - else - echo " ${GREEN}All deployed resources have test coverage${RESET}" - fi - else - # Categorized output format - echo "" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "${BOLD} COVERAGE BY CATEGORY ${RESET}" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" - echo "" - - # Application coverage - local app_cov_pct=0 - if [[ $app_total -gt 0 ]]; then - app_cov_pct=$(( app_tested * 100 / app_total )) - fi - local app_color="$RED" - [[ $app_cov_pct -ge 80 ]] && app_color="$GREEN" - [[ $app_cov_pct -ge 50 && $app_cov_pct -lt 80 ]] && app_color="$YELLOW" - printf " Application Resources: ${app_color}%2d/%2d (%3d%%)${RESET}\n" "$app_tested" "$app_total" "$app_cov_pct" - - # Foundation coverage - local fnd_cov_pct=0 - if [[ $foundation_total -gt 0 ]]; then - fnd_cov_pct=$(( foundation_tested * 100 / foundation_total )) - fi - local fnd_color="$RED" - [[ $fnd_cov_pct -ge 80 ]] && fnd_color="$GREEN" - [[ $fnd_cov_pct -ge 50 && $fnd_cov_pct -lt 80 ]] && fnd_color="$YELLOW" - printf " Foundation Resources: ${fnd_color}%2d/%2d (%3d%%)${RESET}\n" "$foundation_tested" "$foundation_total" "$fnd_cov_pct" - - echo " ${DIM}─────────────────────────────────────────────────────────────────${RESET}" - - # Managed total - local mgd_color="$RED" - [[ $managed_coverage -ge 80 ]] && mgd_color="$GREEN" - [[ $managed_coverage -ge 50 && $managed_coverage -lt 80 ]] && mgd_color="$YELLOW" - printf " ${BOLD}Managed Resources Total: ${mgd_color}%2d/%2d (%3d%%)${RESET}\n" "$managed_tested" "$managed_total" "$managed_coverage" - - # Untested managed resources - if [[ ${#untested[@]} -gt 0 ]]; then - echo "" - echo "${BOLD}Untested Managed Resources:${RESET}" - local rest ns name - for key in "${untested[@]}"; do - kind="${key%%/*}" - rest="${key#*/}" - ns="${rest%%/*}" - name="${rest#*/}" - if [[ "$ns" == "-" ]]; then - printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s\n" "$kind" "$name" - else - printf " ${YELLOW}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET}\n" "$kind" "$name" "$ns" - fi - done | sort - fi - - # System components section - echo "" - echo "${BOLD}System Components (excluded from coverage):${RESET}" - local rest ns name desc - for key in "${system_resources[@]}"; do - kind="${key%%/*}" - rest="${key#*/}" - ns="${rest%%/*}" - name="${rest#*/}" - desc=$(get_system_description "$key") - printf " ${DIM}○${RESET} ${DIM}%-15s${RESET} %s ${DIM}(%s)${RESET} - ${DIM}%s${RESET}\n" "$kind" "$name" "$ns" "$desc" - done | sort - - echo "" - printf " ${DIM}Raw Resource Count: %2d/%2d (%3d%%)${RESET}\n" "$matched" "$deployed_count" "$raw_coverage" - fi - - echo "" - echo "${BOLD}═══════════════════════════════════════════════════════════════════${RESET}" -} - -main() { - setup_colors - - # Parse --raw flag - local args=() - for arg in "$@"; do - if [[ "$arg" == "--raw" ]]; then - RAW_MODE=1 - else - args+=("$arg") - fi - done - - local report_dir - report_dir=$(mktemp -d) - trap 'rm -rf "$report_dir"' EXIT - - local test_failed=0 - if ! run_chainsaw_tests "$report_dir" "${args[@]}"; then - test_failed=1 - fi - - print_test_summary "$report_dir/chainsaw-report.xml" - print_coverage_report "kubernetes/tests/local-k3d" - - exit $test_failed -} - -main "$@" +exec nix run --accept-flake-config --no-warn-dirty \ + "$(git rev-parse --show-toplevel 2>/dev/null || echo .)#k3d-test-coverage" \ + -- "$@" diff --git a/scripts/preview-version.sh b/scripts/preview-version.sh index d8111b181..6d0c86d44 100755 --- a/scripts/preview-version.sh +++ b/scripts/preview-version.sh @@ -1,207 +1,13 @@ #!/usr/bin/env bash -# preview-version.sh - Preview semantic-release version after merging to target branch +# preview-version.sh - Thin shim over the preview-version flake app. # -# Usage: -# ./scripts/preview-version.sh [target-branch] [package-path] -# -# Examples: -# ./scripts/preview-version.sh # Preview root version on main -# ./scripts/preview-version.sh main packages/docs # Preview docs package version on main -# ./scripts/preview-version.sh beta packages/docs # Preview docs version on beta -# -# This script simulates merging the current branch into the target branch and -# runs semantic-release in dry-run mode to preview what version would be released. +# The authoritative implementation lives at +# modules/apps/docs/preview-version.{nix,sh} and is invoked through the +# flake app `.#preview-version`. This shim is retained so out-of-tree +# callers that still reference `./scripts/preview-version.sh` (notably +# `package.json:18`) keep working. Run `nix run .#preview-version -- --help` +# for usage and arguments. set -euo pipefail -NIX_CMD="nix --accept-flake-config" - -# Configuration -TARGET_BRANCH="${1:-main}" -PACKAGE_PATH="${2:-}" -CURRENT_BRANCH=$(git branch --show-current) -REPO_ROOT=$(git rev-parse --show-toplevel) -WORKTREE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/semantic-release-preview.XXXXXX") - -# Save original target branch HEAD for restoration -ORIGINAL_TARGET_HEAD="" -ORIGINAL_REMOTE_HEAD="" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Cleanup function -cleanup() { - local exit_code=$? - - # Always restore target branch to original state if we modified it - if [ -n "$ORIGINAL_TARGET_HEAD" ]; then - echo -e "\n${BLUE}restoring ${TARGET_BRANCH} to original state...${NC}" - git update-ref "refs/heads/$TARGET_BRANCH" "$ORIGINAL_TARGET_HEAD" 2>/dev/null || true - fi - - # Always restore remote-tracking branch to original state if we modified it - if [ -n "$ORIGINAL_REMOTE_HEAD" ]; then - git update-ref "refs/remotes/origin/$TARGET_BRANCH" "$ORIGINAL_REMOTE_HEAD" 2>/dev/null || true - fi - - # Clean up worktree - if [ -d "$WORKTREE_DIR" ]; then - echo -e "${BLUE}cleaning up worktree...${NC}" - git worktree remove --force "$WORKTREE_DIR" 2>/dev/null || true - # Prune any stale worktree references - git worktree prune 2>/dev/null || true - fi - - exit $exit_code -} - -trap cleanup EXIT INT TERM - -# Validation -if [ "$CURRENT_BRANCH" == "$TARGET_BRANCH" ]; then - echo -e "${YELLOW}already on target branch ${TARGET_BRANCH}${NC}" - echo -e "${YELLOW}running test-release instead of preview${NC}\n" - if [ -n "$PACKAGE_PATH" ]; then - cd "$REPO_ROOT/$PACKAGE_PATH" - fi - exec $NIX_CMD develop -c bun run test-release -fi - -# Display what we're doing -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" -echo -e "${BLUE}semantic-release version preview${NC}" -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" -echo -e "current branch: ${GREEN}${CURRENT_BRANCH}${NC}" -echo -e "target branch: ${GREEN}${TARGET_BRANCH}${NC}" -if [ -n "$PACKAGE_PATH" ]; then - echo -e "package: ${GREEN}${PACKAGE_PATH}${NC}" -else - echo -e "package: ${GREEN}(root)${NC}" -fi -echo -e "${BLUE}───────────────────────────────────────────────────────────────${NC}\n" - -# Verify target branch exists -if ! git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then - echo -e "${RED}error: target branch '${TARGET_BRANCH}' does not exist${NC}" >&2 - exit 1 -fi - -# Save original target branch HEAD before any modifications -ORIGINAL_TARGET_HEAD=$(git rev-parse "$TARGET_BRANCH") - -# Save original remote-tracking branch HEAD before any modifications -ORIGINAL_REMOTE_HEAD=$(git rev-parse "origin/$TARGET_BRANCH" 2>/dev/null || echo "") - -# Create merge tree to test if merge is possible -echo -e "${BLUE}simulating merge of ${CURRENT_BRANCH} → ${TARGET_BRANCH}...${NC}" - -# Perform merge-tree operation to test if merge is possible -MERGE_OUTPUT=$(git merge-tree --write-tree "$TARGET_BRANCH" "$CURRENT_BRANCH" 2>&1) -MERGE_EXIT=$? - -if [ $MERGE_EXIT -ne 0 ]; then - echo -e "${RED}error: merge conflicts detected${NC}" >&2 - echo -e "${YELLOW}please resolve conflicts in your branch before previewing${NC}" >&2 - echo -e "\n${YELLOW}conflict details:${NC}" >&2 - echo "$MERGE_OUTPUT" >&2 - exit 1 -fi - -# Extract tree hash from merge-tree output (first line) -MERGE_TREE=$(echo "$MERGE_OUTPUT" | head -1) - -if [ -z "$MERGE_TREE" ]; then - echo -e "${RED}error: failed to create merge tree${NC}" >&2 - exit 1 -fi - -# Create temporary merge commit -echo -e "${BLUE}creating temporary merge commit...${NC}" -TEMP_COMMIT=$(git commit-tree -p "$TARGET_BRANCH" -p "$CURRENT_BRANCH" \ - -m "Temporary merge for semantic-release preview" "$MERGE_TREE") - -if [ -z "$TEMP_COMMIT" ]; then - echo -e "${RED}error: failed to create temporary merge commit${NC}" >&2 - exit 1 -fi - -# Temporarily update target branch to point to merge commit -# This allows semantic-release to analyze the correct commit history -# The cleanup function will ALWAYS restore the original branch HEAD -echo -e "${BLUE}temporarily updating ${TARGET_BRANCH} ref for analysis...${NC}" -git update-ref "refs/heads/$TARGET_BRANCH" "$TEMP_COMMIT" - -# Also update remote-tracking branch to match (so semantic-release sees them as synchronized) -git update-ref "refs/remotes/origin/$TARGET_BRANCH" "$TEMP_COMMIT" - -# Create worktree at target branch (now pointing to merge commit) -echo -e "${BLUE}creating temporary worktree at ${TARGET_BRANCH}...${NC}" -git worktree add --quiet "$WORKTREE_DIR" "$TARGET_BRANCH" - -# Navigate to worktree -cd "$WORKTREE_DIR" - -# Install dependencies in worktree (bun uses global cache, so this is fast) -echo -e "${BLUE}installing dependencies in worktree...${NC}" -$NIX_CMD develop -c bun install --silent - -# Navigate to package if specified -if [ -n "$PACKAGE_PATH" ]; then - if [ ! -d "$PACKAGE_PATH" ]; then - echo -e "${RED}error: package path '${PACKAGE_PATH}' does not exist${NC}" >&2 - exit 1 - fi - cd "$PACKAGE_PATH" -fi - -# Run semantic-release in dry-run mode -echo -e "\n${BLUE}running semantic-release analysis...${NC}\n" - -# Capture output and parse version -# Exclude @semantic-release/github to avoid GitHub token requirement for preview -# This is safe because dry-run skips publish/success/fail steps anyway -PLUGINS="@semantic-release/commit-analyzer,@semantic-release/release-notes-generator" - -if [ -n "$PACKAGE_PATH" ]; then - # For monorepo packages, check if package.json has specific plugins configured - OUTPUT=$(GITHUB_REF="refs/heads/$TARGET_BRANCH" $NIX_CMD develop -c bun run semantic-release --dry-run --no-ci --branches "$TARGET_BRANCH" --plugins "$PLUGINS" 2>&1 || true) -else - # For root package - OUTPUT=$(GITHUB_REF="refs/heads/$TARGET_BRANCH" $NIX_CMD develop -c bun run semantic-release --dry-run --no-ci --branches "$TARGET_BRANCH" --plugins "$PLUGINS" 2>&1 || true) -fi - -# Display semantic-release summary (filter out verbose plugin repetition) -echo "$OUTPUT" | grep -v "^$" | grep -vE "(No more plugins|does not provide step)" | \ - grep -E "(semantic-release|Running|analyzing|Found.*commits|release version|Release note|Features|Bug Fixes|Breaking Changes|Published|\*\s)" || true - -echo -e "\n${BLUE}═══════════════════════════════════════════════════════════════${NC}" - -# Extract and display the next version -if echo "$OUTPUT" | grep -q "There are no relevant changes"; then - echo -e "${YELLOW}no version bump required${NC}" - echo -e "no semantic commits found since last release" -elif echo "$OUTPUT" | grep -q "is not configured to publish from"; then - echo -e "${YELLOW}cannot determine version${NC}" - echo -e "branch ${TARGET_BRANCH} is not in release configuration" -elif VERSION=$(echo "$OUTPUT" | grep -oP 'next release version is \K[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?' | head -1); then - echo -e "${GREEN}next version: ${VERSION}${NC}" - - # Extract release type if available - if TYPE=$(echo "$OUTPUT" | grep -oP 'Release type: \K[a-z]+' | head -1); then - echo -e "release type: ${TYPE}" - fi -else - echo -e "${YELLOW}could not parse version from output${NC}" - echo -e "check the semantic-release output above for details" -fi - -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}\n" - -# Preview completed successfully - exit 0 regardless of whether a version bump is pending. -# "No version bump required" is a valid outcome, not an error. -exit 0 +exec nix run --accept-flake-config --no-warn-dirty .#preview-version -- "$@" diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/secret b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/secret new file mode 100644 index 000000000..4a2328de2 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:/OBJ9hZQfSkB4hH4Vd/WwySOCRTvQ1vtOhnzphTQm/Y=,iv:UyXCBL4d6xC8vpnwRyNRftrZ1C5r+9UFAoyrszFIl8I=,tag:xjoTGWKP8ULziGw/6eiDSQ==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1vn8fpkmkzkjttcuc3prq3jrp7t5fsrdqey74ydu5p88keqmcupvs8jtmv8", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqb2tPanlsYWdKSm5pZVkz\nL2t4ZmxJVlVHV2xaZ3QzSWFaWksrVEo2dUdBCittSkxSd002V0hSbHpqem04U1Ju\ndFlDeTBqSTA0bEhvNTI5VnM1K2xGTjQKLS0tIE54aFhNV2JQZnZtRnZDT3hsSjh2\ncGtHTDN5cjh6bXhaVG4rRVAxOVgrWVUKdFUa2irGlkRqsF0D7Nw/COB1ep7BYzgl\n4FbKGOfktfY5yxCBMnQ6xAjnh7H2BT4sXlwDIEmK7ER7SxuRNCwttg==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-25T04:23:09Z", + "mac": "ENC[AES256_GCM,data:AuF16VAGcd6PHlW/6/8xtJnUxa3GHW/23DJHlhRI5CvyHHYC2h5/Gv10KzWvpn5yrKuR/uLQh9Es/pE58/p1p9AvWO+YT3BI65Aw+b9TpH1gZvrQdaJDsYMJWsjJG0IGwDeHh5h9+l4GdYl+0tKXnVBoD3oZhPXvMnxwm1bxtCw=,iv:xWUbnrZ23rlmTHPRpE2nYt9ZXdxAD1rNdnDbPQyKSzA=,tag:c7oL9VW3dotI3ra3aWJAsQ==,type:str]", + "version": "3.12.2" + } +} diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/users/cameron b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/users/cameron new file mode 120000 index 000000000..015130152 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-account-id/users/cameron @@ -0,0 +1 @@ +../../../../../../sops/users/cameron \ No newline at end of file diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/secret b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/secret new file mode 100644 index 000000000..897d5e102 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:ll/mj1o5a4fmMD/SjK06JgvBuj3kv9w71L3ulXnPiebT95QGxZVmxw==,iv:o13DSTC3My8Beo1c99tcGs2RpoONoKHK90ixG7RF5og=,tag:Vut0YlBwejcaQWCMENoCrw==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1vn8fpkmkzkjttcuc3prq3jrp7t5fsrdqey74ydu5p88keqmcupvs8jtmv8", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ZFlINU80TllhS1A1Z0ZW\nZ1BLZGZHR2pSNzhuSUc4RTJESmlzeFNoZ25vCnZLRHZmeDFEQVd4L1NGaDlkRmdz\nSVZpTHJhY291UDBlaFRGQmwyQjhzcGcKLS0tICt4WWlJMGNCRWZiTFE5SXFCSHF2\nOHN0RHcyWFF3eXZFZjRTR3JPVmF3T1kK3cNIRPEo+2BWR57pzJDailXt3RORYwaS\nxFthTyKc529E482sxfunbw3uOEEspmwaz+N+rd7ilmc+z5vGFfvtTQ==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-25T04:23:09Z", + "mac": "ENC[AES256_GCM,data:7oXBrSrNaHr59jIZPK/KjwyrZgKqfl70jEKhR59sUxO+mnmVbwasbokXFbirIqR8m96cIjO4u7xVLvnDjReYnX88J1AVpwVBQqZjuWjM0iMsAHL8P4S4JD/HeDlNZ/9Jer31IH4Hhl2Lz+u1nbGKjkI0mV6+jRMRkGb+WBaw6As=,iv:NSQiMz2IY/Ld/WhzJq3W6ReQ+/nxxUOv+UJrRy7h72A=,tag:vv21belO0aEFcnwH/fTU2Q==,type:str]", + "version": "3.12.2" + } +} diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/users/cameron b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/users/cameron new file mode 120000 index 000000000..015130152 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/cloudflare-api-token/users/cameron @@ -0,0 +1 @@ +../../../../../../sops/users/cameron \ No newline at end of file diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/secret b/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/secret new file mode 100644 index 000000000..9b1d48292 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:wXZgIB158bRujqibywxsEIqASjayNXYHcgvVHAMEworQaeEoHbViD6YeKGu5D02eFpAMOlFQhsVnD5KCb2iog6uejD3jBBYKn2ZI7rIsLk2WWKkx8xstRj++sA1n,iv:pHLNBkeu3y0a5NlF0cdWQBUGv+fNhYymu766JrULiZQ=,tag:S2qukZQIlVbQ6AqzWdvtOw==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1vn8fpkmkzkjttcuc3prq3jrp7t5fsrdqey74ydu5p88keqmcupvs8jtmv8", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2MjhUMW1RcVV0L0t6U0pn\nTUNyTFV5YVE1bmNiQm9Fc3lkK2tqaThqYmxrCkJ1RVgzQ3RkdU5oWUViS0VGSWRF\nOFFtMkVVM05sQmhHY0ZwbFRudFJhN3MKLS0tIFgrN2NjNW5WWkMzOUJROWNNOTgx\nZjFKYmpjYU14bms2Q2lIMlBBY2ZrQkEKiWBY/9oxKU4Bz79v4FiRR0DvP6B2w/zN\no1K265lmrK1ME8ZpX/VKifAyPRySEMYUrRKjJWITMsJZ/4c1x4YmNg==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-25T04:23:09Z", + "mac": "ENC[AES256_GCM,data:CaUOtHsYZXn2QXLpXMWlVbV0SOvSfT8pVNdgN0arNVXsGDBiOkhWejNXalpK6qW4w7l+30hu1ZdEysb9bT/jTmJBc8jbzaS4H+KsAhGcwTVPQvBWRs9o7069FZw7DwidNL2fXk88xskJsdI80AMIDgiJwaI7sGUg1QVpur14vRk=,iv:SiP9tBtnJjl8jhwi+hK5wyLdl4dr0kSaUnRyGiPjULQ=,tag:y5PzytkR+iMh6zHWrbU/cQ==,type:str]", + "version": "3.12.2" + } +} diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/users/cameron b/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/users/cameron new file mode 120000 index 000000000..015130152 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/github-token/users/cameron @@ -0,0 +1 @@ +../../../../../../sops/users/cameron \ No newline at end of file diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/machines/magnetite b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/machines/magnetite new file mode 120000 index 000000000..41bd9646c --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/machines/magnetite @@ -0,0 +1 @@ +../../../../../../sops/machines/magnetite \ No newline at end of file diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/secret b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/secret new file mode 100644 index 000000000..f2a9d38d5 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/secret @@ -0,0 +1,18 @@ +{ + "data": "ENC[AES256_GCM,data:MO/hhqhg3AN0RUuI/VRzczr+eJSGPUj869RagOJHqlhMluorHo/TXhI8ZchCC4TCtGbI438jED9Tk4/SwgkDvOlh9ImUIPwjBJ4Krf92tjHyCUvmB0RLU65TEM2Y4ZZ/a+/UdN5xI4lFWUiCbi2izvAVkAf7KOM6v0gYd5nSDqd0DnzCEX8S4bYmoK1DujWvkcuoOaSWMCbZj3thmq4UVvR/QekXSwtruIGRVn4HvbRF5jmElitezh5WZ88lMRmdpUMvHMlUBeJ6rhp3QriwiDdhnebHBGJiLJ1NKqFXMyrO+UdnNYj3NyR8rsFEkwygyAqPUCzTLwHXYH+Hdzq055Qh5tRDbNKLDqCdRoJEzi5xV5Xh9fRAMgxX9aEbphKrSmSjKp2hpel2fV46waJhkr45H8+dml9TVpMy9ABL6Z15UMm+Gul423ONGc77Gxfh8KeNl8PJ+N7UwAMIbxOaA2af7Dthf2NsY4wR4IaW8uugEAnRjwg7HBfHbqz5sHQNVKpBoxz9rlMadcP8XFBJIL9XiuYl3gy7fViTbb0iDIC/Yj3EmH4nzrdvXz6Np5EhyXscEIikhRccugkUa/bTFQDuy5P3VW1t7zKqRDcu93r7L7/6K6EHjAbF+d+BpKiJlf0oDvH4F50tyQVPeVE9lxDRPrqrokrsPyA6SD2a3g==,iv:ch1qPhSYuMcmxLWDv6e0rrnCXJuR+PEfkRlTKOFrCB4=,tag:nh3yS+YAz+NGoedW6e7y+w==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1a7a70qcpjemlvk6q4uaf4k77p9eq7lj7wcal5jdj3xuetznyqdrs3mfnsf", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsYzdHVFlPSzBadkhVSlpH\nazUyR2ZXd0pCeUVaS3c5Z3lYQ0ppWmowTTNrClYyaWNIWGxzYitad3hwRWtpTi9l\nRE5MSUNNZjBmbG41TjczY0dwelY5aUkKLS0tIDJXYllRaDRodEE0WXNycHJjWGxn\nYno4dzM2d1VUeGFJR28zNTVUM3QvdUEKipx+/dhr1CajOhxYi0AExLwiTugvlgV5\newpvtd5vPJtRlW1z6gBB3/jQ/mXKmNzZ0ye2/jgi/EXDrc46gKFUAw==\n-----END AGE ENCRYPTED FILE-----\n" + }, + { + "recipient": "age1vn8fpkmkzkjttcuc3prq3jrp7t5fsrdqey74ydu5p88keqmcupvs8jtmv8", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpcjhYVEVxUnJKUk1IVDE5\nQnJ3ajV0eWVjNy9mQ0FPQThsVGFPaTBiV2hZCnJ0OGlXU2MvL0pMYVQyVGtPRHNy\nZGZGTlhGRGhWUlFBS0JFOFVhNFdQSWcKLS0tIEJzNHZrdUR3VVJaWTNZL2RjcnA0\neFBpUURPd1prTEh6YStsSVRRSkdoaW8KImrSlyxhBcEglg5Ng3aD8TEzmz1QuqBc\nvhQvq4fE+al5GTH8y6OI1M0fJ6U86QjgisZ+HTm+autx9uSNKFY07Q==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-25T04:23:09Z", + "mac": "ENC[AES256_GCM,data:YPbRgJjvjQqhuKAFlRObDKeW8T3mfODb2SxvQhKZES0uZbYhukmB6eUL+pG/ahVvxmgEUjp7iUQilnYGwXvFiaUUBXeAvgL5nihsrOf4FqS5VpxY02Ru07EKVRlySAlzzud6qA0CUZaoHeI906Dz1ya+O660LCHjrVF5mJH2CKQ=,iv:lWG/GoD53zd+U6x/6GLKM7BqSnOEyJF3EQSWTcKJaWg=,tag:4TN+TerZQJjknRNAdD+f0w==,type:str]", + "version": "3.12.2" + } +} diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/users/cameron b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/users/cameron new file mode 120000 index 000000000..015130152 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/secrets/users/cameron @@ -0,0 +1 @@ +../../../../../../sops/users/cameron \ No newline at end of file diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/secret b/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/secret new file mode 100644 index 000000000..733054c62 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/secret @@ -0,0 +1,14 @@ +{ + "data": "ENC[AES256_GCM,data:xguBcKIembzF0R1jeBRZpzcMngH7dobeZ9omyA8qsDC9/KyL3xbq88WlMwg100qjOu1qX+Fstd9rDsQIfIxNRs71WxM/98D/nTQ=,iv:z2GfXbqfGm1r2RUkQOhWzkRCa4KK07j7GeQO2+U2tfo=,tag:0GlU1gyhel4vtxBnaKL3Cg==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1vn8fpkmkzkjttcuc3prq3jrp7t5fsrdqey74ydu5p88keqmcupvs8jtmv8", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxRW5xTFBPNE4rS2s1aFB3\nZ2xPUmtpSEtYQmlwbkFxVk5OQnVDaXlDSlE4ClNFVXU1TXI5TmJ0eU51TnMwYm5C\neUhKTkg2VlpJc0cya2RNTUpuODhiZVkKLS0tIENYaE9UQk1sN3U4cGZyM01zY1g5\nOGxCeWZGakpnNHp2R2RzU3NwVER1ZzQKJvfm6jku5uDNZfCYXF+DMl4FvgxwEM9l\ni5efofA/vYKzSYv3P9JbymhfgDdBgcWaVqamj8/PsKrA01w7oby06A==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-04-25T04:23:10Z", + "mac": "ENC[AES256_GCM,data:HhA9HdbqnUYoP1qDD+K3Hz1Ax1OFj2al3uTLz8+U2gJfxuu2wvi3EENZ8frcxyjXuCaV2NfOc+5QjipfxvcOW9hR0d1uo6PCdZNodWY3Ps6Ym54KF+sWopyaQfSs3acOdnePz8TBD2HPQv/iBBczUabWW04R9VQVBLiB+CEFyKs=,iv:AnFFhyikMzgd6r7S4AUnIuNh894TuSYGZLBC528n22o=,tag:khEErihhRvFv11+74XhT1w==,type:str]", + "version": "3.12.2" + } +} diff --git a/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/users/cameron b/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/users/cameron new file mode 120000 index 000000000..015130152 --- /dev/null +++ b/vars/per-machine/magnetite/vanixiets-effects-secrets/sops-age-key/users/cameron @@ -0,0 +1 @@ +../../../../../../sops/users/cameron \ No newline at end of file