Skip to content

deploy: images built in CI, run by digest; service key in Secrets Manager - #3

Merged
frahlg merged 5 commits into
mainfrom
claude/cloud-ci-image-secret
Oct 13, 2026
Merged

frahlg merged 5 commits into
mainfrom
claude/cloud-ci-image-secret

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Follows #2, now on main. The branch merges main in rather than rebasing onto it; the diff against main holds only this PR's changes.

ADR 0008 asks for two things the host lacks: images built in CI from a reviewed commit and deployed by digest, and the service key in Secrets Manager. This PR adds both.

Images from CI

  • .github/workflows/image.yml builds linux/arm64 from every commit on main and pushes it to ghcr.io/srcfl/ftw-cloud:<commit>. It records the build's provenance with actions/attest-build-provenance, so gh attestation verify oci://ghcr.io/srcfl/ftw-cloud@sha256:… -R srcfl/ftw-cloud shows which commit and workflow made a digest. A pull request builds the image and pushes nothing.
  • The Dockerfile cross-compiles (--platform=$BUILDPLATFORM), so the runner emulates nothing, and sets internal/server.Revision with -ldflags, because .git stays out of the build context.
  • /source now names the commit (X-Source-Revision) and the running image digest (X-Source-Image, from FTW_CLOUD_IMAGE in compose.yml). ADR 0008 lists "the running digest published at /source" as a control.
  • ftw-cloud-deploy sha256:<digest> pulls the image, refuses it unless its revision label names a commit on main, checks /opt/ftw-cloud out at that commit so compose.yml matches the image, and starts a new container. It builds nothing and makes no key. With no argument, it runs the digest in compose.env again.

The service key in Secrets Manager

  • The key lives in ftw-cloud/service-key (eu-north-1, aws/secretsmanager).
  • ftw-cloud-key.service runs /usr/local/sbin/ftw-cloud-key before Docker starts. It fetches the key into /run/ftw-cloud/service.key (tmpfs, 65532, 0400) and retries for a minute while the network comes up. The key is no longer on disk.
  • The role ftw-cloud-host gets one inline policy, deploy/host-policy.json: secretsmanager:GetSecretValue on that one secret.

Checked

  • go vet ./... && go test -race ./..., go test -tags ftwclouddev ./... and scripts/check-imports.sh pass. A new test, TestSourceNamesRevisionAndImage, covers the two headers.
  • shellcheck passes on bootstrap.sh and on the two scripts it writes.
  • I built the image for linux/arm64 and, as a cross-compile check, for linux/amd64. In the arm64 image the revision label was set, /healthz said ok, and /source returned the archive with both headers.

On the host

Fredrik said yes to the IAM policy and the host changes on 13 Oct 2026. Done that day:

  1. Created the secret ftw-cloud/service-key (arn:aws:secretsmanager:eu-north-1:976075258475:secret:ftw-cloud/service-key-4WTn0k, tag Project=ftw-cloud) with no value.
  2. Gave ftw-cloud-host the inline policy ftw-cloud-service-key with GetSecretValue and, for the move only, PutSecretValue on that ARN.
  3. On the host, through SSM: put-secret-value --secret-string file:///var/lib/ftw-cloud/service.key, then read it back into tmpfs. The public key from the disk and from the secret are both BG6EvBxFzSHPVsQOES6PYaKFo5vHQmte9lJ6D5OBIT9JgcBrJAOxAucdqKXxS76pToQ7orI1Ivsno0OtxbMuliQ. Nothing printed the key.
  4. Replaced the policy with deploy/host-policy.json. simulate-principal-policy: GetSecretValue allowed, PutSecretValue and ListSecrets denied.
  5. Ran this branch's bootstrap.sh on the host. It installed ftw-cloud-key, the new units and the new ftw-cloud-deploy. The host still has no CI image, so it kept running ftw-cloud:d8d32752ff2d.
  6. Rebooted. The host was up at 06:58:11 UTC. ftw-cloud-key fetched the key at 06:58:16, the container started at 06:58:20 and logged the same service_key, and curl https://cloud.ftw.energy/healthz said ok at 06:58:24. /run/ftw-cloud/service.key is 65532:65532 0400.
  7. Deleted /var/lib/ftw-cloud/service.key and the directory.

Still to do, once this is on main:

  • The Image run on main pushes the first image. Fredrik makes the package ghcr.io/srcfl/ftw-cloud public once (package settings, Change visibility).
  • On the host: ftw-cloud-deploy sha256:<digest>, then check /source headers and /healthz, reboot once more, and prune the old build cache (docker builder prune -af).

🤖 Generated with Claude Code

frahlg and others added 3 commits October 13, 2026 07:22
bootstrap.sh brings up an Amazon Linux 2023 host through SSM: swap, core
dumps and sshd off, Docker as on the relay host, and ftw-cloud-deploy,
which builds the checked-out commit and starts a new container. The
service key is made on the host and copied to tmpfs before Docker starts.

deploy/README.md records the running host: ids, how to update, check and
tear it down, and the cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reboot test showed sshd listening on 22 again: cloud-init.service
wants it, so disabling the unit is not enough. Masking it keeps it off
across reboots. The security group already dropped 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts Manager

ADR 0008 asks for images built in CI from a reviewed commit, deployed by
digest, and for the service key in Secrets Manager. Until now the host
built its own image and kept the key on its disk.

