Skip to content

Terraform to spin up a PDS - #2236

Draft
fastfadingviolets wants to merge 8 commits into
codeforboston:mainfrom
hyphacoop:atproto-pds-terraform
Draft

Terraform to spin up a PDS#2236
fastfadingviolets wants to merge 8 commits into
codeforboston:mainfrom
hyphacoop:atproto-pds-terraform

Conversation

@fastfadingviolets

Copy link
Copy Markdown
Contributor

Summary

Add a short summary of the changes, and a reference to the original issue using # and the issue number, like #1

Checklist

  • On the frontend, I've made my strings translate-able.
  • If I've added shared components, I've added a storybook story.
  • I've made pages responsive and look good on mobile.
  • If I've added new Firestore queries, I've added any new required indexes to firestore.indexes.json (Please do not only create indexes through the Firebase Web UI, even though the error messages may reccommend it - indexes created this way may be obliterated by subsequent deploys)

Screenshots

Add some screenshots highlighting your changes.

Known issues

If you've run against limitations or caveats, include them here. Include follow-up issues as well.

Steps to test/reproduce

For each feature or bug fix, create a step by step list for how a reviewer can test it out. E.g.:

  1. Go to the home page
  2. Click on a testimony
  3. See that it's loaded with a loading spinner

@vercel

vercel Bot commented Sep 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
maple-dev Ready Ready Preview Sep 9, 2026 8:05pm UTC

Request Review

