diff --git a/.github/workflows/teardown.yml b/.github/workflows/teardown.yml new file mode 100644 index 0000000..7c1651c --- /dev/null +++ b/.github/workflows/teardown.yml @@ -0,0 +1,184 @@ +name: Teardown Squawk + +# Squawk is retired. tektum/verity-images now re-evaluates every published +# digest from its attested per-platform SPDX SBOMs inside GitHub Actions and +# reports findings as code scanning alerts (verity-images#1086), so this +# service owns no monitoring responsibility. This workflow deletes the +# Cloudflare footprint one environment at a time and retires the GitHub App +# installation. Nothing here is recoverable: the D1 database is destroyed +# without an export. +on: + workflow_dispatch: + inputs: + environment: + description: Cloudflare environment to destroy. + required: true + default: none + type: choice + options: [none, staging, production] + uninstall_app: + description: Also retire the Squawk GitHub App installation. + required: true + default: false + type: boolean + confirm: + description: Type "destroy squawk" to confirm. + required: true + type: string + +permissions: {} + +concurrency: + group: teardown-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + infrastructure: + if: inputs.environment != 'none' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Confirm intent + env: + CONFIRM: ${{ inputs.confirm }} + run: | + set -euo pipefail + if [[ "$CONFIRM" != "destroy squawk" ]]; then + printf 'confirmation phrase did not match\n' >&2 + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: jetify-com/devbox-install-action@8c6a66ed6273138b1915457069de78cb52fe3bd7 # v0.15.0 + with: + devbox-version: "0.17.5" + enable-cache: "true" + + - run: devbox run install + + # Destroy reads the recorded worker version from state, never from disk, + # so a placeholder module satisfies the declared variable without + # rebuilding the gitignored generated matcher and admin sources. + - name: Stage a placeholder Worker bundle + run: | + set -euo pipefail + mkdir -p .tmp/worker-build + printf 'export default { fetch: () => new Response(null, { status: 410 }) };\n' \ + > .tmp/worker-build/index.js + + - name: Destroy Cloudflare resources + env: + AWS_ACCESS_KEY_ID: ${{ secrets.TF_STATE_ACCESS_KEY_ID }} + AWS_ENDPOINT_URL_S3: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + AWS_SECRET_ACCESS_KEY: ${{ secrets.TF_STATE_SECRET_ACCESS_KEY }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ENVIRONMENT: ${{ inputs.environment }} + TF_VAR_cloudflare_account_id: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + TF_VAR_environment: ${{ inputs.environment }} + TF_VAR_worker_bundle_path: ../.tmp/worker-build/index.js + run: | + set -euo pipefail + devbox run -- tofu -chdir=infra init -input=false \ + -backend-config="key=squawk/${ENVIRONMENT}.tfstate" + devbox run -- tofu -chdir=infra destroy -auto-approve + + - name: Verify the footprint is gone + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ENVIRONMENT: ${{ inputs.environment }} + run: | + set -euo pipefail + account="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}" + # WORKER is exported so the jq filters can read it without embedding + # a shell-looking variable in a single-quoted program. + export WORKER="squawk-${ENVIRONMENT}" + # Cloudflare list endpoints paginate, so a resource on a later page + # must not read as a successful teardown. + count_matching() { + local path=$1 + local filter=$2 + local page=1 + local pages=1 + local matches=0 + local body + while [[ "$page" -le "$pages" ]]; do + body=$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + "${account}/${path}?page=${page}&per_page=50") + matches=$((matches + $(jq \ + "[.result[]? | select(${filter})] | length" <<<"$body"))) + pages=$(jq -r '.result_info.total_pages // 1' <<<"$body") + page=$((page + 1)) + done + printf '%s' "$matches" + } + workers=$(count_matching workers/scripts '.id == env.WORKER') + queues=$(count_matching queues '(.queue_name | startswith(env.WORKER))') + databases=$(count_matching d1/database '.name == env.WORKER') + printf 'remaining workers=%s queues=%s databases=%s\n' "$workers" "$queues" "$databases" + if [[ "$workers" -ne 0 || "$queues" -ne 0 || "$databases" -ne 0 ]]; then + printf '%s still has Cloudflare resources\n' "$WORKER" >&2 + exit 1 + fi + + github-app: + # The installation is shared by both environments, so it is retired only + # with the final production teardown. + if: inputs.uninstall_app && inputs.environment == 'production' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: {} + steps: + - name: Confirm intent + env: + CONFIRM: ${{ inputs.confirm }} + run: | + set -euo pipefail + if [[ "$CONFIRM" != "destroy squawk" ]]; then + printf 'confirmation phrase did not match\n' >&2 + exit 1 + fi + + # Only the App itself may delete its own installation, so the request is + # authenticated with a short-lived App assertion rather than a token. + - name: Retire the installation + env: + APP_ID: ${{ secrets.SQUAWK_APP_ID }} + APP_KEY: ${{ secrets.SQUAWK_APP_PEM }} + INSTALLATION_ID: ${{ vars.GH_APP_INSTALLATION_ID }} + run: | + set -euo pipefail + cat > assertion.mjs <<'NODE' + import { createSign } from "node:crypto"; + + const encode = (value) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const issued = Math.floor(Date.now() / 1000) - 30; + const unsigned = [ + encode({ alg: "RS256", typ: "JWT" }), + encode({ iat: issued, exp: issued + 300, iss: process.env.APP_ID }), + ].join("."); + const signer = createSign("RSA-SHA256"); + signer.update(unsigned); + const signature = signer.sign(process.env.APP_KEY, "base64url"); + process.stdout.write(`${unsigned}.${signature}`); + NODE + assertion=$(node assertion.mjs) + rm assertion.mjs + status=$(curl --silent --show-error --request DELETE \ + --header "Authorization: Bearer ${assertion}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output response.json --write-out '%{http_code}' \ + "https://api.github.com/app/installations/${INSTALLATION_ID}") + printf 'delete installation %s returned %s\n' "$INSTALLATION_ID" "$status" + if [[ "$status" != 204 ]]; then + cat response.json >&2 + exit 1 + fi