- The Image workflow builds linux/arm64 from every commit on main, pushes
  it to the public ghcr.io/srcfl/ftw-cloud and attests its provenance. A
  pull request builds and pushes nothing.
- The Dockerfile cross-compiles, so CI needs no arm64 emulation, and
  passes the commit in with -ldflags, since .git stays out of the context.
- /source names the commit (X-Source-Revision) and the running image
  digest (X-Source-Image), as ADR 0008 asks.
- ftw-cloud-deploy takes a digest, refuses an image whose commit is not on
  main, and checks out that commit for compose.yml. It builds nothing and
  makes no key.
- ftw-cloud-key fetches the key from Secrets Manager into tmpfs at boot.
  The role gets one inline policy: GetSecretValue on that one secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 07:00
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@frahlg
frahlg changed the base branch from claude/cloud-aws-deploy-ed40ed to main October 13, 2026 07:05
frahlg added a commit that referenced this pull request Oct 13, 2026
…a key

Like the service key's fetch in #3: at boot the network and the instance's
credentials may lag, so a read is tried for a minute. A parameter that does
not exist ends it at once, so a host without a key does not hold Docker back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 153288d into main Oct 13, 2026
2 checks passed
frahlg added a commit that referenced this pull request Oct 13, 2026
deploy/ keeps main's version. C4's APNs pieces stay: deploy/apns.sh and
apns.md, and the compose volume that mounts /run/ftw-cloud/apns. The
-apns-dir line goes, since the flag's default is the same path.
server_test.go keeps main's /source test and C3's socket tests; the
sockets test covers /v1/box, so main's uplink-only test goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 13, 2026
* feat(app): phones read their home over Noise at /v1/app

A phone runs Noise IK to the service's Noise key with the home id in the
prologue. The service admits it only through store.For with the key the
handshake proved, so only a phone in the box's signed roster that no
owner has suspended gets in. Message 2 carries the snapshot from memory;
after it each new box envelope the device may read follows on the same
socket, unchanged.

A roster change, a suspension, an expired roster or a purge closes the
sessions it touches at once. While a phone looks, the service asks the
box for one-second updates and renews the ask every 50 s. Limits per
home: 16 sessions, one per device, a queue of 256 per phone and a small
message rate. Owners' suspensions live in the registry and end when the
roster drops the device or its epoch moves.

go/protocol moves to srcfl/ftw claude/cloud-c3-core (7490c64c) for the
app session wire, the Noise initiator and RemoteStatic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: move go/protocol to srcfl/ftw claude/cloud-c3-core c6301a0b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: alerts through APNs, signed by the box (FTW Cloud C4)

A box's signed event now reaches its phones through Apple, wrapped
unchanged; the phone checks the signature, writes its own words and picks
the buttons from the verified kind. The service chooses none of that.

- internal/apns: HTTP/2 to Apple's provider API with an ES256 token from
  the team's .p8, renewed every 40 minutes and once on
  ExpiredProviderToken. 410, BadDeviceToken and DeviceTokenNotForTopic
  mark a token gone. Every alert has the same push type and priority and
  no collapse id.
- internal/registry: a push table bound to roster entries (home, device
  id, epoch), cascading with the home on purge and expiry, and expiring
  30 days after the box's last roster.
- internal/push: registration (only devices whose effective scope reads
  events), roster pruning, fan-out with an hourly cap per home, and the
  dead man's switch: box.unreachable after ten minutes away, held back for
  an hour after bye{why:"update"}, never for a revoke.
- uplink: OnConnect (as C3 has it), OnEvent, OnRoster and OnGone hooks.
- ftwcloudd: -apns-dir; without a key nothing is sent. Development builds
  get -dev-push-listen to register a token by hand before the app session
  can.
- deploy: apns.sh reads the key from Parameter Store into tmpfs at boot;
  the host role can already read it. apns.md is the owner's steps.

Pins go/protocol at srcfl/ftw 8cca4203.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dev): capture alerts to files in development builds

-dev-apns-capture <dir> writes each alert as JSON instead of sending it to
Apple, so an end-to-end test can hand it to a simulator with xcrun simctl
push before an APNs key exists. Release builds refuse the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): a host without a Noise key still serves boxes

/v1/app answers 501 until a Noise key is installed, so deploying this
before the deploy files learn the second secret cannot stop the service.
The deploy files stay as they are: #3 owns them, and the
Noise key goes in beside the service key once it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(push): say when a box goes away and when it is out of reach

Neither line names the home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: the registry also keeps owners' suspensions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* deploy(apns): retry while the network comes up, stop at once without a key

Like the service key's fetch in #3: at boot the network and the instance's
credentials may lag, so a read is tried for a minute. A parameter that does
not exist ends it at once, so a host without a key does not hold Docker back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dev): send a raw payload through APNs in development builds

POST /dev/push/raw on the loopback dev listener sends a payload unchanged
to a home's registered phones, so the end-to-end test can put a forged
alert through Apple's sandbox and watch the phone's extension refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: phones register for alerts over their app session

push.register and push.unregister arrive on a phone's Noise session and
go to the push service with the roster entry and scope store.For gave
that session, never anything from the message. A registration the device
may not make, such as a viewer's, registers nothing and the session goes
on; one the service cannot read closes it as a protocol error. Without a
push service both are ignored.

A device an owner has suspended keeps its tokens but gets no alerts,
box.unreachable included, until the suspension ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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