carlos / platform Public

region HA 2b: the carlos system verbs and the docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev e6159e420bf846ac6b81b663819f90d901898afe parent a8822d6
5 files changed, +52 −3
  • approved_region_ha_2b_copy_test.go +1 −1
  • docs/site/cli.md +23 −0
  • internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv +4 −1
  • system.go +19 −1
  • system_test.go +5 −0
diff --git a/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go
index 34044dfa3..c36232651 100644
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -38,7 +38,7 @@ var regionHA2bCopyIDs = func() []string {
// regionHA2bCopyBuilt is every id whose text the code or docs carry. The
// task that writes a row's text adds its ids here in the same commit, once
// Paul has approved the rows.
-var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C69", "RHB-C70", "RHB-C71"}
+var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39", "RHB-C40", "RHB-C41", "RHB-C42", "RHB-C43", "RHB-C44", "RHB-C45", "RHB-C46", "RHB-C47", "RHB-C48", "RHB-C49", "RHB-C62", "RHB-C63", "RHB-C50", "RHB-C51", "RHB-C52", "RHB-C53", "RHB-C54", "RHB-C55", "RHB-C56", "RHB-C57", "RHB-C58", "RHB-C59", "RHB-C60", "RHB-C61", "RHB-C69", "RHB-C70", "RHB-C71"}
func mustParseRegionHA2bRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 5c09b4231..4376b29d6 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2213,6 +2213,29 @@ carlos system group role ie default
Ops account owners and `CARLOS_SYSTEM_OPERATORS`.
+#### Boxes a region's group launches
+
+In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent.
+
+carlos system host-env write builds /etc/carlos/host.env from the deployment's parameters: SecureStrings in AWS SSM under /carlos/<deployment>/<region>/ and, for the values every region shares, /carlos/<deployment>/shared/. If a required value is missing, it stops and writes nothing.
+
+carlos system install-channel installs the release the box's channel names, as both carlos and carlos.prev, once it passes the published host floor. A box that already runs a release is refused: carlos system update moves it.
+
+```sh
+carlos system host-env write --deployment prod --region ie
+carlos system install-channel
+```
+
+The agent then joins. It publishes the box's identity record and hosts nothing. Once the console has checked with AWS that the new box belongs to the region's group, it invites the box. The box arms its host lifecycle guard, the console checks the group again and enrolls it, and the box restarts as a hosting box. Only then does the load balancer send it traffic.
+
+carlos system host-env seed copies a box's existing host.env into those parameters, once per region. It writes the region's own values and any shared value not yet set. It refuses to change a shared value another region has set.
+
+```sh
+carlos system host-env seed --deployment prod --region ie --kms-key alias/carlos-prod-config
+```
+
+Seeding needs ssm:GetParametersByPath and ssm:PutParameter on the deployment's two parameter paths, and kms:Encrypt and kms:Decrypt on its configuration key.
+
### carlos move
Moves one instance to another region. The instance keeps serving while its
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
index b391766ee..98e14fda8 100644
--- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
+++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv
@@ -6,7 +6,8 @@
# Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched and crossCheck: a group box stopped and started since its enrollment, or whose host.env disagrees with the roster, is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference; internal/status fleet.go: the status listing drops reports no box has rewritten in a day before the key cap). statusLoop, accepted-legacy, is re-sealed as ruled: its tick is groupStatusTick, which publishes a group box's report only under its group reference, resolved afresh each tick, and nothing until it resolves; every other box's tick is statusTick's, unchanged. No other root-read unit is added or changed. The seal moves.
# Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs, ruled authority F,J4 on the condition that the path unit's file is only a trigger: root's join reads nothing from it. The legacy seal stands.
# Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands. Before the gate, RunAgent has a box carrying the group marker resolve its group reference (agentEntry, through Task 6's resolveGroupBoxReference, with backoff), whatever the switches say, so runAgent's loops never keep an empty label; joining touches the join marker again once a minute while the box stays invited, so a failed carlos system join is retried.
-@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3
+# Task 9 (system.go): the install-channel, join and host-env verbs join systemVerb's list and runSystem's dispatch, and the usage gains their lines. The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added.
+@coverage . 374a07016556167813b331508ee60f696872c43a09ba10904b6f3d949e0b70a6
@coverage internal/hostenv 45d9d4b90a5d441020f5ba4573821ca92701246c80b32d6f1823665fb0f65208
internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899
internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c
@@ -27,3 +28,5 @@ internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=ce
@coverage internal/host/units 18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d
internal/host/units/carlos-system-join.path external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; the file is only a trigger: root reads nothing from it and takes no parameter from it file:external@0 8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c
internal/host/units/carlos-system-join.service external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept file:external@0 98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b
+system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 85cee6496231fa0ecf7648dfab68b7a082902f023a951851682c56b4de06adf7
+system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 79c67d7158b89c2b24ab59a72c0ab036f6b3450911959e8c4944e3d9bc36c2c9
diff --git a/system.go b/system.go
index 4a52915ca..0d8b015e4 100644
--- a/system.go
+++ b/system.go
@@ -15,6 +15,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/host"
+ "github.com/carlosframework/platform/internal/hostenv"
)
// carlos system: what is not an app (spec §0, the console-as-an-app
@@ -49,7 +50,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) {
// untouched in rest for runSystemAntivirusVerb to read.
// layout is a family too: migrate|revert|status stay in rest for
// runSystemLayout.
- case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help":
+ case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "install-channel", "join", "host-env", "help", "-h", "--help":
return args[0], args[1:], component
}
return "", args, component
@@ -94,6 +95,15 @@ func runSystem(args []string) {
runSystemBoxVerb(rest)
case "group":
runSystemGroupVerb(rest)
+ case "install-channel":
+ // A joining box's own release (region HA spec 2 §5 step 3); root.
+ host.RunSystemInstallChannel(rest)
+ case "join":
+ // Root's half of joining, behind carlos-system-join.service.
+ host.RunSystemJoin(rest)
+ case "host-env":
+ // host.env from and into the deployment's parameters (§5 step 2).
+ hostenv.RunSystemHostEnv(rest)
case "help", "-h", "--help":
systemUsage()
default:
@@ -130,6 +140,7 @@ func systemUsage() {
carlos system legacy-routes assign --name <name> --to <account>/<app>
carlos system box role <box> <default|legacy|platform>
carlos system group role <region> <default|legacy>
+ carlos system host-env <write|seed> --deployment <name> --region <code> [flags]
The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
@@ -153,6 +164,13 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS):
the same for every box a region's group has enrolled, and for
every box it enrolls later
+A box a region's group launches (region HA):
+
+ host-env write build host.env from the deployment's parameters (root)
+ host-env seed copy a box's host.env into the deployment's parameters
+ install-channel install the release this box's channel names (root)
+ join (root oneshot behind carlos-system-join.service; not for operators)
+
Deployment-wide settings (CARLOS_STORE_OPERATORS):
antivirus status is object scanning wired, switched on, and answering?
diff --git a/system_test.go b/system_test.go
index 5ab7d71d4..233150495 100644
--- a/system_test.go
+++ b/system_test.go
@@ -30,6 +30,11 @@ func TestSystemVerbAcceptsTheComponentForm(t *testing.T) {
{[]string{"layout", "migrate", "--box", "x"}, "layout", "router", 3},
{[]string{"layout", "revert", "--force", "--yes"}, "layout", "router", 3},
{[]string{"layout", "status"}, "layout", "router", 1},
+ // Region HA joining (spec 2 §5): host-env is a family; join and
+ // install-channel take no arguments.
+ {[]string{"host-env", "write", "--deployment", "flagship", "--region", "ie"}, "host-env", "router", 5},
+ {[]string{"install-channel"}, "install-channel", "router", 0},
+ {[]string{"join"}, "join", "router", 0},
{[]string{}, "", "router", 0},
{[]string{"router"}, "", "router", 0},
}