Claim activation tools, run 6: activation invariants (not for merge)
offline-begin refuses until update and recovery state is settled; restore closes only after a bounded verify pass confirms every recorded unit, trigger and file; activation-firing timers start after close; property witness over interruptions on production units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+920
−100
internal/host/activation_adoption_linux.go+1 −1internal/host/activation_command_linux.go+4 −0internal/host/activation_invariants_rootrunner_linux_test.go+248 −0internal/host/activation_matrix_rootrunner_linux_test.go+292 −0internal/host/activation_offline_linux.go+195 −69internal/host/activation_offline_rootrunner_linux_test.go+2 −2internal/host/activation_production_rootrunner_linux_test.go+5 −3internal/host/activation_restore_order_rootrunner_linux_test.go+2 −2internal/host/activation_settled_linux.go+132 −0internal/host/systemapply.go+3 −0internal/host/systemunits.go+5 −0internal/provision/testdata/plan14-root-reads-activation-tools.tsv+31 −23
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 325.811s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.277s
ok github.com/carlosframework/platform/cmd/moveprobe 93.079s
ok github.com/carlosframework/platform/cmd/zddprobe 0.740s
ok github.com/carlosframework/platform/internal/activator 251.013s
ok github.com/carlosframework/platform/internal/agentsock 0.068s
ok github.com/carlosframework/platform/internal/alerting 0.007s
ok github.com/carlosframework/platform/internal/analytics 0.024s
ok github.com/carlosframework/platform/internal/appconfig 2.757s
ok github.com/carlosframework/platform/internal/apperr 0.131s
ok github.com/carlosframework/platform/internal/applog 0.333s
ok github.com/carlosframework/platform/internal/awsinv 0.008s
ok github.com/carlosframework/platform/internal/boxchoice 0.009s
ok github.com/carlosframework/platform/internal/boxid 0.800s
? 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.032s
ok github.com/carlosframework/platform/internal/caps 0.032s
ok github.com/carlosframework/platform/internal/certwarm 0.008s
ok github.com/carlosframework/platform/internal/cgroup 0.732s
ok github.com/carlosframework/platform/internal/changelog 0.577s
ok github.com/carlosframework/platform/internal/claimactivation 0.066s
ok github.com/carlosframework/platform/internal/clamd 0.074s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 402.890s
? 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.764s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.023s
ok github.com/carlosframework/platform/internal/dbverify 0.007s
ok github.com/carlosframework/platform/internal/dnsrec 0.006s
ok github.com/carlosframework/platform/internal/docsite 0.016s
ok github.com/carlosframework/platform/internal/domains 2.701s
ok github.com/carlosframework/platform/internal/doorkey 0.009s
ok github.com/carlosframework/platform/internal/economics 0.009s
ok github.com/carlosframework/platform/internal/edge 28.336s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.014s
ok github.com/carlosframework/platform/internal/fleetapi 0.305s
ok github.com/carlosframework/platform/internal/gate 1.845s
ok github.com/carlosframework/platform/internal/genselect 0.039s
ok github.com/carlosframework/platform/internal/geo 0.022s
ok github.com/carlosframework/platform/internal/geoip 0.054s
ok github.com/carlosframework/platform/internal/historyreadback 0.300s
ok github.com/carlosframework/platform/internal/host 181.522s
ok github.com/carlosframework/platform/internal/hostactors 0.803s
ok github.com/carlosframework/platform/internal/hostbox 4.017s
ok github.com/carlosframework/platform/internal/hostclaim 1.308s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 7.649s
ok github.com/carlosframework/platform/internal/hostfence 0.113s
ok github.com/carlosframework/platform/internal/hostfiles 10.950s
ok github.com/carlosframework/platform/internal/hostfloor 0.060s
ok github.com/carlosframework/platform/internal/hostguard 1.088s
ok github.com/carlosframework/platform/internal/hostops 2.792s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.007s
ok github.com/carlosframework/platform/internal/hostrelocate 1.120s
ok github.com/carlosframework/platform/internal/hostresolve 0.421s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.003s
ok github.com/carlosframework/platform/internal/instancectl 5.271s
ok github.com/carlosframework/platform/internal/instanceexec 2.932s
ok github.com/carlosframework/platform/internal/instancefs 0.029s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.007s
ok github.com/carlosframework/platform/internal/layout 2.382s
ok github.com/carlosframework/platform/internal/lease 10.072s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.023s
ok github.com/carlosframework/platform/internal/ledger 0.051s
ok github.com/carlosframework/platform/internal/mail 0.364s
ok github.com/carlosframework/platform/internal/maintenance 0.149s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.787s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.216s
ok github.com/carlosframework/platform/internal/metrics 0.012s
ok github.com/carlosframework/platform/internal/moves 24.270s
ok github.com/carlosframework/platform/internal/moves/archive 1.470s
ok github.com/carlosframework/platform/internal/objstore 0.454s
ok github.com/carlosframework/platform/internal/ops 0.342s
ok github.com/carlosframework/platform/internal/park 0.331s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 39.732s
ok github.com/carlosframework/platform/internal/publicrelease 0.010s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 6.577s
ok github.com/carlosframework/platform/internal/relay 7.321s
ok github.com/carlosframework/platform/internal/release 25.688s
ok github.com/carlosframework/platform/internal/rollout 4.576s
ok github.com/carlosframework/platform/internal/rolloutwords 0.005s
ok github.com/carlosframework/platform/internal/rootaws 0.010s
ok github.com/carlosframework/platform/internal/rootparticipation 0.065s
ok github.com/carlosframework/platform/internal/rootstate 0.042s
ok github.com/carlosframework/platform/internal/runs 0.657s
ok github.com/carlosframework/platform/internal/s3compat 0.009s
ok github.com/carlosframework/platform/internal/safefile 0.039s
ok github.com/carlosframework/platform/internal/schedule 3.142s
ok github.com/carlosframework/platform/internal/seeds 0.025s
ok github.com/carlosframework/platform/internal/sim/hostlife 22.450s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.624s
ok github.com/carlosframework/platform/internal/status 0.046s
ok github.com/carlosframework/platform/internal/steering 0.009s
ok github.com/carlosframework/platform/internal/stopevidence 0.748s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.012s
ok github.com/carlosframework/platform/internal/termfence 0.009s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.014s
ok github.com/carlosframework/platform/internal/updatewords 0.006s
ok github.com/carlosframework/platform/internal/usage 0.006s
ok github.com/carlosframework/platform/internal/version 0.005s
ok github.com/carlosframework/platform/internal/vet 1.798s
=== 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.03s)
--- 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.02s)
--- 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.032s
=== RUN TestT4StorageProbeMinio
2026/10/16 20:17:36 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF1BE714EA0EA4, 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.026s
minio contract: PASS
diff --git a/internal/host/activation_adoption_linux.go b/internal/host/activation_adoption_linux.go| index e41f9708c..5dfd18ec8 100644 |
| --- a/internal/host/activation_adoption_linux.go |
| +++ b/internal/host/activation_adoption_linux.go |
| @@ -35,7 +35,7 @@ func activationTransactionAllowed(etc string, recovery bool) error { |
| if err != nil { |
| return err |
| } |
| - if r.Phase != "done" && !(recovery && r.Phase == "complete") { |
| + if r.Phase != "done" { |
| return errActivation |
| } |
| return nil |
diff --git a/internal/host/activation_command_linux.go b/internal/host/activation_command_linux.go| index ae72accb5..b0dd5854b 100644 |
| --- a/internal/host/activation_command_linux.go |
| +++ b/internal/host/activation_command_linux.go |
| @@ -260,6 +260,10 @@ func runSystemClaims(args []string, out io.Writer) error { |
| case "offline-begin", "offline-restore", "offline-seed": |
| tx := activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: claimSystemctl, drained: activationProcessesDrained} |
| var err error |
| + tx.updateRoot, err = activationConfiguredUpdateRoot("/etc/carlos/host.env", os.Getenv("CARLOS_OPT_DIR")) |
| + if err != nil { |
| + return err |
| + } |
| if args[0] == "offline-seed" { |
| seedCtx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) |
| defer cancel() |
diff --git a/internal/host/activation_invariants_rootrunner_linux_test.go b/internal/host/activation_invariants_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..007ca683c |
| --- /dev/null |
| +++ b/internal/host/activation_invariants_rootrunner_linux_test.go |
| @@ -0,0 +1,248 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRootActivationSettledPrecondition(t *testing.T) { |
| + for _, name := range []string{"journal.json", "staging", "rearm-pending.json", "../guard/update-begun.json"} { |
| + t.Run(name, func(t *testing.T) { |
| + tx, d := offlineFixture(t) |
| + tx.updateRoot = filepath.Join(d, "system/router") |
| + path := filepath.Join(tx.updateRoot, name) |
| + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("pending"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, dry := range []bool{true, false} { |
| + if err := tx.begin(t.Context(), dry); err == nil || !strings.Contains(err.Error(), "settle the update first") { |
| + t.Fatal("admitted pending update", dry, err) |
| + } |
| + } |
| + if _, err := os.Stat(tx.state); !os.IsNotExist(err) { |
| + t.Fatal("refusal created transaction", err) |
| + } |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("pending"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, operation := range []func(context.Context, bool) error{tx.begin, tx.restore} { |
| + if err := operation(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") { |
| + t.Fatal("resume admitted pending update", err) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootActivationCloseInvariant(t *testing.T) { |
| + for _, scenario := range []string{"one-drift", "persistent-drift", "foreign-inode"} { |
| + t.Run(scenario, func(t *testing.T) { |
| + tx, _ := offlineFixture(t) |
| + base := tx.systemctl |
| + failed := false |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + if args[0] == "reset-failed" && args[1] == "carlos-adopt.service" { |
| + failed = false |
| + } |
| + raw, err := base(ctx, args...) |
| + if args[0] == "show" && args[1] == "carlos-adopt.service" && failed { |
| + raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=failed")) |
| + } |
| + return raw, err |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + passes := 0 |
| + tx.checkpoint = func(point string) error { |
| + if point == "verify-close" { |
| + passes++ |
| + if scenario == "persistent-drift" || passes == 1 { |
| + failed = true |
| + } |
| + if scenario == "foreign-inode" && passes == 1 { |
| + p := filepath.Join(tx.units, "carlos-adopt.service") |
| + raw, err := os.ReadFile(p) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.WriteFile(p+".foreign", raw, 0644); err != nil { |
| + return err |
| + } |
| + return os.Rename(p+".foreign", p) |
| + } |
| + } |
| + return nil |
| + } |
| + err := tx.restore(t.Context(), false) |
| + if scenario == "one-drift" { |
| + if err != nil || passes != 2 || offlineRecord(t, tx).Phase != "done" { |
| + t.Fatal("did not reconcile and verify again", passes, err) |
| + } |
| + } else { |
| + if err == nil || !strings.Contains(err.Error(), "carlos-adopt.service") || offlineRecord(t, tx).Phase != "complete" { |
| + t.Fatal("closed on mismatch", passes, err) |
| + } |
| + if target, _ := os.Readlink(filepath.Join(tx.units, activationEdgeUnit)); target != "/dev/null" { |
| + t.Fatal("released edge") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootActivationTriggerAfterClose(t *testing.T) { |
| + tx, _ := offlineFixture(t) |
| + base := tx.systemctl |
| + active := true |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + if args[1%len(args)] == "carlos-adopt.timer" { |
| + switch args[0] { |
| + case "start": |
| + if offlineRecord(t, tx).Phase != "done" { |
| + return nil, errors.New("timer started before done") |
| + } |
| + active = true |
| + case "stop": |
| + active = false |
| + } |
| + } |
| + raw, err := base(ctx, args...) |
| + if args[0] == "show" && args[1] == "carlos-adopt.timer" && active { |
| + raw = []byte(strings.ReplaceAll(string(raw), "ActiveState=inactive", "ActiveState=active")) |
| + } |
| + return raw, err |
| + } |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.restore(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !active { |
| + t.Fatal("active timer not restored") |
| + } |
| +} |
| + |
| +func TestRootActivationUpdateInProgress(t *testing.T) { |
| + tx, _ := offlineFixture(t) |
| + release, err := tx.settled(t.Context(), false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") { |
| + t.Fatal("update lock admitted", err) |
| + } |
| + release() |
| + base := tx.systemctl |
| + tx.systemctl = func(ctx context.Context, args ...string) ([]byte, error) { |
| + if args[0] == "show" && args[1] == "carlos-system-update.service" { |
| + return []byte("LoadState=loaded\nActiveState=activating\n"), nil |
| + } |
| + return base(ctx, args...) |
| + } |
| + if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), "settle the update first") { |
| + t.Fatal("activating update admitted", err) |
| + } |
| +} |
| + |
| +func TestRootActivationProductionCloseRefusesInode(t *testing.T) { |
| + tx := setupActivationProduction(t) |
| + defer cleanupActivationProduction(t, tx) |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(tx.units, "carlos-stage.path") |
| + changed := false |
| + tx.checkpoint = func(point string) error { |
| + if point == "verify-close" && !changed { |
| + changed = true |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.WriteFile(path+".foreign", raw, 0644); err != nil { |
| + return err |
| + } |
| + return os.Rename(path+".foreign", path) |
| + } |
| + return nil |
| + } |
| + err := tx.restore(t.Context(), false) |
| + if err == nil || !strings.Contains(err.Error(), "carlos-stage.path: recorded file inode") { |
| + t.Fatal("no exact inode refusal", err) |
| + } |
| + if r := offlineRecord(t, tx); r.Phase != "complete" { |
| + t.Fatal("closed on foreign file", r.Phase) |
| + } |
| + if load, _, err := tx.status(t.Context(), activationEdgeUnit); err != nil || load != "masked" { |
| + t.Fatal("edge released", load, err) |
| + } |
| + if sameActivationFile(path, filepath.Join(tx.state, "carlos-stage.path.original")) { |
| + t.Fatal("overwrote foreign inode") |
| + } |
| +} |
| + |
| +func TestRootActivationRecordedStates(t *testing.T) { |
| + for _, index := range []int{0, len(activationTriggers)} { |
| + for _, item := range []activationOfflineItem{ |
| + {LoadState: "loaded", ActiveState: "failed"}, |
| + {LoadState: "masked", ActiveState: "active", Masked: true, Active: true}, |
| + } { |
| + tx, _ := offlineFixture(t) |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := offlineRecord(t, tx) |
| + r.Items[index].LoadState = item.LoadState |
| + r.Items[index].ActiveState = item.ActiveState |
| + r.Items[index].Masked = item.Masked |
| + r.Items[index].Active = item.Active |
| + if differences := tx.closeDifferences(t.Context(), r); !strings.Contains(strings.Join(differences, "\n"), "want load="+item.LoadState+" active="+item.ActiveState) { |
| + t.Fatal("lost recorded state", index, differences) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestRootActivationConfiguredUpdateRoot(t *testing.T) { |
| + tx, d := offlineFixture(t) |
| + config := filepath.Join(d, "host.env") |
| + custom := filepath.Join(d, "custom-opt") |
| + if err := os.WriteFile(config, []byte("CARLOS_OPT_DIR="+custom+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + root, err := activationConfiguredUpdateRoot(config, "") |
| + if err != nil || root != systemRootDir(custom) { |
| + t.Fatal("wrong configured update root", root, err) |
| + } |
| + if _, err := activationConfiguredUpdateRoot(config, "/opt/carlos"); err == nil { |
| + t.Fatal("mismatched operator override accepted") |
| + } |
| + tx.updateRoot = root |
| + activationProductionWrite(t, filepath.Join(root, "journal.json"), []byte("pending"), 0600) |
| + if err := tx.begin(t.Context(), false); err == nil || !strings.Contains(err.Error(), filepath.Join(root, "journal.json")) { |
| + t.Fatal("configured pending journal missed", err) |
| + } |
| + if err := os.WriteFile(config, []byte("CARLOS_OPT_DIR=relative\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := activationConfiguredUpdateRoot(config, ""); err == nil { |
| + t.Fatal("relative root accepted") |
| + } |
| +} |
diff --git a/internal/host/activation_matrix_rootrunner_linux_test.go b/internal/host/activation_matrix_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..608199fae |
| --- /dev/null |
| +++ b/internal/host/activation_matrix_rootrunner_linux_test.go |
| @@ -0,0 +1,292 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const activationMatrixDir = "/var/lib/carlos-activation-matrix" |
| + |
| +type activationMatrixCursor struct { |
| + Cut int |
| + Timer bool |
| + Resume bool |
| + Boot string |
| + Point string |
| + Pending bool |
| +} |
| + |
| +func activationMatrixTransaction() activationOffline { |
| + return activationOffline{units: "/etc/systemd/system", state: "/etc/carlos/claim-activation/offline", systemctl: offlineRealSystemctl, drained: quiesceLegacyRootMutators} |
| +} |
| + |
| +func activationMatrixPending(t *testing.T, tx activationOffline, operation func(context.Context, bool) error) { |
| + t.Helper() |
| + path := filepath.Join(tx.updateDir(), "journal.json") |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Fatal("pre-existing update journal", err) |
| + } |
| + // A valid interrupted install, with the fixture's real previous executable. |
| + raw, err := json.Marshal(systemJournal{Phase: systemPhaseInstalled, Target: "v2.0.0", PrevBuild: "v1.0.0", StartedAt: time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + activationProductionWrite(t, path, raw, 0600) |
| + activationMatrixRefusePending(t, tx, operation) |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func activationMatrixRefusePending(t *testing.T, tx activationOffline, operation func(context.Context, bool) error) { |
| + t.Helper() |
| + before, _ := os.ReadFile(filepath.Join(tx.state, "transaction.json")) |
| + err := operation(t.Context(), false) |
| + if err == nil || !strings.Contains(err.Error(), "settle the update first") { |
| + t.Fatal("pending update admitted", err) |
| + } |
| + after, _ := os.ReadFile(filepath.Join(tx.state, "transaction.json")) |
| + if !bytes.Equal(before, after) { |
| + t.Fatal("refusal changed transaction") |
| + } |
| +} |
| + |
| +func activationMatrixBaseline(t *testing.T, tx activationOffline, timer bool) { |
| + t.Helper() |
| + servingRunnerSystemctl(t, "stop", activationEdgeUnit, "carlos-adopt.timer") |
| + servingRunnerSystemctl(t, append([]string{"stop"}, rootMigrationOfflineUnits...)...) |
| + servingRunnerSystemctl(t, "reset-failed") |
| + if err := os.RemoveAll(tx.state); err != nil { |
| + t.Fatal(err) |
| + } |
| + if timer { |
| + servingRunnerSystemctl(t, "start", "carlos-adopt.timer") |
| + // The production OnBootSec timer can run immediately. Wait for the |
| + // real adoption process to settle before recording the baseline. |
| + for n := 0; n < 100; n++ { |
| + raw, err := tx.systemctl(t.Context(), "show", "carlos-adopt.service", "--property=ActiveState", "--value") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.TrimSpace(string(raw)) != "activating" { |
| + break |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + servingRunnerSystemctl(t, "stop", "carlos-adopt.service") |
| + servingRunnerSystemctl(t, "reset-failed", "carlos-adopt.service") |
| + } |
| + activationMatrixPending(t, tx, tx.begin) |
| + if err := tx.begin(t.Context(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := offlineRecord(t, tx) |
| + if r.Items[0].Active != timer { |
| + t.Fatal("wrong timer baseline", r.Items[0]) |
| + } |
| +} |
| + |
| +func activationMatrixCheck(t *testing.T, tx activationOffline, point string) { |
| + t.Helper() |
| + r := offlineRecord(t, tx) |
| + if r.Phase != "done" { |
| + if target, _ := os.Readlink(filepath.Join(tx.units, activationEdgeUnit)); target != "/dev/null" { |
| + t.Fatal("edge released while open", point, r.Phase) |
| + } |
| + for _, item := range r.Items { |
| + if err := tx.checkItem(item); err != nil { |
| + t.Fatal("foreign inode while open", point, item.Name, err) |
| + } |
| + } |
| + } |
| + if point == "journal-done" && r.EdgePending { |
| + if differences := tx.closeDifferences(t.Context(), r); len(differences) != 0 { |
| + t.Fatal("closed on mismatch", differences) |
| + } |
| + } |
| + if strings.HasPrefix(point, "start-") && strings.HasSuffix(point, ".timer") && r.Phase != "done" { |
| + t.Fatal("timer activated before closure") |
| + } |
| +} |
| + |
| +func activationMatrixInterrupt(t *testing.T, tx activationOffline, cut int) (string, bool) { |
| + t.Helper() |
| + n := 0 |
| + point := "" |
| + stop := errors.New("matrix interruption") |
| + tx.checkpoint = func(name string) error { |
| + activationMatrixCheck(t, tx, name) |
| + n++ |
| + if n == cut { |
| + point = name |
| + return stop |
| + } |
| + return nil |
| + } |
| + err := tx.restore(t.Context(), false) |
| + if err != nil && !errors.Is(err, stop) { |
| + t.Fatal(cut, point, err) |
| + } |
| + return point, errors.Is(err, stop) |
| +} |
| + |
| +func activationMatrixResume(t *testing.T, tx activationOffline, point string) { |
| + t.Helper() |
| + before := offlineRecord(t, tx) |
| + if before.Phase != "done" { |
| + activationMatrixPending(t, tx, tx.restore) |
| + } |
| + tx.checkpoint = func(name string) error { activationMatrixCheck(t, tx, name); return nil } |
| + deadline := time.Now().Add(20 * time.Second) |
| + for { |
| + saved, err := os.ReadFile(filepath.Join(tx.state, "transaction.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = tx.restore(t.Context(), false) |
| + if err == nil { |
| + break |
| + } |
| + if !strings.HasPrefix(err.Error(), "claim activation: settle the update first:") || time.Now().After(deadline) { |
| + t.Fatal("resume", point, err) |
| + } |
| + after, e := os.ReadFile(filepath.Join(tx.state, "transaction.json")) |
| + if e != nil || !bytes.Equal(saved, after) { |
| + t.Fatal("busy recovery refusal changed transaction", e) |
| + } |
| + t.Log("boot recovery still settling; unchanged refusal", err) |
| + time.Sleep(50 * time.Millisecond) |
| + } |
| + r := offlineRecord(t, tx) |
| + if r.Phase != "done" || r.EdgePending || r.TriggersPending { |
| + t.Fatal("unfinished continuation", r.Phase, r.EdgePending, r.TriggersPending) |
| + } |
| + // Once done is durable, timer-driven adoption is ordinary operation. The |
| + // close checkpoint, not a later live service observation, checks equality. |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if item.Active { |
| + _, active, err := tx.status(t.Context(), item.Name) |
| + if err != nil || active != "active" { |
| + t.Fatal("active trigger not restored", item.Name, active, err) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestRootActivationProductionInvariantMatrix(t *testing.T) { |
| + tx := setupActivationProduction(t) |
| + defer cleanupActivationProduction(t, tx) |
| + activationMatrixTimer(t) |
| + for _, timer := range []bool{false, true} { |
| + complete := false |
| + for cut := 1; cut <= 120; cut++ { |
| + activationMatrixBaseline(t, tx, timer) |
| + point, interrupted := activationMatrixInterrupt(t, tx, cut) |
| + activationMatrixResume(t, tx, point) |
| + t.Logf("matrix reboot=false timer=%t pending={present:refused,absent:restored} cut=%d point=%s", timer, cut, point) |
| + if !interrupted { |
| + complete = true |
| + break |
| + } |
| + } |
| + if !complete { |
| + t.Fatal("matrix did not enumerate all boundaries") |
| + } |
| + } |
| +} |
| + |
| +// The designated runner invokes this test from a persistent boot unit. Each |
| +// successful invocation prepares exactly one actual reboot; cursor and boot ID |
| +// are durable. Pending admission is a refused branch, so it has no checkpoints. |
| +func TestRootActivationProductionInvariantBootStep(t *testing.T) { |
| + var c activationMatrixCursor |
| + cursor := activationMatrixDir + "/cursor.json" |
| + raw, err := os.ReadFile(cursor) |
| + tx := activationMatrixTransaction() |
| + if os.IsNotExist(err) { |
| + tx = setupActivationProduction(t) |
| + activationMatrixTimer(t) |
| + c.Cut = 1 |
| + } else if err != nil || json.Unmarshal(raw, &c) != nil { |
| + t.Fatal("cursor", err) |
| + } |
| + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Resume { |
| + if c.Boot == string(boot) { |
| + t.Fatal("no actual reboot") |
| + } |
| + activationMatrixCheck(t, tx, "boot-resume") |
| + if c.Pending { |
| + activationMatrixRefusePending(t, tx, tx.restore) |
| + if err := os.Remove(filepath.Join(tx.updateDir(), "journal.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + activationMatrixResume(t, tx, c.Point) |
| + t.Logf("matrix reboot=true timer=%t pending={present:refused,absent:restored} cut=%d point=%s boot_pending=%t boot=%s", c.Timer, c.Cut, c.Point, c.Pending, strings.TrimSpace(string(boot))) |
| + c.Cut++ |
| + } |
| + if c.Cut > 120 { |
| + t.Fatal("unbounded checkpoint matrix") |
| + } |
| + for { |
| + activationMatrixBaseline(t, tx, c.Timer) |
| + point, interrupted := activationMatrixInterrupt(t, tx, c.Cut) |
| + if !interrupted { |
| + activationMatrixResume(t, tx, point) |
| + if c.Timer { |
| + cleanupActivationProduction(t, tx) |
| + activationProductionWrite(t, activationMatrixDir+"/control", []byte("done\n"), 0600) |
| + return |
| + } |
| + c.Timer = true |
| + c.Cut = 1 |
| + continue |
| + } |
| + c.Pending = offlineRecord(t, tx).Phase != "done" && os.Getenv("ACTIVATION_MATRIX_PENDING") != "absent" |
| + if c.Pending { |
| + raw, err := json.Marshal(systemJournal{Phase: systemPhaseInstalled, Target: "v2.0.0", PrevBuild: "v1.0.0", StartedAt: time.Now()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeFileDurable(filepath.Join(tx.updateDir(), "journal.json"), raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + c.Point = point |
| + c.Resume = true |
| + c.Boot = string(boot) |
| + raw, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeFileDurable(cursor, raw, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := writeFileDurable(activationMatrixDir+"/control", []byte("reboot\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Log(fmt.Sprintf("actual reboot prepared: timer=%t cut=%d point=%s", c.Timer, c.Cut, c.Point)) |
| + return |
| + } |
| +} |
| + |
| +// Keep the production timer, service, executable and dependencies; make its |
| +// boot deadline overdue on every boot so activation really can fire it. |
| +func activationMatrixTimer(t *testing.T) { |
| + activationProductionWrite(t, "/etc/systemd/system/carlos-adopt.timer.d/10-runner-deadline.conf", []byte("[Timer]\nOnBootSec=\nOnBootSec=1us\nRandomizedDelaySec=0\n"), 0644) |
| + servingRunnerSystemctl(t, "daemon-reload") |
| +} |
diff --git a/internal/host/activation_offline_linux.go b/internal/host/activation_offline_linux.go| index d2f7a35a7..cb9c9a3cf 100644 |
| --- a/internal/host/activation_offline_linux.go |
| +++ b/internal/host/activation_offline_linux.go |
| @@ -7,6 +7,7 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "os" |
| "path/filepath" |
| "reflect" |
| @@ -29,6 +30,7 @@ func activationOfflineUnits() []string { |
| type activationOffline struct { |
| units, state string |
| + updateRoot string |
| systemctl func(context.Context, ...string) ([]byte, error) |
| drained func(context.Context) error |
| seedPreflight func(context.Context) error |
| @@ -39,23 +41,31 @@ type activationOfflineItem struct { |
| ActiveState string |
| Name string |
| Masked, Active bool |
| + Inode uint64 `json:",omitempty"` |
| Mode uint32 |
| UID, GID uint32 |
| Size, Modified int64 |
| Digest string |
| } |
| type activationOfflineRecord struct { |
| - V int |
| - Units string |
| - Phase string |
| - Items []activationOfflineItem |
| - SeedGuard *activationOfflineItem `json:",omitempty"` |
| - EdgePending bool `json:",omitempty"` |
| + V int |
| + Units string |
| + Phase string |
| + Items []activationOfflineItem |
| + SeedGuard *activationOfflineItem `json:",omitempty"` |
| + TriggersPending bool `json:",omitempty"` |
| + EdgePending bool `json:",omitempty"` |
| } |
| +type activationInterruption struct{ error } |
| + |
| +func (e activationInterruption) Unwrap() error { return e.error } |
| + |
| func (t activationOffline) point(name string) error { |
| if t.checkpoint != nil { |
| - return t.checkpoint(name) |
| + if err := t.checkpoint(name); err != nil { |
| + return activationInterruption{err} |
| + } |
| } |
| return nil |
| } |
| @@ -97,7 +107,7 @@ func (t activationOffline) status(ctx context.Context, name string) (string, str |
| } |
| load, active := fields["LoadState"], fields["ActiveState"] |
| if load != "loaded" && load != "masked" || active != "active" && active != "inactive" && active != "failed" { |
| - return "", "", errActivation |
| + return load, active, errActivation |
| } |
| return load, active, nil |
| } |
| @@ -111,6 +121,7 @@ func activationUnitSnapshot(path, name string) (activationOfflineItem, error) { |
| if !ok || stat.Uid != 0 || st.Mode().Perm()&0022 != 0 && st.Mode()&os.ModeSymlink == 0 { |
| return item, errActivation |
| } |
| + item.Inode = stat.Ino |
| item.Mode = uint32(st.Mode()) |
| item.UID = stat.Uid |
| item.GID = stat.Gid |
| @@ -233,6 +244,9 @@ func (t activationOffline) checkItem(item activationOfflineItem) error { |
| if err != nil { |
| return err |
| } |
| + if item.Inode == 0 { |
| + got.Inode = 0 |
| + } |
| got.Active = item.Active |
| got.LoadState = item.LoadState |
| got.ActiveState = item.ActiveState |
| @@ -294,6 +308,11 @@ func (t activationOffline) replace(units, state *os.File, item activationOffline |
| return nil |
| } |
| func (t activationOffline) begin(ctx context.Context, dry bool) error { |
| + release, err := t.settled(ctx, dry) |
| + if err != nil { |
| + return err |
| + } |
| + defer release() |
| if dry { |
| state, err := activationDir(t.state, false) |
| if err == nil { |
| @@ -417,55 +436,26 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| if r.Phase == "prepared" || r.Phase == "seed-prepared" { |
| return errActivation |
| } |
| - if r.Phase == "done" && !r.EdgePending { |
| + if r.Phase == "done" && !r.EdgePending && !r.TriggersPending { |
| return nil |
| } |
| + release := func() {} |
| if r.Phase != "done" { |
| + release, err = t.settled(ctx, dry) |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { release() }() |
| for _, item := range r.Items { |
| if err = t.checkItem(item); err != nil { |
| - return err |
| + return fmt.Errorf("%s: recorded file inode, content, or metadata changed: %w", item.Name, err) |
| } |
| } |
| } |
| if dry { |
| return nil |
| } |
| - reconcile := func(item activationOfflineItem) error { |
| - 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(verb + "-" + item.Name); err != nil { |
| - return err |
| - } |
| - load, active, err := t.status(ctx, item.Name) |
| - if err != nil { |
| - return err |
| - } |
| - if verb == "stop" && active == "failed" { |
| - if _, err = t.systemctl(ctx, "reset-failed", item.Name); 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 |
| - } |
| + |
| edge := r.Items[len(r.Items)-1] |
| if r.Phase != "done" { |
| // A resumed complete record still owns every unit. Reconcile them all: |
| @@ -516,47 +506,96 @@ func (t activationOffline) restore(ctx context.Context, dry bool) error { |
| return err |
| } |
| if r.Phase != "complete" { |
| - // Recovery must be callable while service states are finalized. |
| - // Adoption and new updates remain excluded until done. |
| + // All unit writers remain excluded until verified closure. |
| r.Phase = "complete" |
| if err = t.save(state, r); err != nil { |
| return err |
| } |
| } |
| - for _, item := range r.Items[len(activationTriggers) : len(r.Items)-1] { |
| - if err = reconcile(item); err != nil { |
| - return err |
| - } |
| - } |
| - for _, active := range []bool{false, true} { |
| - for _, item := range r.Items[:len(activationTriggers)] { |
| - if (item.Active && !item.Masked) == active { |
| - if err = reconcile(item); err != nil { |
| + // Triggers stay inactive until durable closure. Starting an overdue timer |
| + // here can run a service after that service has already been verified. |
| + var differences []string |
| + const rounds = 3 |
| + for round := 0; round < rounds; round++ { |
| + differences = nil |
| + for i, item := range r.Items { |
| + want := item |
| + if i < len(activationTriggers) && item.Active && !item.Masked { |
| + want.Active = false |
| + want.ActiveState = "inactive" |
| + } |
| + if item.Name == activationEdgeUnit { |
| + want.Masked = true |
| + want.LoadState = "masked" |
| + want.Active = false |
| + want.ActiveState = "inactive" |
| + } |
| + if err = t.reconcile(ctx, want); err != nil { |
| + var interrupted activationInterruption |
| + if errors.As(err, &interrupted) { |
| return err |
| } |
| + differences = append(differences, err.Error()) |
| } |
| } |
| + if err = t.point("verify-close"); err != nil { |
| + return err |
| + } |
| + differences = append(differences, t.closeDifferences(ctx, r)...) |
| + if len(differences) == 0 { |
| + break |
| + } |
| + } |
| + if len(differences) != 0 { |
| + return fmt.Errorf("claim activation: restore remains complete after %d verification rounds:\n%s", rounds, strings.Join(differences, "\n")) |
| } |
| - // Close durably before the edge can request its recovery dependency. |
| - // EdgePending resumes only the ordinary edge step, never the transaction. |
| - r.Phase, r.EdgePending = "done", true |
| + r.Phase, r.EdgePending, r.TriggersPending = "done", true, true |
| if err = t.save(state, r); err != nil { |
| return err |
| } |
| + // Recovery can take the update lock only after closure. |
| + release() |
| + release = func() {} |
| } |
| - if err = t.replace(units, state, edge, true); err != nil { |
| - return err |
| - } |
| - if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| - return err |
| + // Release the edge file before triggers: ordinary adoption may rewrite units |
| + // once done is durable. No transaction-owned replacement follows a trigger. |
| + |
| + if r.EdgePending { |
| + if err = t.replace(units, state, edge, true); err != nil { |
| + return err |
| + } |
| + if _, err = t.systemctl(ctx, "daemon-reload"); err != nil { |
| + return err |
| + } |
| + if err = t.point("edge-reload"); err != nil { |
| + return err |
| + } |
| + if err = t.reconcile(ctx, edge); err != nil { |
| + return err |
| + } |
| + load, active, e := t.status(ctx, edge.Name) |
| + wantLoad, wantActive := activationRecordedState(edge) |
| + if e != nil || load != wantLoad || active != wantActive { |
| + return fmt.Errorf("%s: load=%s active=%s; want load=%s active=%s", edge.Name, load, active, wantLoad, wantActive) |
| + } |
| } |
| - if err = t.point("edge-reload"); err != nil { |
| + r.EdgePending = false |
| + if err = t.save(state, r); err != nil { |
| return err |
| } |
| - if err = reconcile(edge); err != nil { |
| - return err |
| + if r.TriggersPending { |
| + for _, item := range r.Items[:len(activationTriggers)] { |
| + if item.Active && !item.Masked { |
| + if _, err = t.systemctl(ctx, "start", item.Name); err != nil { |
| + return err |
| + } |
| + if err = t.point("start-" + item.Name); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + r.TriggersPending = false |
| } |
| - r.EdgePending = false |
| return t.save(state, r) |
| } |
| @@ -614,3 +653,90 @@ func (t activationOffline) preview(ctx context.Context) ([]activationOfflinePrev |
| } |
| return out, nil |
| } |
| + |
| +func (t activationOffline) reconcile(ctx context.Context, item activationOfflineItem) error { |
| + if item.ActiveState == "failed" { |
| + load, active, err := t.status(ctx, item.Name) |
| + wantLoad, wantActive := activationRecordedState(item) |
| + if err != nil || load != wantLoad || active != wantActive { |
| + return fmt.Errorf("%s: load=%s active=%s; want load=%s active=%s", item.Name, load, active, wantLoad, wantActive) |
| + } |
| + return nil |
| + } |
| + verb := "stop" |
| + if item.Active && !item.Masked { |
| + verb = "start" |
| + } |
| + if _, err := t.systemctl(ctx, verb, item.Name); err != nil { |
| + return fmt.Errorf("%s: %w", item.Name, err) |
| + } |
| + if err := t.point(verb + "-" + item.Name); err != nil { |
| + return err |
| + } |
| + _, active, err := t.status(ctx, item.Name) |
| + if err != nil { |
| + return fmt.Errorf("%s: %w", item.Name, err) |
| + } |
| + if verb == "stop" && active == "failed" { |
| + if _, err := t.systemctl(ctx, "reset-failed", item.Name); err != nil { |
| + return fmt.Errorf("%s: %w", item.Name, err) |
| + } |
| + if err := t.point("reset-failed-" + item.Name); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (t activationOffline) closeDifferences(ctx context.Context, r activationOfflineRecord) []string { |
| + var differences []string |
| + for i, item := range r.Items { |
| + wantLoad, wantActive := activationRecordedState(item) |
| + if i < len(activationTriggers) && item.Active && !item.Masked { |
| + wantActive = "inactive" |
| + } |
| + if item.Name == activationEdgeUnit { |
| + wantLoad, wantActive = "masked", "inactive" |
| + } |
| + load, active, err := t.status(ctx, item.Name) |
| + if err != nil { |
| + differences = append(differences, fmt.Sprintf("%s: load=%s active=%s; want load=%s active=%s: %v", item.Name, load, active, wantLoad, wantActive, err)) |
| + } else if load != wantLoad || active != wantActive { |
| + differences = append(differences, fmt.Sprintf("%s: load=%s active=%s; want load=%s active=%s", item.Name, load, active, wantLoad, wantActive)) |
| + } |
| + suffix := ".original" |
| + if item.Name == activationEdgeUnit && !item.Masked { |
| + suffix = ".mask" |
| + } |
| + if err := t.checkItem(item); err != nil || !sameActivationFile(filepath.Join(t.units, item.Name), filepath.Join(t.state, item.Name+suffix)) { |
| + differences = append(differences, fmt.Sprintf("%s: recorded file inode, content, or metadata changed", item.Name)) |
| + } |
| + } |
| + if r.SeedGuard != nil { |
| + g := activationOffline{units: filepath.Join(t.units, "carlos-edge.service.d"), state: t.state} |
| + if err := g.checkItem(*r.SeedGuard); err != nil || !sameActivationFile(filepath.Join(g.units, r.SeedGuard.Name), filepath.Join(t.state, r.SeedGuard.Name+".original")) { |
| + differences = append(differences, fmt.Sprintf("%s: recorded file inode, content, or metadata changed", r.SeedGuard.Name)) |
| + } |
| + } |
| + if err := t.updateSettled(); err != nil { |
| + differences = append(differences, err.Error()) |
| + } |
| + return differences |
| +} |
| + |
| +func activationRecordedState(item activationOfflineItem) (string, string) { |
| + load, active := item.LoadState, item.ActiveState |
| + if load == "" { |
| + load = "loaded" |
| + if item.Masked { |
| + load = "masked" |
| + } |
| + } |
| + if active == "" { |
| + active = "inactive" |
| + if item.Active { |
| + active = "active" |
| + } |
| + } |
| + return load, active |
| +} |
diff --git a/internal/host/activation_offline_rootrunner_linux_test.go b/internal/host/activation_offline_rootrunner_linux_test.go| index 8d64c0ee5..e3148ba4a 100644 |
| --- a/internal/host/activation_offline_rootrunner_linux_test.go |
| +++ b/internal/host/activation_offline_rootrunner_linux_test.go |
| @@ -35,7 +35,7 @@ func offlineFixture(t *testing.T) (activationOffline, string) { |
| } |
| return nil, nil |
| } |
| - return activationOffline{units: units, state: state, systemctl: ctl, drained: func(context.Context) error { return nil }}, d |
| + return activationOffline{units: units, state: state, updateRoot: filepath.Join(d, "system/router"), systemctl: ctl, drained: func(context.Context) error { return nil }}, d |
| } |
| func TestRootActivationModelOfflineInterruptions(t *testing.T) { |
| for _, phase := range []string{"begin", "restore"} { |
| @@ -187,7 +187,7 @@ func TestRootActivationModelEdgeLast(t *testing.T) { |
| if args[1] == "carlos-edge.service" && offlineRecord(t, tx).Phase != "done" { |
| t.Fatal("edge started before durable completion") |
| } |
| - if args[1] != "carlos-edge.service" { |
| + if args[1] != "carlos-edge.service" && offlineRecord(t, tx).Phase != "done" { |
| target, _ := os.Readlink(filepath.Join(tx.units, "carlos-edge.service")) |
| if target != "/dev/null" { |
| t.Fatal("edge unmasked before triggers restored") |
diff --git a/internal/host/activation_production_rootrunner_linux_test.go b/internal/host/activation_production_rootrunner_linux_test.go| index d390c7680..e5aae5ced 100644 |
| --- a/internal/host/activation_production_rootrunner_linux_test.go |
| +++ b/internal/host/activation_production_rootrunner_linux_test.go |
| @@ -356,10 +356,12 @@ func TestRootActivationProductionRecoveryGate(t *testing.T) { |
| if err := (&systemApplier{etcDir: "/etc"}).Run(t.Context(), false); !errors.Is(err, errActivation) { |
| t.Fatal("complete allowed update", err) |
| } |
| - servingRunnerSystemctl(t, "start", "carlos-system-recover.service") |
| + if _, err := tx.systemctl(t.Context(), "start", "carlos-system-recover.service"); err == nil { |
| + t.Fatal("complete allowed recovery") |
| + } |
| raw, err := tx.systemctl(t.Context(), "show", "carlos-system-recover.service", "--property=Result", "--value") |
| - if err != nil || strings.TrimSpace(string(raw)) != "success" { |
| - t.Fatal("complete blocked real recovery", string(raw), err) |
| + if err != nil || strings.TrimSpace(string(raw)) != "exit-code" { |
| + t.Fatal("complete did not exclude real recovery", string(raw), err) |
| } |
| tx.checkpoint = nil |
| if err := tx.restore(t.Context(), false); err != nil { |
diff --git a/internal/host/activation_restore_order_rootrunner_linux_test.go b/internal/host/activation_restore_order_rootrunner_linux_test.go| index 3cf264aa0..c12e93cc1 100644 |
| --- a/internal/host/activation_restore_order_rootrunner_linux_test.go |
| +++ b/internal/host/activation_restore_order_rootrunner_linux_test.go |
| @@ -132,7 +132,7 @@ func TestRootActivationModelRepairExclusion(t *testing.T) { |
| } |
| stop := errors.New("lost adoption timer start reply") |
| tx.checkpoint = func(name string) error { |
| - if name == "start-carlos-adopt.timer" { |
| + if name == "verify-close" { |
| return stop |
| } |
| return nil |
| @@ -205,7 +205,7 @@ func TestRootActivationAdoptionGate(t *testing.T) { |
| t.Fatal("gate phase", phase, err) |
| } |
| err = activationTransactionAllowed(etc, true) |
| - if (err == nil) != (phase == "complete" || phase == "done") { |
| + if (err == nil) != (phase == "done") { |
| t.Fatal("recovery gate phase", phase, err) |
| } |
| } |
diff --git a/internal/host/activation_settled_linux.go b/internal/host/activation_settled_linux.go| new file mode 100644 |
| index 000000000..48713a5f7 |
| --- /dev/null |
| +++ b/internal/host/activation_settled_linux.go |
| @@ -0,0 +1,132 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "syscall" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func (t activationOffline) updateDir() string { |
| + if t.updateRoot != "" { |
| + return t.updateRoot |
| + } |
| + return systemRootDir("/opt/carlos") |
| +} |
| + |
| +// Hold the updater's lock through admission, so an already admitted update |
| +// cannot race the snapshot. Apply rechecks the transaction after taking it. |
| +func (t activationOffline) settled(ctx context.Context, dry bool) (func(), error) { |
| + refuse := func(err error) (func(), error) { |
| + return nil, fmt.Errorf("claim activation: settle the update first: %s", err) |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return refuse(err) |
| + } |
| + dir, err := activationDir(t.updateDir(), !dry) |
| + if errors.Is(err, os.ErrNotExist) && dry { |
| + if err := t.updateUnitsSettled(ctx); err != nil { |
| + return nil, err |
| + } |
| + return func() {}, t.updateSettled() |
| + } |
| + if err != nil { |
| + return refuse(err) |
| + } |
| + defer dir.Close() |
| + flags := unix.O_RDWR | unix.O_CLOEXEC | unix.O_NOFOLLOW | unix.O_NONBLOCK |
| + if !dry { |
| + flags |= unix.O_CREAT |
| + } |
| + fd, err := unix.Openat(int(dir.Fd()), systemLockFile, flags, 0600) |
| + if errors.Is(err, os.ErrNotExist) && dry { |
| + if err := t.updateUnitsSettled(ctx); err != nil { |
| + return nil, err |
| + } |
| + return func() {}, t.updateSettled() |
| + } |
| + if err != nil { |
| + return refuse(err) |
| + } |
| + lock := os.NewFile(uintptr(fd), filepath.Join(t.updateDir(), systemLockFile)) |
| + st, err := lock.Stat() |
| + if err != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0022 != 0 || st.Sys().(*syscall.Stat_t).Uid != 0 { |
| + lock.Close() |
| + return refuse(errActivation) |
| + } |
| + if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); err != nil { |
| + lock.Close() |
| + return refuse(fmt.Errorf("%s: %w", lock.Name(), err)) |
| + } |
| + if err := t.updateUnitsSettled(ctx); err != nil { |
| + lock.Close() |
| + return nil, err |
| + } |
| + |
| + if err := t.updateSettled(); err != nil { |
| + lock.Close() |
| + return nil, err |
| + } |
| + return func() { lock.Close() }, nil |
| +} |
| + |
| +func (t activationOffline) updateSettled() error { |
| + for _, path := range []string{ |
| + filepath.Join(t.updateDir(), "journal.json"), |
| + filepath.Join(t.updateDir(), rearmPendingFile), |
| + filepath.Join(t.updateDir(), "staging"), |
| + filepath.Join(filepath.Dir(t.updateDir()), "guard", "update-begun.json"), |
| + } { |
| + _, err := os.Lstat(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err == nil { |
| + err = errActivation |
| + } |
| + return fmt.Errorf("claim activation: settle the update first: %s: %w", path, err) |
| + } |
| + return nil |
| +} |
| + |
| +func (t activationOffline) updateUnitsSettled(ctx context.Context) error { |
| + for _, name := range []string{"carlos-system-update.service", "carlos-system-recover.service"} { |
| + _, active, err := t.status(ctx, name) |
| + if err != nil || active != "inactive" && active != "failed" { |
| + return fmt.Errorf("claim activation: settle the update first: %s: active=%s", name, active) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// The systemd updater gets CARLOS_OPT_DIR from protected host.env. An |
| +// operator's shell must not redirect admission to a different lock or journal. |
| +func activationConfiguredUpdateRoot(config, override string) (string, error) { |
| + opt := "/opt/carlos" |
| + raw, err := activationRead(config, false) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return "", err |
| + } |
| + if err == nil { |
| + env, err := activationEnvironment(raw) |
| + if err != nil { |
| + return "", err |
| + } |
| + if env["CARLOS_OPT_DIR"] != "" { |
| + opt = env["CARLOS_OPT_DIR"] |
| + } |
| + } |
| + if !filepath.IsAbs(opt) || filepath.Clean(opt) != opt { |
| + return "", errActivation |
| + } |
| + if override != "" && override != opt { |
| + return "", errors.New("claim activation: settle the update first: CARLOS_OPT_DIR differs from protected configuration") |
| + } |
| + return systemRootDir(opt), nil |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 57fc290f1..fbef3d9fc 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -309,6 +309,9 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) { |
| a.ops.logf("system apply: another apply is running; leaving it to finish") |
| return nil |
| } |
| + if err := activationTransactionAllowed(a.etcDir, recover); err != nil { |
| + return err |
| + } |
| if a.floorHook != nil { |
| if err := a.floorHook(a); err != nil { |
| return err |
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go| index ce722e39c..99d040bd3 100644 |
| --- a/internal/host/systemunits.go |
| +++ b/internal/host/systemunits.go |
| @@ -64,6 +64,11 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st |
| return |
| } |
| + if err := activationAdoptionAllowed(etcDir); err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| + |
| claimSettings, settingsErr := readClaimUnitSettings(filepath.Join(etcDir, "carlos", "host.env")) |
| if settingsErr != nil { |
| logf("adopt: instance-control unit settings: %v", settingsErr) |
diff --git a/internal/provision/testdata/plan14-root-reads-activation-tools.tsv b/internal/provision/testdata/plan14-root-reads-activation-tools.tsv| index 94bb7bdbe..fe9b6a020 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–5: proposals only; loaded after all existing overlays. |
| +# Activation-tools runs 1–6: proposals only; loaded after all existing overlays. |
| @coverage . efeeda08e816fc2c7e3aa451b06cce95ab5778af79682290cf98a60d8b9a841a |
| @coverage internal/claimactivation 625d82dce14bae92f619077ca92a8c9066a9446545dfcc96a6f818e89e20d4c2 |
| -@coverage internal/host cd645b7f97bbb52b333f7fe60419d2d8b8cc41d9237d70b38495c525e8528167 |
| +@coverage internal/host e9a3134343f930cb50566629d2c2d8e3613e75557036efa67158af6a52c113f2 |
| @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 9443a714df9d6a2dbebb48986ae8ef0028a724a853098b316fa985d9a69b9e8b |
| +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 aef08108659e86b3fd122d584b417a82ae0dfdb8cd053e46577c02014d2daeb2 |
| 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 |
| @@ -22,20 +22,20 @@ internal/host/activation_install_linux.go installActivationConsole authority G,J |
| internal/host/activation_install_linux.go installActivationControl 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:ReadFile@616 16e3c76c1d1d06fc429725cfeaee21b17d6a37620da287b40482b12e2f6b20a5 |
| internal/host/activation_install_linux.go installActivationEvidenceKey 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 74f311fcb31aec95f5760ae9781186cc44a6f650858349c379e82f3a1fc6fd10 |
| internal/host/activation_install_linux.go installActivationFenceCredentials 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 9e18de6df5fb9d3e6898967c9ed91e72d19faf5fd6bff7331ee654d76dfe7075 |
| -internal/host/activation_offline_linux.go activationOffline.begin 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 c043caa233ba91721b2a7a7c5792d847b2ebd129ac2975160e4af224bef349fe |
| -internal/host/activation_offline_linux.go activationOffline.checkItem 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 db9c3080a32a91bbc92b9d57b26807a87bff55950b9b5405f0917101f30a0186 |
| -internal/host/activation_offline_linux.go activationOffline.dirs 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:Stat@384 cdbba773717e890be772460b0f8235b4f7f0e158a11977740a2c963aa3a1c923 |
| -internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 34292b297a697dbb9fd4c1b067900a7cdcdd0b41dba0d7c7dc9764f633424e43 |
| -internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861 |
| -internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1 |
| -internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call 6ffaa998c2441b9a2d7889275b526bace11feb269df6856527b199a40cfa5bec |
| -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=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior b41ef5f53c7afe6f1f09fb61f2fd7f617632875f373957b76204d71ea946ce49 |
| -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 372c65c44288c766e9b2aa0ece8cbc55c95fb1ccae7e9bae80a5bfcda2550b4b |
| -internal/host/activation_offline_linux.go activationUnitSnapshot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@135,file:ReadFile@819,file:Readlink@539 bcb4d45f273e849f03715cfb3a6b49b25b3e128d55e88b15a9528175e433ad04 |
| -internal/host/activation_offline_linux.go sameActivationFile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit operator CLI uid 0;basis=explicit root operator command only; protected ancestry, exact inputs, retained mask ownership and durable readback; no automatic claims-off call file:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b |
| +internal/host/activation_offline_linux.go activationOffline.begin authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 5a266bc0738e51f4813bf83c54ef7e0fb7d29fbf73af8e2c86ffa10e16049843 |
| +internal/host/activation_offline_linux.go activationOffline.checkItem authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 55e4f594ebd4666d8b5eff5eb995a163a6f9ef3838b1d6db5f705a90b93a16e8 |
| +internal/host/activation_offline_linux.go activationOffline.dirs authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Stat@384 cdbba773717e890be772460b0f8235b4f7f0e158a11977740a2c963aa3a1c923 |
| +internal/host/activation_offline_linux.go activationOffline.point authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior c28de19acebdc98b1b836151fbba47103ccb431f40d79984fee55a0b921ad0e0 |
| +internal/host/activation_offline_linux.go activationOffline.prepare authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@947,file:Readlink@979 f3723f40487a5fe0fd43a8369be180b3238c808d9ce117612d744058a9fb0861 |
| +internal/host/activation_offline_linux.go activationOffline.preview authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior a092bb6fb96f64e406a266cef59a4516145a7f58935ff8d1bbc42a96a653aab1 |
| +internal/host/activation_offline_linux.go activationOffline.read authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 6ffaa998c2441b9a2d7889275b526bace11feb269df6856527b199a40cfa5bec |
| +internal/host/activation_offline_linux.go activationOffline.replace authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 2a64167ac7053a5c73fe64aa9fea7e4f8db3b1409c7e34fa78c1b842328ccdbb |
| +internal/host/activation_offline_linux.go activationOffline.restore authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior; only recorded-active unmasked triggers defer; all other trigger states compare exactly df2ea187884d439b7b6cac083b0ff53224bd680ab21d3f2deced4296cbcb41e0 |
| +internal/host/activation_offline_linux.go activationOffline.save authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 3082a1585eb287687e05756f495aae7de7e11aab6e96bbee920c50b4807ab9b7 |
| +internal/host/activation_offline_linux.go activationOffline.status authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 616c3058401766ada7c16395412aaca50b4d68be8d3a2e57ae969a53e21292ff |
| +internal/host/activation_offline_linux.go activationOfflineUnits authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 372c65c44288c766e9b2aa0ece8cbc55c95fb1ccae7e9bae80a5bfcda2550b4b |
| +internal/host/activation_offline_linux.go activationUnitSnapshot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@135,file:ReadFile@842,file:Readlink@562 c55777e2f221a6e9c6f2ab5a66fb875596e336e0b403b3479d6c7c04d4591188 |
| +internal/host/activation_offline_linux.go sameActivationFile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@107,file:Lstat@53 72cf415a823352f1fcf5aba8452df074896aacc851da3dfea9a635b37c44669b |
| internal/hostfence/activation_credentials.go ActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 63ffd51457e632687bc44f776fd5c0335748a899e623ff67a0dea31be4ffa7f2 |
| internal/hostfence/activation_credentials.go ValidateActivationCredentials authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal 9a08dd0349bd4bd43ef44d6e621876d43e8c151dcb355ef94c9f134776f0f4d9 |
| internal/hostfence/activation_credentials.go VerifyActivationCredentialReceipt authority F,J4 rev3=PROPOSED-activation-tools;identity=root fence service and explicit installer uid 0;basis=dedicated root fence credentials retain 0400 compatibility; 0600 needs a matching protected installation receipt; absent explicit installation keeps prior refusal bd06fbd520d6808a635f8e3a4a8a5b8a389beb1d1374d56848853b1ad6855d84 |
| @@ -60,11 +60,19 @@ internal/host/activation_seed_linux.go activationOffline.seedRemask authority G, |
| 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 |
| -internal/host/activation_adoption_linux.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior 9f5c63bae1686a4eced50eed59019280bd1c7beb6e1e9f5d1efbd5994e2011d7 |
| +internal/host/activation_adoption_linux.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 9f5c63bae1686a4eced50eed59019280bd1c7beb6e1e9f5d1efbd5994e2011d7 |
| internal/host/activation_adoption_other.go activationAdoptionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 9a70a93e601ea57fe2ce1780cc6cf878ea3cde656770fe8c400d346fbdbca361 |
| -internal/host/activation_offline_linux.go var activationEdgeUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. 42c79dbb523cf08c19f23d2e4f1754902524276b86f58f0d3d5bfb6117d3fcc6 |
| +internal/host/activation_offline_linux.go var activationEdgeUnit authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior 42c79dbb523cf08c19f23d2e4f1754902524276b86f58f0d3d5bfb6117d3fcc6 |
| internal/host/agent.go runAdoptUnderRootRelease authority B3,E1,E2,E3 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. rev3=PROPOSED-p3;run=16;identity=root adopt/update/control uid 0;basis=no census selects legacy proof/history and update effects; proof observation cannot fetch signed recovery manifests on that path; participating adoption retains root release exclusion and independent generation pins whole:Route@11179,whole:Route@18368,whole:Route@18404,whole:Route@8481 2f755c7ed82fcac622a118d1e4ad0b5d746de2b97e0456e125114e12351cd0c8 |
| -internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;identity=root adoption or explicit offline operator;basis=only an explicit protected offline transaction excludes adoption and automatic repair until done; absent transaction preserves existing behavior; complete is durable before edge release; prior authority constraints retained. root-input;kept=F;scope=protected-decision-only file:OpenFile@375,file:ReadFile@3018,file:ReadFile@3151,file:ReadFile@4065,file:Stat@2927 25aa3ae39d4ccbb5aa041caceb98fe2512c4a0ccfe1a1d34776750d46eff3a06 |
| -internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior file:OpenFile@572 c323ba31cd6960119b09163c7eff7f4e7a86b3d78eeffe14baa96e1ced24847c |
| -internal/host/activation_adoption_linux.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior file:Lstat@157,file:Stat@377 2db0bde7302c754e9115cefc4bb211e1ab479383667067f6e1872c944f64a0c3 |
| -internal/host/activation_adoption_other.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=root recovery or explicit offline operator;basis=complete permits recovery only; all recorded services and triggers reconciled before durable done; done releases repair before ordinary guarded edge start; pending edge continuation does not reopen transaction; absent transaction preserves existing behavior 8c451f30ca31ccca7618f0fd6126ed7d31267c74213c0adb6475e6d504c80723 |
| +internal/host/systemunits.go ensureSystemUnits authority F,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:OpenFile@375,file:ReadFile@3129,file:ReadFile@3262,file:ReadFile@4176,file:Stat@3038 eda788ec9bfb20192d503aa2ce07f707871de3bba111830f00bc2be0b5c8daa2 |
| +internal/host/systemapply.go systemApplier.Run authority D,F,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:OpenFile@572 3e714f6843366bdc621c416c6be2a55f1d3ed5de53f1ee12948f73fe855f6996 |
| +internal/host/activation_adoption_linux.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=all automatic writers excluded through complete; only durable done releases ordinary recovery and repair; absent transaction retains legacy behavior file:Lstat@157,file:Stat@377 fb9878a1a768b7faf9244702c330a14e062fe4e6e6b3b0753ae13034384dcf4a |
| +internal/host/activation_adoption_other.go activationTransactionAllowed authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=all automatic writers excluded through complete; only durable done releases ordinary recovery and repair; absent transaction retains legacy behavior 8c451f30ca31ccca7618f0fd6126ed7d31267c74213c0adb6475e6d504c80723 |
| +internal/host/activation_settled_linux.go activationOffline.settled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Openat@663,file:Stat@1011 176b2ba6cd9eb6af75ded2a7e27c4f6f8cd6ff1107f5949b1c40f884e651644a |
| +internal/host/activation_settled_linux.go activationOffline.updateSettled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior file:Lstat@317 38adccbf4e50966a54a966af12c3afffc8c02dbcdf43825423bb60ee92eb9c80 |
| +internal/host/activation_offline_linux.go activationOffline.closeDifferences authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior; only recorded-active unmasked triggers defer; all other trigger states compare exactly a6f36043481b7219e399f0711e76b8150f82a24e977f3a648d58e8592b58628b |
| +internal/host/activation_offline_linux.go activationOffline.reconcile authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior c4f48a941e6cd7ea2d1657ecb7356f1c2978fa879574906af3df3700178d474e |
| +internal/host/activation_settled_linux.go activationOffline.updateDir authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator or root updater;basis=settled update lock and journal precondition; all writers excluded until done; bounded full close verification with held triggers and masked edge; pending trigger and edge continuation after durable done; absent transaction retains legacy behavior d729025e9d11aa6007ec09318e5e4b84cd14ffda17059d305bbbffe982740fb1 |
| +internal/host/activation_settled_linux.go activationOffline.updateUnitsSettled authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=system update and recovery units must be inactive or failed on admission and resume; transient and unknown states refuse before transaction changes f220fdee955f9a1f27d00f2f7678f143aaf15ac0819e3ec5e208b951b8939c1f |
| +internal/host/activation_offline_linux.go activationRecordedState authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=exact recorded load and active state; boolean fallback only for older records 99ed5f22fad14f6a8dc572ec7166a1a04415f7067dd1cb4e624b2ad5797947f9 |
| +internal/host/activation_settled_linux.go activationConfiguredUpdateRoot authority G,J4 rev3=PROPOSED-activation-tools;identity=explicit root operator;basis=protected host.env binds admission to actual updater lock and journals; conflicting shell override refused 8f9e59c225857ef0601d1f6800bc7e0938f0800589dfaf614c6df93b854dcee1 |