//go:build linux package host import ( "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strings" "sync/atomic" "syscall" "testing" "time" "bytes" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostevidence" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) func plan9Fix1RootRetire(t *testing.T) (ClaimRootHTTP, hostguard.FenceTarget, *registry.Registry, string) { t.Helper() coordinator, req, _ := claimStopFixture(t) result, err := coordinator.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } base := t.TempDir() inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { inventory.Close() }) reg, err := registry.Open(filepath.Join(base, "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) owner := registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp, ClaimOwner: "installation", ClaimRowID: "route-1"} if err = reg.Upsert(registry.Route{Host: req.Host, ClaimOwnership: owner}); err != nil { t.Fatal(err) } in := hostbox.StopInventory{Complete: true, Host: req.Host, Incarnation: req.Incarnation, InstallationStamp: req.InstallationStamp, Box: req.Box, Enrollment: req.Enrollment, DirectoryRevision: req.ExpectedDirectoryRevision, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: req.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}} if err = inventory.WriteStopInventory(t.Context(), in); err != nil { t.Fatal(err) } journal := filepath.Join(base, "retire") root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: coordinator}, Roots: map[string]string{"data": base}, Reconcile: hostbox.ReconcileAdapter{Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}} return root, result.Target, reg, base } // C-1: an agent-planted retirement journal cannot add artifacts that root's // own stop inventory does not name, even when it decodes strictly and matches // the host, incarnation, stamp, ownership, target and stop receipt. func TestPlan9Fix1RootRetireRefusesPlantedJournalArtifacts(t *testing.T) { t.Parallel() for _, rootName := range []string{"data", "opt", "units"} { t.Run(rootName, func(t *testing.T) { root, target, reg, base := plan9Fix1RootRetire(t) root.Roots[rootName] = base secret := filepath.Join(base, "secret") if err := os.WriteFile(secret, []byte("root-only"), 0600); err != nil { t.Fatal(err) } var st syscall.Stat_t if err := syscall.Lstat(secret, &st); err != nil { t.Fatal(err) } receipt, err := root.Stop.Coordinator.Guard.FenceReceiptFor(t.Context(), target) if err != nil { t.Fatal(err) } planted, err := hostbox.NewRetirementJournal(target.FenceID, hostbox.RetirementInventory{Host: target.Host, Captured: hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}, Ownership: "installation", Artifacts: []hostbox.Artifact{ {Kind: hostbox.ArtifactRoute, Root: "registry", Name: target.Host, Owner: "installation", RowID: "route-1"}, {Kind: hostbox.ArtifactPath, Root: rootName, Name: "secret", Owner: "installation", Dev: uint64(st.Dev), Ino: st.Ino, Type: st.Mode & syscall.S_IFMT}, }}) if err != nil { t.Fatal(err) } planted.Target = &target planted.StopReceipt = receipt.Bytes body, _ := json.Marshal(planted) dir := root.Reconcile.Retirement.JournalDir if err = os.Mkdir(dir, 0700); err != nil { t.Fatal(err) } if err = os.WriteFile(filepath.Join(dir, target.FenceID+".json"), body, 0600); err != nil { t.Fatal(err) } err = root.retire(t.Context(), target) if raw, readErr := os.ReadFile(secret); readErr != nil || string(raw) != "root-only" { t.Fatalf("root parked an artifact its own inventory never named: %v (retire: %v)", readErr, err) } if !errors.Is(err, hostbox.ErrTypedJournal) { t.Fatalf("planted journal inventory was not refused: %v", err) } if row, err := reg.Get(target.Host); err != nil || row == nil { t.Fatalf("route retired from a planted journal: %v", err) } }) } } // C-1: a symlinked retirement journal directory is refused before any write. func TestPlan9Fix1RootRetireRefusesSymlinkedJournalDir(t *testing.T) { t.Parallel() root, target, reg, base := plan9Fix1RootRetire(t) victim := filepath.Join(base, "victim") if err := os.Mkdir(victim, 0700); err != nil { t.Fatal(err) } if err := os.Symlink(victim, root.Reconcile.Retirement.JournalDir); err != nil { t.Fatal(err) } if err := root.retire(t.Context(), target); err == nil { t.Fatal("symlinked journal directory accepted") } if entries, _ := os.ReadDir(victim); len(entries) != 0 { t.Fatalf("root wrote through the symlink: %v", entries) } if row, err := reg.Get(target.Host); err != nil || row == nil { t.Fatalf("route retired through a symlinked journal: %v", err) } if err := root.RecoverPending(t.Context()); err == nil { t.Fatal("recovery read a symlinked journal directory") } } // C-1: root modes keep retirement and cold-handover journals in root's own // namespace, never under the agent-owned data directory. func TestPlan9Fix1RootRetireJournalIsRootNamespace(t *testing.T) { for _, mode := range []string{"adopt", "instance-control"} { dir := claimRetireJournalDir(mode, "/data") if !strings.HasPrefix(dir, "/var/lib/carlos-claims/") || strings.HasPrefix(dir, "/data") { t.Fatalf("%s retirement journal outside root's namespace: %s", mode, dir) } } } // M5: a refusal after a lost reply is uncertain, never a typed refusal. func TestPlan9Fix1JournalRefusalAfterLostReplyIsUncertain(t *testing.T) { for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} { t.Run(fmt.Sprint(status), func(t *testing.T) { var calls atomic.Int32 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if calls.Add(1) == 1 { if conn, _, err := w.(http.Hijacker).Hijack(); err == nil { conn.Close() } return } w.WriteHeader(status) })) client, _ := hostbox.RemoteJournals(call) err := client.RecordProducer(t.Context(), hostguard.FenceTarget{Host: "app.example"}, "p") var uncertain *hostbox.JournalUncertainError if !errors.As(err, &uncertain) || errors.Is(err, hostbox.ErrTypedJournal) || errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("refusal after a lost reply reads as definite: %v", err) } }) } } // M3: serving writers publish their stop under serving-evidence, not evidence. func TestPlan9Fix1LegacyProofAcceptsServingEvidence(t *testing.T) { root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) signer := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)) store := release.NewDirStore(t.TempDir()) enrollment := strings.Repeat("a", 32) roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 3, RequestLimitNS: int64(hostbox.RequestLimit), Regions: map[string]hostactors.Region{"ie": {}}, Boxes: map[string]hostactors.Box{ "box": {CensusPublicKey: signer.Public().(ed25519.PublicKey), Enrollment: enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}, }, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} raw, err := release.SignRootEnvelope(release.RootSigner(root), roster) if err != nil { t.Fatal(err) } if _, err = store.Put(t.Context(), hostactors.Key, raw, ""); err != nil { t.Fatal(err) } j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "legacy.db")) if err != nil { t.Fatal(err) } defer j.Close() in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: enrollment, BootID: "boot", ControllerID: "controller"}, ControllerPID: 1, ProcessStart: "1", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} if err = j.Create(t.Context(), in); err != nil { t.Fatal(err) } journal, err := j.ReadStopKernelJournal(t.Context(), in.Target) if err != nil { t.Fatal(err) } inventory := hostbox.StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: enrollment, DirectoryRevision: "1"} target := in.Target receipt := StopReceipt{FenceID: "fence", ClaimGen: 1, ObservedBootID: "boot", DirectoryRevision: "1", Binding: "1", CgroupIdentities: []string{"session"}, V: 1, ID: "stop1", Kind: "stopped", Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, RequestLimitNS: int64(hostbox.RequestLimit), EvidenceAt: time.Now().UTC(), InventorySHA256: digestStop(inventory), LocalJournalSHA256: digestStop(journal)} body, _ := json.Marshal(receipt) key := stopEvidenceKey(target.Host, "", target.Box, receipt.ID) if err = hostevidence.Publish(t.Context(), store, signer, key, body); err != nil { t.Fatal(err) } verify := legacyJournalProof(store, hostactors.Directory{Store: store, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"}) if err = verify(t.Context(), in, inventory, journal); err != nil { t.Fatalf("published serving stop refused: %v", err) } } // M3: an active edge is refused before any runtime mask is written. func TestPlan9Fix1QuiesceChecksEdgeBeforeMasking(t *testing.T) { var calls []string restore := claimSystemctl t.Cleanup(func() { claimSystemctl = restore }) restoreOwner := claimQuiesceOwner t.Cleanup(func() { claimQuiesceOwner = restoreOwner }) claimQuiesceOwner = func() (int, int, error) { return 1 << 30, 1 << 30, nil } claimSystemctl = func(_ context.Context, args ...string) ([]byte, error) { calls = append(calls, strings.Join(args, " ")) switch { case args[0] == "is-enabled": return []byte("enabled\n"), nil case args[0] == "show": return []byte("active\n"), nil } return nil, nil } if _, err := quiesceLegacyClaimJournals(t.Context()); err == nil { t.Fatal("active edge accepted") } if len(calls) == 0 { t.Fatal("edge state never inspected") } for _, call := range calls { if strings.HasPrefix(call, "mask") || strings.HasPrefix(call, "unmask") { t.Fatalf("active edge was masked before its state was checked: %v", calls) } } } // M6: the root unit gets memory for the journal service, migration and fence // pass only when the claim flag renders it; flag-off bytes are unchanged. func TestPlan9Fix1ClaimControlUnitMemory(t *testing.T) { raw, err := os.ReadFile("units/carlos-instance-control.service") if err != nil { t.Fatal(err) } if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) { t.Fatal("flag-off unit changed") } got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"})) if strings.Contains(got, "MemoryMax=64M") || !strings.Contains(got, "MemoryMax=256M") || !strings.Contains(got, "GOMEMLIMIT=") { t.Fatalf("flag-on root unit memory not raised:\n%s", got) } }