carlos / platform Public

merge: raspberry pi instance host — region pi on the dev platform (#59)

Raspberry Pi instance host: region pi on the dev platform
Paul Campbell 7ea697f6fa68be750fe9589c86f1137bbef352bf parent f11e8c5parent c7ec608
8 files changed, +299 −2
  • infra/dev/region-pi.md +64 −0
  • infra/modules/README.md +19 −0
  • infra/modules/region-remote-host/README.md +34 −0
  • infra/modules/region-remote-host/main.tf +102 −0
  • infra/modules/region-remote-host/outputs.tf +6 −0
  • infra/modules/region-remote-host/variables.tf +14 −0
  • infra/remote-host.md +6 −2
  • scripts/seed-pi-demo.sh +54 −0
diff --git a/infra/dev/region-pi.md b/infra/dev/region-pi.md
new file mode 100644
index 000000000..e32af707e
--- /dev/null
+++ b/infra/dev/region-pi.md
@@ -0,0 +1,64 @@
+# Region pi — Raspberry Pi 4 host on the dev platform
+
+Hardware: Raspberry Pi 4, 4 GB. Storage: SSD over USB3 strongly preferred;
+high-endurance SD acceptable for the demo (SQLite + WAL streaming is the
+I/O load, and SD I/O is the honest constraint on this board).
+
+OS: 64-bit Raspberry Pi OS Lite — MANDATORY. Hosts pick release artifacts
+by GOOS-GOARCH; this Pi resolves linux-arm64, the same artifact the
+Graviton fleet runs. 32-bit resolves nothing.
+
+Follow infra/remote-host.md with these dev-platform values:
+
+- Tofu: instantiate infra/modules/region-remote-host in the dev root
+ (platform-infrastructure repo) as:
+ module "region_pi" {
+ source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-remote-host"
+ region_code = "pi"
+ data_bucket_name = "carlos-dev-pi-640012953448"
+ deployment_bucket_arn = "arn:aws:s3:::carlos-dev-640012953448"
+ }
+ Apply from the platform-infrastructure checkout (operator step — the
+ root module is not in this repo).
+- /etc/carlos/credentials: the module's access key output (0600).
+- /etc/carlos/host.env additions beyond the standard agent env:
+ AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+ CARLOS_HIBERNATE_BUCKET=carlos-dev-pi-640012953448
+ STATUS_BOX_REGION="Pi · demo"
+ CARLOS_TUNNEL_URL=https://<dev tunnel hostname>
+ CARLOS_TUNNEL_TOKEN=<from the edge's CARLOS_TUNNEL_CLIENTS entry>
+ CARLOS_TUNNEL_NAME=pi-box
+- Dev edge host.env additions:
+ CARLOS_TUNNEL_HOST=<dev tunnel hostname, DNS → dev edge>
+ CARLOS_TUNNEL_CLIENTS=pi-box=<token>
+ (Restart the dev edge to pick these up. Mint the token with
+ `openssl rand -hex 32`; it lives only in these two env files.)
+- No live fallback: no other host gets IAM on the pi bucket. A dark Pi
+ shows UNREACHABLE in the fleet view and `carlos status -live`; instances
+ resume when it re-dials. This is the approved demo posture.
+
+Seeding: scripts/seed-pi-demo.sh (batches of ≤10/day — console limiter;
+re-running an already-seeded batch is safe but not free, since the limiter
+counts attempts, not successes, so only re-run a batch that actually
+failed). Requires:
+
+- `CARLOS_PI_TUNNEL_NAME=pi-box` — the same channel name set as
+ `CARLOS_TUNNEL_NAME` on the Pi and matched in the dev edge's
+ `CARLOS_TUNNEL_CLIENTS=pi-box=<token>` entry above.
+- A carlos CLI build carrying PR #52's `apps create` verb — seeding is
+ blocked until #52 merges and a release ships.
+- App hostnames on dev resolve as `<app>.dev.carlosframework.com` — that's
+ what the script's echoed `<hostname-of-app>` means.
+
+Target: ≥5 resident + ≥50 parked.
+
+## Demo acceptance (run these, tick them)
+
+- [ ] Pi appears in `carlos status -live` with metrics + tunnel=connected.
+- [ ] A resident instance serves through the dev edge over the channel.
+- [ ] A parked instance wakes on first request through the channel.
+- [ ] Pull Wi-Fi, restore it: next request succeeds after re-dial
+ (seconds — the bound the e2e test asserts in-repo).
+- [ ] Power off: fleet shows UNREACHABLE (age, no stale numbers).
+ Power on: serving resumes; `carlos status -live` confirms nothing
+ migrated (instances still pi-resident).
diff --git a/infra/modules/README.md b/infra/modules/README.md
index 245ee36a9..4fecbcbda 100644
--- a/infra/modules/README.md
+++ b/infra/modules/README.md
@@ -78,6 +78,25 @@ only the fleet, with *no* bucket or parameter grants; statelessness is
imposed, not promised). Generalise it only when a second deployment
wants one.
+## region-remote-host
+
+What an off-cloud CARLOS host (a Raspberry Pi today, tito's hq box later)
+needs from AWS when there is no EC2 instance profile to lean on: a region
+data bucket plus a scoped IAM user + access key, limited to exactly the
+deployment bucket and this region's data bucket — the same
+wrong-region-fails-at-IAM residency property `region-host` gives cloud
+hosts. Two consumers: the dev Raspberry Pi (`region_code = "pi"`) and
+tito's hq box (`region_code = "hq"`, later); the module is reusable as-is
+for both.
+
+| Variable | Description |
+|---|---|
+| `region_code` | Short region code this host serves (e.g. `pi`, `hq`). |
+| `data_bucket_name` | Name of this region's data bucket (created here). |
+| `deployment_bucket_arn` | ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to. |
+
+Outputs: `bucket_name`, `access_key_id`, `secret_access_key` (sensitive).
+
## Self-hosting from your own repo
A minimal root module — backend + providers + the two module blocks + a
diff --git a/infra/modules/region-remote-host/README.md b/infra/modules/region-remote-host/README.md
new file mode 100644
index 000000000..4f07c0abc
--- /dev/null
+++ b/infra/modules/region-remote-host/README.md
@@ -0,0 +1,34 @@
+# region-remote-host
+
+Provisions what an off-cloud CARLOS host needs when there is no EC2 instance
+profile to lean on: a region data bucket plus an IAM user carrying a scoped
+inline policy and access key, limited to exactly the deployment bucket and
+this region's data bucket (wrong-region-fails-at-IAM, the same residency
+property `region-host` gives cloud hosts).
+
+Two consumers: the dev Raspberry Pi (`region_code = "pi"`) landing in this
+PR, and tito's hq box (`region_code = "hq"`) in a later step of the
+remote-host plan. The module is reusable as-is for both — they differ only
+in account and variable values, not shape.
+
+The S3 action list in the inline policy is not independently chosen: it is
+copied verbatim from `infra/modules/region-host/main.tf`'s
+`aws_iam_role_policy.host_buckets` (the authority for what this principal
+shape may do to S3), split the same way region-host splits it — object
+actions (`GetObject`/`PutObject`/`DeleteObject`) scoped to the object ARNs,
+and bucket-level actions (`ListBucket`/`GetBucketLocation`) scoped to the
+bucket ARNs. If region-host's action list changes, this module's should
+change with it.
+
+The data bucket follows the same convention `deployment-buckets` uses for
+this class of bucket (see `infra/modules/README.md`): versioning, AES256
+SSE, a full public-access block, and `prevent_destroy`, hard-coded the same
+way `deployment-buckets` hard-codes it (tofu can't take a variable in a
+`lifecycle` block). Boxes are disposable; this bucket is not — parked
+instance state has no other recovery story. A demo region like `pi` that
+needs to go away entirely is not a `tofu destroy`: empty the bucket first
+(S3 refuses to delete a non-empty bucket regardless of `prevent_destroy`),
+then either comment out the `prevent_destroy` line for that one apply or
+`tofu state rm` the bucket before removing the module instance — the same
+manual override `deployment-buckets` requires, not a mechanism specific to
+this module.
diff --git a/infra/modules/region-remote-host/main.tf b/infra/modules/region-remote-host/main.tf
new file mode 100644
index 000000000..90b74d912
--- /dev/null
+++ b/infra/modules/region-remote-host/main.tf
@@ -0,0 +1,102 @@
+# region-remote-host: what an off-cloud CARLOS host (a Raspberry Pi today,
+# tito's hq box later) needs from AWS — a region data bucket plus a scoped
+# IAM principal, since there is no EC2 instance profile to lean on off-cloud.
+#
+# Residency is enforced the same way as region-host: the principal reaches
+# the deployment bucket plus ITS OWN region's data bucket and nothing else.
+# A wrong-region host fails loudly at the IAM layer, same property as every
+# cloud host.
+#
+# The data bucket follows the deployment-buckets convention for this class
+# of bucket (infra/modules/README.md, infra/modules/deployment-buckets/
+# main.tf): boxes are disposable, this bucket is not, hence versioning,
+# AES256 SSE, a full public-access block, and prevent_destroy.
+
+terraform {
+ required_version = ">= 1.10"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 6.0"
+ }
+ }
+}
+
+resource "aws_s3_bucket" "data" {
+ bucket = var.data_bucket_name
+ lifecycle { prevent_destroy = true }
+}
+
+resource "aws_s3_bucket_versioning" "data" {
+ bucket = aws_s3_bucket.data.id
+ versioning_configuration { status = "Enabled" }
+}
+
+resource "aws_s3_bucket_public_access_block" "data" {
+ bucket = aws_s3_bucket.data.id
+ block_public_acls = true
+ block_public_policy = true
+ ignore_public_acls = true
+ restrict_public_buckets = true
+}
+
+resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
+ bucket = aws_s3_bucket.data.id
+ rule {
+ apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
+ }
+}
+
+# The off-cloud host principal. No instance profile exists off EC2; this
+# user + access key is the C6 decision-6 shape (IAM Roles Anywhere is the
+# later hardening — the policy below is what either principal would carry).
+resource "aws_iam_user" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+}
+
+resource "aws_iam_access_key" "host" {
+ user = aws_iam_user.host.name
+}
+
+# Scope: the deployment bucket + THIS region's data bucket and nothing
+# else — wrong-region-fails-at-IAM, same property as every cloud host.
+#
+# The action list is not independently chosen: region-host's
+# aws_iam_role_policy.host_buckets (infra/modules/region-host/main.tf) is
+# the authority for what this principal shape may do to S3, and this
+# policy copies it verbatim — object actions (GetObject/PutObject/
+# DeleteObject) scoped to the object ARNs, list/location actions
+# (ListBucket/GetBucketLocation) scoped to the bucket ARNs.
+resource "aws_iam_user_policy" "host" {
+ name = "carlos-remote-host-${var.region_code}"
+ user = aws_iam_user.host.name
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "DeploymentBucketObjects"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = ["${var.deployment_bucket_arn}/*"]
+ },
+ {
+ Sid = "DeploymentBucketList"
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [var.deployment_bucket_arn]
+ },
+ {
+ Sid = "OwnRegionDataObjects"
+ Effect = "Allow"
+ Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
+ Resource = ["${aws_s3_bucket.data.arn}/*"]
+ },
+ {
+ Sid = "OwnRegionDataList"
+ Effect = "Allow"
+ Action = ["s3:ListBucket", "s3:GetBucketLocation"]
+ Resource = [aws_s3_bucket.data.arn]
+ },
+ ]
+ })
+}
diff --git a/infra/modules/region-remote-host/outputs.tf b/infra/modules/region-remote-host/outputs.tf
new file mode 100644
index 000000000..4a277965e
--- /dev/null
+++ b/infra/modules/region-remote-host/outputs.tf
@@ -0,0 +1,6 @@
+output "bucket_name" { value = aws_s3_bucket.data.bucket }
+output "access_key_id" { value = aws_iam_access_key.host.id }
+output "secret_access_key" {
+ value = aws_iam_access_key.host.secret
+ sensitive = true
+}
diff --git a/infra/modules/region-remote-host/variables.tf b/infra/modules/region-remote-host/variables.tf
new file mode 100644
index 000000000..8b1240278
--- /dev/null
+++ b/infra/modules/region-remote-host/variables.tf
@@ -0,0 +1,14 @@
+variable "region_code" {
+ description = "Short region code this host serves (e.g. pi, hq)."
+ type = string
+}
+
+variable "data_bucket_name" {
+ description = "Name of this region's data bucket (created here)."
+ type = string
+}
+
+variable "deployment_bucket_arn" {
+ description = "ARN of the existing deployment bucket the host reads releases/status from and writes heartbeats to."
+ type = string
+}
diff --git a/infra/remote-host.md b/infra/remote-host.md
index 9782cfdb4..7c02f1dc1 100644
--- a/infra/remote-host.md
+++ b/infra/remote-host.md
@@ -63,8 +63,10 @@ Set in the edge box's `host.env`:
name in `CARLOS_TUNNEL_CLIENTS` must match `^[a-z0-9-]{1,32}$` or the edge
refuses to boot.
-Back routes to a tunneled host with:
+`carlos route` repoints an EXISTING route — it does not create one. Add the
+route first, then back it with the tunnel channel:
+ carlos add <h>
carlos route -host <h> -tunnel <name>
## Security note
@@ -92,4 +94,6 @@ never point `CARLOS_TUNNEL_URL` at an HTTP edge outside a dev sandbox.
## Related
The hq PHYSICAL half (racking, power, network uplink for a region-hq box)
-lands separately in `infra/tito/region-hq.md`.
+lands separately in `infra/tito/region-hq.md`. For the dev-platform Pi
+instantiation of this runbook (concrete values, seeding), see
+`infra/dev/region-pi.md`.
diff --git a/scripts/seed-pi-demo.sh b/scripts/seed-pi-demo.sh
new file mode 100755
index 000000000..a46503622
--- /dev/null
+++ b/scripts/seed-pi-demo.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Seed the Pi demo fleet on the dev platform: N resident + M parked demo
+# instances, all resident in region "pi" (the Pi's own data bucket — no
+# placement machinery, residency IS placement), edge routes backed by the
+# Pi's reverse channel.
+#
+# The console API's app-create limiter is 10/address/day, so run this in
+# daily batches: seed-pi-demo.sh <start-index> <count>. State is the dev
+# platform itself — re-running an already-claimed index is SAFE (it will
+# not double-create), but it is NOT free: the limiter counts attempts, not
+# successes, and is checked before the claim (internal/console/authcli.go),
+# so every `exists:` line still spends one of the day's 10. Only re-run a
+# batch that actually failed — don't re-run a batch that already succeeded
+# just to double-check it. `carlos apps create` refuses a taken name with
+# "that name is taken" (HTTP 409), and this script treats that refusal as
+# success rather than a fresh error.
+#
+# Requires a carlos CLI build carrying PR #52's `apps create` verb (API
+# mode ONLY — sign in first with `carlos auth login -console <url>`; direct
+# bucket mode has no claims table to write to and this verb refuses it).
+# PR #52 is unmerged as of this writing (branch worktree-cli-app-create) —
+# seeding cannot run until it lands. Note also: neither main nor PR #52
+# has an `apps list`/`ls` verb over the console API, so idempotence here is
+# done by interpreting create's own "taken" refusal, not by listing first.
+set -euo pipefail
+
+START=${1:?usage: seed-pi-demo.sh <start-index> <count>}
+COUNT=${2:?usage: seed-pi-demo.sh <start-index> <count>}
+TUNNEL_NAME=${CARLOS_PI_TUNNEL_NAME:?set CARLOS_PI_TUNNEL_NAME (the Pi tunnel channel name)}
+if (( COUNT > 10 )); then
+ echo "warning: console API limits app creation to 10/address/day — run this in batches of <=10" >&2
+fi
+
+for i in $(seq "$START" $((START + COUNT - 1))); do
+ app=$(printf 'pi-demo-%03d' "$i")
+ if out=$(carlos apps create -app "$app" 2>&1); then
+ echo "created: $app"
+ elif [[ "$out" == *"that name is taken"* ]]; then
+ echo "exists: $app"
+ else
+ echo "$out" >&2
+ echo "failed: $app" >&2
+ exit 1
+ fi
+done
+
+echo
+echo "Next, on the DEV EDGE box, add the route then back it with the Pi channel"
+echo "(carlos route repoints an EXISTING route — it does not create one):"
+echo " carlos add <hostname-of-app>"
+echo " carlos route -host <hostname-of-app> -tunnel ${TUNNEL_NAME}"
+echo "and on the PI, add the matching local routes (carlos add / adopt as usual)."
+echo "First 5 apps stay resident (no hibernate flag); the rest get the"
+echo "-hibernate flag on their Pi-local routes so they park."