Claim activation tools, run 2 (not for merge)
Guarded console startup, authenticated reporting and reset, fixture validation, seed recovery, and the leo trial command sequence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+1481
−11
docs/site/cli.md+4 −0internal/host/activation_command.go+3 −1internal/host/activation_command_linux.go+45 −3internal/host/activation_console_binary_rootrunner_linux_test.go+16 −0internal/host/activation_console_report_linux.go+336 −0internal/host/activation_console_report_linux_test.go+21 −0internal/host/activation_console_rootrunner_linux_test.go+494 −0internal/host/activation_offline_linux.go+2 −2internal/host/activation_seed_linux.go+279 −0internal/host/activation_seed_rootrunner_linux_test.go+238 −0internal/provision/activation_tools_census_test.go+2 −0internal/provision/testdata/plan14-root-reads-activation-tools.tsv+15 −5scripts/activation-tools-root-runner.sh+26 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 306.688s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.599s
ok github.com/carlosframework/platform/cmd/moveprobe 92.236s
ok github.com/carlosframework/platform/cmd/zddprobe 0.708s
ok github.com/carlosframework/platform/internal/activator 250.814s
ok github.com/carlosframework/platform/internal/agentsock 0.023s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.023s
ok github.com/carlosframework/platform/internal/appconfig 2.453s
ok github.com/carlosframework/platform/internal/apperr 0.087s
ok github.com/carlosframework/platform/internal/applog 0.153s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.582s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.009s
ok github.com/carlosframework/platform/internal/caps 0.015s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.700s
ok github.com/carlosframework/platform/internal/changelog 0.314s
ok github.com/carlosframework/platform/internal/claimactivation 0.023s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 392.690s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.130s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.372s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.010s
ok github.com/carlosframework/platform/internal/edge 26.971s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.138s
ok github.com/carlosframework/platform/internal/gate 1.671s
ok github.com/carlosframework/platform/internal/genselect 0.019s
ok github.com/carlosframework/platform/internal/geo 0.026s
ok github.com/carlosframework/platform/internal/geoip 0.034s
ok github.com/carlosframework/platform/internal/historyreadback 0.164s
ok github.com/carlosframework/platform/internal/host 174.120s
ok github.com/carlosframework/platform/internal/hostactors 0.506s
ok github.com/carlosframework/platform/internal/hostbox 2.640s
ok github.com/carlosframework/platform/internal/hostclaim 0.539s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 4.787s
ok github.com/carlosframework/platform/internal/hostfence 0.036s
ok github.com/carlosframework/platform/internal/hostfiles 10.467s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 0.990s
ok github.com/carlosframework/platform/internal/hostops 1.617s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.493s
ok github.com/carlosframework/platform/internal/hostresolve 0.166s
ok github.com/carlosframework/platform/internal/hostwrite 0.002s
ok github.com/carlosframework/platform/internal/infraview 0.006s
ok github.com/carlosframework/platform/internal/instancectl 5.229s
ok github.com/carlosframework/platform/internal/instanceexec 2.753s
ok github.com/carlosframework/platform/internal/instancefs 0.019s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.561s
ok github.com/carlosframework/platform/internal/lease 9.816s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.030s
ok github.com/carlosframework/platform/internal/ledger 0.013s
ok github.com/carlosframework/platform/internal/mail 0.157s
ok github.com/carlosframework/platform/internal/maintenance 0.083s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.516s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.118s
ok github.com/carlosframework/platform/internal/moves/archive 0.815s
ok github.com/carlosframework/platform/internal/objstore 0.190s
ok github.com/carlosframework/platform/internal/ops 0.186s
ok github.com/carlosframework/platform/internal/park 0.206s
ok github.com/carlosframework/platform/internal/privatefile 0.006s
ok github.com/carlosframework/platform/internal/provision 28.157s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.442s
ok github.com/carlosframework/platform/internal/relay 7.234s
ok github.com/carlosframework/platform/internal/release 21.983s
ok github.com/carlosframework/platform/internal/rollout 3.663s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.059s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.582s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 2.496s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.391s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.437s
ok github.com/carlosframework/platform/internal/status 0.041s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.403s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.005s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.567s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 48627874 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/16 15:48:25 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF0D36D0D33F63, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.021s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 25f914b73..29f632816 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2158,6 +2158,10 @@ The member command remains `carlos ops host resolve`. |
| `offline-begin` persistently masks the migration services, their triggers and the edge. `offline-restore` restores the transaction's own masks and previous active states, with the edge last. `install-control` installs only instance-control and reloads systemd. `console-config` uses a separate console environment file and leaves host execution claims off. Neither installer starts its service. |
| +After starting the guarded console, run `console-report` on its box as root. It checks the signed roster, guard, systemd process and running executable, then records that process in the existing private box report. It refuses stale box evidence and leaves the report’s timestamp unchanged. Run it again after a console restart and immediately before floor publication; the next agent report may replace the process entry. |
| + |
| +`offline-seed` runs files seed in the delegated edge unit while the other migration units remain masked. Its persistent override keeps normal serving off across a reboot. It saves the invocation result, stops the one-shot and masks the edge again before removing the override. Resume an interrupted seed with the same command. `offline-restore` refuses an unfinished seed. |
| + |
| #### carlos system journal-migrate |
| Before starting claim-enabled services on a box with legacy journals, stop the edge and agent. Run `carlos system journal-migrate` as root with `CARLOS_HOST_CLAIM=1` and the deployment environment loaded. The command imports journals only when signed root stop evidence verifies them. It leaves the original files in place. After migration, root ignores changed legacy files and logs an alert. Keep the migration marker. |
diff --git a/internal/host/activation_command.go b/internal/host/activation_command.go| index 0bd8625a3..6bed7773f 100644 |
| --- a/internal/host/activation_command.go |
| +++ b/internal/host/activation_command.go |
| @@ -18,10 +18,12 @@ const activationClaimsUsage = `usage: carlos system claims <command> [flags] |
| evidence-key --roster FILE --expect-revision N --bucket B --box BOX --enrollment E --public-key K |
| fence-credentials --source FILE --bucket B --aws-region R --role ARN |
| console-config --roster FILE --expect-revision N --bucket B --console-id ID --public-key K [--enable-console-runtime] |
| + console-report --bucket B --aws-region R --expect-revision N --console-id ID --public-key K |
| install-control |
| offline-begin |
| + offline-seed |
| offline-restore |
| -Add --dry-run to validate and print the planned operation without writing. |
| +For commands that write, add --dry-run to validate and print the planned operation without writing. |
| Run signing phases on the trusted operator host. Run box installers as root. |
| ` |
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go| index c6ba7b8a4..b3dc4fcbe 100644 |
| --- a/internal/host/activation_command_linux.go |
| +++ b/internal/host/activation_command_linux.go |
| @@ -11,7 +11,9 @@ import ( |
| "flag" |
| "io" |
| "os" |
| + "os/signal" |
| "strconv" |
| + "syscall" |
| "github.com/carlosframework/platform/internal/claimactivation" |
| "github.com/carlosframework/platform/internal/hostactors" |
| @@ -75,6 +77,31 @@ func runSystemClaims(args []string, out io.Writer) error { |
| ETag string `json:"etag"` |
| SignedRoster json.RawMessage `json:"signed_roster"` |
| }{roster.Roster, roster.ETag, wire}) |
| + case "console-report": |
| + if *consoleID == "" || *bucket == "" || *region == "" || *revision == 0 { |
| + return errActivation |
| + } |
| + pub, err := release.ParsePublicKey(*pubValue, "public-key") |
| + if err != nil { |
| + return errActivation |
| + } |
| + store, err := release.NewS3StoreIn(ctx, *bucket, *region) |
| + if err != nil { |
| + return err |
| + } |
| + report, err := publishActivationConsoleReport(ctx, store, pub, *bucket, *consoleID, *revision, *dry) |
| + if err != nil { |
| + return err |
| + } |
| + localRaw, err := activationRead("/etc/carlos/host-actor.json", false) |
| + if err != nil { |
| + return err |
| + } |
| + var local hostfloor.Enrollment |
| + if json.Unmarshal(localRaw, &local) != nil { |
| + return errActivation |
| + } |
| + return encode(map[string]any{"dry_run": *dry, "bucket": *bucket, "key": "status/boxes/" + local.Box + ".json", "expected_roster_revision": *revision, "console_process": report}) |
| case "console-config": |
| if *consoleID == "" || *bucket == "" || *revision == 0 || *rosterPath == "" { |
| return errActivation |
| @@ -230,13 +257,21 @@ func runSystemClaims(args []string, out io.Writer) error { |
| return err |
| } |
| return encode(map[string]any{"dry_run": *dry, "source": "/etc/carlos/host.env", "target": "/etc/systemd/system/" + claimControlUnit, "reload": true, "start": false}) |
| - case "offline-begin", "offline-restore": |
| + case "offline-begin", "offline-restore", "offline-seed": |
| tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: claimSystemctl, drained: activationProcessesDrained} |
| preview, err := tx.preview(ctx) |
| if err != nil { |
| return err |
| } |
| - if args[0] == "offline-begin" { |
| + if args[0] == "offline-seed" { |
| + seedCtx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) |
| + defer cancel() |
| + ctx = seedCtx |
| + if err = activationSeedPreflight(ctx); err != nil { |
| + return err |
| + } |
| + err = tx.seed(ctx, []byte(activationSeedUnit), *dry) |
| + } else if args[0] == "offline-begin" { |
| err = tx.begin(ctx, *dry) |
| } else { |
| err = tx.restore(ctx, *dry) |
| @@ -244,7 +279,14 @@ func runSystemClaims(args []string, out io.Writer) error { |
| if err != nil { |
| return err |
| } |
| - return encode(map[string]any{"dry_run": *dry, "phase": args[0], "units": preview, "transaction": tx.state}) |
| + result := map[string]any{"dry_run": *dry, "phase": args[0], "units": preview, "transaction": tx.state} |
| + if args[0] == "offline-seed" { |
| + result["override_path"] = "/etc/systemd/system/carlos-edge.service.d/" + activationSeedDropIn |
| + result["override"] = activationSeedUnit |
| + result["result_path"] = tx.state + "/seed-result.txt" |
| + result["actions"] = []string{"install-override", "restore-edge-unit", "reload", "verify-seed-command", "start-seed", "save-result", "stop-seed", "mask-edge", "reload", "remove-override", "reload"} |
| + } |
| + return encode(result) |
| default: |
| return errActivation |
| } |
diff --git a/internal/host/activation_console_binary_rootrunner_linux_test.go b/internal/host/activation_console_binary_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..84d0b8407 |
| --- /dev/null |
| +++ b/internal/host/activation_console_binary_rootrunner_linux_test.go |
| @@ -0,0 +1,16 @@ |
| +//go:build linux && rootrunner && activationconsole |
| + |
| +package host |
| + |
| +import _ "embed" |
| + |
| +//go:embed activation-console-runner.bin |
| +var activationConsoleBinary []byte |
| + |
| +//go:embed activation-carlos-runner.bin |
| +var activationCarlosBinary []byte |
| + |
| +func init() { |
| + activationConsoleImage = activationConsoleBinary |
| + activationCarlosImage = activationCarlosBinary |
| +} |
diff --git a/internal/host/activation_console_report_linux.go b/internal/host/activation_console_report_linux.go| new file mode 100644 |
| index 000000000..2bc2be57c |
| --- /dev/null |
| +++ b/internal/host/activation_console_report_linux.go |
| @@ -0,0 +1,336 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "runtime" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func activationConsolePID(r hostactors.Report) (int, string, error) { |
| + p := strings.Split(r.ProcessID, ":") |
| + if len(p) != 4 || p[0] != r.BootID { |
| + return 0, "", errActivation |
| + } |
| + for _, field := range p[1:] { |
| + n, e := strconv.ParseUint(field, 10, 64) |
| + if e != nil || n == 0 { |
| + return 0, "", errActivation |
| + } |
| + } |
| + pid, err := strconv.Atoi(p[2]) |
| + if err != nil || pid <= 0 { |
| + return 0, "", errActivation |
| + } |
| + return pid, p[3], nil |
| +} |
| + |
| +// This observation is deliberately on demand. It never refreshes an old box |
| +// report or asserts that every possible console launch path is guarded. |
| +func activationConsoleProcess(ctx context.Context, pub ed25519.PublicKey, bucket string, o hostactors.Observed, id string, r hostactors.Report) (status.ConsoleProcess, error) { |
| + var zero status.ConsoleProcess |
| + c, ok := o.Roster.Consoles[id] |
| + if !ok || c.Compute == nil || r.Actor != id || r.Component != "console" || r.Compute == nil || *r.Compute != *c.Compute { |
| + return zero, errActivation |
| + } |
| + localRaw, err := activationRead("/etc/carlos/host-actor.json", false) |
| + if err != nil { |
| + return zero, err |
| + } |
| + var local hostfloor.Enrollment |
| + if json.Unmarshal(localRaw, &local) != nil || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment { |
| + return zero, errActivation |
| + } |
| + hostEnv, err := activationRead("/etc/carlos/host.env", false) |
| + if err != nil { |
| + return zero, err |
| + } |
| + expected, err := activationConsoleEnvironment(o.Roster, local, hostEnv, id, r.HostClaimEnabled) |
| + if err != nil { |
| + return zero, err |
| + } |
| + actual, err := activationRead("/etc/carlos/claim-console.env", true) |
| + if err != nil || !bytes.Equal(actual, expected) { |
| + return zero, errActivation |
| + } |
| + d := hostfloor.Disk{Dir: "/opt/carlos/system/guard", Bucket: bucket, PublicKey: pub} |
| + policyRaw, err := activationRead(filepath.Join(d.Dir, "policy.json"), false) |
| + if err != nil { |
| + return zero, err |
| + } |
| + var policy hostfloor.Policy |
| + if json.Unmarshal(policyRaw, &policy) != nil || policy.Mode == "unpublished" { |
| + return zero, errActivation |
| + } |
| + floor, err := policy.Verify(pub, bucket) |
| + if err != nil || floor.Digest != r.FloorSHA256 { |
| + return zero, errActivation |
| + } |
| + path, err := activationRead(filepath.Join(d.Dir, "console-ready.json"), false) |
| + if err != nil { |
| + return zero, err |
| + } |
| + guard, err := activationRead("/etc/systemd/system/console.service.d/90-host-floor.conf", false) |
| + if err != nil || string(guard) != hostfloor.ConsoleGuardDropIn(string(path)) { |
| + return zero, errActivation |
| + } |
| + if !filepath.IsAbs(string(path)) || filepath.Clean(string(path)) != string(path) || strings.ContainsAny(string(path), " \t\r\n;") { |
| + return zero, errActivation |
| + } |
| + snapshot := func() (map[string]string, error) { |
| + raw, e := claimSystemctl(ctx, "show", "console.service", "--property=MainPID,ActiveState,SubState,ControlGroup,ExecStart,FragmentPath,DropInPaths") |
| + if e != nil { |
| + return nil, e |
| + } |
| + m := map[string]string{} |
| + for _, line := range strings.Split(string(raw), "\n") { |
| + k, v, ok := strings.Cut(line, "=") |
| + if ok { |
| + m[k] = v |
| + } |
| + } |
| + return m, nil |
| + } |
| + unit, err := snapshot() |
| + if err != nil { |
| + return zero, err |
| + } |
| + pid, start, err := activationConsolePID(r) |
| + if err != nil { |
| + return zero, err |
| + } |
| + wantExec := "{ path=/opt/carlos/system/guard/carlos ; argv[]=/opt/carlos/system/guard/carlos system guard-exec console " + string(path) + " serve ;" |
| + if unit["ActiveState"] != "active" || unit["SubState"] != "running" || unit["MainPID"] != strconv.Itoa(pid) || !strings.HasPrefix(unit["ExecStart"], wantExec) || strings.Count(unit["ExecStart"], "argv[]=") != 1 || unit["ControlGroup"] != "/system.slice/console.service" { |
| + return zero, errActivation |
| + } |
| + for _, p := range append([]string{unit["FragmentPath"]}, strings.Fields(unit["DropInPaths"])...) { |
| + if _, err := activationRead(p, false); err != nil { |
| + return zero, err |
| + } |
| + } |
| + if !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/90-host-floor.conf") || !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/91-claim-runtime.conf") { |
| + return zero, errActivation |
| + } |
| + proc := fmt.Sprintf("/proc/%d", pid) |
| + check := func() error { |
| + boot, e := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if e != nil || strings.TrimSpace(string(boot)) != r.BootID { |
| + return errActivation |
| + } |
| + st, e := os.ReadFile(proc + "/stat") |
| + if e != nil { |
| + return e |
| + } |
| + end := strings.LastIndexByte(string(st), ')') |
| + if end < 0 { |
| + return errActivation |
| + } |
| + fields := strings.Fields(string(st[end+1:])) |
| + if len(fields) < 20 || fields[19] != start { |
| + return errActivation |
| + } |
| + var ns unix.Stat_t |
| + if unix.Stat(proc+"/ns/pid", &ns) != nil || strconv.FormatUint(ns.Ino, 10) != strings.Split(r.ProcessID, ":")[1] { |
| + return errActivation |
| + } |
| + group, e := os.ReadFile(proc + "/cgroup") |
| + if e != nil || string(group) != "0::"+unit["ControlGroup"]+"\n" { |
| + return errActivation |
| + } |
| + return nil |
| + } |
| + if err = check(); err != nil { |
| + return zero, err |
| + } |
| + command, err := os.ReadFile(proc + "/cmdline") |
| + if err != nil || string(command) != string(path)+"\x00serve\x00" { |
| + return zero, errActivation |
| + } |
| + processEnv, err := os.ReadFile(proc + "/environ") |
| + if err != nil { |
| + return zero, err |
| + } |
| + wantedEnv, err := activationEnvironment(expected) |
| + if err != nil { |
| + return zero, err |
| + } |
| + for _, name := range []string{"CARLOS_CONSOLE_ID", "CONSOLE_HOME_REGION", "CARLOS_HOST_CLAIM"} { |
| + count := 0 |
| + for _, entry := range strings.Split(string(processEnv), "\x00") { |
| + key, value, ok := strings.Cut(entry, "=") |
| + if ok && key == name { |
| + count++ |
| + if value != wantedEnv[name] { |
| + return zero, errActivation |
| + } |
| + } |
| + } |
| + if count != 1 { |
| + return zero, errActivation |
| + } |
| + } |
| + checker, err := hostfloor.OpenProtected(filepath.Join(d.Dir, "carlos")) |
| + if err != nil { |
| + return zero, err |
| + } |
| + _, err = d.VerifyFile(checker, runtime.GOOS+"-"+runtime.GOARCH) |
| + checker.Close() |
| + if err != nil { |
| + return zero, err |
| + } |
| + candidate, err := hostfloor.OpenProtected(string(path)) |
| + if err != nil { |
| + return zero, err |
| + } |
| + defer candidate.Close() |
| + f, err := os.Open(proc + "/exe") |
| + if err != nil { |
| + return zero, err |
| + } |
| + defer f.Close() |
| + currentInfo, err := f.Stat() |
| + if err != nil { |
| + return zero, err |
| + } |
| + candidateInfo, err := candidate.Stat() |
| + if err != nil || !os.SameFile(currentInfo, candidateInfo) { |
| + return zero, errActivation |
| + } |
| + var st unix.Stat_t |
| + if unix.Fstat(int(f.Fd()), &st) != nil || st.Uid != 0 || st.Mode&0022 != 0 { |
| + return zero, errActivation |
| + } |
| + hash, err := d.VerifyFile(f, runtime.GOOS+"-"+runtime.GOARCH, "console") |
| + if err != nil || hash != r.BinarySHA256 { |
| + return zero, errActivation |
| + } |
| + if err = check(); err != nil { |
| + return zero, err |
| + } |
| + again, err := snapshot() |
| + if err != nil || !reflect.DeepEqual(again, unit) { |
| + return zero, errActivation |
| + } |
| + return status.ConsoleProcess{ConsoleID: id, Enrollment: c.Enrollment, ProcessID: r.ProcessID, BinarySHA256: hash, GuardFloorSHA256: floor.Digest}, nil |
| +} |
| + |
| +func publishActivationConsoleReport(ctx context.Context, store release.Store, pub ed25519.PublicKey, bucket, id string, revision uint64, dry bool) (status.ConsoleProcess, error) { |
| + var zero status.ConsoleProcess |
| + d := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket} |
| + o, err := d.Read(ctx) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if revision == 0 || o.Roster.Revision != revision { |
| + return zero, errActivation |
| + } |
| + c, ok := o.Roster.Consoles[id] |
| + if !ok || c.Compute == nil { |
| + return zero, errActivation |
| + } |
| + e := hostactors.ExactEvidence{Store: store, PublicKey: pub, Boxes: d.PrivateBoxReport} |
| + r, err := e.ConsoleReport(ctx, id, c) |
| + if err != nil { |
| + return zero, err |
| + } |
| + p, err := activationConsoleProcess(ctx, pub, bucket, o, id, r) |
| + if err != nil { |
| + return zero, err |
| + } |
| + raw, err := activationRead("/opt/carlos/system/guard/policy.json", false) |
| + if err != nil { |
| + return zero, err |
| + } |
| + var policy hostfloor.Policy |
| + if json.Unmarshal(raw, &policy) != nil { |
| + return zero, errActivation |
| + } |
| + floor, err := policy.Verify(pub, bucket) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if err = hostactors.VerifyReport(ctx, e, pub, floor, r, c.Enrollment, id, time.Now()); err != nil { |
| + return zero, err |
| + } |
| + b := o.Roster.Boxes[c.Compute.Box] |
| + key := "status/boxes/" + c.Compute.Box + ".json" |
| + body, tag, err := store.Get(ctx, key) |
| + if err != nil { |
| + return zero, err |
| + } |
| + br, err := d.PrivateBoxReport(ctx, c.Compute.Box, b) |
| + if err != nil { |
| + return zero, err |
| + } |
| + if err = hostactors.VerifyReport(ctx, e, pub, floor, br, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil { |
| + return zero, err |
| + } |
| + unchanged, unchangedTag, err := store.Get(ctx, key) |
| + if err != nil || unchangedTag != tag || !bytes.Equal(unchanged, body) { |
| + return zero, errActivation |
| + } |
| + if br.BootID != r.BootID { |
| + return zero, errActivation |
| + } |
| + // Keep fields unknown to this binary, including future box metrics. |
| + var box map[string]json.RawMessage |
| + if json.Unmarshal(body, &box) != nil { |
| + return zero, errActivation |
| + } |
| + var metrics map[string]json.RawMessage |
| + if json.Unmarshal(box["metrics"], &metrics) != nil { |
| + return zero, errActivation |
| + } |
| + var processes []status.ConsoleProcess |
| + if v := metrics["console_processes"]; len(v) > 0 && json.Unmarshal(v, &processes) != nil { |
| + return zero, errActivation |
| + } |
| + next := []status.ConsoleProcess{} |
| + for _, old := range processes { |
| + if old.ConsoleID != id { |
| + next = append(next, old) |
| + } |
| + } |
| + next = append(next, p) |
| + metrics["console_processes"], _ = json.Marshal(next) |
| + box["metrics"], _ = json.Marshal(metrics) |
| + body, _ = json.Marshal(box) |
| + fresh, err := d.Read(ctx) |
| + if err != nil || fresh.ETag != o.ETag { |
| + return zero, errActivation |
| + } |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { |
| + return zero, err |
| + } |
| + if dry { |
| + return p, nil |
| + } |
| + written, err := store.Put(ctx, key, body, tag) |
| + if err != nil { |
| + return zero, err |
| + } |
| + got, readTag, err := store.Get(ctx, key) |
| + if err != nil || readTag != written || !bytes.Equal(got, body) { |
| + return zero, errActivation |
| + } |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { |
| + return zero, err |
| + } |
| + return p, nil |
| +} |
diff --git a/internal/host/activation_console_report_linux_test.go b/internal/host/activation_console_report_linux_test.go| new file mode 100644 |
| index 000000000..4aa9653f2 |
| --- /dev/null |
| +++ b/internal/host/activation_console_report_linux_test.go |
| @@ -0,0 +1,21 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "testing" |
| +) |
| + |
| +func TestActivationConsoleProcessIdentity(t *testing.T) { |
| + r := hostactors.Report{ProcessID: "boot:4026531836:123:456", BootID: "boot"} |
| + if pid, start, err := activationConsolePID(r); err != nil || pid != 123 || start != "456" { |
| + t.Fatal(pid, start, err) |
| + } |
| + for _, id := range []string{"boot:123", "boot:4026531836:0:456", "other:4026531836:123:456", "boot:4026531836:123:0", "boot:bad:123:456"} { |
| + r.ProcessID = id |
| + if _, _, err := activationConsolePID(r); err == nil { |
| + t.Fatal("accepted", id) |
| + } |
| + } |
| +} |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..7b0d5e17e |
| --- /dev/null |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -0,0 +1,494 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/xml" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/claimactivation" |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +var activationConsoleImage, activationCarlosImage []byte |
| + |
| +// This test allows only loopback HTTP to its S3 and console fixtures. The |
| +// executable, guard, systemd, proc observation, HTTP auth and reset are real. |
| +// Sophie uses the runner’s real AWS identity; Leo, box telemetry and initial |
| +// app/claim state are fixtures. No deployment bucket or tenant is touched. |
| +func TestRootActivationConsoleRuntime(t *testing.T) { |
| + if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 { |
| + t.Fatal("requires root and embedded activation binaries") |
| + } |
| + uid, gid := generationRunnerRegistryUser(t) |
| + instance := status.InstanceID() |
| + if instance == "" { |
| + t.Fatal("runner instance identity unavailable") |
| + } |
| + for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} { |
| + if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing fixture path", p, err) |
| + } |
| + } |
| + d := activationRootFixture(t) |
| + check := func(err error) { |
| + t.Helper() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write := func(path string, b []byte, mode os.FileMode) { |
| + t.Helper() |
| + check(os.MkdirAll(filepath.Dir(path), 0755)) |
| + check(os.WriteFile(path, b, mode)) |
| + } |
| + t.Cleanup(func() { |
| + out, _ := exec.Command("journalctl", "-u", "console.service", "--no-pager", "-n", "45").CombinedOutput() |
| + t.Log(string(out)) |
| + exec.Command("systemctl", "stop", "console.service").Run() |
| + for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} { |
| + os.RemoveAll(p) |
| + } |
| + exec.Command("systemctl", "daemon-reload").Run() |
| + exec.Command("systemctl", "reset-failed", "console.service").Run() |
| + }) |
| + store := release.NewDirStore(filepath.Join(d, "bucket")) |
| + endpoint := activationS3Fixture(t, store) |
| + pub, key, _ := ed25519.GenerateKey(nil) |
| + signer := release.RootSigner(key) |
| + const bucket = "activation-fixture" |
| + ctx := t.Context() |
| + directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket} |
| + runner := claimactivation.Runner{Directory: directory, Signer: signer} |
| + phase := func(p claimactivation.Plan) { |
| + t.Helper() |
| + p.Bucket = bucket |
| + if o, e := directory.Read(ctx); e == nil { |
| + p.ExpectedRevision = o.Roster.Revision |
| + } |
| + before := activationStoreSnapshot(t, store) |
| + preview, e := runner.Run(ctx, p, true) |
| + check(e) |
| + if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| + t.Fatal("dry run mutated bucket", p.Phase) |
| + } |
| + result, e := runner.Run(ctx, p, false) |
| + check(e) |
| + if preview.Revision != result.Revision || len(preview.Writes) != len(result.Writes) { |
| + t.Fatal("preview/apply differ", p.Phase, preview, result) |
| + } |
| + t.Log("phase", p.Phase, "revision", result.Revision, "writes", len(result.Writes)) |
| + } |
| + phase(claimactivation.Plan{Phase: "initialize"}) |
| + phase(claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "640012953448", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}) |
| + identityRecord, err := readIdentityRecord(&http.Client{Timeout: 3 * time.Second}, imdsBase, "sophie", time.Now()) |
| + check(err) |
| + check(boxid.Publish(ctx, store, identityRecord)) |
| + boxPlan := claimactivation.BoxInput{Reference: "sophie", Namespace: "platform", Label: "sophie", Region: "ie", Pool: "console", AWSRegion: "eu-west-1", InstanceID: instance, Operator: "root-runner"} |
| + phase(claimactivation.Plan{Phase: "invite-box", Box: &boxPlan}) |
| + phase(claimactivation.Plan{Phase: "enroll-box", Box: &boxPlan}) |
| + o, err := directory.Read(ctx) |
| + check(err) |
| + next := hostactors.Clone(o.Roster) |
| + next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("2", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "640012953448", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}} |
| + _, err = directory.Replace(ctx, signer, o, next) |
| + check(err) |
| + c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}} |
| + phase(claimactivation.Plan{Phase: "invite-console", Console: &c}) |
| + phase(claimactivation.Plan{Phase: "enroll-console", Console: &c}) |
| + rel := release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: "bootstrap/console/v1.0.0/carlos-linux-arm64", SHA256: hostfloor.Digest(activationConsoleImage)}}} |
| + _, err = release.PromoteSystem(ctx, store, signer, "router", "stable", rel, release.SystemPromoteOptions{}) |
| + check(err) |
| + identity, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| + check(err) |
| + check(release.PublishSystemIdentity(ctx, store, pub, identity)) |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| + floorRaw, _ := json.Marshal(floor) |
| + auth, err := release.SignRootEnvelope(signer, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: bucket, Key: hostfloor.Key, SHA256: hostfloor.Digest(floorRaw)}) |
| + check(err) |
| + policy := hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "armed", Floor: floorRaw, Authorization: auth} |
| + policyRaw, _ := json.Marshal(policy) |
| + local := hostfloor.Enrollment{Box: "sophie", Enrollment: c.Compute.Enrollment, InstanceID: instance} |
| + localRaw, _ := json.Marshal(local) |
| + write("/opt/carlos/carlos", activationCarlosImage, 0755) |
| + write("/opt/carlos/carlos.prev", activationCarlosImage, 0755) |
| + write("/opt/carlos/console", activationConsoleImage, 0755) |
| + write("/opt/carlos/console.prev", activationConsoleImage, 0755) |
| + write(d+"/policy.json", policyRaw, 0600) |
| + write(d+"/identity.json", identity, 0600) |
| + write(d+"/enrollment.json", localRaw, 0600) |
| + check(os.MkdirAll(d+"/data", 0755)) |
| + check(os.Chown(d+"/data", uid, gid)) |
| + check(os.Chmod(d, 0755)) |
| + environment := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_RELEASE_KEY=" + base64.StdEncoding.EncodeToString(key.Seed()), "CARLOS_DATA_DIR=" + d + "/data"} |
| + write("/etc/carlos/host.env", []byte(strings.Join(environment[1:], "\n")+"\n"), 0600) |
| + cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "guard-arm", "--policy", d+"/policy.json", "--identity", d+"/identity.json", "--enrollment", d+"/enrollment.json", "--console", "/opt/carlos/console") |
| + cmd.Env = environment |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatal("guard arm", string(output), err) |
| + } |
| + dbPath := d + "/data/console.db" |
| + st, err := console.Open(dbPath) |
| + check(err) |
| + now := time.Now().UTC() |
| + check(st.CreateAccount(123, "bab", "fixture", "operator@example.test", now)) |
| + _, err = st.CreateAppIn(123, "trial", "operator@example.test", now) |
| + check(err) |
| + // OpenGated's schema migration is also used on the fixture before serving. |
| + check(st.Close()) |
| + st, err = console.OpenGated(dbPath) |
| + check(err) |
| + check(st.Close()) |
| + st, err = console.Open(dbPath) |
| + check(err) |
| + check(st.BindClaimAppIdentity(123, "trial", "trial-app-id")) |
| + tokenFor := func(address string) string { |
| + user, device, e := st.NewCLIAuth("fixture", "127.0.0.1", now) |
| + check(e) |
| + check(st.ApproveCLIAuth(user, address, now)) |
| + _, token, _, e := st.PollCLIAuth(device, now) |
| + check(e) |
| + return token |
| + } |
| + token := tokenFor("operator@example.test") |
| + other := tokenFor("member@example.test") |
| + check(st.SetOperator("operator@example.test", true)) |
| + check(st.Close()) |
| + for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { |
| + if _, e := os.Stat(p); e == nil { |
| + check(os.Chown(p, uid, gid)) |
| + } |
| + } |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + check(err) |
| + addr := ln.Addr().String() |
| + check(ln.Close()) |
| + unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=" + d + "/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n" |
| + write("/etc/systemd/system/console.service", []byte(unit), 0644) |
| + config := func(enabled bool) { |
| + t.Helper() |
| + o, e := directory.Read(ctx) |
| + check(e) |
| + wire, _, e := store.Get(ctx, hostactors.Key) |
| + check(e) |
| + write(d+"/roster.json", wire, 0600) |
| + args := []string{"console-config", "--roster", d + "/roster.json", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--bucket", bucket, "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub)} |
| + if enabled { |
| + args = append(args, "--enable-console-runtime") |
| + } |
| + var output bytes.Buffer |
| + check(runSystemClaims(append(append([]string{}, args...), "--dry-run"), &output)) |
| + check(runSystemClaims(args, &output)) |
| + } |
| + config(false) |
| + ctl := func(args ...string) { |
| + t.Helper() |
| + out, e := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| + if e != nil { |
| + t.Fatal(args, string(out), e) |
| + } |
| + } |
| + ctl("start", "console.service") |
| + report := func(enabled bool, previous string) hostactors.Report { |
| + t.Helper() |
| + deadline := time.Now().Add(20 * time.Second) |
| + for time.Now().Before(deadline) { |
| + raw, _, e := store.Get(ctx, "status/consoles/dev-console.json") |
| + var r hostactors.Report |
| + if e == nil && json.Unmarshal(raw, &r) == nil && r.HostClaimEnabled == enabled && r.ProcessID != previous { |
| + return r |
| + } |
| + time.Sleep(100 * time.Millisecond) |
| + } |
| + t.Fatal("console did not report", enabled) |
| + return hostactors.Report{} |
| + } |
| + first := report(false, "") |
| + // Box reports are fixture telemetry. The console process entry is produced |
| + // by the real root observer below, never copied from the console report. |
| + put := func(k string, v any) { |
| + t.Helper() |
| + raw, _ := json.Marshal(v) |
| + _, tag, _ := store.Get(ctx, k) |
| + _, e := store.Put(ctx, k, raw, tag) |
| + check(e) |
| + } |
| + for ref, b := range next.Boxes { |
| + put("status/boxes/"+ref+".json", status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: b.Enrollment, InstanceID: b.Cloud.InstanceID, Release: "v1.0.0", ReleaseTag: "v1.0.0", Platform: "linux-arm64", BinarySHA256: hostfloor.Digest(activationCarlosImage), MovesProtocol: 1, BootID: first.BootID, FloorSHA256: hostfloor.Digest(floorRaw), FloorGuardReady: true}}) |
| + } |
| + o, err = directory.Read(ctx) |
| + check(err) |
| + if _, err = runner.Run(ctx, claimactivation.Plan{Phase: "publish-floor", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Floor: &floor}, true); err == nil { |
| + t.Fatal("published without independent process observation") |
| + } |
| + publish := func() { |
| + t.Helper() |
| + o, e := directory.Read(ctx) |
| + check(e) |
| + before := activationStoreSnapshot(t, store) |
| + p, e := publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, true) |
| + check(e) |
| + if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| + t.Fatal("report dry run wrote") |
| + } |
| + cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "claims", "console-report", "--bucket", bucket, "--aws-region", "eu-west-1", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub)) |
| + cmd.Env = environment |
| + if output, e := cmd.CombinedOutput(); e != nil { |
| + t.Fatal("report CLI", string(output), e) |
| + } |
| + t.Log("authenticated process", p.ProcessID, p.BinarySHA256) |
| + } |
| + publish() |
| + phase(claimactivation.Plan{Phase: "publish-floor", Floor: &floor}) |
| + phase(claimactivation.Plan{Phase: "home", HomeRegion: "ie"}) |
| + binding := hostactors.CensusKeyBinding{Bucket: bucket, Box: "leo", Enrollment: next.Boxes["leo"].Enrollment} |
| + proof, err := installActivationEvidenceKey(d+"/leo-evidence.key", binding, false) |
| + check(err) |
| + phase(claimactivation.Plan{Phase: "bind-key", Binding: &proof.Binding, Proof: proof.Proof}) |
| + config(true) |
| + ctl("restart", "console.service") |
| + second := report(true, first.ProcessID) |
| + publish() |
| + raw, err := os.ReadFile("/etc/carlos/host.env") |
| + check(err) |
| + if bytes.Contains(raw, []byte("CARLOS_HOST_CLAIM=")) { |
| + t.Fatal("enabled host execution") |
| + } |
| + o, err = directory.Read(ctx) |
| + check(err) |
| + forged := second |
| + forged.ProcessID = first.ProcessID |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil { |
| + t.Fatal("accepted old process") |
| + } |
| + forged = second |
| + forged.BinarySHA256 = strings.Repeat("a", 64) |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil { |
| + t.Fatal("accepted false process digest") |
| + } |
| + activationResetFixture(t, store, "http://"+addr, token, other) |
| + ctl("stop", "console.service") |
| + if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, false); err == nil { |
| + t.Fatal("reported stopped console") |
| + } |
| + t.Log("PASS real guard-arm, guarded console, claims-off/on reports, floor/home/key sequence, authenticated reset and refusals") |
| +} |
| + |
| +func activationStoreSnapshot(t *testing.T, s release.Store) map[string]string { |
| + t.Helper() |
| + keys, err := s.List(t.Context(), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + result := map[string]string{} |
| + for _, k := range keys { |
| + raw, tag, e := s.Get(t.Context(), k) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + result[k] = tag + ":" + string(raw) |
| + } |
| + return result |
| +} |
| + |
| +func activationS3Fixture(t *testing.T, s release.Store) string { |
| + t.Helper() |
| + var mu sync.Mutex |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") |
| + fail := func(code int, name string) { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(code) |
| + fmt.Fprintf(w, "<Error><Code>%s</Code></Error>", name) |
| + } |
| + switch r.Method { |
| + case "GET", "HEAD": |
| + if key == "" { |
| + prefix := r.URL.Query().Get("prefix") |
| + keys, e := s.List(r.Context(), prefix) |
| + if e != nil { |
| + fail(500, "InternalError") |
| + return |
| + } |
| + type item struct{ Key string } |
| + type common struct{ Prefix string } |
| + result := struct { |
| + XMLName xml.Name `xml:"ListBucketResult"` |
| + IsTruncated bool |
| + Contents []item |
| + CommonPrefixes []common |
| + }{} |
| + seen := map[string]bool{} |
| + for _, k := range keys { |
| + if delim := r.URL.Query().Get("delimiter"); delim != "" { |
| + rest := strings.TrimPrefix(k, prefix) |
| + if at := strings.Index(rest, delim); at >= 0 { |
| + p := prefix + rest[:at+len(delim)] |
| + if !seen[p] { |
| + result.CommonPrefixes = append(result.CommonPrefixes, common{p}) |
| + seen[p] = true |
| + } |
| + continue |
| + } |
| + } |
| + result.Contents = append(result.Contents, item{k}) |
| + } |
| + w.Header().Set("Content-Type", "application/xml") |
| + xml.NewEncoder(w).Encode(result) |
| + return |
| + } |
| + raw, tag, e := s.Get(r.Context(), key) |
| + if e != nil { |
| + fail(404, "NoSuchKey") |
| + return |
| + } |
| + w.Header().Set("ETag", `"`+tag+`"`) |
| + w.Header().Set("Content-Length", fmt.Sprint(len(raw))) |
| + if r.Method == "GET" { |
| + w.Write(raw) |
| + } |
| + case "PUT": |
| + raw, e := io.ReadAll(r.Body) |
| + if e != nil { |
| + fail(400, "IncompleteBody") |
| + return |
| + } |
| + _, old, e := s.Get(r.Context(), key) |
| + if e != nil && !errors.Is(e, os.ErrNotExist) { |
| + fail(500, "InternalError") |
| + return |
| + } |
| + before := strings.Trim(r.Header.Get("If-Match"), `"`) |
| + if r.Header.Get("If-None-Match") == "*" && old != "" || before != "" && before != old { |
| + fail(412, "PreconditionFailed") |
| + return |
| + } |
| + tag, e := s.Put(r.Context(), key, raw, old) |
| + if e != nil { |
| + fail(412, "PreconditionFailed") |
| + return |
| + } |
| + w.Header().Set("ETag", `"`+tag+`"`) |
| + case "DELETE": |
| + if e := s.Delete(r.Context(), key); e != nil && !errors.Is(e, os.ErrNotExist) { |
| + fail(500, "InternalError") |
| + return |
| + } |
| + w.WriteHeader(204) |
| + default: |
| + fail(400, "InvalidRequest") |
| + } |
| + })) |
| + t.Cleanup(server.Close) |
| + return server.URL |
| +} |
| + |
| +func activationResetFixture(t *testing.T, s release.Store, origin, token, other string) { |
| + t.Helper() |
| + ctx := t.Context() |
| + check := func(e error) { |
| + t.Helper() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + now := time.Now().UTC().Add(-time.Minute) |
| + const host = "trial.bab.example.test" |
| + record := []byte(`{"schema":1,"host":"trial.bab.example.test","account":"bab","app":"trial","region":"ie","created_at":"` + now.Format(time.RFC3339Nano) + `"}`) |
| + o, err := hostclaim.Read(ctx, s, host) |
| + check(err) |
| + o, err = hostclaim.Update(ctx, s, o, hostclaim.BeginCreate(hostclaim.NewHolder{Family: "instance", Key: "apps/bab/trial/instances/" + host + ".json", Account: "bab", App: "trial", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now}, record, "create", "dev-console", now)) |
| + check(err) |
| + o, err = hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("create", "leo")) |
| + check(err) |
| + for i := 0; i < hostclaim.WakeRetryLimit; i++ { |
| + a := hostclaim.WakeRetryAttempt{ID: fmt.Sprint(i), Box: "leo", Enrollment: strings.Repeat("2", 32), BootID: "fixture", ControllerID: "fixture", InvocationID: "fixture", LaunchInputs: "fixture"} |
| + o, err = hostclaim.Update(ctx, s, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now})) |
| + check(err) |
| + o, err = hostclaim.Update(ctx, s, o, hostclaim.FailWakeRetryAttempt(hostclaim.WakeRetryResult{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now})) |
| + check(err) |
| + now = now.Add(20 * time.Second) |
| + } |
| + admission := hostops.AppAdmission{V: 1, AppID: "trial-app-id", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}} |
| + raw, _ := json.Marshal(admission) |
| + key, _ := hostops.AdmissionKey("bab", "trial") |
| + _, err = s.Put(ctx, key, raw, "") |
| + check(err) |
| + payload := `{"action":"reset-wake-retry","incarnation":"` + o.Claim.Holder.Incarnation + `","episode":"exhausted","request_id":"fixture-reset"}` |
| + call := func(auth, body string) int { |
| + t.Helper() |
| + req, _ := http.NewRequestWithContext(ctx, "POST", origin+"/api/cli/ops/hosts/"+host+"/resolve", strings.NewReader(body)) |
| + req.Header.Set("Content-Type", "application/json") |
| + if auth != "" { |
| + req.Header.Set("Authorization", "Bearer "+auth) |
| + } |
| + client := http.Client{Timeout: 10 * time.Second} |
| + resp, e := client.Do(req) |
| + check(e) |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + t.Log("reset response", resp.StatusCode, string(b)) |
| + return resp.StatusCode |
| + } |
| + for _, test := range []struct { |
| + token string |
| + status int |
| + }{{"", 401}, {other, 404}} { |
| + if code := call(test.token, payload); code != test.status { |
| + t.Fatal(code, test.status) |
| + } |
| + } |
| + before, _ := hostclaim.Read(ctx, s, host) |
| + if before.ETag != o.ETag { |
| + t.Fatal("unauthorized reset changed claim") |
| + } |
| + if code := call(token, payload); code != 200 { |
| + t.Fatal("reset failed", code) |
| + } |
| + after, err := hostclaim.Read(ctx, s, host) |
| + check(err) |
| + retry := after.Claim.Holder.WakeRetry |
| + if retry.EpisodeID == "exhausted" || retry.AttemptsReserved != 0 || retry.State != "open" || retry.ResetRequest != `{"Actor":"operator@example.test","RequestID":"fixture-reset"}` { |
| + t.Fatal("reset result", retry) |
| + } |
| + previous := *o.Claim.Holder |
| + previous.WakeRetry = retry |
| + if !reflect.DeepEqual(&previous, after.Claim.Holder) || !reflect.DeepEqual(o.Claim.Op, after.Claim.Op) { |
| + t.Fatal("reset changed execution authority") |
| + } |
| + if code := call(token, payload); code != 409 { |
| + t.Fatal("stale reset accepted", code) |
| + } |
| + final, err := hostclaim.Read(ctx, s, host) |
| + check(err) |
| + if final.ETag != after.ETag { |
| + t.Fatal("stale reset mutated claim") |
| + } |
| + t.Log("exhausted retry reset through production runtime; holder/session/stamp preserved") |
| +} |
diff --git a/internal/host/activation_offline_linux.go b/internal/host/activation_offline_linux.go| index 33e1fde30..2fb6806b1 100644 |
| --- a/internal/host/activation_offline_linux.go |
| +++ b/internal/host/activation_offline_linux.go |
| @@ -173,7 +173,7 @@ func (t activationOffline) read(state *os.File) (activationOfflineRecord, error) |
| } |
| } |
| switch r.Phase { |
| - case "prepared", "held", "restoring", "released", "done": |
| + case "prepared", "held", "seed-prepared", "restoring", "released", "done": |
| default: |
| return r, errActivation |
| } |
| @@ -409,7 +409,7 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if err != nil { |
| return err |
| } |
| - if r.Phase == "prepared" { |
| + if r.Phase == "prepared" || r.Phase == "seed-prepared" { |
| return errActivation |
| } |
| if r.Phase == "done" { |
diff --git a/internal/host/activation_seed_linux.go b/internal/host/activation_seed_linux.go| new file mode 100644 |
| index 000000000..44c86f5f6 |
| --- /dev/null |
| +++ b/internal/host/activation_seed_linux.go |
| @@ -0,0 +1,279 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +const activationSeedDropIn = "95-claim-activation-seed.conf" |
| +const activationSeedUnit = `[Unit] |
| +Requires= |
| +Requires=carlos-instance-control.service |
| +After=carlos-instance-control.service |
| +OnSuccess= |
| +OnFailure= |
| +[Service] |
| +Type=oneshot |
| +RemainAfterExit=yes |
| +ExecCondition= |
| +ExecStartPre= |
| +ExecStart= |
| +ExecStart=/opt/carlos/carlos system files seed |
| +ExecStartPost= |
| +ExecReload= |
| +ExecStop= |
| +ExecStopPost= |
| +Restart=no |
| +TimeoutStartSec=0 |
| +TimeoutStopSec=45s |
| +` |
| + |
| +func activationSeedPreflight(ctx context.Context) error { |
| + raw, err := activationRead("/etc/carlos/host.env", false) |
| + if err != nil { |
| + return err |
| + } |
| + env, err := activationEnvironment(raw) |
| + if err != nil { |
| + return err |
| + } |
| + if env["CARLOS_HOST_CLAIM"] == "" || env["CARLOS_HOST_CLAIM_ORIGINS"] != "1" || env["CARLOS_MOVES_PHASE1"] != "" || env["CARLOS_REGION"] != "" { |
| + return errActivation |
| + } |
| + pub, err := release.ParsePublicKey(env["CARLOS_RELEASE_PUBKEY"], "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return errActivation |
| + } |
| + d := hostfloor.Disk{Dir: "/opt/carlos/system/guard", Bucket: env["CARLOS_DEPLOYMENT_BUCKET"], PublicKey: pub} |
| + if policy, _, err := d.Load(); err != nil || policy.Mode == "unpublished" { |
| + return errActivation |
| + } |
| + f, err := hostfloor.OpenProtected("/opt/carlos/carlos") |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + if _, err = d.VerifyFile(f, runtime.GOOS+"-"+runtime.GOARCH); err != nil { |
| + return err |
| + } |
| + raw, err = claimSystemctl(ctx, "is-active", "carlos-instance-control.service") |
| + if err != nil || strings.TrimSpace(string(raw)) != "active" { |
| + return errActivation |
| + } |
| + return verifyOfflineRootMigration(ctx) |
| +} |
| + |
| +// The persistent override is installed before the edge mask is lifted and |
| +// removed only after that mask is durable again. A reboot during the window |
| +// can therefore run only seed, never the normal serving agent. |
| +func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) error { |
| + units, state, err := t.dirs(false) |
| + if err != nil { |
| + return err |
| + } |
| + defer units.Close() |
| + defer state.Close() |
| + if !dry { |
| + lock, e := activationLock(state) |
| + if e != nil { |
| + return e |
| + } |
| + defer lock.Close() |
| + } |
| + r, err := t.read(state) |
| + if err != nil { |
| + return err |
| + } |
| + if r.Phase != "held" && r.Phase != "seed-prepared" { |
| + return errActivation |
| + } |
| + edge := r.Items[len(r.Items)-1] |
| + if edge.Name != "carlos-edge.service" || edge.Masked { |
| + return errActivation |
| + } |
| + for _, item := range r.Items { |
| + if err = t.checkItem(item); err != nil { |
| + return err |
| + } |
| + if item.Name != edge.Name { |
| + load, active, e := t.status(ctx, item.Name) |
| + if e != nil { |
| + return e |
| + } |
| + if load != "masked" || active != "inactive" && active != "failed" { |
| + return errActivation |
| + } |
| + } |
| + } |
| + dropPath := filepath.Join(t.units, edge.Name+".d") |
| + drop, err := activationDir(dropPath, false) |
| + exists := false |
| + if err == nil { |
| + old, e := activationReadAt(drop, activationSeedDropIn, false) |
| + exists = e == nil |
| + if e != nil && !errors.Is(e, os.ErrNotExist) { |
| + drop.Close() |
| + return e |
| + } |
| + if exists && (r.Phase == "held" || !bytes.Equal(old, content)) { |
| + drop.Close() |
| + return errActivation |
| + } |
| + drop.Close() |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + masked := sameActivationFile(filepath.Join(t.units, edge.Name), filepath.Join(t.state, edge.Name+".mask")) |
| + if !masked && (r.Phase != "seed-prepared" || !exists) { |
| + return errActivation |
| + } |
| + if err = activationSeedDropIns(t.units); err != nil { |
| + return err |
| + } |
| + if dry { |
| + return nil |
| + } |
| + if r.Phase == "held" { |
| + r.Phase = "seed-prepared" |
| + if err = t.save(state, r); err != nil { |
| + return err |
| + } |
| + } |
| + drop, err = activationDir(dropPath, true) |
| + if err != nil { |
| + return err |
| + } |
| + defer drop.Close() |
| + if err = safefile.WriteAt(int(drop.Fd()), activationSeedDropIn, content, 0644); err != nil { |
| + return err |
| + } |
| + if err = t.point("seed-override"); err != nil { |
| + return err |
| + } |
| + if err = t.replace(units, state, edge, true); err != nil { |
| + return err |
| + } |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + if err = t.point("seed-reload"); err != nil { |
| + return err |
| + } |
| + effective, err := t.systemctl(ctx, "show", edge.Name, "--property=ExecStart,Type,RemainAfterExit,Restart,Requires") |
| + if err != nil { |
| + return err |
| + } |
| + command := "" |
| + for _, line := range strings.Split(string(content), "\n") { |
| + if strings.HasPrefix(line, "ExecStart=") { |
| + command = strings.TrimPrefix(line, "ExecStart=") |
| + } |
| + } |
| + fields := strings.Fields(command) |
| + if len(fields) == 0 || !strings.Contains(string(effective), "ExecStart={ path="+fields[0]+" ; argv[]="+command+" ;") || strings.Count(string(effective), "argv[]=") != 1 || !strings.Contains(string(effective), "Type=oneshot\n") || !strings.Contains(string(effective), "RemainAfterExit=yes\n") || !strings.Contains(string(effective), "Restart=no\n") || strings.Contains(string(effective), "carlos-system-recover.service") { |
| + return errActivation |
| + } |
| + // The caller retains a failed seed's result and retries deliberately. Even |
| + // if start fails, stop and restore the mask before deleting the override. |
| + _, startErr := t.systemctl(ctx, "start", edge.Name) |
| + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) |
| + defer cancel() |
| + ctx = cleanup |
| + result, showErr := t.systemctl(ctx, "show", edge.Name, "--property=InvocationID,Result,ExecMainStatus,ActiveState,SubState") |
| + if showErr == nil { |
| + showErr = safefile.WriteAt(int(state.Fd()), "seed-result.txt", result, 0600) |
| + } |
| + if err = t.point("seed-started"); err != nil { |
| + return err |
| + } |
| + if _, err = t.systemctl(ctx, "stop", edge.Name); err != nil { |
| + return errors.Join(startErr, showErr, err) |
| + } |
| + if err = t.point("seed-stopped"); err != nil { |
| + return err |
| + } |
| + if err = t.replace(units, state, edge, false); err != nil { |
| + return err |
| + } |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + load, active, err := t.status(ctx, edge.Name) |
| + if err != nil || load != "masked" || active != "inactive" && active != "failed" { |
| + return errActivation |
| + } |
| + if err = t.point("seed-remasked"); err != nil { |
| + return err |
| + } |
| + old, err := activationReadAt(drop, activationSeedDropIn, false) |
| + if err != nil || !bytes.Equal(old, content) { |
| + return errActivation |
| + } |
| + if err = unix.Unlinkat(int(drop.Fd()), activationSeedDropIn, 0); err != nil { |
| + return err |
| + } |
| + if err = drop.Sync(); err != nil { |
| + return err |
| + } |
| + if err = t.point("seed-removed"); err != nil { |
| + return err |
| + } |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + r.Phase = "held" |
| + if err = t.save(state, r); err != nil { |
| + return err |
| + } |
| + return errors.Join(startErr, showErr) |
| +} |
| + |
| +// A later drop-in could replace the seed command at boot before the loaded |
| +// unit can be checked. Refuse that shape before removing the persistent mask. |
| +func activationSeedDropIns(units string) error { |
| + roots := []string{units, "/etc/systemd/system.control", "/run/systemd/system.control", "/run/systemd/transient", "/run/systemd/generator.early", "/run/systemd/system", "/usr/local/lib/systemd/system", "/usr/lib/systemd/system", "/run/systemd/generator", "/run/systemd/generator.late"} |
| + for _, root := range roots { |
| + for _, name := range []string{"service.d", "carlos-.service.d", "carlos-edge.service.d"} { |
| + dir, err := activationDir(filepath.Join(root, name), false) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + entries, err := dir.ReadDir(-1) |
| + if err != nil { |
| + dir.Close() |
| + return err |
| + } |
| + for _, entry := range entries { |
| + if !strings.HasSuffix(entry.Name(), ".conf") { |
| + continue |
| + } |
| + if entry.Name() > activationSeedDropIn { |
| + dir.Close() |
| + return errActivation |
| + } |
| + if _, err := activationReadAt(dir, entry.Name(), false); err != nil { |
| + dir.Close() |
| + return err |
| + } |
| + } |
| + dir.Close() |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/activation_seed_rootrunner_linux_test.go b/internal/host/activation_seed_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..7fe29f199 |
| --- /dev/null |
| +++ b/internal/host/activation_seed_rootrunner_linux_test.go |
| @@ -0,0 +1,238 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRootActivationSeedTransaction(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + if err := tx.begin(t.Context(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if _, err := os.Lstat(drop); !os.IsNotExist(err) { |
| + t.Fatal("existing edge overrides", err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(drop) }) |
| + // The transaction mechanics run a harmless one-shot; the seed CLI has its |
| + // own delegated root witness. It must not resume the original sleep unit. |
| + raw := activationSeedFixtureUnit |
| + env := filepath.Join(offlineRebootState, "leo.env") |
| + if err := os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + control := filepath.Join(tx.units, claimControlUnit) |
| + if _, err := os.Lstat(control); !os.IsNotExist(err) { |
| + t.Fatal("existing control unit", err) |
| + } |
| + t.Cleanup(func() { os.Remove(control); offlineRealSystemctl(context.Background(), "daemon-reload") }) |
| + ctl := func(args ...string) (string, error) { b, e := tx.systemctl(t.Context(), args...); return string(b), e } |
| + if err := installActivationControl(env, tx.units, true, ctl); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(control); !os.IsNotExist(err) { |
| + t.Fatal("dry run installed control") |
| + } |
| + if err := installActivationControl(env, tx.units, false, ctl); err != nil { |
| + t.Fatal(err) |
| + } |
| + source := filepath.Join(offlineRebootState, "credentials.json") |
| + credentials := []byte(`{"role":"arn:aws:iam::640012953448:role/fixture","region":"eu-west-1","bucket":"activation-fixture","access_key":"fixture","secret_key":"fixture"}`) |
| + if err := os.WriteFile(source, credentials, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + target := filepath.Join(offlineRebootState, "fence", "credentials.json") |
| + scope := activationFenceScope{Bucket: "activation-fixture", Region: "eu-west-1", Role: "arn:aws:iam::640012953448:role/fixture"} |
| + if err := installActivationFenceCredentials(source, target, scope, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(target); !os.IsNotExist(err) { |
| + t.Fatal("credential preview wrote") |
| + } |
| + if err := installActivationFenceCredentials(source, target, scope, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(drop, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + late := filepath.Join(drop, "99-operator.conf") |
| + if err := os.WriteFile(late, []byte("[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.seed(t.Context(), raw, true); err == nil { |
| + t.Fatal("late command override accepted") |
| + } |
| + os.Remove(late) |
| + os.Remove(drop) |
| + if err := tx.seed(t.Context(), raw, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Lstat(drop); !os.IsNotExist(err) { |
| + t.Fatal("dry run wrote override") |
| + } |
| + if err := tx.seed(t.Context(), raw, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := verifyOfflineRootMigration(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if target, err := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")); err != nil || target != "/dev/null" { |
| + t.Fatal("seed did not restore edge mask", target, err) |
| + } |
| + if _, err := os.Lstat(filepath.Join(drop, activationSeedDropIn)); !os.IsNotExist(err) { |
| + t.Fatal("seed override retained", err) |
| + } |
| + if err := tx.restore(t.Context(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestRootActivationSequence(t *testing.T) { |
| + t.Run("console-and-roster", TestRootActivationConsoleRuntime) |
| + t.Run("leo-offline-tools", TestRootActivationSeedTransaction) |
| +} |
| + |
| +var activationSeedFixtureUnit = []byte(strings.ReplaceAll(strings.ReplaceAll(activationSeedUnit, "/opt/carlos/carlos system files seed", "/usr/bin/true"), "carlos-instance-control.service", "")) |
| + |
| +func TestRootActivationSeedInterruptions(t *testing.T) { |
| + for _, point := range []string{"journal-seed-prepared", "seed-override", "stage-carlos-edge.service", "replace-carlos-edge.service", "seed-reload", "seed-started", "seed-stopped", "seed-remasked", "seed-removed", "journal-held"} { |
| + t.Run(point, func(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if _, e := os.Lstat(drop); !os.IsNotExist(e) { |
| + t.Fatal("preexisting override directory", e) |
| + } |
| + defer os.RemoveAll(drop) |
| + if e := tx.begin(t.Context(), false); e != nil { |
| + t.Fatal(e) |
| + } |
| + injected := errors.New("interruption") |
| + hit := false |
| + tx.checkpoint = func(name string) error { |
| + if !hit && name == point { |
| + hit = true |
| + return injected |
| + } |
| + return nil |
| + } |
| + if e := tx.seed(t.Context(), activationSeedFixtureUnit, false); !errors.Is(e, injected) || !hit { |
| + t.Fatal("missing interruption", e) |
| + } |
| + tx.checkpoint = nil |
| + if point != "journal-held" { |
| + if e := tx.restore(t.Context(), false); e == nil { |
| + t.Fatal("restore accepted unfinished seed") |
| + } |
| + } |
| + if e := tx.seed(t.Context(), activationSeedFixtureUnit, false); e != nil { |
| + t.Fatal("resume", e) |
| + } |
| + if e := tx.restore(t.Context(), false); e != nil { |
| + t.Fatal("restore", e) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootRebootActivationSeedPhase1(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + if e := tx.begin(t.Context(), false); e != nil { |
| + t.Fatal(e) |
| + } |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if _, e := os.Lstat(drop); !os.IsNotExist(e) { |
| + t.Fatal("preexisting override directory", e) |
| + } |
| + injected := errors.New("reboot window") |
| + tx.checkpoint = func(name string) error { |
| + if name == "seed-started" { |
| + return injected |
| + } |
| + return nil |
| + } |
| + if e := tx.seed(t.Context(), activationSeedFixtureUnit, false); !errors.Is(e, injected) { |
| + t.Fatal(e) |
| + } |
| + boot, e := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = os.WriteFile(offlineRebootState+"/seed-boot", boot, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func TestRootRebootActivationSeedPhase2(t *testing.T) { |
| + before, e := os.ReadFile(offlineRebootState + "/seed-boot") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + now, e := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if e != nil || bytes.Equal(before, now) { |
| + t.Fatal("no actual reboot", e) |
| + } |
| + tx := activationOffline{units: "/etc/systemd/system", state: offlineRebootState + "/transaction", systemctl: offlineRealSystemctl, drained: quiesceLegacyRootMutators} |
| + defer cleanupOfflineReal(t, tx) |
| + defer os.RemoveAll(filepath.Join(tx.units, "carlos-edge.service.d")) |
| + unit, e := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=Type,SubState,ExecStart") |
| + if e != nil || !strings.Contains(string(unit), "Type=oneshot") || !strings.Contains(string(unit), "path=/usr/bin/true") || strings.Contains(string(unit), "sleep") { |
| + t.Fatal("normal edge after reboot", string(unit), e) |
| + } |
| + if e = tx.restore(t.Context(), false); e == nil { |
| + t.Fatal("restore accepted unfinished seed") |
| + } |
| + if e = tx.seed(t.Context(), activationSeedFixtureUnit, false); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = tx.restore(t.Context(), false); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| + |
| +func TestRootActivationSeedFailure(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if _, e := os.Lstat(drop); !os.IsNotExist(e) { |
| + t.Fatal("existing override directory", e) |
| + } |
| + defer os.RemoveAll(drop) |
| + if e := tx.begin(t.Context(), false); e != nil { |
| + t.Fatal(e) |
| + } |
| + raw := bytes.ReplaceAll(activationSeedFixtureUnit, []byte("/usr/bin/true"), []byte("/usr/bin/false")) |
| + if e := tx.seed(t.Context(), raw, false); e == nil { |
| + t.Fatal("failed seed accepted") |
| + } |
| + if e := verifyOfflineRootMigration(t.Context()); e != nil { |
| + t.Fatal("failure did not remask", e) |
| + } |
| + result, e := os.ReadFile(filepath.Join(tx.state, "seed-result.txt")) |
| + if e != nil || !bytes.Contains(result, []byte("ExecMainStatus=1")) { |
| + t.Fatal("missing failed result", string(result), e) |
| + } |
| + if _, e := os.Lstat(filepath.Join(drop, activationSeedDropIn)); !os.IsNotExist(e) { |
| + t.Fatal("failed seed override retained", e) |
| + } |
| + if e := tx.seed(t.Context(), activationSeedFixtureUnit, false); e != nil { |
| + t.Fatal("retry", e) |
| + } |
| + if e := tx.restore(t.Context(), false); e != nil { |
| + t.Fatal("restore", e) |
| + } |
| +} |
diff --git a/internal/provision/activation_tools_census_test.go b/internal/provision/activation_tools_census_test.go| index b80c99784..8c90ebd5b 100644 |
| --- a/internal/provision/activation_tools_census_test.go |
| +++ b/internal/provision/activation_tools_census_test.go |
| @@ -5,5 +5,7 @@ package provision |
| func plan14ActivationToolsWriteCensusInventory() []censusEntry { |
| return append(plan14P3WriteCensusInventory(), censusEntry{ |
| "internal/claimactivation/runner.go", "checkedStore.Put", "nonrecord", "def:Put,sel:Put", "26c6e1f7cb2a057ef8b19deb30bc18fd62fe08f81ff48fd4022bdfc3dd22d6b0", |
| + }, censusEntry{ |
| + "internal/host/activation_console_report_linux.go", "publishActivationConsoleReport", "nonrecord", "sel:Put", "f33733cab3dfbeede384906fdfb4649305dbaa27f756964df2abccdab8408cbb", |
| }) |
| } |
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv| index 3af5ef902..2f6467056 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv |
| @@ -1,7 +1,7 @@ |
| -# Activation-tools run 1: proposals only; loaded after all existing overlays. |
| +# Activation-tools runs 1–2: proposals only; loaded after all existing overlays. |
| @coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a |
| @coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2 |
| -@coverage internal/host e159204d107057af3a44f7e4015dd969e5e5744d71e27172a0ccb69011671e81 |
| +@coverage internal/host 0592dc882f4945b1a9319f8a7687406c87a38cea8d562cba82098360981d1b0d |
| @coverage internal/hostfence 552570510fb62d2a8822a3f24fa76aeb1437c0a9516966dc5ce15ef58e3fb4c6 |
| internal/claimactivation/runner.go Runner.Run authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@310,file:Read@5986,file:Read@6318 683059aedf27656d4b49c88102089b2bb2f2ce7fcc108ff8545e52a86a0f8385 |
| internal/claimactivation/runner.go checkedStore.Delete authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put 6bc9103a30cfd53cea59d611ccacb7dcc0ca0ea620b403497ef543f5bdaa0217 |
| @@ -9,7 +9,7 @@ internal/claimactivation/runner.go checkedStore.Get authority F,G,J4 rev3=PROPOS |
| internal/claimactivation/runner.go checkedStore.Put authority F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=trusted operator phases bind expected signed roster revision, exact deployment and actors; verify each write; dry-run overlay never calls backing Put file:Read@1357 26c6e1f7cb2a057ef8b19deb30bc18fd62fe08f81ff48fd4022bdfc3dd22d6b0 |
| internal/host/activation_command.go RunSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call cc8289fbcb3f34d8d43cd912a6a43ace727ed7289c6c32c5d35c8d00e6194cf3 |
| internal/host/activation_command_linux.go activationProcessesDrained authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:ReadDir@219,file:Stat@458 104e95618024c8f84acb7f18c39cdc18ac6834d34242a075f4652fdb6ed84e75 |
| -internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Read@1518 ed3cfa4f82a5b8b918d8c52cd58c979b0c451e3e34a5b20076b1744ea695c55c |
| +internal/host/activation_command_linux.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Read@1518 477332359355b840ac90d65e4f05ce829e19408e813baefdb7d1c167e381f172 |
| internal/host/activation_command_other.go runSystemClaims authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call fcf45d665275c2ac99ff9bfc37302217bb6134b34ff74c3b17f50d1348760151 |
| internal/host/activation_console.go activationConsoleEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 9d1144ac45e5849f43872d12ac6389c620ad07f0b2e69575a1353bfaf218b9fd |
| internal/host/activation_install.go activationEnvironment authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 289ff3c1267e613afd102f83f68592bfd99dafc3d6b7eaba05e630e6b2e7be3e |
| @@ -28,9 +28,9 @@ internal/host/activation_offline_linux.go activationOffline.dirs authority G,J4 |
| internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 34292b297a697dbb9fd4c1b067900a7cdcdd0b41dba0d7c7dc9764f633424e43 |
| internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861 |
| internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1 |
| -internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call b6a9e2446fa1a163a2afdd039ca6dad27260ffbb099c91d4a9fcbc487d065470 |
| +internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 2f782555914ab53ca36f53ed9bd881b9d8d55225c3828941b4733921d52d77a0 |
| internal/host/activation_offline_linux.go activationOffline.replace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb |
| -internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call db8fea8f8ec23ca29f763d6e8653e6b82f8abc0fba79e4a969b86198e3f8a5e5 |
| +internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call f552912d3051631061eca2e2f2b2e228bb5e5b795d12bf464d31207596d7ad41 |
| internal/host/activation_offline_linux.go activationOffline.save authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7 |
| internal/host/activation_offline_linux.go activationOffline.status authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 22467426d7d4cdb3d3f41dfab898f5a5ee49c34a58d4489ab53d6f04ab213587 |
| internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 946bc600ded790992d034479eb50b5363a3c9c50e3a3b47693e90318c357c8cc |
| @@ -43,3 +43,13 @@ internal/hostfence/activation_credentials.go activationCredentialReader authorit |
| internal/hostfence/writer.go OpenRootFenceStore authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal file:Stat@346 ec25dedc946b9be45336b89962a9b55f940cb24e747c4f3284d8fc3458e27482 |
| system.go runSystem authority B,C,D,F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=adds explicit system claims operator dispatch; every existing command branch unchanged 840490df69e8afea3148a1bff69b98d8e0b047adab82a209a8d5554fd09c4a14 |
| system.go systemVerb authority B,C,D,F,G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=adds explicit system claims operator dispatch; every existing command branch unchanged eb6c83689a425682db92ef36b4012f42a10ebaa94cfe731daedf5e4cc8f64218 |
| +internal/host/activation_console_report_linux.go activationConsolePID authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call e8ebb8a006f4b8407e377ddb01e39162e0bbc5d88f656bfc07cc4220828281c2 |
| +internal/host/activation_console_report_linux.go activationConsoleProcess authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call file:Fstat@5748,file:Open@5443,file:ReadFile@3554,file:ReadFile@3703,file:ReadFile@4156,file:ReadFile@4369,file:ReadFile@4520,file:Stat@4002,file:Stat@5543,file:Stat@5614 29235a700ba8fece0602b97a216a4db9d0d9471e3d59a2da4e913f443fbfa923 |
| +internal/host/activation_console_report_linux.go publishActivationConsoleReport authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=signed roster and identity, protected guard and configuration, live systemd MainPID plus proc boot/namespace/start/cgroup/executable; exact private box report CAS and readback; no automatic call file:Read@2593,file:Read@296 f33733cab3dfbeede384906fdfb4649305dbaa27f756964df2abccdab8408cbb |
| +internal/host/activation_seed_linux.go activationOffline.seed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=owned offline transaction only; durable seed override precedes unmask; later overrides refused; mask restored before override removal; interrupted seed blocks ordinary restore; no automatic call 8a61e0669aa3e05bccb202f940c1b87ff65a1a73668fbeb7c435ae673cadeb9b |
| +internal/host/activation_seed_linux.go activationSeedDropIns authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=owned offline transaction only; durable seed override precedes unmask; later overrides refused; mask restored before override removal; interrupted seed blocks ordinary restore; no automatic call file:ReadDir@636 261aaac4d9c60efbb6612857a6a47c8b1d69883cd385be242e994f81c1b972cf |
| +internal/host/activation_seed_linux.go activationSeedPreflight authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=owned offline transaction only; durable seed override precedes unmask; later overrides refused; mask restored before override removal; interrupted seed blocks ordinary restore; no automatic call 3d5596c6bb0cb4a9758867c249828915b15c2a5e4822429308e7d71d2b3c3f61 |
| +internal/host/activation_seed_linux.go const activationSeedDropIn authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=owned offline transaction only; durable seed override precedes unmask; later overrides refused; mask restored before override removal; interrupted seed blocks ordinary restore; no automatic call e21d702f24a9406e5a889041647e2f24185a6d06b7a8a1d0288c77b5359a85f6 |
| +internal/host/activation_seed_linux.go const activationSeedUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=owned offline transaction only; durable seed override precedes unmask; later overrides refused; mask restored before override removal; interrupted seed blocks ordinary restore; no automatic call fa0d60fded66a0b3f7114c07e958d820325627923814e03e21e8b018ab61894e |
| +@coverage scripts 9285af7eaf41c3b5962d6bd41c2aad94a2811b0cda46b09b9c9cd76f6c2a5aed |
| +scripts/activation-tools-root-runner.sh external diagnostic G,J4 rev3=PROPOSED-activation-tools;identity=authorized root-runner operator;basis=builds real console and CLI into test-only embedded images; serial designated runner invocation; deletes only owned build artifacts file:external@0 b96384ea9faabd7db0f541bbe1566803e69013d72e12cf2fb12f029271ab9b71 |
diff --git a/scripts/activation-tools-root-runner.sh b/scripts/activation-tools-root-runner.sh| new file mode 100644 |
| index 000000000..d2ffe2b35 |
| --- /dev/null |
| +++ b/scripts/activation-tools-root-runner.sh |
| @@ -0,0 +1,26 @@ |
| +#!/usr/bin/env bash |
| +set -euo pipefail |
| +runner=$(realpath "$1") |
| +shift |
| +cd "$(dirname "$0")/.." |
| +worktree=$(pwd) |
| +scratch=$(mktemp -d) |
| +artifacts=() |
| +trap 'rm -f -- "${artifacts[@]}"; rm -rf -- "$scratch"' EXIT |
| +export GOFLAGS=-buildvcs=false |
| +unset GOCACHE |
| +for component in console carlos; do |
| + artifact=internal/host/activation-$component-runner.bin |
| + test ! -e "$artifact" |
| + package=. |
| + if [ "$component" = console ]; then package=./cmd/console; fi |
| + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 nice -n 10 go build -ldflags '-X github.com/carlosframework/platform/internal/version.Build=v1.0.0' -o "$scratch/$component" "$package" |
| + python3 - "$scratch/$component" "$artifact" <<'PY' |
| +import shutil,sys |
| +with open(sys.argv[1], 'rb') as src, open(sys.argv[2], 'xb') as dst: |
| + shutil.copyfileobj(src,dst) |
| +PY |
| + artifacts+=("$artifact") |
| + sha256sum "$artifact" |
| +done |
| +nice -n 10 python3 -u "$runner" --worktree "$worktree" --pkg ./internal/host --tags rootrunner,activationconsole "$@" |