carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux && rootrunner
2
3package host
4
5import (
6 "bytes"
7 "crypto/ed25519"
8 "crypto/rand"
9 "debug/buildinfo"
10 "encoding/base64"
11 "encoding/hex"
12 "encoding/json"
13 "encoding/xml"
14 "errors"
15 "fmt"
16 "io"
17 "net"
18 "net/http"
19 "net/http/httptest"
20 "os"
21 "os/exec"
22 "os/user"
23 "path/filepath"
24 "reflect"
25 "strings"
26 "sync"
27 "syscall"
28 "testing"
29 "time"
30
31 "github.com/carlosframework/platform/internal/boxid"
32 "github.com/carlosframework/platform/internal/claimactivation"
33 "github.com/carlosframework/platform/internal/console"
34 "github.com/carlosframework/platform/internal/hostactors"
35 "github.com/carlosframework/platform/internal/hostclaim"
36 "github.com/carlosframework/platform/internal/hostfloor"
37 "github.com/carlosframework/platform/internal/hostops"
38 "github.com/carlosframework/platform/internal/registry"
39 "github.com/carlosframework/platform/internal/release"
40 "github.com/carlosframework/platform/internal/status"
41)
42
43var activationConsoleImage, activationCarlosImage, activationCarlosOldImage []byte
44
45// activationImagesBuild is how the three embedded images are built, from the
46// worktree root, before `go test -c -tags rootrunner,activationconsole`
47// (controller ruling C5). All are stripped (-s -w), as release builds are:
48// an unstripped Go binary carries compressed DWARF sections, which the
49// bounded ELF pre-check refuses (F1). The release under test is v1.0.0:
50// guard-arm compares the carlos build with the identity, and the console's
51// own floor report (Disk.Running) compares the console's build with it, so
52// publish-identity refuses a console stamped otherwise. v0.9.0 is the older
53// release the box is first installed with, so the update leaves it in
54// carlos.prev (prev-rehearsal R2). checkActivationImages asserts all three
55// images match this command.
56const activationImagesBuild = `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-carlos-runner.bin .
57GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v0.9.0" -o internal/host/activation-carlos-old-runner.bin .
58GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-console-runner.bin ./cmd/console`
59
60func checkActivationImages(t *testing.T) {
61 t.Helper()
62 for _, img := range []struct {
63 raw []byte
64 path, ldflag string
65 }{
66 {activationCarlosImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
67 {activationCarlosOldImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v0.9.0"},
68 {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"},
69 } {
70 info, err := buildinfo.Read(bytes.NewReader(img.raw))
71 if err != nil {
72 t.Fatal("embedded image has no build info; build it with:\n"+activationImagesBuild, err)
73 }
74 settings := map[string]string{}
75 for _, s := range info.Settings {
76 settings[s.Key] = s.Value
77 }
78 if info.Path != img.path || settings["GOOS"] != "linux" || settings["GOARCH"] != "arm64" || settings["CGO_ENABLED"] != "0" || settings["-ldflags"] != img.ldflag {
79 t.Fatalf("embedded image %s (%v) is not the one this command builds:\n%s", info.Path, settings, activationImagesBuild)
80 }
81 }
82}
83
84// activationInspected is `carlos system claims inspect`'s output.
85type activationInspected struct {
86 Roster hostactors.Roster `json:"roster"`
87 ETag string `json:"etag"`
88 SignedRoster json.RawMessage `json:"signed_roster"`
89}
90
91// activationPhaseResult is `carlos system claims run`'s output.
92type activationPhaseResult struct {
93 claimactivation.Result
94 Out string `json:"out,omitempty"`
95}
96
97// The claim trial's steps 6 and 7, rehearsed on a box the platform made
98// (prev-rehearsal ruling R2) with no hand-made input on the trial's path
99// (untagged-pointer ruling R2). Every pointer, identity, floor, policy,
100// roster and slot comes from the platform's own commands and code paths, as
101// on dev:
102//
103// - the box is installed at v0.9.0 (README steps 1 to 4: the binary,
104// host.env, `carlos bootstrap`, the edge);
105// - the operator's system promote of v1.0.0 is an in-process store.Put of
106// the artifact (scaffolding) and release.PromoteSystem with the untagged
107// release {Version, Source "release:<tag>", Artifacts}, so the pointer
108// carries no release tag and no moves protocol, as dev's v0.28.3
109// pointers do. It is tied to `carlos system update`'s door only through
110// the console's TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor,
111// which drives the real door with no floor and pins that shape. The real agent and `carlos system apply` then update the box
112// to v1.0.0, leaving v0.9.0 in carlos.prev;
113// - its AWS identity record is published by `carlos system box
114// publish-record` as the agent user (step 5.0);
115// - the console release is step 5.0a's: `carlos channels digests on`,
116// `carlos ship` and `carlos promote` run by the operator's CLI, and the
117// box stages the promoted head through the adopt pass's staging
118// (release.StageSignedHead) into /opt/carlos/versions;
119// - the roster is made by `claims run` initialize to enroll-console, with
120// the runner's real AWS identity; sign-policy is the real binary;
121// publish-identity is claims run's own producing path
122// (reserveActivationOutput, runProducingPhase, activationRunner) with
123// only the sums key swapped, because the vendor key cannot sign a
124// fixture (the real binary's refusal of the fixture sums is checked);
125// - the trial's 6.2 stop is reproduced, then `install-channel --policy
126// --identity` (R1) makes carlos.prev the release over the untagged
127// pointer, and step 6 and step 7 run as the runbook gives them, with
128// the real agent's box reports as evidence.
129//
130// One AWS instance can be enrolled as one box only (boxid refuses a second
131// reference for an instance), so this box is both the trial's hosting box
132// and its console box: guard-arm runs in leo's form, then in sophie's.
133// What the witness still writes itself, all outside the trial's path, is
134// listed in the untagged-pointer report: the loopback S3 bucket, the
135// operator's inputs (host.env, the first binary, plan files, floor.json, the
136// fixture sums), the file transfers the runbook does with box_put and jq,
137// the console unit and its database, and the claim state behind the
138// authenticated reset after step 7. Network: every carlos unit gets a
139// runtime drop-in allowing only loopback and the instance metadata service.
140func TestRootActivationConsoleRuntimePayload(t *testing.T) {
141 if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 || len(activationCarlosOldImage) == 0 {
142 t.Fatal("requires root and embedded activation binaries")
143 }
144 checkActivationImages(t)
145 if pid1, err := os.ReadFile("/proc/1/comm"); err != nil || strings.TrimSpace(string(pid1)) != "systemd" {
146 t.Fatal("root runner requires systemd", err)
147 }
148 instance := status.InstanceID()
149 if instance == "" {
150 t.Fatal("runner instance identity unavailable")
151 }
152 // The box's name is the one its agent publishes under.
153 box := status.Label(readMachineID())
154 if !boxid.ValidBox(box) {
155 t.Fatal("runner box name", box)
156 }
157 const bucket = "activation-fixture"
158 const consoleID = "dev-console"
159 opt := release.OptDir()
160 owned := []string{"/opt/carlos", "/etc/carlos", "/data", "/run/carlos", "/etc/systemd/system/carlos-edge.service", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d"}
161 for _, p := range owned {
162 if _, err := os.Lstat(p); !os.IsNotExist(err) {
163 t.Fatal("pre-existing fixture path", p, err)
164 }
165 }
166 if _, err := user.Lookup(serviceUser); err == nil {
167 t.Fatal("the service user exists before the box is made")
168 }
169 // Everything bootstrap and the steps add is found by name afterwards.
170 listed := []string{"/etc/systemd/system", "/run/systemd/system", "/etc/tmpfiles.d", "/etc/sysusers.d", "/etc/systemd/journald.conf.d"}
171 before := map[string]map[string]bool{}
172 for _, dir := range listed {
173 before[dir] = activationDirNames(dir)
174 }
175 d := activationRootFixture(t)
176 check := func(err error) {
177 t.Helper()
178 if err != nil {
179 t.Fatal(err)
180 }
181 }
182 write := func(path string, b []byte, mode os.FileMode) {
183 t.Helper()
184 check(os.MkdirAll(filepath.Dir(path), 0755))
185 check(os.WriteFile(path, b, mode))
186 check(os.Chmod(path, mode))
187 }
188 absent := func(path string) {
189 t.Helper()
190 if _, e := os.Lstat(path); !os.IsNotExist(e) {
191 t.Fatal("present", path, e)
192 }
193 }
194 journal := func(unit string, n int) string {
195 out, _ := exec.Command("journalctl", "-u", unit, "--no-pager", "-n", fmt.Sprint(n)).CombinedOutput()
196 return string(out)
197 }
198 t.Cleanup(func() {
199 for _, u := range []string{"carlos-edge.service", "carlos-system-update.service", "console.service"} {
200 t.Log(u, "journal:\n"+journal(u, 80))
201 }
202 var added []string
203 for _, dir := range listed {
204 for name := range activationDirNames(dir) {
205 if !before[dir][name] {
206 added = append(added, filepath.Join(dir, name))
207 }
208 }
209 }
210 var unitNames []string
211 for _, p := range added {
212 if filepath.Dir(p) == "/etc/systemd/system" && !strings.HasSuffix(p, ".d") && !strings.HasSuffix(p, ".wants") {
213 unitNames = append(unitNames, filepath.Base(p))
214 }
215 }
216 exec.Command("systemctl", "stop", "console.service", "carlos-edge.service").Run()
217 if len(unitNames) > 0 {
218 exec.Command("systemctl", append([]string{"stop"}, unitNames...)...).Run()
219 exec.Command("systemctl", append([]string{"disable"}, unitNames...)...).Run()
220 }
221 for _, m := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} {
222 exec.Command("systemctl", "stop", m).Run()
223 }
224 for _, p := range added {
225 os.RemoveAll(p)
226 }
227 for _, p := range owned {
228 os.RemoveAll(p)
229 }
230 exec.Command("systemctl", "daemon-reload").Run()
231 exec.Command("systemctl", "reset-failed").Run()
232 exec.Command("userdel", serviceUser).Run()
233 exec.Command("groupdel", socketsGroup).Run()
234 })
235 store := release.NewDirStore(filepath.Join(d, "bucket"))
236 endpoint := activationS3Fixture(t, store)
237 pub, key, _ := ed25519.GenerateKey(nil)
238 signer := release.RootSigner(key)
239 pubArg := base64.StdEncoding.EncodeToString(pub)
240 ctx := t.Context()
241 // host.env (README step 2), with the hibernation block a hosting box
242 // carries, as leo's does. No release key: a box only verifies. (An agent
243 // with no CARLOS_HIBERNATE_BUCKET panics at start on this tree: runAgent
244 // dereferences the nil activator config at registerProductionMovesParks.
245 // Reported, not fixed here.)
246 hostEnv := []string{"CARLOS_DEPLOYMENT_BUCKET=" + bucket, "CARLOS_HIBERNATE_BUCKET=" + bucket + "-ie", "CARLOS_HIBERNATE_REGION=eu-west-1", "CARLOS_HIBERNATE_PREFIX=instances", "CARLOS_RELEASE_PUBKEY=" + pubArg, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint}
247 for _, kv := range hostEnv {
248 if k, v, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "AWS_") || k == "CARLOS_S3_ENDPOINT" {
249 t.Setenv(k, v)
250 }
251 }
252 // A root shell on the box after `set -a; . /etc/carlos/host.env; set +a`,
253 // as the runbook's box blocks run. The signer's commands get the same
254 // bucket and credentials.
255 shellEnv := append([]string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}, hostEnv...)
256 try := func(bin string, args ...string) (string, string, error) {
257 t.Helper()
258 cmd := exec.CommandContext(ctx, bin, args...)
259 cmd.Env = shellEnv
260 cmd.Dir = d
261 var stdout, stderr bytes.Buffer
262 cmd.Stdout, cmd.Stderr = &stdout, &stderr
263 err := cmd.Run()
264 return stdout.String(), stderr.String(), err
265 }
266 run := func(want bool, bin string, args ...string) (string, string) {
267 t.Helper()
268 stdout, stderr, err := try(bin, args...)
269 if (err == nil) != want {
270 t.Fatal(bin, args, "\nstdout:", stdout, "\nstderr:", stderr, err)
271 }
272 return stdout, stderr
273 }
274 carlos := func(want bool, args ...string) (string, string) {
275 t.Helper()
276 return run(want, "/opt/carlos/carlos", args...)
277 }
278 ctl := func(args ...string) string {
279 t.Helper()
280 out, e := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
281 if e != nil {
282 t.Fatal(args, string(out), e)
283 }
284 return strings.TrimSpace(string(out))
285 }
286 readers := func() (string, string) {
287 t.Helper()
288 cur, _ := run(true, systemBinaryPath(opt), "version")
289 prev, _ := run(true, systemPrevPath(opt), "version")
290 return strings.TrimSpace(cur), strings.TrimSpace(prev)
291 }
292
293 // The box, made by the platform's own install and update path.
294 // README step 1: the binary. The box's first build is the older release.
295 write(systemBinaryPath(opt), activationCarlosOldImage, 0755)
296 // Step 2: host.env.
297 write("/etc/carlos/host.env", []byte(strings.Join(hostEnv, "\n")+"\n"), 0600)
298 // Step 3: carlos bootstrap.
299 out, _ := carlos(true, "bootstrap")
300 t.Log("bootstrap:", out)
301 for _, u := range []string{"carlos-edge.service", "carlos-system-update.service", "carlos-system-recover.service", "carlos-adopt.service", "carlos-stage.service", "carlos-restart.service", "carlos-config-sync.service", "carlos-instance-control.service"} {
302 write("/run/systemd/system/"+u+".d/10-rootrunner-network.conf", []byte("[Service]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"), 0644)
303 }
304 ctl("daemon-reload")
305 // The operator's promote of v1.0.0, before any floor is published. This
306 // is not the console door or the carlos system update CLI: it is an
307 // in-process store.Put of the artifact (scaffolding, standing in for the
308 // door's mirror) and release.PromoteSystem with the untagged release
309 // {Version, Source "release:<tag>", Artifacts}. That shape is tied to the
310 // real door only through the console's
311 // TestSystemUpdateWritesAnUntaggedPointerBeforeTheFloor, which drives the
312 // door with no floor and pins exactly this pointer.
313 artifact := release.SystemArtifact{Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)}
314 _, err := store.Put(ctx, artifact.Key, activationCarlosImage, "")
315 check(err)
316 _, err = release.PromoteSystem(ctx, store, signer, release.SystemComponentRouter, "stable", release.SystemRelease{Version: "v1.0.0", Source: "release:v1.0.0", Artifacts: map[string]release.SystemArtifact{"linux-arm64": artifact}}, release.SystemPromoteOptions{Actor: "operator@example.test"})
317 check(err)
318 // The pointer is dev's: no release tag, no moves protocol.
319 if p, _, e := release.GetSystemPointer(ctx, store, pub, release.SystemComponentRouter, "stable"); e != nil || p.Current.ReleaseTag != "" || p.Current.MovesProtocol != 0 || p.Current.Source != "release:v1.0.0" {
320 t.Fatal("the operator's promote is not the untagged pointer the trial found", p, e)
321 }
322 // Step 4: the edge. Its agent finds the promote, fetches the build and
323 // asks root, and carlos-system-update.service applies it.
324 ctl("start", "carlos-edge.service")
325 deadline := time.Now().Add(5 * time.Minute)
326 for {
327 r, _ := readSystemResult(opt)
328 if r != nil && r.Version == "v1.0.0" && r.Outcome == systemOutcomeApplied {
329 break
330 }
331 if r != nil && r.Version == "v1.0.0" && r.Outcome != systemOutcomeDeferred {
332 t.Fatalf("the update to v1.0.0 ended %s: %s\n%s", r.Outcome, r.Reason, journal("carlos-system-update.service", 120))
333 }
334 if time.Now().After(deadline) {
335 t.Fatalf("the update to v1.0.0 did not apply: %+v\n%s\n%s", r, journal("carlos-edge.service", 120), journal("carlos-system-update.service", 120))
336 }
337 time.Sleep(2 * time.Second)
338 }
339 if j, _ := readSystemJournal(opt); j != nil {
340 t.Fatal("the update left its journal", j)
341 }
342 cur, _ := os.ReadFile(systemBinaryPath(opt))
343 prev, _ := os.ReadFile(systemPrevPath(opt))
344 if !bytes.Equal(cur, activationCarlosImage) || !bytes.Equal(prev, activationCarlosOldImage) {
345 t.Fatal("the update did not leave v1.0.0 in carlos and v0.9.0 in carlos.prev")
346 }
347 if applied, e := readSystemApplied(opt); e != nil || applied == nil || applied.Version != "v1.0.0" {
348 t.Fatal("applied", applied, e)
349 }
350 absent(channelIdentityPath(opt))
351 if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v0.9.0" {
352 t.Fatal("readers before install-channel", c, p)
353 }
354 if state := ctl("is-active", "carlos-edge.service"); state != "active" {
355 t.Fatal("edge", state)
356 }
357 t.Log("box made: carlos v1.0.0, carlos.prev v0.9.0, by bootstrap and the real update")
358
359 // Step 5.0: the box's AWS identity record, as the agent user.
360 publishRecord := func() string {
361 t.Helper()
362 out, _ := run(true, "systemd-run", "--wait", "--pipe", "--quiet", "-p", "EnvironmentFile=/etc/carlos/host.env", "-p", "User="+serviceUser, "-p", "IPAddressDeny=any", "-p", "IPAddressAllow=127.0.0.1 169.254.169.254", systemBinaryPath(opt), "system", "box", "publish-record")
363 return strings.TrimSpace(out)
364 }
365 if line := publishRecord(); line != fmt.Sprintf("published the identity record for %s (instance %s) to control/boxes/%s.json", box, instance, box) {
366 t.Fatal("publish-record", line)
367 }
368 if line := publishRecord(); !strings.HasPrefix(line, fmt.Sprintf("the record at control/boxes/%s.json is valid AWS-signed evidence for %s (instance %s), so nothing was written", box, box, instance)) {
369 t.Fatal("publish-record verify", line)
370 }
371
372 // The trusted signing host: its CLI, the key in a root 0600 file.
373 sd := filepath.Join(d, "signer")
374 check(os.MkdirAll(sd, 0700))
375 write(sd+"/carlos", activationCarlosImage, 0755)
376 write(sd+"/release.key", []byte(base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600)
377 signerCLI := func(want bool, args ...string) (string, string) {
378 t.Helper()
379 return run(want, sd+"/carlos", args...)
380 }
381 inspectWith := func(bin string) activationInspected {
382 t.Helper()
383 out, _ := run(true, bin, "system", "claims", "inspect", "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg)
384 var o activationInspected
385 check(json.Unmarshal([]byte(out), &o))
386 return o
387 }
388 refresh := func() activationInspected { t.Helper(); return inspectWith(sd + "/carlos") }
389 // The control objects a phase may write; the agent writes status beside
390 // them throughout.
391 controlSnapshot := func() map[string]string {
392 t.Helper()
393 all := activationStoreSnapshot(t, store)
394 for k := range all {
395 keep := k == hostactors.Key || k == hostfloor.Key || k == hostfloor.AuthKey
396 for _, prefix := range []string{"control/host-actor-invitations/", "control/host-console-invitations/", "control/system/router/releases/"} {
397 keep = keep || strings.HasPrefix(k, prefix)
398 }
399 if !keep {
400 delete(all, k)
401 }
402 }
403 return all
404 }
405 runArgs := func(out string) []string {
406 args := []string{"system", "claims", "run", "--plan", sd + "/plan.json", "--signing-key", sd + "/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1"}
407 if out != "" {
408 args = append(args, "--out", out)
409 }
410 return args
411 }
412 // apply_plan (and phase_out, with out): the dry run, then the apply.
413 tryPlan := func(p claimactivation.Plan, out string) (activationPhaseResult, error) {
414 t.Helper()
415 raw, _ := json.Marshal(p)
416 write(sd+"/plan.json", raw, 0600)
417 before := controlSnapshot()
418 if stdout, stderr, e := try(sd+"/carlos", append(runArgs(out), "--dry-run")...); e != nil {
419 return activationPhaseResult{}, fmt.Errorf("%s dry run: %v: %s %s", p.Phase, e, stdout, stderr)
420 }
421 if !reflect.DeepEqual(before, controlSnapshot()) {
422 t.Fatal("dry run wrote", p.Phase)
423 }
424 if out != "" {
425 absent(out)
426 }
427 stdout, stderr, e := try(sd+"/carlos", runArgs(out)...)
428 if e != nil {
429 return activationPhaseResult{}, fmt.Errorf("%s: %v: %s %s", p.Phase, e, stdout, stderr)
430 }
431 var r activationPhaseResult
432 check(json.Unmarshal([]byte(stdout), &r))
433 t.Log("phase", p.Phase, "revision", r.Revision, "writes", len(r.Writes))
434 return r, nil
435 }
436 applyPlan := func(p claimactivation.Plan, out string) activationPhaseResult {
437 t.Helper()
438 r, e := tryPlan(p, out)
439 check(e)
440 return r
441 }
442 phase := func(name string, p claimactivation.Plan) activationPhaseResult {
443 t.Helper()
444 p.Phase, p.Bucket, p.ExpectedRevision = name, bucket, refresh().Roster.Revision
445 return applyPlan(p, "")
446 }
447
448 // Step 5.1: roster and enrollment.
449 applyPlan(claimactivation.Plan{Phase: "initialize", Bucket: bucket}, "")
450 phase("configure", claimactivation.Plan{Regions: map[string]hostactors.Region{"ie": {Account: "640012953448", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"cohort1", "console"}}}})
451 boxInput := claimactivation.BoxInput{Reference: box, Namespace: "platform", Label: box, Region: "ie", Pool: "cohort1", AWSRegion: "eu-west-1", InstanceID: instance, Operator: "root-runner"}
452 phase("invite-box", claimactivation.Plan{Box: &boxInput})
453 phase("enroll-box", claimactivation.Plan{Box: &boxInput})
454 enrolled, ok := refresh().Roster.Boxes[box]
455 if !ok || enrolled.Pool != "cohort1" || enrolled.Cloud.InstanceID != instance || enrolled.State != "active" {
456 t.Fatal("enrolled box", enrolled)
457 }
458 token := make([]byte, 16)
459 _, err = rand.Read(token)
460 check(err)
461 consoleIn := hostactors.Console{InstanceID: consoleID, Enrollment: hex.EncodeToString(token), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: box, Enrollment: enrolled.Enrollment}}
462 phase("invite-console", claimactivation.Plan{Console: &consoleIn})
463 phase("enroll-console", claimactivation.Plan{Console: &consoleIn})
464 if o := refresh(); o.Roster.Consoles[consoleID].Compute == nil || len(o.Roster.Boxes) != 1 {
465 t.Fatal("inspect", o.Roster)
466 }
467 // The box's staged inputs (/root/claim-trial on the trial's boxes). The
468 // enrollment file is 5.1's jq over the signer's inspect output,
469 // '{box:$box,enrollment:.roster.boxes[$box].enrollment,instance_id:.roster.boxes[$box].cloud.instance_id}',
470 // as jq prints it, then box_put. enrollmentRaw is what guard-arm then
471 // writes to host-actor.json.
472 bd := filepath.Join(d, "box")
473 check(os.MkdirAll(bd, 0700))
474 inspected := refresh().Roster.Boxes[box]
475 enrollmentFile, err := json.MarshalIndent(struct {
476 Box string `json:"box"`
477 Enrollment string `json:"enrollment"`
478 InstanceID string `json:"instance_id"`
479 }{box, inspected.Enrollment, inspected.Cloud.InstanceID}, "", " ")
480 check(err)
481 enrollmentFile = append(enrollmentFile, '\n')
482 write(sd+"/"+box+"-enrollment.json", enrollmentFile, 0600)
483 write(bd+"/"+box+"-enrollment.json", enrollmentFile, 0600)
484 enrollmentRaw, _ := json.Marshal(hostfloor.Enrollment{Box: box, Enrollment: enrolled.Enrollment, InstanceID: enrolled.Cloud.InstanceID})
485
486 // Step 6.1: the armed policy, through the real binary's sign-policy.
487 floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}
488 floorRaw, _ := json.Marshal(floor)
489 signed := applyPlan(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor}, sd+"/policy.v1.0.0.json")
490 policyRaw, err := os.ReadFile(sd + "/policy.v1.0.0.json")
491 check(err)
492 if len(signed.Writes) != 0 || signed.FloorSHA256 != hostfloor.Digest(floorRaw) || signed.OutputSHA256 != hostfloor.Digest(policyRaw) {
493 t.Fatal("sign-policy", signed.Result)
494 }
495 signerCLI(false, runArgs(sd+"/policy.v1.0.0.json")...) // --out is never replaced
496 var policy hostfloor.Policy
497 check(json.Unmarshal(policyRaw, &policy))
498 if st, e := os.Stat(sd + "/policy.v1.0.0.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" {
499 t.Fatal("policy file", st, e)
500 }
501 // Step 5.0a, the console release, by the operator's CLI in direct-bucket
502 // mode as ship-app.sh runs it: pointer digests on for the account, then
503 // carlos ship and carlos promote of the prebuilt console.
504 od := filepath.Join(d, "operator")
505 check(os.MkdirAll(od+"/home", 0700))
506 write(od+"/console-linux-arm64", activationConsoleImage, 0755)
507 operator := func(args ...string) string {
508 t.Helper()
509 cmd := exec.CommandContext(ctx, sd+"/carlos", args...)
510 cmd.Env = append(append([]string{}, shellEnv...), "HOME="+od+"/home", "CARLOS_RELEASE_KEY="+base64.StdEncoding.EncodeToString(key.Seed()))
511 cmd.Dir = od
512 out, e := cmd.CombinedOutput()
513 if e != nil {
514 t.Fatal(args, string(out), e)
515 }
516 return string(out)
517 }
518 t.Log(operator("channels", "digests", "on", "--account-id", "bab"))
519 t.Log(operator("ship", "--app", "console", "--account-id", "bab", "--version", "abc1234", "--kind", "binary", od+"/console-linux-arm64"))
520 t.Log(operator("promote", "-app", "console", "--account-id", "bab", "abc1234", "canary/console"))
521 // publish-identity through claims run's own producing path (phase_out:
522 // --out reserved before the phase, the dry run, then the apply, the file
523 // read back) over the production S3 store against the loopback bucket.
524 // The runner is the CLI's (activationRunner) with only its sums key
525 // swapped: the vendor key cannot sign a fixture checksum list. The real
526 // binary's own refusal of that list is checked below.
527 s3, err := release.NewS3StoreIn(ctx, bucket, "eu-west-1")
528 check(err)
529 sumsPub, sumsKey, _ := ed25519.GenerateKey(nil)
530 identityRunner := activationRunner(s3, pub, bucket, key)
531 identityRunner.SumsKey = base64.StdEncoding.EncodeToString(sumsPub)
532 sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n" + hostfloor.Digest([]byte("amd64 deb")) + " carlos_1.0.0_amd64.deb\n" + hostfloor.Digest([]byte("arm64 deb")) + " carlos_1.0.0_arm64.deb\n"
533 identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums,
534 SumsSignature: base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums))),
535 Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}
536 producing := func(out string, dry bool) (claimactivation.Result, error) {
537 t.Helper()
538 reserved, e := reserveActivationOutput(out, dry)
539 if e != nil {
540 return claimactivation.Result{}, e
541 }
542 return runProducingPhase(ctx, identityRunner, &putAttempts{Store: s3}, identityPlan, reserved, dry)
543 }
544 beforeIdentity := controlSnapshot()
545 preview, err := producing(sd+"/identity.v1.0.0.json", true)
546 check(err)
547 if _, _, e := store.Get(ctx, "control/system/router/releases/v1.0.0.json"); !errors.Is(e, os.ErrNotExist) || preview.Output != nil || !reflect.DeepEqual(beforeIdentity, controlSnapshot()) {
548 t.Fatal("identity dry run published", e)
549 }
550 absent(sd + "/identity.v1.0.0.json")
551 published, err := producing(sd+"/identity.v1.0.0.json", false)
552 check(err)
553 identityRaw, err := os.ReadFile(sd + "/identity.v1.0.0.json")
554 check(err)
555 if published.OutputSHA256 != hostfloor.Digest(identityRaw) || published.IdentityKey != "control/system/router/releases/v1.0.0.json" {
556 t.Fatal("publish-identity's --out file", published)
557 }
558 planRaw, _ := json.Marshal(identityPlan)
559 write(sd+"/plan.json", planRaw, 0600)
560 if _, stderr := signerCLI(false, runArgs(sd+"/identity-vendor.v1.0.0.json")...); !strings.Contains(stderr, sd+"/identity-vendor.v1.0.0.json was kept. run it again with a new --out path") {
561 t.Fatal("vendor-key refusal", stderr)
562 }
563 if st, e := os.Stat(sd + "/identity-vendor.v1.0.0.json"); e != nil || st.Size() != 0 {
564 t.Fatal("the refused run's reservation", st, e)
565 }
566 // Staged on the box, byte for byte (box_put).
567 write(bd+"/policy.v1.0.0.json", policyRaw, 0600)
568 write(bd+"/identity.v1.0.0.json", identityRaw, 0600)
569
570 // Step 6.2 as the trial ran it: guard-arm refuses the updated box, whose
571 // carlos.prev is the release before, and writes nothing.
572 armLeo := []string{"system", "guard-arm", "--policy", bd + "/policy.v1.0.0.json", "--identity", bd + "/identity.v1.0.0.json", "--enrollment", bd + "/" + box + "-enrollment.json"}
573 _, guardBefore := os.Lstat(systemGuardDir(opt))
574 if _, stderr := carlos(false, armLeo...); !strings.Contains(stderr, "hostfloor: authenticated floor required") {
575 t.Fatal("the trial's stop did not reproduce", stderr)
576 }
577 absent("/etc/carlos/host-actor.json")
578 absent("/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf")
579 if _, e := os.Lstat(systemGuardDir(opt)); os.IsNotExist(e) != os.IsNotExist(guardBefore) {
580 t.Fatal("the refused guard-arm changed the guard directory", e)
581 }
582 t.Log("step 6.2 stop reproduced: guard-arm refuses carlos.prev v0.9.0")
583 // install-channel without --policy is the trial's loop: no floor yet.
584 if _, stderr := carlos(false, "system", "install-channel"); !strings.HasSuffix("\n"+strings.TrimSpace(stderr), "\nno host floor is published, so this box could not arm its guard and nothing was installed") {
585 t.Fatal("install-channel without --policy", stderr)
586 }
587 // --policy alone is refused up front (untagged-pointer R1), and changes
588 // nothing.
589 slotsBefore := activationSlotFiles(t, opt)
590 if _, stderr, e := try("/opt/carlos/carlos", "system", "install-channel", "--policy", bd+"/policy.v1.0.0.json"); e == nil || !strings.HasSuffix("\n"+strings.TrimSpace(stderr), "\n--policy and --identity go together, so nothing was installed") {
591 t.Fatal("install-channel --policy without --identity", stderr, e)
592 }
593 if !reflect.DeepEqual(slotsBefore, activationSlotFiles(t, opt)) {
594 t.Fatal("the refused install-channel changed the box")
595 }
596 // 6.1a: install-channel --policy --identity over the untagged pointer.
597 installChannel := []string{"system", "install-channel", "--policy", bd + "/policy.v1.0.0.json", "--identity", bd + "/identity.v1.0.0.json"}
598 if out, _ := carlos(true, installChannel...); strings.TrimSpace(out) != "replaced the rollback copy carlos.prev with v1.0.0 (v1.0.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" {
599 t.Fatal("install-channel --policy --identity", out)
600 }
601 cur, _ = os.ReadFile(systemBinaryPath(opt))
602 prev, _ = os.ReadFile(systemPrevPath(opt))
603 if !bytes.Equal(cur, activationCarlosImage) || !bytes.Equal(prev, activationCarlosImage) {
604 t.Fatal("install-channel --policy did not leave v1.0.0 in both slots")
605 }
606 if held, e := os.ReadFile(channelIdentityPath(opt)); e != nil || !bytes.Equal(held, identityRaw) {
607 t.Fatal("the channel identity is not the published one", e)
608 }
609 if out, _ := carlos(true, installChannel...); strings.TrimSpace(out) != "v1.0.0 (v1.0.0) from the stable channel is already installed" {
610 t.Fatal("install-channel --policy --identity again", out)
611 }
612 // Readers (P3's box line): both slots are the trial release.
613 if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v1.0.0" {
614 t.Fatal("readers after install-channel", c, p)
615 }
616 // Step 6.2: arm the hosting box (leo's form).
617 armed := time.Now()
618 carlos(true, armLeo...)
619 if raw, e := os.ReadFile("/etc/carlos/host-actor.json"); e != nil || !bytes.Equal(raw, enrollmentRaw) {
620 t.Fatal("host-actor.json", e)
621 }
622 if raw, e := os.ReadFile("/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf"); e != nil || !strings.Contains(string(raw), "guard-exec edge") {
623 t.Fatal("edge drop-in", e)
624 }
625 t.Log("step 6.2 passed: guard-arm armed the updated box")
626
627 // Step 6.3: the console box. The console release's build, staged in a
628 // carlos-owned versions tree as on the dev console box.
629 uid, gid := activationServiceIDs(t)
630 check(os.Chmod(d, 0755))
631 // 5.0a on the console box: the adopt pass stages the promoted head into
632 // the box's versions tree (StageSignedHead: the channel's signed
633 // digest, the manifest that hashes to it, then the artefact by its
634 // sha256), and install-console takes it as --source, as sophie's
635 // /opt/carlos/versions/50f5b268/console.
636 _, stagedManifest, err := release.StageSignedHead(ctx, s3, pub, nil, opt, registry.Route{Account: "bab", App: "console", Channel: "canary/console", Host: "platform.example.test"})
637 check(err)
638 source := release.VersionPath(opt, "bab", "abc1234", "console")
639 if body, e := os.ReadFile(source); e != nil || !bytes.Equal(body, activationConsoleImage) || stagedManifest.Version != "abc1234" {
640 t.Fatal("the staged console is not the promoted build", source, e)
641 }
642 // A refusal input only: a build that is not the release.
643 write(d+"/versions/other/console", append([]byte("not the release "), activationConsoleImage[:64]...), 0755)
644 // The box-only console.service and its database.
645 dbPath := "/data/console.db"
646 st, err := console.Open(dbPath)
647 check(err)
648 now := time.Now().UTC()
649 check(st.CreateAccount(123, "bab", "fixture", "operator@example.test", now))
650 _, err = st.CreateAppIn(123, "trial", "operator@example.test", now)
651 check(err)
652 check(st.Close())
653 st, err = console.OpenGated(dbPath)
654 check(err)
655 check(st.Close())
656 st, err = console.Open(dbPath)
657 check(err)
658 check(st.BindClaimAppIdentity(123, "trial", "trial-app-id"))
659 tokenFor := func(address string) string {
660 user, device, e := st.NewCLIAuth("fixture", "127.0.0.1", now)
661 check(e)
662 check(st.ApproveCLIAuth(user, address, now))
663 _, token, _, e := st.PollCLIAuth(device, now)
664 check(e)
665 return token
666 }
667 operatorToken := tokenFor("operator@example.test")
668 memberToken := tokenFor("member@example.test")
669 check(st.SetOperator("operator@example.test", true))
670 check(st.Close())
671 for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
672 if _, e := os.Stat(p); e == nil {
673 check(os.Chown(p, uid, gid))
674 }
675 }
676 ln, err := net.Listen("tcp", "127.0.0.1:0")
677 check(err)
678 addr := ln.Addr().String()
679 check(ln.Close())
680 write("/etc/carlos/console-fixture.env", []byte("CARLOS_RELEASE_KEY="+base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600)
681 unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironmentFile=/etc/carlos/console-fixture.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/system/console/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"
682 write("/etc/systemd/system/console.service", []byte(unit), 0644)
683 ctl("daemon-reload")
684 // P9: no console mail name anywhere the console's environment comes from.
685 for _, p := range []string{"/etc/carlos/host.env", "/etc/carlos/console-fixture.env", "/etc/systemd/system/console.service"} {
686 raw, e := os.ReadFile(p)
687 check(e)
688 for _, name := range []string{"CARLOS_CONSOLE_MAIL_FROM", "CARLOS_MAIL_REGION", "CARLOS_MAIL_PLATFORM_SUFFIX", "CARLOS_MAIL_DMARC_RUA", "CARLOS_MAIL_SNS_TOPIC_ARN", "CARLOS_MAIL_OPERATORS", "CARLOS_MAIL_LEGACY_SWEEP"} {
689 if bytes.Contains(raw, []byte(name)) {
690 t.Fatal("P9: SET", name, p)
691 }
692 }
693 }
694 // box_inspect: the box reads the roster with its own credentials.
695 boxInspect := func() uint64 {
696 t.Helper()
697 o := inspectWith(systemBinaryPath(opt))
698 write(bd+"/roster.json", o.SignedRoster, 0600)
699 return o.Roster.Revision
700 }
701 rev := boxInspect()
702 install := func(src string) []string {
703 return []string{"system", "claims", "install-console", "--identity", bd + "/identity.v1.0.0.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg}
704 }
705 carlos(false, install(d+"/versions/other/console")...)
706 carlos(false, install(systemBinaryPath(opt))...)
707 absent("/opt/carlos/system/console")
708 carlos(true, append(install(source), "--dry-run")...)
709 absent("/opt/carlos/system/console")
710 out, _ = carlos(true, install(source)...)
711 t.Log("install-console", out)
712 for _, p := range []string{"/opt/carlos/system/console/console", "/opt/carlos/system/console/console.prev"} {
713 body, e := os.ReadFile(p)
714 st, se := os.Stat(p)
715 if e != nil || se != nil || !bytes.Equal(body, activationConsoleImage) || st.Mode().Perm() != 0755 || st.Sys().(*syscall.Stat_t).Uid != 0 || st.Sys().(*syscall.Stat_t).Gid != 0 {
716 t.Fatal("protected console", p, e, se)
717 }
718 }
719 if out, _ := carlos(true, install(source)...); !strings.Contains(out, `"already_installed": true`) {
720 t.Fatal("rerun was not a no-op", out)
721 }
722 // guard-arm on the console box (sophie's form).
723 carlos(true, append(append([]string{}, armLeo...), "--console", "/opt/carlos/system/console/console")...)
724 dropIn, err := os.ReadFile("/etc/systemd/system/console.service.d/90-host-floor.conf")
725 check(err)
726 if string(dropIn) != hostfloor.ConsoleGuardDropIn("/opt/carlos/system/console/console") {
727 t.Fatal("guard drop-in", string(dropIn))
728 }
729 consoleConfig := func(enable bool) {
730 t.Helper()
731 args := []string{"system", "claims", "console-config", "--roster", bd + "/roster.json", "--expect-revision", fmt.Sprint(rev), "--bucket", bucket, "--console-id", consoleID, "--public-key", pubArg}
732 if enable {
733 args = append(args, "--enable-console-runtime")
734 }
735 carlos(true, append(append([]string{}, args...), "--dry-run")...)
736 carlos(true, args...)
737 }
738 consoleConfig(false)
739 ctl("restart", "console.service")
740 if state := ctl("is-active", "console.service"); state != "active" {
741 t.Fatal("console", state)
742 }
743 consoleReported := func(enabled bool, previous string) hostactors.Report {
744 t.Helper()
745 deadline := time.Now().Add(30 * time.Second)
746 for time.Now().Before(deadline) {
747 raw, _, e := store.Get(ctx, "status/consoles/"+consoleID+".json")
748 var r hostactors.Report
749 if e == nil && json.Unmarshal(raw, &r) == nil && r.HostClaimEnabled == enabled && r.ProcessID != "" && r.ProcessID != previous {
750 return r
751 }
752 time.Sleep(200 * time.Millisecond)
753 }
754 t.Fatal("console did not report", enabled, journal("console.service", 60))
755 return hostactors.Report{}
756 }
757 first := consoleReported(false, "")
758 // The console runs under the guard drop-in as the service user, from the
759 // protected copy's bytes.
760 pidOut := ctl("show", "-p", "MainPID", "--value", "console.service")
761 statusRaw, err := os.ReadFile("/proc/" + pidOut + "/status")
762 check(err)
763 if !strings.Contains(string(statusRaw), fmt.Sprintf("\nUid:\t%d\t%d\t", uid, uid)) {
764 t.Fatal("console is not the service user", string(statusRaw))
765 }
766 exe, err := os.ReadFile("/proc/" + pidOut + "/exe")
767 check(err)
768 if hostfloor.Digest(exe) != hostfloor.Digest(activationConsoleImage) {
769 t.Fatal("console process image differs from the protected copy")
770 }
771 // The agent's own box report, once it has seen the armed guard: the
772 // evidence the console report and the floor publication read. Nothing
773 // here writes it.
774 boxReported := func() {
775 t.Helper()
776 deadline := time.Now().Add(9 * time.Minute)
777 for {
778 raw, _, e := store.Get(ctx, "status/boxes/"+box+".json")
779 var b status.Box
780 if e == nil && json.Unmarshal(raw, &b) == nil && b.Metrics != nil && b.Metrics.FloorGuardReady && b.Metrics.FloorSHA256 == hostfloor.Digest(floorRaw) && b.Metrics.Enrollment == enrolled.Enrollment && b.GeneratedAt.After(armed) {
781 t.Log("agent box report", b.GeneratedAt, b.Metrics.Release, b.Metrics.BinarySHA256)
782 return
783 }
784 if time.Now().After(deadline) {
785 t.Fatalf("the agent did not report the armed guard: %s\n%s", raw, journal("carlos-edge.service", 80))
786 }
787 time.Sleep(5 * time.Second)
788 }
789 }
790 boxReported()
791 // console-report: dry run, then the report. The agent's next box report
792 // replaces the console process entry, so a report that loses that race
793 // is run again (the runbook's rule).
794 consoleReport := func() {
795 t.Helper()
796 var last error
797 for attempt := 1; attempt <= 3; attempt++ {
798 rev = boxInspect()
799 args := []string{"system", "claims", "console-report", "--bucket", bucket, "--aws-region", "eu-west-1", "--expect-revision", fmt.Sprint(rev), "--console-id", consoleID, "--public-key", pubArg}
800 if stdout, stderr, e := try(systemBinaryPath(opt), append(append([]string{}, args...), "--dry-run")...); e != nil {
801 last = fmt.Errorf("dry run: %v: %s %s", e, stdout, stderr)
802 } else if stdout, stderr, e := try(systemBinaryPath(opt), args...); e != nil {
803 last = fmt.Errorf("%v: %s %s", e, stdout, stderr)
804 } else {
805 t.Log("console-report", stdout)
806 return
807 }
808 t.Log("console-report attempt", attempt, last)
809 time.Sleep(3 * time.Second)
810 }
811 t.Fatal("console-report", last)
812 }
813 consoleReport()
814 t.Log("step 6 passed")
815
816 // Step 7.1: readers. Every slot is the trial release.
817 if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v1.0.0" {
818 t.Fatal("readers", c, p)
819 }
820 // Step 7.2: floor and home, immediately after the console's report.
821 floorPlan := func() claimactivation.Plan {
822 return claimactivation.Plan{Phase: "publish-floor", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor}
823 }
824 for attempt := 1; ; attempt++ {
825 if _, e := tryPlan(floorPlan(), ""); e == nil {
826 break
827 } else if attempt == 3 {
828 t.Fatal("publish-floor", e)
829 } else {
830 t.Log("publish-floor attempt", attempt, e)
831 }
832 consoleReport()
833 }
834 // The floor publish-floor wrote is the armed policy's, byte for byte.
835 for k, want := range map[string][]byte{hostfloor.Key: policy.Floor, hostfloor.AuthKey: policy.Authorization} {
836 got, _, e := store.Get(ctx, k)
837 if e != nil || !bytes.Equal(got, want) {
838 t.Fatal("published floor differs from the armed policy", k, e)
839 }
840 }
841 phase("home", claimactivation.Plan{HomeRegion: "ie"})
842 // Step 7.3: the box's evidence key, then bind-key on the signer.
843 rev = boxInspect()
844 evidence := []string{"system", "claims", "evidence-key", "--roster", bd + "/roster.json", "--expect-revision", fmt.Sprint(rev), "--bucket", bucket, "--box", box, "--enrollment", enrolled.Enrollment, "--public-key", pubArg}
845 carlos(true, append(append([]string{}, evidence...), "--dry-run")...)
846 absent("/etc/carlos/host-evidence.key")
847 proofOut, _ := carlos(true, evidence...)
848 write(bd+"/key-proof.json", []byte(proofOut), 0600)
849 var keyProof struct {
850 Proof activationKeyProof `json:"key_proof"`
851 }
852 check(json.Unmarshal([]byte(proofOut), &keyProof))
853 if fi, e := os.Lstat("/etc/carlos/host-evidence.key"); e != nil || fi.Mode().Perm() != 0600 || fi.Sys().(*syscall.Stat_t).Uid != 0 || fi.Sys().(*syscall.Stat_t).Nlink != 1 {
854 t.Fatal("host-evidence.key", fi, e)
855 }
856 phase("bind-key", claimactivation.Plan{Binding: &keyProof.Proof.Binding, Proof: keyProof.Proof.Proof})
857 if o := refresh(); o.Roster.HomeRegion != "ie" || o.Roster.Publication != "published" || o.Roster.RequestLimitNS != 120000000000 {
858 t.Fatal("roster after step 7.3", o.Roster.HomeRegion, o.Roster.Publication, o.Roster.RequestLimitNS)
859 }
860 // Step 7.4: the console runtime.
861 rev = boxInspect()
862 consoleConfig(true)
863 ctl("restart", "console.service")
864 if state := ctl("is-active", "console.service"); state != "active" {
865 t.Fatal("console", state)
866 }
867 second := consoleReported(true, first.ProcessID)
868 consoleReport()
869 t.Log("step 7 passed")
870
871 // The console runtime's own refusals, as before.
872 raw, err := os.ReadFile("/etc/carlos/host.env")
873 check(err)
874 if bytes.Contains(raw, []byte("CARLOS_HOST_CLAIM=")) {
875 t.Fatal("enabled host execution")
876 }
877 o, err := (hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}).Read(ctx)
878 check(err)
879 forged := second
880 forged.ProcessID = first.ProcessID
881 if _, err = activationConsoleProcess(ctx, pub, bucket, o, consoleID, forged); err == nil {
882 t.Fatal("accepted old process")
883 }
884 forged = second
885 forged.BinarySHA256 = strings.Repeat("a", 64)
886 if _, err = activationConsoleProcess(ctx, pub, bucket, o, consoleID, forged); err == nil {
887 t.Fatal("accepted false process digest")
888 }
889 activationResetFixture(t, store, "http://"+addr, operatorToken, memberToken, box, enrolled.Enrollment)
890 // Observation for the report: the agent's next box report is written
891 // whole (status.WriteBox), so whether the console process entry
892 // console-report added survives it is recorded here, not asserted.
893 reportedAt := time.Now()
894 for deadline := time.Now().Add(7 * time.Minute); time.Now().Before(deadline); time.Sleep(5 * time.Second) {
895 raw, _, e := store.Get(ctx, "status/boxes/"+box+".json")
896 var b status.Box
897 if e == nil && json.Unmarshal(raw, &b) == nil && b.Metrics != nil && b.GeneratedAt.After(reportedAt) {
898 t.Log("OBSERVATION: the agent's next box report at", b.GeneratedAt, "carries", len(b.Metrics.ConsoleProcesses), "console process entries")
899 break
900 }
901 }
902 ctl("stop", "console.service")
903 if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, consoleID, o.Roster.Revision, false); err == nil {
904 t.Fatal("reported stopped console")
905 }
906 t.Log("PASS steps 6 and 7 on a box made by bootstrap and the real update: the 6.2 stop reproduced, install-channel --policy --identity over the untagged pointer, guard-arm in both forms, the guarded console, floor, home, key and console runtime, with the agent's own box reports")
907}
908
909// activationServiceIDs is the service user bootstrap created.
910func activationServiceIDs(t *testing.T) (int, int) {
911 t.Helper()
912 u, err := user.Lookup(serviceUser)
913 if err != nil {
914 t.Fatal(err)
915 }
916 var uid, gid int
917 if _, err := fmt.Sscan(u.Uid, &uid); err != nil {
918 t.Fatal(err)
919 }
920 if _, err := fmt.Sscan(u.Gid, &gid); err != nil {
921 t.Fatal(err)
922 }
923 return uid, gid
924}
925
926// activationDirNames is the set of names in dir; a missing dir is empty.
927func activationDirNames(dir string) map[string]bool {
928 names := map[string]bool{}
929 ents, _ := os.ReadDir(dir)
930 for _, e := range ents {
931 names[e.Name()] = true
932 }
933 return names
934}
935
936func activationStoreSnapshot(t *testing.T, s release.Store) map[string]string {
937 t.Helper()
938 keys, err := s.List(t.Context(), "")
939 if err != nil {
940 t.Fatal(err)
941 }
942 result := map[string]string{}
943 for _, k := range keys {
944 raw, tag, e := s.Get(t.Context(), k)
945 if errors.Is(e, os.ErrNotExist) {
946 continue // removed between the list and the read
947 }
948 if e != nil {
949 t.Fatal(e)
950 }
951 result[k] = tag + ":" + string(raw)
952 }
953 return result
954}
955
956func activationS3Fixture(t *testing.T, s release.Store) string {
957 t.Helper()
958 var mu sync.Mutex
959 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
960 mu.Lock()
961 defer mu.Unlock()
962 _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
963 fail := func(code int, name string) {
964 w.Header().Set("Content-Type", "application/xml")
965 w.WriteHeader(code)
966 fmt.Fprintf(w, "<Error><Code>%s</Code></Error>", name)
967 }
968 switch r.Method {
969 case "GET", "HEAD":
970 if key == "" {
971 prefix := r.URL.Query().Get("prefix")
972 keys, e := s.List(r.Context(), prefix)
973 if e != nil {
974 fail(500, "InternalError")
975 return
976 }
977 type item struct{ Key string }
978 type common struct{ Prefix string }
979 result := struct {
980 XMLName xml.Name `xml:"ListBucketResult"`
981 IsTruncated bool
982 Contents []item
983 CommonPrefixes []common
984 }{}
985 seen := map[string]bool{}
986 for _, k := range keys {
987 if delim := r.URL.Query().Get("delimiter"); delim != "" {
988 rest := strings.TrimPrefix(k, prefix)
989 if at := strings.Index(rest, delim); at >= 0 {
990 p := prefix + rest[:at+len(delim)]
991 if !seen[p] {
992 result.CommonPrefixes = append(result.CommonPrefixes, common{p})
993 seen[p] = true
994 }
995 continue
996 }
997 }
998 result.Contents = append(result.Contents, item{k})
999 }
1000 w.Header().Set("Content-Type", "application/xml")
1001 xml.NewEncoder(w).Encode(result)
1002 return
1003 }
1004 raw, tag, e := s.Get(r.Context(), key)
1005 if e != nil {
1006 fail(404, "NoSuchKey")
1007 return
1008 }
1009 w.Header().Set("ETag", `"`+tag+`"`)
1010 w.Header().Set("Content-Length", fmt.Sprint(len(raw)))
1011 if r.Method == "GET" {
1012 w.Write(raw)
1013 }
1014 case "PUT":
1015 raw, e := io.ReadAll(r.Body)
1016 if e != nil {
1017 fail(400, "IncompleteBody")
1018 return
1019 }
1020 _, old, e := s.Get(r.Context(), key)
1021 if e != nil && !errors.Is(e, os.ErrNotExist) {
1022 fail(500, "InternalError")
1023 return
1024 }
1025 before := strings.Trim(r.Header.Get("If-Match"), `"`)
1026 if r.Header.Get("If-None-Match") == "*" && old != "" || before != "" && before != old {
1027 fail(412, "PreconditionFailed")
1028 return
1029 }
1030 tag, e := s.Put(r.Context(), key, raw, old)
1031 if e != nil {
1032 fail(412, "PreconditionFailed")
1033 return
1034 }
1035 w.Header().Set("ETag", `"`+tag+`"`)
1036 case "DELETE":
1037 if e := s.Delete(r.Context(), key); e != nil && !errors.Is(e, os.ErrNotExist) {
1038 fail(500, "InternalError")
1039 return
1040 }
1041 w.WriteHeader(204)
1042 default:
1043 fail(400, "InvalidRequest")
1044 }
1045 }))
1046 t.Cleanup(server.Close)
1047 return server.URL
1048}
1049
1050// activationResetFixture is the authenticated reset of an exhausted wake
1051// retry through the guarded console. Its claim state is fixture state; box
1052// and enrollment are the enrolled box's.
1053func activationResetFixture(t *testing.T, s release.Store, origin, token, other, box, enrollment string) {
1054 t.Helper()
1055 ctx := t.Context()
1056 check := func(e error) {
1057 t.Helper()
1058 if e != nil {
1059 t.Fatal(e)
1060 }
1061 }
1062 now := time.Now().UTC().Add(-time.Minute)
1063 const host = "trial.bab.example.test"
1064 record := []byte(`{"schema":1,"host":"trial.bab.example.test","account":"bab","app":"trial","region":"ie","created_at":"` + now.Format(time.RFC3339Nano) + `"}`)
1065 o, err := hostclaim.Read(ctx, s, host)
1066 check(err)
1067 o, err = hostclaim.Update(ctx, s, o, hostclaim.BeginCreate(hostclaim.NewHolder{Family: "instance", Key: "apps/bab/trial/instances/" + host + ".json", Account: "bab", App: "trial", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now}, record, "create", "dev-console", now))
1068 check(err)
1069 o, err = hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("create", box))
1070 check(err)
1071 for i := 0; i < hostclaim.WakeRetryLimit; i++ {
1072 a := hostclaim.WakeRetryAttempt{ID: fmt.Sprint(i), Box: box, Enrollment: enrollment, BootID: "fixture", ControllerID: "fixture", InvocationID: "fixture", LaunchInputs: "fixture"}
1073 o, err = hostclaim.Update(ctx, s, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now}))
1074 check(err)
1075 o, err = hostclaim.Update(ctx, s, o, hostclaim.FailWakeRetryAttempt(hostclaim.WakeRetryResult{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now}))
1076 check(err)
1077 now = now.Add(20 * time.Second)
1078 }
1079 admission := hostops.AppAdmission{V: 1, AppID: "trial-app-id", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}
1080 raw, _ := json.Marshal(admission)
1081 key, _ := hostops.AdmissionKey("bab", "trial")
1082 _, err = s.Put(ctx, key, raw, "")
1083 check(err)
1084 payload := `{"action":"reset-wake-retry","incarnation":"` + o.Claim.Holder.Incarnation + `","episode":"exhausted","request_id":"fixture-reset"}`
1085 call := func(auth, body string) int {
1086 t.Helper()
1087 req, _ := http.NewRequestWithContext(ctx, "POST", origin+"/api/cli/ops/hosts/"+host+"/resolve", strings.NewReader(body))
1088 req.Header.Set("Content-Type", "application/json")
1089 if auth != "" {
1090 req.Header.Set("Authorization", "Bearer "+auth)
1091 }
1092 client := http.Client{Timeout: 10 * time.Second}
1093 resp, e := client.Do(req)
1094 check(e)
1095 defer resp.Body.Close()
1096 b, _ := io.ReadAll(resp.Body)
1097 t.Log("reset response", resp.StatusCode, string(b))
1098 return resp.StatusCode
1099 }
1100 for _, test := range []struct {
1101 token string
1102 status int
1103 }{{"", 401}, {other, 404}} {
1104 if code := call(test.token, payload); code != test.status {
1105 t.Fatal(code, test.status)
1106 }
1107 }
1108 before, _ := hostclaim.Read(ctx, s, host)
1109 if before.ETag != o.ETag {
1110 t.Fatal("unauthorized reset changed claim")
1111 }
1112 if code := call(token, payload); code != 200 {
1113 t.Fatal("reset failed", code)
1114 }
1115 after, err := hostclaim.Read(ctx, s, host)
1116 check(err)
1117 retry := after.Claim.Holder.WakeRetry
1118 if retry.EpisodeID == "exhausted" || retry.AttemptsReserved != 0 || retry.State != "open" || retry.ResetRequest != `{"Actor":"operator@example.test","RequestID":"fixture-reset"}` {
1119 t.Fatal("reset result", retry)
1120 }
1121 previous := *o.Claim.Holder
1122 previous.WakeRetry = retry
1123 if !reflect.DeepEqual(&previous, after.Claim.Holder) || !reflect.DeepEqual(o.Claim.Op, after.Claim.Op) {
1124 t.Fatal("reset changed execution authority")
1125 }
1126 if code := call(token, payload); code != 409 {
1127 t.Fatal("stale reset accepted", code)
1128 }
1129 final, err := hostclaim.Read(ctx, s, host)
1130 check(err)
1131 if final.ETag != after.ETag {
1132 t.Fatal("stale reset mutated claim")
1133 }
1134 t.Log("exhausted retry reset through production runtime; holder/session/stamp preserved")
1135}
1136
1137// activationSlotFiles is every file in the box's /opt/carlos and in root's
1138// system/router directory, by name and content, so a refused command can be
1139// shown to have changed nothing.
1140func activationSlotFiles(t *testing.T, opt string) map[string]string {
1141 t.Helper()
1142 got := map[string]string{}
1143 for _, dir := range []string{opt, systemRootDir(opt)} {
1144 ents, err := os.ReadDir(dir)
1145 if err != nil {
1146 t.Fatal(err)
1147 }
1148 for _, e := range ents {
1149 path := filepath.Join(dir, e.Name())
1150 if fi, err := os.Lstat(path); err == nil && fi.Mode().IsRegular() {
1151 b, _ := os.ReadFile(path)
1152 got[path] = hostfloor.Digest(b)
1153 } else if err == nil {
1154 got[path] = fi.Mode().String()
1155 }
1156 }
1157 }
1158 return got
1159}
1160