fastfadingviolets added a commit to hyphacoop/maple that referenced this pull request Sep 9, 2026
…ecksums off (codeforboston#174)

Review of codeforboston#2236 found the boot script fragile in five ways; all fixed here.

- Secrets: fetch_secret had no retry and swallowed curl's stderr, so one
  Secret Manager blip left the PDS down until a human rebooted, and 403/404/
  network all printed the same line. Now: --retry 5 --retry-all-errors, the
  HTTP status in the log (404 no version, 403 grant unapplied, 000 network),
  and a pds-startup-retry.timer that re-runs the script every 3 minutes until
  /run/pds/pds.env exists. secrets.sh no longer needs to reset the VM. Exit
  status is 0 only when every gap is "no version yet".
- A flock around the whole run so the timer cannot race a slow first boot.
- apt: DPkg::Lock::Timeout=600 — apt-daily/unattended-upgrades hold the lock
  on a fresh Debian 12 image and, under set -e, ended the run before the disk
  was mounted or the unit installed.
- Data disk: format only on blkid exit 2 ("no filesystem"); exit 0 with an
  empty type or any other failure refuses. The old empty-string test could
  not tell "no filesystem" from "probe failed".
- Blobs: AWS SDK v3 >= 3.729 (bundled in pds 0.4.5027) sends CRC32 flexible
  checksums by default and GCS's XML API rejects the signature, so uploadBlob
  4xx'd while _health stayed green. AWS_REQUEST_CHECKSUM_CALCULATION and
  AWS_RESPONSE_CHECKSUM_VALIDATION=when_required turn that off.
- Container logs capped (10m x 3, as upstream's installer does) on the 10 GB
  boot disk; LOG_ENABLED=true so the PDS logs at all.
- Comment correction: dockerd persists the container env on the boot disk;
  "never on the persistent disk" now says "never on the data disk".

Rendered with dev values: bash -n and shellcheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuwSnBqJkHM4qWekjr6TjD
fastfadingviolets and others added 7 commits September 9, 2026 11:32
…over runbook

A Terraform root at infra/gcp/dns for the org's public zone: signed from
day one (ECDSA P-256), every record as served today, keyed "<name> <type>"
and checked byte for byte, TTLs included, against
ns-cloud-e1.googledomains.com on 2026-09-09. One zone, in
digital-testimony-prod.

README.md is the runbook: permissions, then the switchover. DNSSEC is on at
the registrar today, so the order is build, diff, DNSSEC off and wait, swap
name servers, re-sign with the new DS. Squarespace's name-server screen
bundles the DNSSEC-off step with the NS change; the runbook keeps them apart.

dns-zone-checks.yml runs fmt and validate on the root via a setup-terraform
composite action with a root-agnostic provider cache key. The lock file
records linux_amd64, darwin_arm64 and darwin_amd64 hashes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yrzeyMUG1Y7X1SxSVNDQ3
Walking the live zone's NSEC3 chain turned up at least seventeen owner
names against the five this file declared. Five of the missing ones are
recovered here: the two SendGrid domain-authentication CNAMEs, and the
mail.mapletestimony.org SPF/verification TXT with its two Firebase DKIM
CNAMEs. Without them the name-server swap turns outbound password-reset
and digest mail unsigned and out of SPF alignment.

Four CNAMEs are still unaccounted for; their hashes did not reverse. The
header comment now says so plainly instead of claiming the set is
complete, and names where to read them off.

Also:

- README step 3 drove its diff loop off records.tf, so a name that was
  never copied out of the panel was never queried and the check passed
  clean right up to cutover. It now drives off the step 1 inventory, and
  carries an NSEC3 census command to count against.
- README step 6 checked the ad flag against whatever resolver resolv.conf
  names, which usually does not validate. Pinned to 8.8.8.8.
- outputs.tf: google_dns_keys returns retired key signing keys too, so
  one() over the whole list would fail at plan time after the first KSK
  roll, taking down the module and the step 6 command with it. Filter on
  is_active.
- zone.tf: the comment justified algorithm 13 as what Squarespace signs
  with. The live DS is 60530 8 2, RSASHA256. Corrected, with why the
  algorithm change is safe across the cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AMn3BkyiY1PxhFxPA83eh
PDS on a Compute Engine VM with a persistent data disk, the firehose
consumer on Cloud Run, two environments mirroring the Firebase projects,
secrets in Secret Manager, one Terraform root per environment with IAM
grants inside it so an editor's apply fails only the grants.

PDS hostnames are sibling names under the parent domain, one delegated
zone each: pds-dev for dev, pds reserved for prod. The parent zone is the
Cloud DNS zone infra/gcp/dns owns in the prod project; the delegation is
an NS record the PDS root writes there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
MAPLE's DID is minted outside the PDS by the identity tool, with the ops
key in Cloud KMS (HSM secp256k1) signed by named people and never by a
service account, and the recovery key offline. Section 6a records what
the PDS actually requires at activation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
Phase 1 changes nothing. For the day the PDS opens native signup: the
PDS rotation key moves into Cloud KMS through an upstream contribution,
a fleet recovery key is held offline, and accounts minted before the
move are migrated one scripted operation each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
One root, one state per environment (envs/<env>.*), applied by people;
no workflow applies it. Delivers the dev PDS at pds-dev.mapletestimony.org
with pds.mapletestimony.org reserved for prod.

- PDS on an e2-small VM with a separate data disk and snapshot policy;
  the startup script retries on a timer instead of rebooting, guards the
  disk before mounting, and renders pds.env into /run (tmpfs) so secrets
  never touch the data disk or its snapshots.
- Blobs in a GCS bucket through the PDS's S3 blobstore; the HMAC key is
  minted out of band by scripts/secrets.sh so it never enters state.
- Secret Manager holds the PDS secrets as resources only; secrets.sh adds
  the versions, and the VM picks them up within minutes without a reboot.
- MAPLE's PLC ops key is a Cloud KMS HSM secp256k1 key; only the people in
  identity_signers (user:/group:, enforced) may sign, never a service
  account, so the VM cannot move the identity.
- IAM lives in iam.tf with *_iam_member only, written so an editor's
  apply creates everything and fails only the grants; an owner's apply
  then plans exactly the grants.
- The PDS hostname is apply-once (it lands in the DID document) and the
  delegated zone carries prevent_destroy to enforce it.
- The delegation is an NS record the root writes into the parent zone
  infra/gcp/dns owns in digital-testimony-prod (parent_zone in the tfvars,
  the value of that root's `zone` output). Writing it needs dns.admin
  there and follows the same editor/owner rule as the grants; until the
  parent zone's name-server swap the record is silent. The dns README
  notes that the delegated names are expected only on the new servers.
- scripts/bootstrap.sh enables the APIs and creates the state bucket,
  idempotently; the same bucket the dns root uses in prod, under a
  different prefix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
fmt and validate on every PR that touches infra/gcp; a dev
`plan -refresh=false` for same-repo PRs, run in a terraform-plan GitHub
environment whose key is the state-bucket reader named by ci_planner in
envs/dev.tfvars. The plan is config-versus-state only, so it needs no
access to the parent zone's project. A missing bucket skips the plan; a
403 fails it. No workflow applies.

infra/gcp/dns is excluded from the path filters: it is its own root with
its own workflow. The setup-terraform action already covers both roots.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
The old README explained the rules the stack follows and why; those live
in ADR 0001 and in the comments next to the code. The README is now the
operator's runbook only: who needs which role, the apply steps and the
done check, an inventory of what each file creates, and how to roll back
a config change, a secret, the data, the state, or an environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQJGuzu2yoqRFPCvW9Dnq4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant