carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 03111eff8c28956ec95c2af98021badd29b03d3f parent 0e81652
8 files changed, +823 −41
  • internal/host/activation_command_linux.go +10 −8
  • internal/host/activation_console_rootrunner_linux_test.go +5 −0
  • internal/host/activation_offline_linux.go +75 −18
  • internal/host/activation_offline_rootrunner_linux_test.go +122 −0
  • internal/host/activation_seed_cli_rootrunner_linux_test.go +297 −0
  • internal/host/activation_seed_linux.go +179 −3
  • internal/host/activation_seed_rootrunner_linux_test.go +119 −3
  • internal/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