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
2
3package host
4
5import (
6 "context"
7 "crypto/ed25519"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync/atomic"
16 "syscall"
17 "testing"
18 "time"
19
20 "bytes"
21 "github.com/carlosframework/platform/internal/hostactors"
22 "github.com/carlosframework/platform/internal/hostbox"
23 "github.com/carlosframework/platform/internal/hostevidence"
24 "github.com/carlosframework/platform/internal/hostguard"
25 "github.com/carlosframework/platform/internal/registry"
26 "github.com/carlosframework/platform/internal/release"
27)
28
29func plan9Fix1RootRetire(t *testing.T) (ClaimRootHTTP, hostguard.FenceTarget, *registry.Registry, string) {
30 t.Helper()
31 coordinator, req, _ := claimStopFixture(t)
32 result, err := coordinator.FenceAndStop(t.Context(), req)
33 if err != nil {
34 t.Fatal(err)
35 }
36 base := t.TempDir()
37 inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db"))
38 if err != nil {
39 t.Fatal(err)
40 }
41 t.Cleanup(func() { inventory.Close() })
42 reg, err := registry.Open(filepath.Join(base, "registry.db"))
43 if err != nil {
44 t.Fatal(err)
45 }
46 t.Cleanup(func() { reg.Close() })
47 owner := registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp, ClaimOwner: "installation", ClaimRowID: "route-1"}
48 if err = reg.Upsert(registry.Route{Host: req.Host, ClaimOwnership: owner}); err != nil {
49 t.Fatal(err)
50 }
51 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"}}}
52 if err = inventory.WriteStopInventory(t.Context(), in); err != nil {
53 t.Fatal(err)
54 }
55 journal := filepath.Join(base, "retire")
56 root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: coordinator}, Roots: map[string]string{"data": base}, Reconcile: hostbox.ReconcileAdapter{Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}}
57 return root, result.Target, reg, base
58}
59
60// C-1: an agent-planted retirement journal cannot add artifacts that root's
61// own stop inventory does not name, even when it decodes strictly and matches
62// the host, incarnation, stamp, ownership, target and stop receipt.
63func TestPlan9Fix1RootRetireRefusesPlantedJournalArtifacts(t *testing.T) {
64 t.Parallel()
65 for _, rootName := range []string{"data", "opt", "units"} {
66 t.Run(rootName, func(t *testing.T) {
67 root, target, reg, base := plan9Fix1RootRetire(t)
68 root.Roots[rootName] = base
69 secret := filepath.Join(base, "secret")
70 if err := os.WriteFile(secret, []byte("root-only"), 0600); err != nil {
71 t.Fatal(err)
72 }
73 var st syscall.Stat_t
74 if err := syscall.Lstat(secret, &st); err != nil {
75 t.Fatal(err)
76 }
77 receipt, err := root.Stop.Coordinator.Guard.FenceReceiptFor(t.Context(), target)
78 if err != nil {
79 t.Fatal(err)
80 }
81 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{
82 {Kind: hostbox.ArtifactRoute, Root: "registry", Name: target.Host, Owner: "installation", RowID: "route-1"},
83 {Kind: hostbox.ArtifactPath, Root: rootName, Name: "secret", Owner: "installation", Dev: uint64(st.Dev), Ino: st.Ino, Type: st.Mode & syscall.S_IFMT},
84 }})
85 if err != nil {
86 t.Fatal(err)
87 }
88 planted.Target = &target
89 planted.StopReceipt = receipt.Bytes
90 body, _ := json.Marshal(planted)
91 dir := root.Reconcile.Retirement.JournalDir
92 if err = os.Mkdir(dir, 0700); err != nil {
93 t.Fatal(err)
94 }
95 if err = os.WriteFile(filepath.Join(dir, target.FenceID+".json"), body, 0600); err != nil {
96 t.Fatal(err)
97 }
98 err = root.retire(t.Context(), target)
99 if raw, readErr := os.ReadFile(secret); readErr != nil || string(raw) != "root-only" {
100 t.Fatalf("root parked an artifact its own inventory never named: %v (retire: %v)", readErr, err)
101 }
102 if !errors.Is(err, hostbox.ErrTypedJournal) {
103 t.Fatalf("planted journal inventory was not refused: %v", err)
104 }
105 if row, err := reg.Get(target.Host); err != nil || row == nil {
106 t.Fatalf("route retired from a planted journal: %v", err)
107 }
108
109 })
110 }
111}
112
113// C-1: a symlinked retirement journal directory is refused before any write.
114func TestPlan9Fix1RootRetireRefusesSymlinkedJournalDir(t *testing.T) {
115 t.Parallel()
116 root, target, reg, base := plan9Fix1RootRetire(t)
117 victim := filepath.Join(base, "victim")
118 if err := os.Mkdir(victim, 0700); err != nil {
119 t.Fatal(err)
120 }
121 if err := os.Symlink(victim, root.Reconcile.Retirement.JournalDir); err != nil {
122 t.Fatal(err)
123 }
124 if err := root.retire(t.Context(), target); err == nil {
125 t.Fatal("symlinked journal directory accepted")
126 }
127 if entries, _ := os.ReadDir(victim); len(entries) != 0 {
128 t.Fatalf("root wrote through the symlink: %v", entries)
129 }
130 if row, err := reg.Get(target.Host); err != nil || row == nil {
131 t.Fatalf("route retired through a symlinked journal: %v", err)
132 }
133 if err := root.RecoverPending(t.Context()); err == nil {
134 t.Fatal("recovery read a symlinked journal directory")
135 }
136}
137
138// C-1: root modes keep retirement and cold-handover journals in root's own
139// namespace, never under the agent-owned data directory.
140func TestPlan9Fix1RootRetireJournalIsRootNamespace(t *testing.T) {
141 for _, mode := range []string{"adopt", "instance-control"} {
142 dir := claimRetireJournalDir(mode, "/data")
143 if !strings.HasPrefix(dir, "/var/lib/carlos-claims/") || strings.HasPrefix(dir, "/data") {
144 t.Fatalf("%s retirement journal outside root's namespace: %s", mode, dir)
145 }
146 }
147}
148
149// M5: a refusal after a lost reply is uncertain, never a typed refusal.
150func TestPlan9Fix1JournalRefusalAfterLostReplyIsUncertain(t *testing.T) {
151 for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} {
152 t.Run(fmt.Sprint(status), func(t *testing.T) {
153 var calls atomic.Int32
154 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155 if calls.Add(1) == 1 {
156 if conn, _, err := w.(http.Hijacker).Hijack(); err == nil {
157 conn.Close()
158 }
159 return
160 }
161 w.WriteHeader(status)
162 }))
163 client, _ := hostbox.RemoteJournals(call)
164 err := client.RecordProducer(t.Context(), hostguard.FenceTarget{Host: "app.example"}, "p")
165 var uncertain *hostbox.JournalUncertainError
166 if !errors.As(err, &uncertain) || errors.Is(err, hostbox.ErrTypedJournal) || errors.Is(err, hostbox.ErrTypedInventory) {
167 t.Fatalf("refusal after a lost reply reads as definite: %v", err)
168 }
169 })
170 }
171}
172
173// M3: serving writers publish their stop under serving-evidence, not evidence.
174func TestPlan9Fix1LegacyProofAcceptsServingEvidence(t *testing.T) {
175 root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
176 signer := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize))
177 store := release.NewDirStore(t.TempDir())
178 enrollment := strings.Repeat("a", 32)
179 roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 3, RequestLimitNS: int64(hostbox.RequestLimit), Regions: map[string]hostactors.Region{"ie": {}}, Boxes: map[string]hostactors.Box{
180 "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"},
181 }, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
182 raw, err := release.SignRootEnvelope(release.RootSigner(root), roster)
183 if err != nil {
184 t.Fatal(err)
185 }
186 if _, err = store.Put(t.Context(), hostactors.Key, raw, ""); err != nil {
187 t.Fatal(err)
188 }
189 j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "legacy.db"))
190 if err != nil {
191 t.Fatal(err)
192 }
193 defer j.Close()
194 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}}}
195 if err = j.Create(t.Context(), in); err != nil {
196 t.Fatal(err)
197 }
198 journal, err := j.ReadStopKernelJournal(t.Context(), in.Target)
199 if err != nil {
200 t.Fatal(err)
201 }
202 inventory := hostbox.StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: enrollment, DirectoryRevision: "1"}
203 target := in.Target
204 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)}
205 body, _ := json.Marshal(receipt)
206 key := stopEvidenceKey(target.Host, "", target.Box, receipt.ID)
207 if err = hostevidence.Publish(t.Context(), store, signer, key, body); err != nil {
208 t.Fatal(err)
209 }
210 verify := legacyJournalProof(store, hostactors.Directory{Store: store, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"})
211 if err = verify(t.Context(), in, inventory, journal); err != nil {
212 t.Fatalf("published serving stop refused: %v", err)
213 }
214}
215
216// M3: an active edge is refused before any runtime mask is written.
217func TestPlan9Fix1QuiesceChecksEdgeBeforeMasking(t *testing.T) {
218 var calls []string
219 restore := claimSystemctl
220 t.Cleanup(func() { claimSystemctl = restore })
221 restoreOwner := claimQuiesceOwner
222 t.Cleanup(func() { claimQuiesceOwner = restoreOwner })
223 claimQuiesceOwner = func() (int, int, error) { return 1 << 30, 1 << 30, nil }
224 claimSystemctl = func(_ context.Context, args ...string) ([]byte, error) {
225 calls = append(calls, strings.Join(args, " "))
226 switch {
227 case args[0] == "is-enabled":
228 return []byte("enabled\n"), nil
229 case args[0] == "show":
230 return []byte("active\n"), nil
231 }
232 return nil, nil
233 }
234 if _, err := quiesceLegacyClaimJournals(t.Context()); err == nil {
235 t.Fatal("active edge accepted")
236 }
237 if len(calls) == 0 {
238 t.Fatal("edge state never inspected")
239 }
240 for _, call := range calls {
241 if strings.HasPrefix(call, "mask") || strings.HasPrefix(call, "unmask") {
242 t.Fatalf("active edge was masked before its state was checked: %v", calls)
243 }
244 }
245}
246
247// M6: the root unit gets memory for the journal service, migration and fence
248// pass only when the claim flag renders it; flag-off bytes are unchanged.
249func TestPlan9Fix1ClaimControlUnitMemory(t *testing.T) {
250 raw, err := os.ReadFile("units/carlos-instance-control.service")
251 if err != nil {
252 t.Fatal(err)
253 }
254 if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) {
255 t.Fatal("flag-off unit changed")
256 }
257 got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"}))
258 if strings.Contains(got, "MemoryMax=64M") || !strings.Contains(got, "MemoryMax=256M") || !strings.Contains(got, "GOMEMLIMIT=") {
259 t.Fatalf("flag-on root unit memory not raised:\n%s", got)
260 }
261}
262