merge: raspberry pi instance host — region pi on the dev platform (#59)
Raspberry Pi instance host: region pi on the dev platform
8 files changed,
+299
−2
infra/dev/region-pi.md+64 −0infra/modules/README.md+19 −0infra/modules/region-remote-host/README.md+34 −0infra/modules/region-remote-host/main.tf+102 −0infra/modules/region-remote-host/outputs.tf+6 −0infra/modules/region-remote-host/variables.tf+14 −0infra/remote-host.md+6 −2scripts/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." |