WIP claim Plan 14 track E checkpoint 18 (not for merge): E2 claims-on root launch reservations; also the E1 operator workflow's CLI files (history_cli.go, proof_cli.go, docs/site/cli.md) left out of the checkpoint 17 commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+1295
−9
docs/site/cli.md+43 −0history_cli.go+198 −0history_cli_test.go+173 −0internal/host/claimlaunch_production.go+1 −0internal/host/claimlaunch_root.go+22 −2internal/host/plan14_root_generations_test.go+321 −0internal/host/rootgenerations_launch.go+167 −0internal/provision/plan9_b8_census_test.go+1 −0internal/provision/testdata/plan14-root-reads-e.tsv+29 −6internal/release/plan14_root_generations_test.go+171 −0internal/release/rootgenerations.go+166 −0proof_cli.go+3 −1
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 345.564s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.169s
ok github.com/carlosframework/platform/cmd/moveprobe 93.199s
ok github.com/carlosframework/platform/cmd/zddprobe 0.704s
ok github.com/carlosframework/platform/internal/activator 233.952s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.018s
ok github.com/carlosframework/platform/internal/appconfig 2.389s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.131s
ok github.com/carlosframework/platform/internal/awsinv 0.005s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.805s
? 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.008s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.720s
ok github.com/carlosframework/platform/internal/changelog 0.291s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 363.766s
? 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.572s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
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.141s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.079s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.124s
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.016s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.157s
ok github.com/carlosframework/platform/internal/host 134.089s
ok github.com/carlosframework/platform/internal/hostactors 0.308s
ok github.com/carlosframework/platform/internal/hostbox 2.304s
ok github.com/carlosframework/platform/internal/hostclaim 0.503s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 4.877s
ok github.com/carlosframework/platform/internal/hostfence 0.033s
ok github.com/carlosframework/platform/internal/hostfiles 10.493s
ok github.com/carlosframework/platform/internal/hostfloor 0.013s
ok github.com/carlosframework/platform/internal/hostguard 1.013s
ok github.com/carlosframework/platform/internal/hostops 1.465s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.012s
ok github.com/carlosframework/platform/internal/hostquarantine 0.012s
ok github.com/carlosframework/platform/internal/hostrelocate 0.462s
ok github.com/carlosframework/platform/internal/hostresolve 0.160s
ok github.com/carlosframework/platform/internal/hostwrite 0.014s
ok github.com/carlosframework/platform/internal/infraview 0.014s
ok github.com/carlosframework/platform/internal/instancectl 1.827s
ok github.com/carlosframework/platform/internal/instanceexec 2.706s
ok github.com/carlosframework/platform/internal/instancefs 0.016s
ok github.com/carlosframework/platform/internal/instancekey 0.014s
ok github.com/carlosframework/platform/internal/instanceupdates 0.019s
ok github.com/carlosframework/platform/internal/layout 1.457s
ok github.com/carlosframework/platform/internal/lease 9.468s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.177s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.528s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.075s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.061s
ok github.com/carlosframework/platform/internal/moves/archive 0.782s
ok github.com/carlosframework/platform/internal/objstore 0.177s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.207s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.633s
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 4.033s
ok github.com/carlosframework/platform/internal/relay 7.241s
ok github.com/carlosframework/platform/internal/release 20.705s
ok github.com/carlosframework/platform/internal/rollout 3.727s
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.012s
ok github.com/carlosframework/platform/internal/rootstate 0.025s
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.294s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.505s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.139s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.130s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.007s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.002s
ok github.com/carlosframework/platform/internal/updatewords 0.006s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.560s
=== 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
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 06:01:51 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE500B4F737699, 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.023s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md| index e2bc11208..72df28d99 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1882,6 +1882,49 @@ refused. |
| `get` and `put` work only with `CARLOS_DEPLOYMENT_BUCKET` or |
| `CARLOS_DEPLOYMENT_DIR` set, because a proof lives only in a bucket. |
| +#### carlos proof history |
| + |
| +Read or reconstruct a box's root release history. These operator commands use |
| +`CARLOS_DEPLOYMENT_BUCKET` (or `CARLOS_DEPLOYMENT_DIR` for a local store), |
| +`CARLOS_RELEASE_PUBKEY` and the deployment root signing key in |
| +`CARLOS_RELEASE_KEY`. Scoped signing keys cannot authorize this work. |
| + |
| +```sh |
| +carlos proof history status --bucket <bucket> --box <box> --enrollment <id> --account <sqid> --app <app> --host <host> --state status.json |
| +``` |
| + |
| +The command saves its signed request before delivery, then waits up to 30 |
| +seconds for a signed response from the enrolled box. The JSON response includes |
| +category completeness, revisions, pending reconstruction and retained recovery |
| +outcomes. A successful status read does not mean migration is complete. |
| + |
| +To reconstruct history, prepare a JSON `HistoryReconstruction` decision using |
| +the identity, revision, app revision, bake revision and host sources from that |
| +response. Set `schema` to `1`, `kind` to `carlos-release-history`, a unique |
| +`operation` and the current `issued_at` time. Name every affected host and its |
| +revision and refusals. Explicitly state the historical `production`, |
| +`production_set_at` and `holds` bounds; hold windows use nanoseconds. Review |
| +those decisions before signing. For an interrupted reconstruction, `replaces` |
| +must name the exact `pending_digest`. Root retains the pending decision's denies. |
| + |
| +```sh |
| +carlos proof history reconstruct --bucket <bucket> --box <box> --enrollment <id> --account <sqid> --app <app> --host <host> --input decision.json --state reconstruction.json |
| +carlos proof history resume --bucket <bucket> --state reconstruction.json |
| +``` |
| + |
| +`reconstruct` succeeds only after the box confirms the exact signed decision. |
| +It does not retire independent recovery outcomes or establish a fresh install. |
| +`retire` takes the same flags with a `HistoryRecoveryRetirement` decision: |
| +`schema: 1`, `kind: carlos-release-recovery-retirement`, `identity`, `operation`, |
| +`issued_at`, `binding`, `source`, `attempt_id`, `outcome_digest`, `verified_id` |
| +and `verified_digest`. The witness must be a later root-verified success. |
| +Pending originals and unrelated outcomes remain restrictive. |
| + |
| +Use a new `--state` file for each decision. Keep it if the command times out or |
| +is interrupted. `resume` reuses the saved authorization and challenge without |
| +a signing key. It does not refresh their 24-hour validity. `--wait` changes the |
| +readback wait. A delivery acknowledgement alone never reports completion. |
| + |
| ### carlos economics |
| Records one month's AWS bill so the console's economics dashboard has a real |
diff --git a/history_cli.go b/history_cli.go| new file mode 100644 |
| index 000000000..bff69965a |
| --- /dev/null |
| +++ b/history_cli.go |
| @@ -0,0 +1,198 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/historyreadback" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +const historyUsage = "usage: carlos proof history <status|reconstruct|retire|resume> --bucket <bucket> --state <file> [--box <box> --enrollment <id> --account <sqid> --app <app> --host <host>] [--input <file>] [--wait <duration>]" |
| +const historyPending = "History readback is not complete. Resume with the same --state file. Last result: %v" |
| + |
| +type historyConnect func(string, bool) (hostactors.Directory, release.Signer, error) |
| + |
| +func runHistory(args []string) { |
| + err := historyCommand(context.Background(), args, func(bucket string, resume bool) (hostactors.Directory, release.Signer, error) { |
| + if configured := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); configured != "" && configured != bucket { |
| + return hostactors.Directory{}, release.Signer{}, errors.New("--bucket must match CARLOS_DEPLOYMENT_BUCKET") |
| + } |
| + d := hostactors.Directory{Store: openStore(), PublicKey: publicKey(), Bucket: bucket} |
| + if resume { |
| + return d, release.Signer{}, nil |
| + } |
| + return d, releaseSigner(), nil |
| + }, stdout) |
| + if err != nil && !errors.Is(err, flag.ErrHelp) { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +func historyCommand(ctx context.Context, args []string, connect historyConnect, out io.Writer) error { |
| + return historyCommandWithSave(ctx, args, connect, out, historySave) |
| +} |
| + |
| +func historyCommandWithSave(ctx context.Context, args []string, connect historyConnect, out io.Writer, save func(string, historyreadback.Operation) error) error { |
| + if len(args) == 0 { |
| + return errors.New(historyUsage) |
| + } |
| + verb := args[0] |
| + if verb != "status" && verb != "reconstruct" && verb != "retire" && verb != "resume" { |
| + return errors.New(historyUsage) |
| + } |
| + fs := flag.NewFlagSet("proof history "+verb, flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + bucket := fs.String("bucket", os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), "deployment bucket named in the signed box roster") |
| + box := fs.String("box", "", "box named in the signed roster") |
| + enrollment := fs.String("enrollment", "", "current box enrollment ID") |
| + account := fs.String("account", "", "account sqid") |
| + app := fs.String("app", "", "app name") |
| + host := fs.String("host", "", "canonical instance hostname") |
| + state := fs.String("state", "", "save the signed operation here before delivery; resume reuses this file") |
| + input := fs.String("input", "", "JSON reconstruction or retirement decision to sign") |
| + wait := fs.Duration("wait", 30*time.Second, "how long to wait for verified root readback") |
| + if err := fs.Parse(args[1:]); err != nil { |
| + return err |
| + } |
| + resume := verb == "resume" |
| + scope := historyreadback.Scope{Bucket: *bucket, Box: *box, Enrollment: *enrollment, Binding: release.HistoryBinding{Account: *account, App: *app, Host: *host}} |
| + if fs.NArg() != 0 || *bucket == "" || *state == "" || *wait <= 0 || (resume && (*input != "" || *box != "" || *enrollment != "" || *account != "" || *app != "" || *host != "")) || (!resume && (*box == "" || *enrollment == "" || *account == "" || *app == "" || *host == "")) || ((verb == "reconstruct" || verb == "retire") != (*input != "")) { |
| + return errors.New(historyUsage) |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, *wait) |
| + defer cancel() |
| + var op historyreadback.Operation |
| + var reconstruction *release.HistoryReconstruction |
| + var retirement *release.HistoryRecoveryRetirement |
| + if resume { |
| + if err := historyReadFile(*state, &op, true); err != nil { |
| + return err |
| + } |
| + } else if verb == "reconstruct" { |
| + reconstruction = &release.HistoryReconstruction{} |
| + if err := historyReadFile(*input, reconstruction, false); err != nil { |
| + return err |
| + } |
| + } else if verb == "retire" { |
| + retirement = &release.HistoryRecoveryRetirement{} |
| + if err := historyReadFile(*input, retirement, false); err != nil { |
| + return err |
| + } |
| + } |
| + d, signer, err := connect(*bucket, resume) |
| + if err != nil { |
| + return err |
| + } |
| + if !resume { |
| + op, err = historyreadback.PrepareOperation(ctx, d, signer, scope, reconstruction, retirement, time.Now()) |
| + if err != nil { |
| + return err |
| + } |
| + if err = save(*state, op); err != nil { |
| + return err |
| + } |
| + } |
| + if err = op.Deliver(ctx, d, time.Now()); err != nil { |
| + return err |
| + } |
| + for { |
| + receipt, readErr := op.Read(ctx, d, time.Now()) |
| + if readErr == nil { |
| + enc := json.NewEncoder(out) |
| + enc.SetIndent("", " ") |
| + return enc.Encode(receipt) |
| + } |
| + timer := time.NewTimer(time.Second) |
| + select { |
| + case <-ctx.Done(): |
| + timer.Stop() |
| + return fmt.Errorf(historyPending, readErr) |
| + case <-timer.C: |
| + } |
| + } |
| +} |
| + |
| +func historyReadFile(path string, dst any, repair bool) error { |
| + f, err := safefile.OpenFile(path, os.O_RDONLY, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + raw, err := io.ReadAll(io.LimitReader(f, 3*release.MaxRootEnvelopeBytes+1)) |
| + if err != nil { |
| + return err |
| + } |
| + if len(raw) > 3*release.MaxRootEnvelopeBytes { |
| + return os.ErrPermission |
| + } |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.DisallowUnknownFields() |
| + if err = dec.Decode(dst); err != nil { |
| + return err |
| + } |
| + if err = dec.Decode(new(any)); err != io.EOF { |
| + return os.ErrPermission |
| + } |
| + if repair { |
| + return historySync(f, path) |
| + } |
| + return nil |
| +} |
| + |
| +func historySave(path string, op historyreadback.Operation) error { |
| + raw, err := json.Marshal(op) |
| + if err != nil { |
| + return err |
| + } |
| + f, err := safefile.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + if n, err := f.Write(raw); err != nil { |
| + return err |
| + } else if n != len(raw) { |
| + return io.ErrShortWrite |
| + } |
| + return historySync(f, path) |
| +} |
| + |
| +func historySync(f *os.File, path string) error { |
| + if err := f.Sync(); err != nil { |
| + return err |
| + } |
| + parent, err := safefile.OpenDir(filepath.Dir(path)) |
| + if err != nil { |
| + return err |
| + } |
| + defer parent.Close() |
| + current, err := safefile.OpenFile(path, os.O_RDONLY, 0) |
| + if err != nil { |
| + return err |
| + } |
| + defer current.Close() |
| + a, err := f.Stat() |
| + if err != nil { |
| + return err |
| + } |
| + b, err := current.Stat() |
| + if err != nil { |
| + return err |
| + } |
| + if !os.SameFile(a, b) { |
| + return os.ErrPermission |
| + } |
| + return parent.Sync() |
| +} |
diff --git a/history_cli_test.go b/history_cli_test.go| new file mode 100644 |
| index 000000000..609d94d43 |
| --- /dev/null |
| +++ b/history_cli_test.go |
| @@ -0,0 +1,173 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/historyreadback" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPlan14HistoryCommandResume(t *testing.T) { |
| + for _, boundary := range []string{"timeout", "save failure"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + pub, key, _ := ed25519.GenerateKey(nil) |
| + _, boxKey, _ := ed25519.GenerateKey(nil) |
| + signer := release.RootSigner(key) |
| + d := hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"} |
| + enrollment := strings.Repeat("a", 32) |
| + roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": {Enrollment: enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| + wire, err := release.SignRootEnvelope(signer, roster) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.Store.Put(t.Context(), hostactors.Key, wire, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + state := filepath.Join(t.TempDir(), "operation.json") |
| + connect := func(bucket string, resume bool) (hostactors.Directory, release.Signer, error) { |
| + if bucket != "bucket" { |
| + t.Fatal(bucket) |
| + } |
| + if resume { |
| + return d, release.Signer{}, nil |
| + } |
| + return d, signer, nil |
| + } |
| + args := []string{"status", "--bucket", "bucket", "--box", "box", "--enrollment", enrollment, "--account", "abc", "--app", "web", "--host", "web.example", "--state", state, "--wait", "1ms"} |
| + var out bytes.Buffer |
| + save := historySave |
| + if boundary == "save failure" { |
| + save = func(path string, op historyreadback.Operation) error { |
| + if err := historySave(path, op); err != nil { |
| + return err |
| + } |
| + return os.ErrPermission |
| + } |
| + } |
| + if err := historyCommandWithSave(t.Context(), args, connect, &out, save); err == nil { |
| + t.Fatal("missing receipt succeeded") |
| + } |
| + raw, err := os.ReadFile(state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var op historyreadback.Operation |
| + if err = json.Unmarshal(raw, &op); err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "save failure" { |
| + scope := historyreadback.Scope{Bucket: "bucket", Box: "box", Enrollment: enrollment, Binding: release.HistoryBinding{Account: "abc", App: "web", Host: "web.example"}} |
| + if _, _, err := d.Store.Get(t.Context(), historyreadback.RequestKey(scope)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("published after save failure", err) |
| + } |
| + if err := op.Deliver(t.Context(), d, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + var q historyreadback.Request |
| + if err = release.VerifyRootEnvelope(pub, op.Request, &q); err != nil { |
| + t.Fatal(err) |
| + } |
| + status := release.HistoryMigrationStatus{Schema: 1, Account: "abc", App: "web", HistoryReconstructionStatus: release.HistoryReconstructionStatus{Identity: strings.Repeat("c", 64), Revision: 1}, Hosts: []release.HistoryHostStatus{{HistoryReconstructionHost: release.HistoryReconstructionHost{Host: "web.example", Source: "canonical"}}}} |
| + if err = historyreadback.Respond(t.Context(), d, q.Scope, boxKey, time.Now(), func(context.Context) (release.HistoryMigrationStatus, error) { return status, nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + out.Reset() |
| + if err = historyCommand(t.Context(), []string{"resume", "--bucket", "bucket", "--state", state, "--wait", "1ms"}, connect, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + var got historyreadback.Receipt |
| + if err = json.Unmarshal(out.Bytes(), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Status.AppComplete { |
| + t.Fatal("invented migration completion") |
| + } |
| + again, _ := os.ReadFile(state) |
| + if !bytes.Equal(raw, again) { |
| + t.Fatal("resume changed saved operation") |
| + } |
| + if err = historyCommand(t.Context(), args, connect, &out); err == nil { |
| + t.Fatal("overwrote existing operation") |
| + } |
| + |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14HistoryCommandInputRefusals(t *testing.T) { |
| + for _, args := range [][]string{{}, {"wat"}, {"resume"}, {"status", "--state", "x", "--wait", "0s"}, {"resume", "--state", "x", "--input", "other"}, {"status", "--state", "x", "--unknown"}} { |
| + connect := func(string, bool) (hostactors.Directory, release.Signer, error) { |
| + t.Fatal("invalid arguments connected") |
| + return hostactors.Directory{}, release.Signer{}, nil |
| + } |
| + if err := historyCommand(t.Context(), args, connect, &bytes.Buffer{}); err == nil { |
| + t.Fatal("accepted", args) |
| + } |
| + } |
| +} |
| + |
| +func TestPlan14HistoryCLIDispatch(t *testing.T) { |
| + bin := buildCarlos(t) |
| + cmd := exec.CommandContext(t.Context(), bin, "proof", "history", "status", "--help") |
| + cmd.Env = carlosTestEnv(t) |
| + cmd.Dir = t.TempDir() |
| + out, err := cmd.CombinedOutput() |
| + if err != nil || !bytes.Contains(out, []byte("save the signed operation here before delivery")) { |
| + t.Fatalf("dispatch: %v\n%s", err, out) |
| + } |
| +} |
| + |
| +func TestPlan14HistorySavedFiles(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "state.json") |
| + op := historyreadback.Operation{Request: json.RawMessage(`{"saved":"request"}`)} |
| + if err := historySave(path, op); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := historySave(path, historyreadback.Operation{Request: json.RawMessage(`{"other":"request"}`)}); err == nil { |
| + t.Fatal("replaced saved operation") |
| + } |
| + var got historyreadback.Operation |
| + if err := historyReadFile(path, &got, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, _ := os.ReadFile(path) |
| + if !bytes.Equal(before, after) { |
| + t.Fatal("repair changed state") |
| + } |
| + for _, raw := range []string{`{"request":{}`, `{"request":{},"unknown":true}`, `{"request":{}} {}`, strings.Repeat("x", 3*release.MaxRootEnvelopeBytes+1)} { |
| + bad := filepath.Join(t.TempDir(), "bad.json") |
| + if err := os.WriteFile(bad, []byte(raw), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := historyReadFile(bad, &got, true); err == nil { |
| + t.Fatal("accepted incomplete state") |
| + } |
| + } |
| + if runtime.GOOS == "linux" { |
| + alias := filepath.Join(dir, "alias") |
| + if err := os.Symlink(path, alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := historyReadFile(alias, &got, true); err == nil { |
| + t.Fatal("followed state symlink") |
| + } |
| + } |
| +} |
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index f661ca022..2ef8eee94 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -74,6 +74,7 @@ func WithProductionClaimLaunch(ctx context.Context, handler http.Handler) (http. |
| } |
| wrapped.Launch.Local = a.Local |
| wrapped.Launch.history = root.history |
| + wrapped.Launch.generations = productionRootGenerations(a.Claims, a.ManagedRoots["opt"]) |
| wrapped.Launch.installedProgram = wrapped.Launch.rootInstalledProgram |
| return wrapped, nil |
| } |
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go| index 8870c832c..ee3dd2b21 100644 |
| --- a/internal/host/claimlaunch_root.go |
| +++ b/internal/host/claimlaunch_root.go |
| @@ -7,6 +7,7 @@ import ( |
| "fmt" |
| "net" |
| "net/http" |
| + "os" |
| "sort" |
| "strconv" |
| "time" |
| @@ -61,6 +62,7 @@ type ClaimLaunchObserved struct { |
| // A request only names which reservation to run. |
| type ClaimLaunchRoot struct { |
| history rootHistoryAccess |
| + generations *rootGenerationSource |
| installedProgram func(context.Context, hostbox.RoleLaunchRequest, instancectl.ClaimUnitStarted, ClaimLaunchObserved) (string, error) |
| executionState rootstate.Store |
| ValidateExecutable func(host, path, digest string) error |
| @@ -163,6 +165,9 @@ func (h *ClaimLaunchRoot) launchRecord(ctx context.Context, peer StopPeer, t hos |
| func (h *ClaimLaunchRoot) start(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest) (hostbox.RoleLaunchGrant, error) { |
| if h.history == nil { |
| + if h.generations != nil { |
| + return hostbox.RoleLaunchGrant{}, os.ErrPermission |
| + } |
| return h.startRecorded(ctx, peer, q, nil) |
| } |
| var out hostbox.RoleLaunchGrant |
| @@ -219,8 +224,17 @@ func (h *ClaimLaunchRoot) startRecorded(ctx context.Context, peer StopPeer, q ho |
| return err |
| } |
| } |
| + var pin *generationLaunchPermit |
| + if h.generations != nil { |
| + var err error |
| + pin, err = h.generations.reserve(ctx, h, peer, q) |
| + if err != nil { |
| + return err |
| + } |
| + defer pin.ledger.Store.Close() |
| + } |
| var e error |
| - grant, e = h.launchLocked(ctx, peer, q, r) |
| + grant, e = h.launchLocked(ctx, peer, q, r, pin) |
| if e == nil && history != nil { |
| started := instancectl.ClaimUnitStarted{Unit: grant.Unit, InvocationID: grant.UnitInvocationID, MainPID: grant.PID, ControlGroup: grant.Group.RelativePath} |
| observed, err := h.waitReady(ctx, started, r.Executable) |
| @@ -253,7 +267,7 @@ func (h *ClaimLaunchRoot) startRecorded(ctx context.Context, peer StopPeer, q ho |
| return grant, err |
| } |
| -func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest, r hostbox.LaunchReservation) (hostbox.RoleLaunchGrant, error) { |
| +func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest, r hostbox.LaunchReservation, pin *generationLaunchPermit) (hostbox.RoleLaunchGrant, error) { |
| t := r.Target |
| if err := h.launchRecord(ctx, peer, t); err != nil { |
| return hostbox.RoleLaunchGrant{}, err |
| @@ -330,6 +344,9 @@ func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hos |
| if err := h.authorize(ctx, peer, q, fresh.Claim); err != nil { |
| return err |
| } |
| + if err := pin.check(); err != nil { |
| + return err |
| + } |
| _, boot, err := h.Clock.Now() |
| if err != nil { |
| return err |
| @@ -350,6 +367,9 @@ func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hos |
| if err == nil { |
| err = h.Journal.StartedUnit(ctx, r, group, started.MainPID, observed.ProcessStart) |
| } |
| + if err == nil { |
| + err = pin.started(hostbox.RoleLaunchGrant{PID: started.MainPID, ProcessStart: observed.ProcessStart, Group: group, UnitInvocationID: started.InvocationID}, observed) |
| + } |
| if err != nil { |
| // A replacement detected while waiting is never stopped by the old |
| // reservation. Settle only if root can prove the old invocation gone. |
diff --git a/internal/host/plan14_root_generations_test.go b/internal/host/plan14_root_generations_test.go| new file mode 100644 |
| index 000000000..877ea6dc4 |
| --- /dev/null |
| +++ b/internal/host/plan14_root_generations_test.go |
| @@ -0,0 +1,321 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +type generationDisk struct { |
| + *rollbackDisk |
| + fail int |
| +} |
| + |
| +func (s *generationDisk) Write(k string, b []byte) error { |
| + if err := s.rollbackDisk.Write(k, b); err != nil { |
| + return err |
| + } |
| + if s.fail > 0 { |
| + s.fail-- |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func generationLaunchFixture(t *testing.T) (*launchFixture, *generationDisk, provision.Record) { |
| + t.Helper() |
| + f := newLaunchFixture(t, servingOp()) |
| + store := release.NewDirStore(t.TempDir()) |
| + rec := provision.Record{Host: f.target.Host, Account: "bab", App: "shop", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)} |
| + plan14PutRecord(t, store, rec) |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + previous, err := f.journal.Read(t.Context(), f.target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.target.Incarnation = inc |
| + f.req.Reservation.Target = f.target |
| + previous.Target = f.target |
| + previous.Groups[0].RelativePath = filepath.Join("system.slice/carlos-edge.service", cgroup.ExecName(claimContainmentName(f.target))) |
| + if err := f.journal.Prelaunch(t.Context(), previous); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.claims.set(func(c *hostclaim.Claim) { |
| + c.Holder.Incarnation = inc |
| + c.Holder.Account = rec.Account |
| + c.Holder.App = rec.App |
| + c.Holder.Key = provision.RecordKey(rec.Account, rec.App, rec.Host) |
| + c.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: f.target.Box, Region: f.target.Region, Session: f.target.Session, Generation: c.Holder.Generation}} |
| + }) |
| + f.root.Guard.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()}) |
| + in := hostguard.RootInstallation{Host: rec.Host, Account: rec.Account, App: rec.App, Record: provision.RecordKey(rec.Account, rec.App, rec.Host), Incarnation: inc, Stamp: f.target.Stamp, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, InventoryRevision: 1} |
| + if err := f.root.Guard.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + history := &release.RootHistory{Store: &rollbackDisk{dir: t.TempDir()}} |
| + if err := history.RecordCreation(release.HistoryBinding{Host: rec.Host, Account: rec.Account, App: rec.App}, rootHistoryInstallation(in)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.root.history = func(ctx context.Context, fn func(*release.RootHistory) error) error { return fn(history) } |
| + f.root.installedProgram = func(context.Context, hostbox.RoleLaunchRequest, instancectl.ClaimUnitStarted, ClaimLaunchObserved) (string, error) { |
| + return "observed/program", nil |
| + } |
| + opt := t.TempDir() |
| + body := []byte("the signed build") |
| + if err := release.StageArtifact(opt, rec.Account, "v1", rec.App, body); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := release.VersionPath(opt, rec.Account, "v1", rec.App) |
| + file, err := os.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := rootstate.Identity(file) |
| + file.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + f.kernel.path = path |
| + f.kernel.digest = hex.EncodeToString(sum[:]) |
| + f.kernel.want = hostbox.LaunchFile{Device: id.Device, Inode: id.Inode} |
| + f.kernel.observed.Executable = f.kernel.want |
| + f.kernel.observed.Running = f.kernel.want |
| + f.req.Executable = path |
| + f.req.Reservation.Executable = f.kernel.want |
| + f.req.Spec.Executable = path |
| + f.req.Spec.Digest = f.kernel.digest |
| + digest, err := f.req.Spec.Hash() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.req.Reservation.Inputs = digest |
| + f.claims.set(func(c *hostclaim.Claim) { |
| + a := *c.Holder.WakeRetry.Attempt |
| + a.LaunchInputs = digest |
| + r := *c.Holder.WakeRetry |
| + r.Attempt = &a |
| + c.Holder.WakeRetry = &r |
| + }) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m := release.Manifest{App: rec.App, Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}} |
| + if err := release.Ship(t.Context(), store, rec.Account, m, map[string][]byte{release.AdoptPlatform(): body}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(t.Context(), store, priv, rec.Account, rec.App, "canary/console", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + disk := &generationDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}} |
| + f.root.generations = &rootGenerationSource{store: store, optDir: opt, pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return disk, nil }, openFile: func(p string) (*os.File, error) { return safefile.OpenFile(p, os.O_RDONLY, 0) }} |
| + return f, disk, rec |
| +} |
| + |
| +func readGenerationLaunch(t *testing.T, d *generationDisk) release.GenerationReservation { |
| + t.Helper() |
| + names, err := d.Names() |
| + if err != nil || len(names) != 1 { |
| + t.Fatalf("reservations: %v %v", names, err) |
| + } |
| + raw, err := d.Read(names[0]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var r release.GenerationReservation |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + t.Fatal(err) |
| + } |
| + return r |
| +} |
| + |
| +func TestPlan14GenerationReservedBeforeClaimSpawn(t *testing.T) { |
| + f, d, _ := generationLaunchFixture(t) |
| + checked := false |
| + f.units.beforeCheck = func() { |
| + r := readGenerationLaunch(t, d) |
| + if r.ID == "" || r.Binding.Path != f.req.Executable || r.Binding.Digest != f.req.Spec.Digest || r.Binding.Binding.Account != "bab" || r.Binding.Binding.App != "shop" || r.Process.PID != 0 { |
| + t.Fatalf("pre-spawn record: %+v", r) |
| + } |
| + checked = true |
| + } |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !checked { |
| + t.Fatal("spawn boundary was not checked") |
| + } |
| + r := readGenerationLaunch(t, d) |
| + if r.Process.PID != f.units.started.MainPID || r.Process.TenantPID != f.kernel.observed.TenantPID || r.Process.GroupIdentity.Inode != f.kernel.observed.Group.Inode { |
| + t.Fatalf("missing process evidence: %+v", r) |
| + } |
| + if err := f.root.stop(t.Context(), f.peer, *f.req.Reservation); err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := readGenerationLaunch(t, d); after != r { |
| + t.Fatal("unit settlement retired the generation pin") |
| + } |
| +} |
| + |
| +func TestPlan14GenerationLaunchRefusals(t *testing.T) { |
| + for _, attack := range []string{"open", "sync", "no-lock", "unsigned", "wrong-account", "wrong-digest", "tree-replaced", "record-changed", "reservation-erased"} { |
| + t.Run(attack, func(t *testing.T) { |
| + f, d, rec := generationLaunchFixture(t) |
| + switch attack { |
| + case "open": |
| + f.root.generations.open = func() (rootstate.Store, error) { return nil, os.ErrPermission } |
| + case "sync": |
| + d.fail = 1 |
| + case "no-lock": |
| + f.root.history = nil |
| + case "unsigned": |
| + f.root.generations.pub = func() (ed25519.PublicKey, error) { p, _, e := ed25519.GenerateKey(nil); return p, e } |
| + case "wrong-account": |
| + rec.Account = "bap" |
| + plan14PutRecord(t, f.root.generations.store, rec) |
| + if err := f.root.generations.store.Delete(t.Context(), provision.RecordKey("bab", rec.App, rec.Host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "wrong-digest": |
| + if err := os.WriteFile(f.req.Executable, []byte("replaced"), 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "tree-replaced": |
| + f.units.beforeCheck = func() { |
| + dir := filepath.Dir(f.req.Executable) |
| + if err := os.Rename(dir, dir+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(dir, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(filepath.Join(dir+".old", filepath.Base(f.req.Executable)), f.req.Executable); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + case "record-changed": |
| + f.units.beforeCheck = func() { rec.Environment = "changed"; plan14PutRecord(t, f.root.generations.store, rec) } |
| + case "reservation-erased": |
| + f.units.beforeCheck = func() { |
| + names, err := d.Names() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, n := range names { |
| + if err := d.Remove(n); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + } |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil { |
| + t.Fatal("unreserved launch succeeded") |
| + } |
| + if n, _ := f.units.counts(); n != 0 { |
| + t.Fatalf("refused launch started %d units", n) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationStartedSyncFailureRetainsPin(t *testing.T) { |
| + f, d, _ := generationLaunchFixture(t) |
| + f.kernel.observe = func(instancectl.ClaimUnitStarted) (ClaimLaunchObserved, error) { |
| + d.fail = 1 |
| + return f.kernel.observed, nil |
| + } |
| + if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil { |
| + t.Fatal("unsynced launch reported success") |
| + } |
| + if n, stops := f.units.counts(); n != 1 || stops != 1 { |
| + t.Fatalf("starts=%d stops=%d", n, stops) |
| + } |
| + r := readGenerationLaunch(t, d) |
| + if r.ID == "" || r.Binding.Path != f.req.Executable { |
| + t.Fatal("lost failed launch pin") |
| + } |
| + d.fail = 0 |
| + if _, err := (&release.RootGenerations{Store: d}).Read(r.Binding); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationReservationProcessCrash(t *testing.T) { |
| + if boundary := os.Getenv("PLAN14_GENERATION_CRASH"); boundary != "" { |
| + f, d, _ := generationLaunchFixture(t) |
| + d.dir = os.Getenv("PLAN14_GENERATION_DIR") |
| + d.afterWrite = func(_ string, raw []byte) { |
| + var r release.GenerationReservation |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + t.Fatal(err) |
| + } |
| + if boundary == "reserved" && r.Revision == 1 || boundary == "observed" && r.Revision == 2 { |
| + os.Exit(73) |
| + } |
| + } |
| + if boundary == "before-spawn" { |
| + f.units.beforeCheck = func() { os.Exit(73) } |
| + } |
| + _, err := f.root.Serve(t.Context(), f.peer, f.req) |
| + t.Fatal("crash boundary not reached", err) |
| + } |
| + for _, boundary := range []string{"reserved", "before-spawn", "observed"} { |
| + t.Run(boundary, func(t *testing.T) { |
| + dir := t.TempDir() |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14GenerationReservationProcessCrash$", "-test.count=1") |
| + cmd.Env = append(os.Environ(), "PLAN14_GENERATION_CRASH="+boundary, "PLAN14_GENERATION_DIR="+dir) |
| + out, err := cmd.CombinedOutput() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 73 { |
| + t.Fatalf("child: %v %s", err, out) |
| + } |
| + d := &generationDisk{rollbackDisk: &rollbackDisk{dir: dir}} |
| + r := readGenerationLaunch(t, d) |
| + got, err := (&release.RootGenerations{Store: d}).Read(r.Binding) |
| + if err != nil || got != r || r.ID == "" || r.Binding.Executable.Inode == 0 || r.Binding.TreeIdentity.Inode == 0 { |
| + t.Fatalf("crash lost pin: %+v %v", got, err) |
| + } |
| + if (r.Process.PID > 0) != (boundary == "observed") { |
| + t.Fatalf("wrong process evidence: %+v", r) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationProductionWiring(t *testing.T) { |
| + f, _, _ := generationLaunchFixture(t) |
| + root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }, Coordinator: ClaimStopCoordinator{Kernel: stopKernelFunc(nil), Guard: f.root.Guard}}} |
| + assembly := &ClaimAssembly{Claims: f.root.generations.store, ManagedRoots: map[string]string{"opt": f.root.generations.optDir, "data": "/data"}, Local: f.root.Local} |
| + ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Assembly: assembly}) |
| + handler, err := WithProductionClaimLaunch(ctx, root) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + launch := handler.(ClaimRootWithLaunch).Launch |
| + if launch.generations == nil || launch.generations.open == nil || launch.generations.openFile == nil || launch.generations.pub == nil || launch.generations.store != assembly.Claims || launch.history == nil { |
| + t.Fatal("production launch lacks a mandatory root reservation") |
| + } |
| +} |
diff --git a/internal/host/rootgenerations_launch.go b/internal/host/rootgenerations_launch.go| new file mode 100644 |
| index 000000000..3750ee2ec |
| --- /dev/null |
| +++ b/internal/host/rootgenerations_launch.go |
| @@ -0,0 +1,167 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +type rootGenerationSource struct { |
| + store release.Store |
| + optDir string |
| + pub func() (ed25519.PublicKey, error) |
| + open func() (rootstate.Store, error) |
| + openFile func(string) (*os.File, error) |
| +} |
| + |
| +func productionRootGenerations(store release.Store, optDir string) *rootGenerationSource { |
| + return &rootGenerationSource{store: store, optDir: optDir, |
| + pub: func() (ed25519.PublicKey, error) { |
| + return release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + }, |
| + open: func() (rootstate.Store, error) { return rootstate.Open(release.RootGenerationsDir, true, false) }, |
| + openFile: rootstate.OpenTrustedFile} |
| +} |
| + |
| +// resolve runs under release and host exclusion. The request nominates a build; |
| +// root binds it to its installation, the deployment record and signed manifest. |
| +func (s *rootGenerationSource) resolve(ctx context.Context, h *ClaimLaunchRoot, peer StopPeer, q hostbox.RoleLaunchRequest) (release.GenerationBinding, error) { |
| + var b release.GenerationBinding |
| + if s == nil || s.store == nil || s.pub == nil || s.openFile == nil || h.Guard.Root == nil || q.Reservation == nil || q.Spec == nil { |
| + return b, os.ErrPermission |
| + } |
| + t := q.Reservation.Target |
| + v, err := h.Guard.Root.View(ctx, t.Host, hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp}) |
| + if err != nil { |
| + return b, err |
| + } |
| + in := v.Installation |
| + if in.Host != t.Host || in.Incarnation != t.Incarnation || in.Stamp != t.Stamp || in.Box != t.Box || in.Enrollment != t.Enrollment || in.InventoryRevision == 0 || in.Record != provision.RecordKey(in.Account, in.App, in.Host) { |
| + return b, os.ErrPermission |
| + } |
| + rec, etag, err := provision.GetRecordETag(ctx, s.store, in.Account, in.App, in.Host) |
| + if err != nil { |
| + return b, err |
| + } |
| + inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| + if err != nil || inc != in.Incarnation || rec.Account != in.Account || rec.App != in.App || rec.Host != in.Host || etag == "" { |
| + return b, os.ErrPermission |
| + } |
| + rel, err := filepath.Rel(filepath.Join(s.optDir, "versions"), q.Executable) |
| + if err != nil || !filepath.IsLocal(rel) { |
| + return b, os.ErrPermission |
| + } |
| + version, _, ok := strings.Cut(rel, string(filepath.Separator)) |
| + if !ok || !release.ValidVersion(version) || q.Executable != release.VersionPath(s.optDir, in.Account, version, in.App) { |
| + return b, os.ErrPermission |
| + } |
| + pub, err := s.pub() |
| + if err != nil { |
| + return b, err |
| + } |
| + m, err := release.AuthorizedManifest(ctx, s.store, pub, in.Account, in.App, version) |
| + if err != nil { |
| + return b, err |
| + } |
| + if m.Kind != "binary" || m.Artifacts[release.AdoptPlatform()].SHA256 != q.Spec.Digest { |
| + return b, os.ErrPermission |
| + } |
| + f, err := s.openFile(q.Executable) |
| + if err != nil { |
| + return b, err |
| + } |
| + defer f.Close() |
| + id, err := rootstate.Identity(f) |
| + if err != nil { |
| + return b, err |
| + } |
| + if id.Device != q.Reservation.Executable.Device || id.Inode != q.Reservation.Executable.Inode { |
| + return b, os.ErrPermission |
| + } |
| + hash := sha256.New() |
| + if _, err := io.Copy(hash, f); err != nil { |
| + return b, err |
| + } |
| + if hex.EncodeToString(hash.Sum(nil)) != q.Spec.Digest { |
| + return b, os.ErrPermission |
| + } |
| + // OpenTrustedFile already checked this ancestry; release exclusion prevents |
| + // another root release writer replacing it while the identities are captured. |
| + tree := filepath.Dir(q.Executable) |
| + d, err := os.Open(tree) |
| + if err != nil { |
| + return b, err |
| + } |
| + defer d.Close() |
| + treeID, err := rootstate.Identity(d) |
| + if err != nil { |
| + return b, err |
| + } |
| + b = release.GenerationBinding{Binding: release.HistoryBinding{Host: in.Host, Account: in.Account, App: in.App}, |
| + Target: release.GenerationOwner{Host: t.Host, Incarnation: t.Incarnation, Stamp: t.Stamp, Session: t.Session, Box: t.Box, Enrollment: t.Enrollment, BootID: peer.BootID, ControllerID: peer.ControllerID}, |
| + Installation: rootHistoryInstallation(in), Record: in.Record, RecordETag: etag, |
| + Attempt: q.Reservation.ID, Inputs: q.Reservation.Inputs, Invocation: q.Reservation.InvocationID, ControllerPID: peer.PID, ControllerStart: peer.ProcessStart, |
| + Version: version, Digest: q.Spec.Digest, Path: q.Executable, Tree: tree, Executable: id, TreeIdentity: treeID} |
| + return b, nil |
| +} |
| + |
| +type generationLaunchPermit struct { |
| + ledger *release.RootGenerations |
| + reservation release.GenerationReservation |
| + checkBinding func() (release.GenerationBinding, error) |
| +} |
| + |
| +func (s *rootGenerationSource) reserve(ctx context.Context, h *ClaimLaunchRoot, peer StopPeer, q hostbox.RoleLaunchRequest) (*generationLaunchPermit, error) { |
| + if s == nil || s.open == nil { |
| + return nil, os.ErrPermission |
| + } |
| + b, err := s.resolve(ctx, h, peer, q) |
| + if err != nil { |
| + return nil, err |
| + } |
| + store, err := s.open() |
| + if err != nil { |
| + return nil, err |
| + } |
| + g := &release.RootGenerations{Store: store} |
| + r, err := g.Reserve(b) |
| + if err != nil { |
| + store.Close() |
| + return nil, err |
| + } |
| + return &generationLaunchPermit{ledger: g, reservation: r, checkBinding: func() (release.GenerationBinding, error) { return s.resolve(ctx, h, peer, q) }}, nil |
| +} |
| + |
| +func (p *generationLaunchPermit) check() error { |
| + if p == nil { |
| + return nil |
| + } |
| + b, err := p.checkBinding() |
| + if err != nil { |
| + return err |
| + } |
| + if b != p.reservation.Binding { |
| + return os.ErrPermission |
| + } |
| + return p.ledger.Check(p.reservation) |
| +} |
| + |
| +func (p *generationLaunchPermit) started(grant hostbox.RoleLaunchGrant, observed ClaimLaunchObserved) error { |
| + if p == nil { |
| + return nil |
| + } |
| + return p.ledger.Started(p.reservation, release.GenerationProcess{PID: grant.PID, Start: grant.ProcessStart, TenantPID: observed.TenantPID, TenantStart: observed.TenantStart, |
| + Group: grant.Group.RelativePath, Invocation: grant.UnitInvocationID, GroupIdentity: rootstate.FileIdentity{Device: observed.Group.Device, Inode: observed.Group.Inode}}) |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index e09e62dc7..5d55256e0 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -473,6 +473,7 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| + {"internal/host/rootgenerations_launch.go", "rootGenerationSource.resolve", "key-site", "key:RecordKey", "1d66e9ffa6aca3ea4a2d370c04f782b01c8a15d9ae6ca93a9356632d2b032977"}, |
| {"internal/host/rootinventory.go", "ClaimRootHTTP.recoverRootInventory", "key-site", "key:RecordKey", "10d09b5ab6d07d316437233c9a65bf0bcdfe1083db4d0769fa8303b255dfd0ac"}, |
| {"internal/host/proofnamed.go", "proofBindingSource.record", "key-site", "key:RecordKey", "10a99ff4b355907831a904010b7019d480ce93f2f595e99e076d3c702d10ca2b"}, |
| {"internal/host/rootrecovery_rebind.go", "type recoveryRebindRequest", "key-site", "key:RecordKey", "b5db3e4dcd4807edea1d053eb1787fbf222be97d3f892e2ab66771457a51dc3d"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv| index 9123f2813..b49cfde76 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-e.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-e.tsv |
| @@ -54,8 +54,8 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S |
| internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a |
| internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab |
| internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7 |
| -@coverage internal/host 602c4df9eb0b2143825502d87198495d19f4a4bfcc378a925b7816d474507560 |
| -@coverage internal/release a69c59eb1ff514d78b138292d8fe617887993c0be43bd9132394a95243ac8d3c |
| +@coverage internal/host aa5107fd5f62f16db48f9f788c22c2df3279b5ba0740b8d3978bd1593ff42f92 |
| +@coverage internal/release b3659b929285d525c77f4e298fc1eb588d696b37f16c5dd3a77e231012bc29ce |
| @coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05 |
| internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154 |
| internal/host/legacyrestart.go restartEffectFailed authority B3 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. b68bb65884a14fe0cf78a65f56ded4ea41584bba99af9195827d1ea191b340a9 |
| @@ -159,10 +159,6 @@ internal/host/rootrestart_requests_source.go rootRestartRequestSource.inventory |
| internal/host/rootrestart_requests_source.go rootRestartRequestSource.target authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. registry:Unit@1211,whole:Route@891 301b62efbf0281f713cbed7fb15cd244b7d2c479882f4ad930c8d482b51177ff |
| internal/host/rootrestart_requests_source.go type rootRestartRequestSource authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. db7ff5c46b6db3fe88657fddf8b4e17324b896fce9b21042d7f0f63140c1d776 |
| internal/release/rootrestart_history.go RootRestarts.Bindings authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. file:Read@324 d22b7e6b5710ca0025639395101933bec5ce0bd38768ebcf44a0805f1f159b08 |
| -internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. eddb5f39afb0a4a7e94f8063955159d105f38574e600f15fda8d39c6837183a0 |
| -internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. fe4bea8c5fff3656ca8e1c42eec41e0d2eedd3a4a3b2f00e1203740cb8ebc4f7 |
| -internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. file:Read@2599 1cdb9e6359ed4994f2a7ebd12390f84f3f85c944e2c003cc6911ceae93f201d9 |
| -internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. b01a2842411ca0ef5d5b0d5b189dc6300e1ac8c62e9a5b907a1afe79ca2e66c7 |
| internal/host/claimretire_transport.go type ClaimRootHTTP authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. e87bb168975b9a7c4334de1c1fc027c556d69597122a6134770e770c719a45be |
| internal/host/claimunit.go claimUnitContent authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. 38dcfeb754659895bc28b024bc3186c6b12edeb17d087546507fa1299eb8e331 |
| internal/host/roothistory_creation.go productionCreationHistory authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. file:Open@295,file:Open@374 b4f4f82422cf0b1d874be7a0d9ee4a2032a4704b022bf73b8264097c2a53705c |
| @@ -188,3 +184,30 @@ proof_cli.go runProof authority E1 E17: operator saves exact signed authorizatio |
| internal/historyreadback/readback.go RequestStatus authority E1 E17: operator saves exact signed authorization and challenge before delivery, resumes without renewal, and verifies enrolled-root exact completion. Existing proof branches are unchanged. Tests: TestPlan14HistoryOperatorWorkflow, TestPlan14HistoryOperatorConflicts, TestPlan14HistoryOperatorExactCompletion, TestPlan14HistoryWorkflowProcessCrash, TestPlan14HistoryCommandResume, TestPlan14HistorySavedFiles, TestPlan14HistoryCLIDispatch. d89052b0cdd12c8c8dcbc353e9ae995005caeb88d8e4c70974cb9e6f4bfd46d7 |
| @coverage . 85921915751b89a3baf64b24fefeb4c036fa056819801d4d3950629a9616d605 |
| @coverage internal/historyreadback eaca4d4caacf499db8a4c4e8044d565eec5bb42e0fc765b66f6c7559fbb04842 |
| +internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 183f783e1998ffbe49fec813501dfcb9238ffae3b8a09dc545cf88d1b1609dd0 |
| +internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@2340,file:Read@2499,file:Read@707 ab5d6ab6c237ed569904210a69ce0f2bdef564bbd8ff1a65f427c503f52abb48 |
| +internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 78796fbb8f2516f96d1de84cfdd97f294cbcbd21ee63e8b47aa4e2fc2bad3980 |
| +internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@2813 f24033673d1d9b8c36304c204a271a7729e94b0e61620f3df6ea1f80537da4c2 |
| +internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 14f50101580ce4b094e0a321db3db200994da9c6708745c2f7b3a8fd3409adfd |
| +internal/host/rootgenerations_launch.go generationLaunchPermit.check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 788e600dd37fec60c5b98dc42b23c182c337df064088a041e2e20b68573942b5 |
| +internal/host/rootgenerations_launch.go generationLaunchPermit.started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 8f8b0ab8295f39fd54dd8a7d78ef2653eb7bc3b2ac581c47f8657ed59de84326 |
| +internal/host/rootgenerations_launch.go productionRootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@346 a1132403ace7213de9e12720d5a393a3bb85045d1d2c53f0c37ea2ae8e933b04 |
| +internal/host/rootgenerations_launch.go rootGenerationSource.reserve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. c4269e65bcac751cd1a51a439e48eda77b055320554b3489d605fea47fa7f3a8 |
| +internal/host/rootgenerations_launch.go rootGenerationSource.resolve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@2514 1d66e9ffa6aca3ea4a2d370c04f782b01c8a15d9ae6ca93a9356632d2b032977 |
| +internal/host/rootgenerations_launch.go type generationLaunchPermit authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 52b30a79d7ced7875f435eff76d3c924ff4e460d3a2360cda5a9f12c407a2442 |
| +internal/host/rootgenerations_launch.go type rootGenerationSource authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 59a336027ef7750d36f1a36a27cf3d0449395493c05789291c4ed9bc16724e5f |
| +internal/release/rootgenerations.go GenerationBinding.valid authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 636d5eb63f80ec3f3f2b87b859c96ce09700948ea3f3a5d58f04e5b1cf5ac3d3 |
| +internal/release/rootgenerations.go GenerationProcess.valid authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 6032100296677f5397c44226c3158eb75f336f0317af6994c4f3ebbfb2894861 |
| +internal/release/rootgenerations.go RootGenerations.Check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@78 5bb55d8607353739eb9a8c96a7795ba0f9a0bcf6fc59974657620a313e26ce01 |
| +internal/release/rootgenerations.go RootGenerations.Read authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@206,file:Read@26 d77a3e6d7f8bdc2c16973910b3089aa7fb7c3c40b2d0b94f2ca7cdf32f9463b6 |
| +internal/release/rootgenerations.go RootGenerations.Reserve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@397,file:Read@99 be082a4f3529a2dffc21dd5110db36581e0e1e4853ec63c6176a877bfbfaefae |
| +internal/release/rootgenerations.go RootGenerations.Started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@147 c1e96eedb87a4baf5e87e65cf0b63511e7e4cc1b8a71d882cd2cd2a880c370b8 |
| +internal/release/rootgenerations.go RootGenerations.write authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 72f8b4aa9f0487ee8b26843b5cbdc76aa5447d60535766cd4e4ce32f4baa5a2a |
| +internal/release/rootgenerations.go const RootGenerationsDir authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. e235f24f29b1f8511349cae97fbbc3f3472b49fdc6781c781d5b6f93ff3d45ff |
| +internal/release/rootgenerations.go generationKey authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. cdd7e0d8cdff508772ab54e421a4ab914dbb15ea5d97847f35a93544f0a9259a |
| +internal/release/rootgenerations.go type GenerationBinding authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 9bd76e1f6ef4d809df1a59719a1880215c910b0cab78449ad8cd79a678da401a |
| +internal/release/rootgenerations.go type GenerationOwner authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 6a6c3576622656c2bc4b65075c8f5d70b29968203101a1916926adc48b20bce5 |
| +internal/release/rootgenerations.go type GenerationProcess authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. eeaafe23c23e4c95e58c45797065baa9ca6c784fab593e361e0d647ddaaa2957 |
| +internal/release/rootgenerations.go type GenerationReservation authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 4d1b20a5f99117b6bc2e16c6a60033496f99796c47e682ab19bbddbdb6cc35e4 |
| +internal/release/rootgenerations.go type RootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 240860014efc91f51ca7e3e920ffb5cfa98076340dc0c9e9972c35051e5eca84 |
| +internal/release/rootgenerations.go validGeneration authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 7fa482be0515f5b94acd6fe5e3072b2a45e89c043961e30ab764d0f719b9a7cd |
diff --git a/internal/release/plan14_root_generations_test.go b/internal/release/plan14_root_generations_test.go| new file mode 100644 |
| index 000000000..955cf2374 |
| --- /dev/null |
| +++ b/internal/release/plan14_root_generations_test.go |
| @@ -0,0 +1,171 @@ |
| +package release |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +func generationFixture(t *testing.T) (*RootGenerations, *healthyRetryStore, GenerationBinding) { |
| + t.Helper() |
| + s := &healthyRetryStore{dir: t.TempDir(), fault: errors.New("directory sync failed")} |
| + b := GenerationBinding{Binding: HistoryBinding{Host: "shop.example", Account: "bab", App: "shop"}, |
| + Installation: "installation", Record: "record", RecordETag: "etag", Attempt: "attempt:main", Inputs: "inputs", Invocation: "invocation", |
| + Target: GenerationOwner{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 2, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller"}, |
| + ControllerPID: 42, ControllerStart: "start", Version: "v1", Digest: strings.Repeat("a", 64), |
| + Path: "/opt/carlos/versions/v1/bab/shop", Tree: "/opt/carlos/versions/v1/bab", |
| + Executable: rootstate.FileIdentity{Device: 1, Inode: 2}, TreeIdentity: rootstate.FileIdentity{Device: 1, Inode: 3}} |
| + return &RootGenerations{Store: s}, s, b |
| +} |
| + |
| +func TestPlan14GenerationReservationDurability(t *testing.T) { |
| + g, s, b := generationFixture(t) |
| + s.fail = 1 |
| + if _, err := g.Reserve(b); err == nil { |
| + t.Fatal("directory sync failure granted a reservation") |
| + } |
| + raw, err := s.Read(generationKey(b)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s.fail = 1 |
| + if _, err := g.Reserve(b); err == nil { |
| + t.Fatal("visible reservation bypassed directory sync retry") |
| + } |
| + r, err := g.Reserve(b) |
| + if err != nil || r.ID == "" || r.Binding != b || r.Revision != 1 { |
| + t.Fatalf("reserve: %+v %v", r, err) |
| + } |
| + after, err := s.Read(generationKey(b)) |
| + if err != nil || string(raw) != string(after) { |
| + t.Fatalf("retry changed reservation identity: %v", err) |
| + } |
| + for _, attack := range []string{"empty", "missing", "rename"} { |
| + t.Run(attack, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := GenerationsPath(dir) |
| + if err := WriteGenerations(path, Generations{Hosts: map[string][]string{b.Binding.Host: {b.Version}}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "empty": |
| + if err := WriteGenerations(path, Generations{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "missing": |
| + if err := os.Remove(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(GenerationsSeqPath(path)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "rename": |
| + if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := g.Read(b) |
| + if err != nil || got != r { |
| + t.Fatalf("legacy edit changed root reservation: %+v %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationReservationConflicts(t *testing.T) { |
| + g, _, b := generationFixture(t) |
| + r, err := g.Reserve(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, change := range map[string]func(*GenerationBinding){ |
| + "account": func(b *GenerationBinding) { b.Binding.Account = "bap" }, |
| + "app": func(b *GenerationBinding) { b.Binding.App = "other" }, |
| + "version": func(b *GenerationBinding) { b.Version = "v2" }, |
| + "digest": func(b *GenerationBinding) { b.Digest = strings.Repeat("b", 64) }, |
| + "inode": func(b *GenerationBinding) { b.Executable.Inode++ }, |
| + "tree": func(b *GenerationBinding) { b.TreeIdentity.Inode++ }, |
| + "installation": func(b *GenerationBinding) { b.Installation = "replacement" }, |
| + "process": func(b *GenerationBinding) { b.ControllerStart = "replacement" }, |
| + "inputs": func(b *GenerationBinding) { b.Inputs = "replacement" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + other := b |
| + change(&other) |
| + if _, err := g.Reserve(other); err == nil { |
| + t.Fatal("rebound existing reservation") |
| + } |
| + got, err := g.Read(b) |
| + if err != nil || got != r { |
| + t.Fatalf("conflict changed old pin: %+v %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestPlan14GenerationObservationKeepsReservation(t *testing.T) { |
| + g, s, b := generationFixture(t) |
| + r, err := g.Reserve(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o := GenerationProcess{PID: 51, Start: "51", TenantPID: 52, TenantStart: "52", Group: "system.slice/run.service", GroupIdentity: rootstate.FileIdentity{Device: 2, Inode: 3}, Invocation: "unit-invocation"} |
| + s.fail = 1 |
| + if err := g.Started(r, o); err == nil { |
| + t.Fatal("unsynced started record succeeded") |
| + } |
| + if err := g.Started(r, o); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := g.Read(b) |
| + if err != nil || got.Binding != b || got.ID != r.ID || got.Process != o || got.Revision != 2 { |
| + t.Fatalf("started: %+v %v", got, err) |
| + } |
| + o.PID++ |
| + if err := g.Started(r, o); err == nil { |
| + t.Fatal("changed running lifetime") |
| + } |
| + if _, err := g.Reserve(b); err == nil { |
| + t.Fatal("running reservation authorized another launch") |
| + } |
| +} |
| + |
| +func TestPlan14GenerationRecordRefusals(t *testing.T) { |
| + for _, attack := range []string{"missing", "malformed", "unknown field", "oversize"} { |
| + t.Run(attack, func(t *testing.T) { |
| + g, s, b := generationFixture(t) |
| + if _, err := g.Reserve(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := generationKey(b) |
| + raw, err := s.Read(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + switch attack { |
| + case "missing": |
| + if err := os.Remove(filepath.Join(s.dir, key)); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "malformed": |
| + raw = []byte("{") |
| + case "unknown field": |
| + raw = append(raw[:len(raw)-1], []byte(`,"complete":true}`)...) |
| + case "oversize": |
| + raw = []byte(strings.Repeat(" ", rootstate.MaxRecord+1)) |
| + } |
| + if attack != "missing" { |
| + if err := s.Write(key, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if _, err := g.Read(b); err == nil { |
| + t.Fatal("unavailable reservation granted a launch") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/release/rootgenerations.go b/internal/release/rootgenerations.go| new file mode 100644 |
| index 000000000..1d8d081a8 |
| --- /dev/null |
| +++ b/internal/release/rootgenerations.go |
| @@ -0,0 +1,166 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/rootstate" |
| +) |
| + |
| +const RootGenerationsDir = "/var/lib/carlos-release/private/generations" |
| + |
| +type GenerationOwner struct { |
| + Host, Incarnation, Box, Enrollment, BootID, ControllerID string |
| + Stamp, Session uint64 |
| +} |
| + |
| +type GenerationBinding struct { |
| + Binding HistoryBinding |
| + Target GenerationOwner |
| + Installation, Attempt, Inputs, Invocation string |
| + Record, RecordETag string |
| + ControllerPID int |
| + ControllerStart string |
| + Version, Digest, Path, Tree string |
| + Executable, TreeIdentity rootstate.FileIdentity |
| +} |
| + |
| +type GenerationProcess struct { |
| + PID, TenantPID int |
| + Start, TenantStart, Group, Invocation string |
| + GroupIdentity rootstate.FileIdentity |
| +} |
| + |
| +type GenerationReservation struct { |
| + Schema int |
| + Revision uint64 |
| + ID string |
| + Binding GenerationBinding |
| + Process GenerationProcess |
| +} |
| + |
| +// RootGenerations records additional pins under the caller's release exclusion. |
| +// A reservation is never retired by unit settlement or an agent publication. |
| +// These records alone cannot establish a complete generation census. |
| +type RootGenerations struct{ Store rootstate.Store } |
| + |
| +func generationKey(b GenerationBinding) string { |
| + raw, _ := json.Marshal([]any{b.Target, b.Attempt}) |
| + sum := sha256.Sum256(raw) |
| + return hex.EncodeToString(sum[:]) + ".json" |
| +} |
| + |
| +func (b GenerationBinding) valid() bool { |
| + t := b.Target |
| + digest, err := hex.DecodeString(b.Digest) |
| + return b.Binding.valid() && t.Host == b.Binding.Host && t.Incarnation != "" && t.Box != "" && t.Enrollment != "" && t.BootID != "" && t.ControllerID != "" && t.Stamp > 0 && t.Session > 0 && |
| + b.Record != "" && b.RecordETag != "" && b.Installation != "" && b.Attempt != "" && b.Inputs != "" && b.Invocation != "" && b.ControllerPID > 0 && b.ControllerStart != "" && |
| + ValidVersion(b.Version) && err == nil && len(digest) == sha256.Size && hex.EncodeToString(digest) == b.Digest && |
| + filepath.IsAbs(b.Path) && filepath.Clean(b.Path) == b.Path && b.Tree == filepath.Dir(b.Path) && |
| + b.Executable.Device > 0 && b.Executable.Inode > 0 && b.TreeIdentity.Device > 0 && b.TreeIdentity.Inode > 0 |
| +} |
| + |
| +func (p GenerationProcess) valid() bool { |
| + return p.PID > 0 && p.TenantPID > 0 && p.PID != p.TenantPID && p.Start != "" && p.TenantStart != "" && |
| + filepath.IsLocal(p.Group) && p.Group != "." && p.Invocation != "" && p.GroupIdentity.Device > 0 && p.GroupIdentity.Inode > 0 |
| +} |
| + |
| +func validGeneration(r GenerationReservation) bool { |
| + id, err := hex.DecodeString(r.ID) |
| + return r.Schema == 1 && err == nil && len(id) == 32 && hex.EncodeToString(id) == r.ID && r.Binding.valid() && |
| + ((r.Revision == 1 && r.Process == (GenerationProcess{})) || (r.Revision == 2 && r.Process.valid())) |
| +} |
| + |
| +func (g *RootGenerations) Read(b GenerationBinding) (GenerationReservation, error) { |
| + var r GenerationReservation |
| + if g == nil || g.Store == nil || !b.valid() { |
| + return r, os.ErrPermission |
| + } |
| + raw, err := g.Store.Read(generationKey(b)) |
| + if err != nil { |
| + return r, err |
| + } |
| + if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validGeneration(r) || r.Binding != b { |
| + return GenerationReservation{}, os.ErrPermission |
| + } |
| + // Recommit before relying on a rename a prior process may not have synced. |
| + if err := g.Store.Write(generationKey(b), raw); err != nil { |
| + return GenerationReservation{}, err |
| + } |
| + return r, nil |
| +} |
| + |
| +func (g *RootGenerations) write(r GenerationReservation) error { |
| + if g == nil || g.Store == nil || !validGeneration(r) { |
| + return os.ErrPermission |
| + } |
| + raw, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + if len(raw) > rootstate.MaxRecord { |
| + return os.ErrPermission |
| + } |
| + return g.Store.Write(generationKey(r.Binding), raw) |
| +} |
| + |
| +// Reserve reuses an exact pending request after a lost response. Root allocates |
| +// the ID. A changed request or an already observed launch cannot reserve again. |
| +func (g *RootGenerations) Reserve(b GenerationBinding) (GenerationReservation, error) { |
| + r, err := g.Read(b) |
| + if err == nil { |
| + if r.Process != (GenerationProcess{}) { |
| + return GenerationReservation{}, os.ErrPermission |
| + } |
| + return r, nil |
| + } |
| + if !errors.Is(err, os.ErrNotExist) || g == nil || g.Store == nil || !b.valid() { |
| + return GenerationReservation{}, err |
| + } |
| + var id [32]byte |
| + if _, err := rand.Read(id[:]); err != nil { |
| + return GenerationReservation{}, err |
| + } |
| + r = GenerationReservation{Schema: 1, Revision: 1, ID: hex.EncodeToString(id[:]), Binding: b} |
| + if err := g.write(r); err != nil { |
| + return GenerationReservation{}, err |
| + } |
| + return r, nil |
| +} |
| + |
| +func (g *RootGenerations) Check(r GenerationReservation) error { |
| + got, err := g.Read(r.Binding) |
| + if err != nil { |
| + return err |
| + } |
| + if got != r || r.Process != (GenerationProcess{}) { |
| + return os.ErrPermission |
| + } |
| + return nil |
| +} |
| + |
| +func (g *RootGenerations) Started(r GenerationReservation, p GenerationProcess) error { |
| + if !p.valid() { |
| + return os.ErrPermission |
| + } |
| + got, err := g.Read(r.Binding) |
| + if err != nil { |
| + return err |
| + } |
| + if got.ID != r.ID { |
| + return os.ErrPermission |
| + } |
| + if got.Process == p { |
| + return nil |
| + } |
| + if got != r || got.Process != (GenerationProcess{}) { |
| + return os.ErrPermission |
| + } |
| + got.Process, got.Revision = p, 2 |
| + return g.write(got) |
| +} |
diff --git a/proof_cli.go b/proof_cli.go| index 5c0108c48..a8e287f4a 100644 |
| --- a/proof_cli.go |
| +++ b/proof_cli.go |
| @@ -28,7 +28,7 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const proofUsage = "usage: carlos proof keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>" |
| +const proofUsage = "usage: carlos proof history <status|reconstruct|retire|resume> [flags] | keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>" |
| func runProof(args []string) { |
| if len(args) == 0 { |
| @@ -36,6 +36,8 @@ func runProof(args []string) { |
| } |
| ctx := context.Background() |
| switch args[0] { |
| + case "history": |
| + runHistory(args[1:]) |
| case "keygen": |
| fs := flag.NewFlagSet("proof keygen", flag.ExitOnError) |
| out := fs.String("out", "/etc/carlos/proving.key", "where to write the private key (root only, mode 0600)") |