Claim activation tools, run 3: review fixes (not for merge)
Guarded seed: journaled replacement of the edge's dependency-bearing configuration with exact restore and remask on failure, proven against a guard-armed edge through interruption and reboot; restore reconciles active and inactive trigger states across reboot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+823
−41
internal/host/activation_command_linux.go+10 −8internal/host/activation_console_rootrunner_linux_test.go+5 −0internal/host/activation_offline_linux.go+75 −18internal/host/activation_offline_rootrunner_linux_test.go+122 −0internal/host/activation_seed_cli_rootrunner_linux_test.go+297 −0internal/host/activation_seed_linux.go+179 −3internal/host/activation_seed_rootrunner_linux_test.go+119 −3internal/provision/testdata/plan14-root-reads-activation-tools.tsv+16 −9
CI passed — run details
CI log
=== gate ===
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading golang.org/x/crypto v0.53.0
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/superfly/ltx v0.5.1
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.0.1
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading golang.org/x/net v0.56.0
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading golang.org/x/sync v0.21.0
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/pion/transport/v4 v4.0.1
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/text v0.38.0
go: downloading github.com/pion/transport/v3 v3.0.8
go: downloading github.com/pion/dtls/v3 v3.0.7
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
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 ./...
go: downloading golang.org/x/tools v0.47.0
go: downloading golang.org/x/mod v0.37.0
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 320.186s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.808s
ok github.com/carlosframework/platform/cmd/moveprobe 92.380s
ok github.com/carlosframework/platform/cmd/zddprobe 0.712s
ok github.com/carlosframework/platform/internal/activator 250.291s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.020s
ok github.com/carlosframework/platform/internal/appconfig 2.489s
ok github.com/carlosframework/platform/internal/apperr 0.072s
ok github.com/carlosframework/platform/internal/applog 0.129s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.394s
? 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.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.703s
ok github.com/carlosframework/platform/internal/changelog 0.311s
ok github.com/carlosframework/platform/internal/claimactivation 0.026s
ok github.com/carlosframework/platform/internal/clamd 0.058s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 399.507s
? 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 5.916s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.003s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.381s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 27.620s
? 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.135s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.014s
ok github.com/carlosframework/platform/internal/geo 0.015s
ok github.com/carlosframework/platform/internal/geoip 0.032s
ok github.com/carlosframework/platform/internal/historyreadback 0.168s
ok github.com/carlosframework/platform/internal/host 182.436s
ok github.com/carlosframework/platform/internal/hostactors 0.413s
ok github.com/carlosframework/platform/internal/hostbox 2.831s
ok github.com/carlosframework/platform/internal/hostclaim 0.635s
ok github.com/carlosframework/platform/internal/hostevidence 0.007s
ok github.com/carlosframework/platform/internal/hostexec 5.249s
ok github.com/carlosframework/platform/internal/hostfence 0.031s
ok github.com/carlosframework/platform/internal/hostfiles 10.818s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.051s
ok github.com/carlosframework/platform/internal/hostops 1.687s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.004s
ok github.com/carlosframework/platform/internal/hostquarantine 0.002s
ok github.com/carlosframework/platform/internal/hostrelocate 0.574s
ok github.com/carlosframework/platform/internal/hostresolve 0.138s
ok github.com/carlosframework/platform/internal/hostwrite 0.007s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.254s
ok github.com/carlosframework/platform/internal/instanceexec 2.751s
ok github.com/carlosframework/platform/internal/instancefs 0.006s
ok github.com/carlosframework/platform/internal/instancekey 0.002s
ok github.com/carlosframework/platform/internal/instanceupdates 0.009s
ok github.com/carlosframework/platform/internal/layout 1.629s
ok github.com/carlosframework/platform/internal/lease 9.865s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.006s
ok github.com/carlosframework/platform/internal/ledger 0.027s
ok github.com/carlosframework/platform/internal/mail 0.186s
ok github.com/carlosframework/platform/internal/maintenance 0.082s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.650s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.085s
ok github.com/carlosframework/platform/internal/metrics 0.009s
ok github.com/carlosframework/platform/internal/moves 21.630s
ok github.com/carlosframework/platform/internal/moves/archive 0.908s
ok github.com/carlosframework/platform/internal/objstore 0.199s
ok github.com/carlosframework/platform/internal/ops 0.176s
ok github.com/carlosframework/platform/internal/park 0.211s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 93.955s
ok github.com/carlosframework/platform/internal/publicrelease 0.014s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.846s
ok github.com/carlosframework/platform/internal/relay 7.284s
ok github.com/carlosframework/platform/internal/release 23.200s
ok github.com/carlosframework/platform/internal/rollout 3.697s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.058s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/runs 0.588s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.545s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.951s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.541s
ok github.com/carlosframework/platform/internal/status 0.057s
ok github.com/carlosframework/platform/internal/steering 0.007s
ok github.com/carlosframework/platform/internal/stopevidence 0.305s
ok github.com/carlosframework/platform/internal/svcrole 0.006s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.006s
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.005s
ok github.com/carlosframework/platform/internal/usage 0.006s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.647s
=== 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: 48693410 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.026s
=== RUN TestT4StorageProbeMinio
2026/10/16 16:41:09 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF1017650C6ED4, 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.022s
minio contract: PASS
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go| index b3dc4fcbe..ae72accb5 100644 |
| --- a/internal/host/activation_command_linux.go |
| +++ b/internal/host/activation_command_linux.go |
| @@ -259,17 +259,12 @@ func runSystemClaims(args []string, out io.Writer) error { |
| 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", "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 |
| - } |
| + var err error |
| 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 |
| - } |
| + tx.seedPreflight = activationSeedPreflight |
| err = tx.seed(ctx, []byte(activationSeedUnit), *dry) |
| } else if args[0] == "offline-begin" { |
| err = tx.begin(ctx, *dry) |
| @@ -279,12 +274,19 @@ func runSystemClaims(args []string, out io.Writer) error { |
| if err != nil { |
| return err |
| } |
| + preview, err := tx.preview(ctx) |
| + if err != nil { |
| + return err |
| + } |
| 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["guard_dependency_path"] = "/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf" |
| + result["guard_dependency_replacement"] = activationSeedGuardReplacement |
| + result["guard_original_path"] = tx.state + "/90-host-floor.conf.original" |
| 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"} |
| + result["actions"] = []string{"journal-guard-original", "replace-guard-dependency", "install-override", "restore-edge-unit", "reload", "verify-seed-command", "start-seed", "save-result", "stop-seed", "mask-edge", "reload", "restore-guard-original", "remove-override", "reload"} |
| } |
| return encode(result) |
| default: |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| index 7b0d5e17e..c54084b5e 100644 |
| --- a/internal/host/activation_console_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -151,6 +151,11 @@ func TestRootActivationConsoleRuntime(t *testing.T) { |
| if output, err := cmd.CombinedOutput(); err != nil { |
| t.Fatal("guard arm", string(output), err) |
| } |
| + installedGuard, err := os.ReadFile("/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf") |
| + check(err) |
| + if string(installedGuard) != activationSeedGuardOriginal { |
| + t.Fatal("seed fixture differs from guard-arm output") |
| + } |
| dbPath := d + "/data/console.db" |
| st, err := console.Open(dbPath) |
| check(err) |
diff --git a/internal/host/activation_offline_linux.go b/internal/host/activation_offline_linux.go| index 2fb6806b1..914b1e4ec 100644 |
| --- a/internal/host/activation_offline_linux.go |
| +++ b/internal/host/activation_offline_linux.go |
| @@ -26,10 +26,11 @@ func activationOfflineUnits() []string { |
| } |
| type activationOffline struct { |
| - units, state string |
| - systemctl func(context.Context, ...string) ([]byte, error) |
| - drained func(context.Context) error |
| - checkpoint func(string) error |
| + units, state string |
| + systemctl func(context.Context, ...string) ([]byte, error) |
| + drained func(context.Context) error |
| + seedPreflight func(context.Context) error |
| + checkpoint func(string) error |
| } |
| type activationOfflineItem struct { |
| LoadState string |
| @@ -42,10 +43,11 @@ type activationOfflineItem struct { |
| Digest string |
| } |
| type activationOfflineRecord struct { |
| - V int |
| - Units string |
| - Phase string |
| - Items []activationOfflineItem |
| + V int |
| + Units string |
| + Phase string |
| + Items []activationOfflineItem |
| + SeedGuard *activationOfflineItem `json:",omitempty"` |
| } |
| func (t activationOffline) point(name string) error { |
| @@ -417,7 +419,7 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| } |
| pending := r.Items |
| if r.Phase == "released" { |
| - pending = r.Items[len(r.Items)-1:] |
| + pending = append(append([]activationOfflineItem(nil), r.Items[:len(activationTriggers)]...), r.Items[len(r.Items)-1]) |
| } |
| for _, item := range pending { |
| if err = t.checkItem(item); err != nil { |
| @@ -444,36 +446,88 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| return err |
| } |
| // Once triggers run, adopt may replace a restored unit. Relinquish those |
| - // originals durably before starting any trigger; only the edge remains ours. |
| + // service files durably before starting any trigger. Trigger files and |
| + // their active states remain ours until done, as does the edge. |
| r.Phase = "released" |
| if err = t.save(state, r); err != nil { |
| return err |
| } |
| } |
| - start := func(item activationOfflineItem) error { |
| - if !item.Active || item.Masked { |
| + reconcile := func(item activationOfflineItem, owned bool) error { |
| + if owned { |
| + if err := t.checkItem(item); err != nil { |
| + return err |
| + } |
| + } else if !item.Active || item.Masked { |
| return nil |
| } |
| - if _, err := t.systemctl(ctx, "start", item.Name); err != nil { |
| + verb := "stop" |
| + if item.Active && !item.Masked { |
| + verb = "start" |
| + } |
| + if _, err := t.systemctl(ctx, verb, item.Name); err != nil { |
| return err |
| } |
| - if err := t.point("start-" + item.Name); err != nil { |
| + if err := t.point(verb + "-" + item.Name); err != nil { |
| return err |
| } |
| load, active, err := t.status(ctx, item.Name) |
| if err != nil { |
| return err |
| } |
| - if load != "loaded" || active != "active" { |
| + wantLoad, wantActive := "loaded", "inactive" |
| + if item.Masked { |
| + wantLoad = "masked" |
| + } |
| + if item.Active && !item.Masked { |
| + wantActive = "active" |
| + } |
| + if load != wantLoad || active != wantActive { |
| return errActivation |
| } |
| return nil |
| } |
| - for _, item := range r.Items[:len(r.Items)-1] { |
| - if err = start(item); err != nil { |
| + // Stop originally inactive triggers before starting any of the others. |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if !item.Active || item.Masked { |
| + if err = reconcile(item, true); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + for i, item := range r.Items[:len(r.Items)-1] { |
| + if i < len(activationTriggers) && (!item.Active || item.Masked) { |
| + continue |
| + } |
| + if err = reconcile(item, i < len(activationTriggers)); err != nil { |
| return err |
| } |
| } |
| + verifyTriggers := func() error { |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if err := t.checkItem(item); err != nil { |
| + return err |
| + } |
| + load, active, err := t.status(ctx, item.Name) |
| + if err != nil { |
| + return err |
| + } |
| + wantLoad, wantActive := "loaded", "inactive" |
| + if item.Masked { |
| + wantLoad = "masked" |
| + } |
| + if item.Active && !item.Masked { |
| + wantActive = "active" |
| + } |
| + if load != wantLoad || active != wantActive { |
| + return errActivation |
| + } |
| + } |
| + return nil |
| + } |
| + if err = verifyTriggers(); err != nil { |
| + return err |
| + } |
| edge := r.Items[len(r.Items)-1] |
| if err = t.replace(units, state, edge, true); err != nil { |
| return err |
| @@ -484,7 +538,10 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if err = t.point("edge-reload"); err != nil { |
| return err |
| } |
| - if err = start(edge); err != nil { |
| + if err = reconcile(edge, true); err != nil { |
| + return err |
| + } |
| + if err = verifyTriggers(); err != nil { |
| return err |
| } |
| r.Phase = "done" |
diff --git a/internal/host/activation_offline_rootrunner_linux_test.go b/internal/host/activation_offline_rootrunner_linux_test.go| index 58ade0d48..2a522d380 100644 |
| --- a/internal/host/activation_offline_rootrunner_linux_test.go |
| +++ b/internal/host/activation_offline_rootrunner_linux_test.go |
| @@ -365,3 +365,125 @@ func TestRootActivationOfflineResumeAfterTriggerRepair(t *testing.T) { |
| t.Fatal("restored over new owner") |
| } |
| } |
| + |
| +func TestRootActivationRestoreInactiveTrigger(t *testing.T) { |
| + for _, point := range []string{"restore-reload", "journal-released"} { |
| + t.Run(point, func(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + if out, err := tx.systemctl(t.Context(), "stop", "carlos-adopt.timer"); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + injected := errors.New("restore interruption") |
| + tx.checkpoint = func(name string) error { |
| + if name == point { |
| + return injected |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, injected) { |
| + t.Fatal(err) |
| + } |
| + tx.checkpoint = nil |
| + if out, err := tx.systemctl(t.Context(), "start", "carlos-adopt.timer"); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, active, err := tx.status(t.Context(), "carlos-adopt.timer") |
| + if err != nil || active != "inactive" { |
| + t.Fatal("originally inactive timer still running", active, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootActivationRestoreReleasedTriggerOwnership(t *testing.T) { |
| + tx, _ := offlineFixture(t) |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + injected := errors.New("released") |
| + tx.checkpoint = func(name string) error { |
| + if name == "journal-released" { |
| + return injected |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, injected) { |
| + t.Fatal(err) |
| + } |
| + tx.checkpoint = nil |
| + p := filepath.Join(tx.units, "carlos-adopt.timer") |
| + if err := os.WriteFile(p+".operator", []byte("operator replacement"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(p+".operator", p); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.restore(t.Context(), false); err == nil { |
| + t.Fatal("claimed authority over replaced trigger") |
| + } |
| + got, _ := os.ReadFile(p) |
| + if string(got) != "operator replacement" { |
| + t.Fatal("replacement changed") |
| + } |
| +} |
| + |
| +func TestRootRebootActivationRestorePhase1(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + if out, err := tx.systemctl(t.Context(), "stop", "carlos-adopt.timer"); err != nil { |
| + t.Fatal(string(out), err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + injected := errors.New("restore reboot window") |
| + tx.checkpoint = func(name string) error { |
| + if name == "journal-released" { |
| + return injected |
| + } |
| + return nil |
| + } |
| + if err := tx.restore(t.Context(), false); !errors.Is(err, injected) { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/boot", boot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestRootRebootActivationRestorePhase2(t *testing.T) { |
| + tx := activationOffline{units: "/etc/systemd/system", state: offlineRebootState + "/transaction", systemctl: offlineRealSystemctl} |
| + defer cleanupOfflineReal(t, tx) |
| + before, err := os.ReadFile(offlineRebootState + "/boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil || string(before) == string(after) { |
| + t.Fatal("no actual reboot", err) |
| + } |
| + _, active, err := tx.status(t.Context(), "carlos-adopt.timer") |
| + if err != nil || active != "active" { |
| + t.Fatal("enabled timer did not start on reboot", active, err) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-adopt.timer") |
| + if err != nil || active != "inactive" { |
| + t.Fatal("inactive state lost", active, err) |
| + } |
| + _, active, err = tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || active != "active" { |
| + t.Fatal("edge not restored", active, err) |
| + } |
| +} |
diff --git a/internal/host/activation_seed_cli_rootrunner_linux_test.go b/internal/host/activation_seed_cli_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..0a0fb6855 |
| --- /dev/null |
| +++ b/internal/host/activation_seed_cli_rootrunner_linux_test.go |
| @@ -0,0 +1,297 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The operator CLI is the real built binary. The signed seed payload is a |
| +// local fixture: it accepts the exact argv, can block for interruption, and |
| +// never contacts a deployment. Files-seed data effects have separate witnesses. |
| +const activationSeedCLIPayload = `#!/bin/sh |
| +set -eu |
| +test "$*" = 'system files seed' |
| +echo started > /etc/carlos-activation-rootrunner/payload-started |
| +while test -f /etc/carlos-activation-rootrunner/payload-wait; do sleep 0.1; done |
| +exit 0 |
| +` |
| + |
| +func setupActivationSeedCLI(t *testing.T) activationOffline { |
| + t.Helper() |
| + if len(activationCarlosImage) == 0 { |
| + t.Fatal("requires embedded real CLI") |
| + } |
| + generationRunnerRegistryUser(t) |
| + for _, path := range []string{"/etc/carlos", "/opt/carlos", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-instance-control.service", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} { |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("existing fixture path", path, err) |
| + } |
| + } |
| + tx := setupOfflineReal(t) |
| + tx.state = "/etc/carlos/claim-activation/offline" |
| + write := func(path string, b []byte, mode os.FileMode) { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, b, mode); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write(offlineRebootState+"/carlos-cli", activationCarlosImage, 0755) |
| + write("/opt/carlos/carlos", activationCarlosImage, 0755) |
| + write("/opt/carlos/carlos.prev", activationCarlosImage, 0755) |
| + pub, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + signer := release.RootSigner(key) |
| + const bucket = "activation-seed-fixture" |
| + store := release.NewDirStore(offlineRebootState + "/bucket") |
| + 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)}}} |
| + if _, err := release.PromoteSystem(t.Context(), store, signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + identity, _, err := store.Get(t.Context(), release.SystemPointerKey("router", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + floor, _ := json.Marshal(hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}) |
| + auth, err := release.SignRootEnvelope(signer, hostfloor.Authorization{Schema: 1, Purpose: "moves-protocol", DeploymentBucket: bucket, Key: hostfloor.Key, SHA256: hostfloor.Digest(floor)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: bucket, Mode: "armed", Floor: floor, Authorization: auth}) |
| + write("/etc/carlos/host.env", []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\nCARLOS_DEPLOYMENT_BUCKET="+bucket+"\nCARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub)+"\n"), 0600) |
| + if err := (hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}).Initialize(t.Context(), signer); err != nil { |
| + t.Fatal(err) |
| + } |
| + local := hostfloor.Enrollment{Box: "seed-fixture", Enrollment: strings.Repeat("a", 32), InstanceID: "i-fixture"} |
| + invitation, err := release.SignRootEnvelope(signer, hostactors.Invitation{V: 1, Bucket: bucket, Reference: local.Box, Enrollment: local.Enrollment, EvidenceSHA256: strings.Repeat("b", 64)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(t.Context(), "control/host-actor-invitations/"+local.Box+".json", invitation, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + localRaw, _ := json.Marshal(local) |
| + write(offlineRebootState+"/enrollment.json", localRaw, 0600) |
| + write(offlineRebootState+"/policy.json", policy, 0600) |
| + write(offlineRebootState+"/identity.json", identity, 0600) |
| + // The real guard-arm installs the dependency-bearing drop-in. Its signed |
| + // roster invitation and loopback S3 endpoint are fixture authorization. |
| + if err := os.Mkdir(offlineRebootState+"/data", 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + endpoint := activationS3Fixture(t, store) |
| + arm := exec.CommandContext(t.Context(), offlineRebootState+"/carlos-cli", "system", "guard-arm", "--policy", offlineRebootState+"/policy.json", "--identity", offlineRebootState+"/identity.json", "--enrollment", offlineRebootState+"/enrollment.json") |
| + arm.Dir = offlineRebootState |
| + arm.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "HOME=" + offlineRebootState, "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_DATA_DIR=" + offlineRebootState + "/data", "AWS_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "CARLOS_S3_ENDPOINT=" + endpoint} |
| + if out, err := arm.CombinedOutput(); err != nil { |
| + t.Fatal("guard-arm", string(out), err) |
| + } |
| + guard, err := os.ReadFile(filepath.Join(tx.units, "carlos-edge.service.d/90-host-floor.conf")) |
| + if err != nil || string(guard) != activationSeedGuardOriginal { |
| + t.Fatal("unexpected production guard", err) |
| + } |
| + // Authorize a harmless payload separately; the operator and installed guard |
| + // remain real binaries. No production signing key or deployment is used. |
| + rel.Version, rel.Source, rel.ReleaseTag = "v1.0.1", "release:v1.0.1", "v1.0.1" |
| + rel.Artifacts["linux-arm64"] = release.SystemArtifact{Key: release.SystemArtifactKey("v1.0.1", "linux-arm64"), SHA256: hostfloor.Digest([]byte(activationSeedCLIPayload))} |
| + if _, err := release.PromoteSystem(t.Context(), store, signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + payloadIdentity, _, err := store.Get(t.Context(), release.SystemPointerKey("router", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + write("/opt/carlos/system/guard/identities/v1.0.1.json", payloadIdentity, 0644) |
| + write("/opt/carlos/carlos", []byte(activationSeedCLIPayload), 0755) |
| + write(filepath.Join(tx.units, claimControlUnit), []byte("[Service]\nExecStart=/usr/bin/sleep infinity\n[Install]\nWantedBy=multi-user.target\n"), 0644) |
| + servingRunnerSystemctl(t, "stop", "carlos-edge.service") |
| + servingRunnerSystemctl(t, "daemon-reload") |
| + servingRunnerSystemctl(t, "enable", "--now", claimControlUnit) |
| + activationSeedCLI(t, "offline-begin") |
| + return tx |
| +} |
| +func activationSeedCLICommand(t *testing.T, args ...string) *exec.Cmd { |
| + t.Helper() |
| + cmd := exec.CommandContext(t.Context(), offlineRebootState+"/carlos-cli", append([]string{"system", "claims"}, args...)...) |
| + cmd.Dir = offlineRebootState |
| + cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "HOME=" + offlineRebootState, "AWS_EC2_METADATA_DISABLED=true"} |
| + return cmd |
| +} |
| +func activationSeedCLI(t *testing.T, args ...string) { |
| + t.Helper() |
| + out, err := activationSeedCLICommand(t, args...).CombinedOutput() |
| + if err != nil { |
| + t.Fatal(args, string(out), err) |
| + } |
| +} |
| +func cleanupActivationSeedCLI(t *testing.T, tx activationOffline) { |
| + t.Helper() |
| + offlineRealSystemctl(context.Background(), "disable", "--now", claimControlUnit) |
| + cleanupOfflineReal(t, tx) |
| + for _, path := range []string{"/etc/carlos", "/opt/carlos", filepath.Join(tx.units, "carlos-edge.service.d"), filepath.Join(tx.units, claimControlUnit), filepath.Join(tx.units, "carlos-system-update.service.d"), filepath.Join(tx.units, "carlos-system-recover.service.d")} { |
| + os.RemoveAll(path) |
| + } |
| + offlineRealSystemctl(context.Background(), "daemon-reload") |
| +} |
| +func assertActivationSeedCLIHeld(t *testing.T, tx activationOffline) { |
| + t.Helper() |
| + load, active, err := tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || load != "masked" || active != "inactive" { |
| + t.Fatal("edge not held", load, active, err) |
| + } |
| + guard, err := os.ReadFile(filepath.Join(tx.units, "carlos-edge.service.d/90-host-floor.conf")) |
| + if err != nil || string(guard) != activationSeedGuardOriginal { |
| + t.Fatal("guard bytes not restored", err) |
| + } |
| + state, err := activationDir(tx.state, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer state.Close() |
| + r, err := tx.read(state) |
| + if err != nil || r.Phase != "held" || r.SeedGuard == nil { |
| + t.Fatal("seed journal", r.Phase, err) |
| + } |
| + g := activationOffline{units: filepath.Join(tx.units, "carlos-edge.service.d"), state: tx.state} |
| + if err := g.checkItem(*r.SeedGuard); err != nil { |
| + t.Fatal("guard metadata/identity changed", err) |
| + } |
| +} |
| +func startActivationSeedCLIBlocked(t *testing.T) *exec.Cmd { |
| + t.Helper() |
| + if err := os.WriteFile(offlineRebootState+"/payload-wait", nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(offlineRebootState + "/payload-started") |
| + cmd := activationSeedCLICommand(t, "offline-seed") |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + deadline := time.Now().Add(20 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if _, err := os.Stat(offlineRebootState + "/payload-started"); err == nil { |
| + return cmd |
| + } |
| + time.Sleep(50 * time.Millisecond) |
| + } |
| + cmd.Process.Kill() |
| + cmd.Wait() |
| + t.Fatal("seed payload did not start") |
| + return nil |
| +} |
| +func TestRootActivationSeedCLI(t *testing.T) { |
| + tx := setupActivationSeedCLI(t) |
| + defer cleanupActivationSeedCLI(t, tx) |
| + activationSeedCLI(t, "offline-seed", "--dry-run") |
| + activationSeedCLI(t, "offline-seed") |
| + assertActivationSeedCLIHeld(t, tx) |
| + result, err := os.ReadFile(filepath.Join(tx.state, "seed-result.txt")) |
| + if err != nil || !bytes.Contains(result, []byte("ExecMainStatus=0")) { |
| + t.Fatal("missing successful seed", string(result), err) |
| + } |
| + interruptActivationSeedCLI(t, tx) |
| + activationSeedCLI(t, "offline-restore") |
| +} |
| +func interruptActivationSeedCLI(t *testing.T, tx activationOffline) { |
| + t.Helper() |
| + cmd := startActivationSeedCLIBlocked(t) |
| + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cmd.Wait(); err == nil { |
| + t.Fatal("interrupted CLI succeeded") |
| + } |
| + load, active, err := tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || load != "masked" || active != "inactive" && active != "failed" { |
| + t.Fatal("cancel did not remask", load, active, err) |
| + } |
| + os.Remove(offlineRebootState + "/payload-wait") |
| + activationSeedCLI(t, "offline-seed") |
| + assertActivationSeedCLIHeld(t, tx) |
| +} |
| +func TestRootRebootActivationSeedCLIPhase1(t *testing.T) { |
| + tx := setupActivationSeedCLI(t) |
| + cmd := startActivationSeedCLIBlocked(t) |
| + if err := cmd.Process.Kill(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cmd.Wait(); err == nil { |
| + t.Fatal("killed CLI succeeded") |
| + } |
| + load, err := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=LoadState", "--value") |
| + if err != nil || strings.TrimSpace(string(load)) != "loaded" { |
| + t.Fatal("crash did not leave unmask window", string(load), err) |
| + } |
| + guard, err := os.ReadFile(filepath.Join(tx.units, "carlos-edge.service.d/90-host-floor.conf")) |
| + if err != nil || string(guard) != activationSeedGuardReplacement { |
| + t.Fatal("dependency replacement not durable", err) |
| + } |
| + invocation, err := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=InvocationID", "--value") |
| + if err != nil || strings.TrimSpace(string(invocation)) == "" { |
| + t.Fatal("missing pre-reboot invocation", err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/invocation", invocation, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(offlineRebootState+"/boot", boot, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +func TestRootRebootActivationSeedCLIPhase2(t *testing.T) { |
| + tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: offlineRealSystemctl} |
| + defer cleanupActivationSeedCLI(t, tx) |
| + before, err := os.ReadFile(offlineRebootState + "/boot") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil || bytes.Equal(before, after) { |
| + t.Fatal("no actual reboot", err) |
| + } |
| + unit, err := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=Type,ExecStart,Requires") |
| + if err != nil || !strings.Contains(string(unit), "Type=oneshot") || !strings.Contains(string(unit), "/opt/carlos/carlos system files seed") || strings.Contains(string(unit), "carlos-system-recover.service") { |
| + t.Fatal("wrong reboot command", string(unit), err) |
| + } |
| + previous, err := os.ReadFile(offlineRebootState + "/invocation") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + current, err := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=InvocationID", "--value") |
| + if err != nil || strings.TrimSpace(string(current)) == "" || bytes.Equal(previous, current) { |
| + t.Fatal("no new seed invocation after reboot", string(current), err) |
| + } |
| + pid, err := filesSeedEdgePID(t.Context()) |
| + if err != nil || pid <= 0 { |
| + t.Fatal("seed not running after reboot", pid, err) |
| + } |
| + t.Log("reboot started a new seed invocation", strings.TrimSpace(string(current)), "pid", pid) |
| + os.Remove(offlineRebootState + "/payload-wait") |
| + activationSeedCLI(t, "offline-seed") |
| + assertActivationSeedCLIHeld(t, tx) |
| + interruptActivationSeedCLI(t, tx) |
| + activationSeedCLI(t, "offline-restore") |
| +} |
diff --git a/internal/host/activation_seed_linux.go b/internal/host/activation_seed_linux.go| index 44c86f5f6..88bdcd768 100644 |
| --- a/internal/host/activation_seed_linux.go |
| +++ b/internal/host/activation_seed_linux.go |
| @@ -6,6 +6,7 @@ import ( |
| "bytes" |
| "context" |
| "errors" |
| + "io" |
| "os" |
| "path/filepath" |
| "runtime" |
| @@ -20,7 +21,6 @@ import ( |
| const activationSeedDropIn = "95-claim-activation-seed.conf" |
| const activationSeedUnit = `[Unit] |
| -Requires= |
| Requires=carlos-instance-control.service |
| After=carlos-instance-control.service |
| OnSuccess= |
| @@ -79,7 +79,7 @@ func activationSeedPreflight(ctx context.Context) error { |
| // 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 { |
| +func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) (resultErr error) { |
| units, state, err := t.dirs(false) |
| if err != nil { |
| return err |
| @@ -104,6 +104,28 @@ func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) e |
| if edge.Name != "carlos-edge.service" || edge.Masked { |
| return errActivation |
| } |
| + // Cleanup also covers reload, verification and cancellation failures. A killed |
| + // process leaves the durable seed configuration for the next invocation. |
| + if !dry { |
| + defer func() { |
| + if resultErr == nil { |
| + return |
| + } |
| + cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute) |
| + defer cancel() |
| + resultErr = errors.Join(resultErr, t.seedRemask(cleanup, units, state, edge)) |
| + }() |
| + } |
| + if !dry && r.Phase == "seed-prepared" { |
| + if err = t.seedRemask(ctx, units, state, edge); err != nil { |
| + return err |
| + } |
| + } |
| + if t.seedPreflight != nil { |
| + if err = t.seedPreflight(ctx); err != nil { |
| + return err |
| + } |
| + } |
| for _, item := range r.Items { |
| if err = t.checkItem(item); err != nil { |
| return err |
| @@ -143,6 +165,13 @@ func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) e |
| if err = activationSeedDropIns(t.units); err != nil { |
| return err |
| } |
| + guard, err := t.seedGuard(state, &r, dry) |
| + if err != nil { |
| + return err |
| + } |
| + if guard != nil { |
| + defer guard.Close() |
| + } |
| if dry { |
| return nil |
| } |
| @@ -152,6 +181,14 @@ func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) e |
| return err |
| } |
| } |
| + if guard != nil { |
| + if err = t.seedGuardReplace(guard, state, r, false); err != nil { |
| + return err |
| + } |
| + if err = t.point("seed-guard-replaced"); err != nil { |
| + return err |
| + } |
| + } |
| drop, err = activationDir(dropPath, true) |
| if err != nil { |
| return err |
| @@ -218,6 +255,14 @@ func (t activationOffline) seed(ctx context.Context, content []byte, dry bool) e |
| if err = t.point("seed-remasked"); err != nil { |
| return err |
| } |
| + if guard != nil { |
| + if err = t.seedGuardReplace(guard, state, r, true); err != nil { |
| + return err |
| + } |
| + if err = t.point("seed-guard-restored"); err != nil { |
| + return err |
| + } |
| + } |
| old, err := activationReadAt(drop, activationSeedDropIn, false) |
| if err != nil || !bytes.Equal(old, content) { |
| return errActivation |
| @@ -267,7 +312,7 @@ func activationSeedDropIns(units string) error { |
| dir.Close() |
| return errActivation |
| } |
| - if _, err := activationReadAt(dir, entry.Name(), false); err != nil { |
| + if _, err := activationSeedReadAt(dir, entry.Name()); err != nil { |
| dir.Close() |
| return err |
| } |
| @@ -277,3 +322,134 @@ func activationSeedDropIns(units string) error { |
| } |
| return nil |
| } |
| + |
| +// This is the exact guard-arm edge configuration. Only its recovery dependency |
| +// is suspended; the normal guard command remains until the seed override wins. |
| +const activationSeedGuardOriginal = "[Unit]\nRequires=carlos-system-recover.service\nAfter=carlos-system-recover.service\n[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n" |
| +const activationSeedGuardReplacement = "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n" |
| + |
| +func (t activationOffline) seedRemask(ctx context.Context, units, state *os.File, edge activationOfflineItem) error { |
| + // Validate ownership before stopping anything. Persist the mask even when |
| + // stop fails, so a reboot cannot start the serving command. |
| + if err := t.checkItem(edge); err != nil { |
| + return err |
| + } |
| + _, stopErr := t.systemctl(ctx, "stop", edge.Name) |
| + maskErr := t.replace(units, state, edge, false) |
| + _, reloadErr := t.systemctl(ctx, "daemon-reload") |
| + load, active, statusErr := t.status(ctx, edge.Name) |
| + if statusErr == nil && (load != "masked" || active != "inactive" && active != "failed") { |
| + statusErr = errActivation |
| + } |
| + return errors.Join(stopErr, maskErr, reloadErr, statusErr) |
| +} |
| + |
| +func (t activationOffline) seedGuard(state *os.File, r *activationOfflineRecord, dry bool) (*os.File, error) { |
| + dir, err := activationDir(filepath.Join(t.units, "carlos-edge.service.d"), false) |
| + if errors.Is(err, os.ErrNotExist) && r.SeedGuard == nil { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + fail := func(err error) (*os.File, error) { dir.Close(); return nil, err } |
| + const name = "90-host-floor.conf" |
| + if r.Phase == "seed-prepared" && r.SeedGuard != nil { |
| + if err := t.seedGuardCheck(r); err != nil { |
| + return fail(err) |
| + } |
| + return dir, nil |
| + } |
| + raw, err := activationSeedReadAt(dir, name) |
| + if errors.Is(err, os.ErrNotExist) && r.SeedGuard == nil { |
| + dir.Close() |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return fail(err) |
| + } |
| + if string(raw) != activationSeedGuardOriginal || r.Phase == "seed-prepared" { |
| + return fail(errActivation) |
| + } |
| + item, err := activationUnitSnapshot(filepath.Join(t.units, "carlos-edge.service.d", name), name) |
| + if err != nil { |
| + return fail(err) |
| + } |
| + if dry { |
| + dir.Close() |
| + return nil, nil |
| + } |
| + backup := filepath.Join(t.state, name+".original") |
| + if err = os.Link(filepath.Join(t.units, "carlos-edge.service.d", name), backup); err != nil && !errors.Is(err, os.ErrExist) { |
| + return fail(err) |
| + } |
| + if !sameActivationFile(filepath.Join(t.units, "carlos-edge.service.d", name), backup) { |
| + return fail(errActivation) |
| + } |
| + // The original inode, metadata and bytes remain outside systemd's search path. |
| + if err = safefile.WriteAt(int(state.Fd()), name+".mask", []byte(activationSeedGuardReplacement), 0644); err != nil { |
| + return fail(err) |
| + } |
| + if err = state.Sync(); err != nil { |
| + return fail(err) |
| + } |
| + r.SeedGuard = &item |
| + return dir, nil |
| +} |
| + |
| +func (t activationOffline) seedGuardCheck(r *activationOfflineRecord) error { |
| + if r.SeedGuard == nil || r.SeedGuard.Name != "90-host-floor.conf" || r.SeedGuard.Masked { |
| + return errActivation |
| + } |
| + g := activationOffline{units: filepath.Join(t.units, "carlos-edge.service.d"), state: t.state} |
| + if err := g.checkItem(*r.SeedGuard); err != nil { |
| + return err |
| + } |
| + state, err := activationDir(t.state, false) |
| + if err != nil { |
| + return err |
| + } |
| + defer state.Close() |
| + raw, err := activationSeedReadAt(state, r.SeedGuard.Name+".mask") |
| + if err != nil || string(raw) != activationSeedGuardReplacement { |
| + return errActivation |
| + } |
| + return nil |
| +} |
| + |
| +func (t activationOffline) seedGuardReplace(dir, state *os.File, r activationOfflineRecord, restore bool) error { |
| + if err := t.seedGuardCheck(&r); err != nil { |
| + return err |
| + } |
| + g := activationOffline{units: filepath.Join(t.units, "carlos-edge.service.d"), state: t.state, checkpoint: t.checkpoint} |
| + return g.replace(dir, state, *r.SeedGuard, restore) |
| +} |
| + |
| +// Transaction-owned originals and replacements deliberately have retained hard |
| +// links. Keep the ordinary installer reader's single-link restriction intact. |
| +func activationSeedReadAt(dir *os.File, name string) ([]byte, error) { |
| + if filepath.Base(name) != name { |
| + return nil, errActivation |
| + } |
| + fd, err := unix.Openat(int(dir.Fd()), name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + f := os.NewFile(uintptr(fd), name) |
| + defer f.Close() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return nil, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Uid != 0 || st.Mode&0022 != 0 { |
| + return nil, errActivation |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(f, 65537)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(raw) > 65536 { |
| + return nil, errActivation |
| + } |
| + return raw, nil |
| +} |
diff --git a/internal/host/activation_seed_rootrunner_linux_test.go b/internal/host/activation_seed_rootrunner_linux_test.go| index 7fe29f199..3ea6beeb4 100644 |
| --- a/internal/host/activation_seed_rootrunner_linux_test.go |
| +++ b/internal/host/activation_seed_rootrunner_linux_test.go |
| @@ -110,7 +110,7 @@ func TestRootActivationSequence(t *testing.T) { |
| 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"} { |
| + for _, point := range []string{"journal-seed-prepared", "stage-90-host-floor.conf", "replace-90-host-floor.conf", "seed-guard-replaced", "seed-guard-restored", "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) |
| @@ -119,6 +119,15 @@ func TestRootActivationSeedInterruptions(t *testing.T) { |
| t.Fatal("preexisting override directory", e) |
| } |
| defer os.RemoveAll(drop) |
| + if err := os.Mkdir(drop, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(drop, "90-host-floor.conf"), []byte(activationSeedGuardOriginal), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(drop, "91-runner.conf"), []byte("[Service]\nExecStart=\nExecStart=/usr/bin/sleep infinity\n"), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| if e := tx.begin(t.Context(), false); e != nil { |
| t.Fatal(e) |
| } |
| @@ -135,6 +144,9 @@ func TestRootActivationSeedInterruptions(t *testing.T) { |
| t.Fatal("missing interruption", e) |
| } |
| tx.checkpoint = nil |
| + if target, err := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")); err != nil || target != "/dev/null" { |
| + t.Fatal("failure left edge unmasked", err) |
| + } |
| if point != "journal-held" { |
| if e := tx.restore(t.Context(), false); e == nil { |
| t.Fatal("restore accepted unfinished seed") |
| @@ -189,8 +201,8 @@ func TestRootRebootActivationSeedPhase2(t *testing.T) { |
| 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") { |
| + unit, e := tx.systemctl(t.Context(), "show", "carlos-edge.service", "--property=LoadState,ActiveState") |
| + if e != nil || !strings.Contains(string(unit), "LoadState=masked") || !strings.Contains(string(unit), "ActiveState=inactive") { |
| t.Fatal("normal edge after reboot", string(unit), e) |
| } |
| if e = tx.restore(t.Context(), false); e == nil { |
| @@ -236,3 +248,107 @@ func TestRootActivationSeedFailure(t *testing.T) { |
| t.Fatal("restore", e) |
| } |
| } |
| + |
| +func TestRootActivationSeedGuardArmed(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if err := os.Mkdir(drop, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(drop) |
| + guard := []byte("[Unit]\nRequires=carlos-system-recover.service\nAfter=carlos-system-recover.service\n[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n") |
| + path := filepath.Join(drop, "90-host-floor.conf") |
| + if err := os.WriteFile(path, guard, 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.seed(t.Context(), activationSeedFixtureUnit, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := os.ReadFile(path) |
| + if err != nil || !bytes.Equal(got, guard) { |
| + t.Fatal("guard not restored", err) |
| + } |
| + if target, err := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")); err != nil || target != "/dev/null" { |
| + t.Fatal("edge not remasked", err) |
| + } |
| +} |
| + |
| +func TestRootActivationSeedRemasksEarlyFailures(t *testing.T) { |
| + for _, failure := range []string{"reload", "show", "effective", "preflight-resume"} { |
| + t.Run(failure, func(t *testing.T) { |
| + tx := setupOfflineReal(t) |
| + defer cleanupOfflineReal(t, tx) |
| + drop := filepath.Join(tx.units, "carlos-edge.service.d") |
| + if err := os.Mkdir(drop, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(drop) |
| + if err := os.WriteFile(filepath.Join(drop, "90-host-floor.conf"), []byte(activationSeedGuardOriginal), 0644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + injected := errors.New("seed boundary failure") |
| + base := tx.systemctl |
| + hit := false |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + unmasked := sameActivationFile(filepath.Join(tx.units, "carlos-edge.service"), filepath.Join(tx.state, "carlos-edge.service.original")) |
| + if !hit && unmasked && (failure == "reload" && args[0] == "daemon-reload" || (failure == "show" || failure == "effective") && args[0] == "show" && strings.Contains(strings.Join(args, " "), "ExecStart,Type")) { |
| + hit = true |
| + if failure == "effective" { |
| + return []byte("ExecStart=wrong"), nil |
| + } |
| + return nil, injected |
| + } |
| + return base(ctx, args...) |
| + } |
| + if failure == "preflight-resume" { |
| + tx.checkpoint = func(name string) error { |
| + if name == "seed-reload" { |
| + hit = true |
| + return injected |
| + } |
| + return nil |
| + } |
| + } |
| + if err := tx.seed(t.Context(), activationSeedFixtureUnit, false); err == nil || !hit { |
| + t.Fatal("failure not reached", err) |
| + } |
| + tx.systemctl = base |
| + tx.checkpoint = nil |
| + if failure == "preflight-resume" { |
| + // Reconstruct the unmasked crash window, then fail the retry preflight. |
| + units, state, err := tx.dirs(false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r, err := tx.read(state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.replace(units, state, r.Items[len(r.Items)-1], true); err != nil { |
| + t.Fatal(err) |
| + } |
| + units.Close() |
| + state.Close() |
| + tx.seedPreflight = func(context.Context) error { return injected } |
| + if err := tx.seed(t.Context(), activationSeedFixtureUnit, false); !errors.Is(err, injected) { |
| + t.Fatal(err) |
| + } |
| + tx.seedPreflight = nil |
| + } |
| + load, active, err := tx.status(t.Context(), "carlos-edge.service") |
| + if err != nil || load != "masked" || active != "inactive" { |
| + t.Fatal("failure left edge available", load, active, err) |
| + } |
| + if err := tx.seed(t.Context(), activationSeedFixtureUnit, false); err != nil { |
| + t.Fatal("resume", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv| index 2f6467056..ba436b9e8 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 runs 1–2: proposals only; loaded after all existing overlays. |
| +# Activation-tools runs 1–3: proposals only; loaded after all existing overlays. |
| @coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a |
| @coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2 |
| -@coverage internal/host 0592dc882f4945b1a9319f8a7687406c87a38cea8d562cba82098360981d1b0d |
| +@coverage internal/host 6c413d6f9a104e24e0f333f9db7ea469e02b5ad02e57dd2bdfa4ad16babf0146 |
| @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 477332359355b840ac90d65e4f05ce829e19408e813baefdb7d1c167e381f172 |
| +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 9443a714df9d6a2dbebb48986ae8ef0028a724a853098b316fa985d9a69b9e8b |
| 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 |
| @@ -30,7 +30,7 @@ internal/host/activation_offline_linux.go activationOffline.prepare authority G, |
| 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 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 f552912d3051631061eca2e2f2b2e228bb5e5b795d12bf464d31207596d7ad41 |
| +internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=released relinquishes ordinary service files only; retain trigger and edge ownership until done; reconcile inactive and active triggers and verify all before edge and completion; refuse replaced trigger cde53e59e9880e1b81209803fa5e556e817f43add08578d09070b971112e5852 |
| 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 |
| @@ -46,10 +46,17 @@ system.go systemVerb authority B,C,D,F,G,J4 rev3=PROPOSED-activation-tools;ident |
| 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 |
| +internal/host/activation_seed_linux.go activationOffline.seed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 6ac2e84577498a4c8ad1cc91f631aca5c946b531b524453c5172f803fe607125 |
| +internal/host/activation_seed_linux.go activationSeedDropIns authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot file:ReadDir@636 9ba061b030ee71b2782babfc6e9bf42e0ca2a1964b5b46340bec753147637a98 |
| +internal/host/activation_seed_linux.go activationSeedPreflight authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 3d5596c6bb0cb4a9758867c249828915b15c2a5e4822429308e7d71d2b3c3f61 |
| +internal/host/activation_seed_linux.go const activationSeedDropIn authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot e21d702f24a9406e5a889041647e2f24185a6d06b7a8a1d0288c77b5359a85f6 |
| +internal/host/activation_seed_linux.go const activationSeedUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 678e56e4a6a23982f3d6bc8c0f9c465ef8fefb9605dfe36354d3d064ebf22714 |
| @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 |
| +internal/host/activation_seed_linux.go activationOffline.seedGuard authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 61ad6431a4874f1d01dfafa5a246b816becc7e3946b7c72eebbd02ee52c53c03 |
| +internal/host/activation_seed_linux.go activationOffline.seedGuardCheck authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 1deb9a7d2e0c01aebdcc0e190ddcb084577ff6c5155b571cf1ea63066bc40f28 |
| +internal/host/activation_seed_linux.go activationOffline.seedGuardReplace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 56cdf1c30d97949c89707cfab1524240c3728c21a87a3ee8bb6e7c570afbe750 |
| +internal/host/activation_seed_linux.go activationOffline.seedRemask authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot 3292bedf44f4b105eceb76d38a73c3f73e5700e9e949e822e5314c853fc3ad61 |
| +internal/host/activation_seed_linux.go const activationSeedGuardOriginal authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot de4e081ef3584a30477e20d3d30bcc0d1f3582607be533921c3896d6548440f2 |
| +internal/host/activation_seed_linux.go const activationSeedGuardReplacement authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=exact guard replacement journaled before unmask; retain original bytes metadata and inode outside search path; remask on returned errors and interrupted retry preflight; seed only across crash/reboot d5d0cacb84f15b9d5098e05f41a7981852dc53add1c20b946a1a870ef3dc2736 |
| +internal/host/activation_seed_linux.go activationSeedReadAt authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=protected directory descriptor; nofollow nonblocking regular-file read; root ownership and bounded bytes; retained hardlinks permitted only in seed configuration inspection file:Fstat@368,file:Openat@148,file:ReadAll@545 0fecdfd3967cc9db0f5a5f2cb81a8eb4d440ffb145ab9ef604c77f654d74e94e |