carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostbox
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostguard"
13)
14
15// plan9Mutation prepares a mutation exactly as the remote client does.
16func plan9Mutation(t *testing.T, q JournalRequest) JournalRequest {
17 t.Helper()
18 q, err := journalPrepare(q, time.Now())
19 if err != nil {
20 t.Fatal(err)
21 }
22 return q
23}
24
25func journalFixture(t *testing.T) (*JournalService, string, LaunchInventory) {
26 t.Helper()
27 path := filepath.Join(t.TempDir(), "journal.db")
28 j, err := OpenLaunchJournal(path)
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { j.Close() })
33 s, err := NewJournalService(j)
34 if err != nil {
35 t.Fatal(err)
36 }
37 in := 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: []LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
38 return s, path, in
39}
40func TestPlan9JournalReplaySurvivesLostReplyAndRestart(t *testing.T) {
41 s, path, in := journalFixture(t)
42 q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in})
43 first, err := s.Execute(t.Context(), "peer", q)
44 if err != nil {
45 t.Fatal(err)
46 }
47 if err = s.Launches.Close(); err != nil {
48 t.Fatal(err)
49 }
50 j, err := OpenLaunchJournal(path)
51 if err != nil {
52 t.Fatal(err)
53 }
54 defer j.Close()
55 s, err = NewJournalService(j)
56 if err != nil {
57 t.Fatal(err)
58 }
59 replay, err := s.Execute(t.Context(), "peer", q)
60 if err != nil || replay.ID != first.ID {
61 t.Fatalf("replay: %+v %v", replay, err)
62 }
63 if _, err = s.Execute(t.Context(), "other-peer", q); !errors.Is(err, ErrChanged) {
64 t.Fatalf("peer alias: %v", err)
65 }
66 q.Launch.ProcessStart = "other"
67 if _, err = s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) {
68 t.Fatalf("body alias: %v", err)
69 }
70 got, err := s.Launches.Read(t.Context(), in.Target)
71 if err != nil || got.ProcessStart != in.ProcessStart {
72 t.Fatalf("history changed: %+v %v", got, err)
73 }
74}
75func TestPlan9JournalReceiptFailureRollsBackMutation(t *testing.T) {
76 s, _, in := journalFixture(t)
77 if _, err := s.Launches.db.Exec("CREATE TRIGGER refuse_receipt BEFORE INSERT ON journal_requests BEGIN SELECT RAISE(ABORT,'injected'); END"); err != nil {
78 t.Fatal(err)
79 }
80 q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in})
81 if _, err := s.Execute(t.Context(), "peer", q); err == nil {
82 t.Fatal("receipt failure accepted")
83 }
84 if _, err := s.Launches.Read(t.Context(), in.Target); !errors.Is(err, sql.ErrNoRows) {
85 t.Fatalf("mutation escaped rollback: %v", err)
86 }
87}
88func TestPlan9JournalClientUsesTypedRootService(t *testing.T) {
89 s, _, in := journalFixture(t)
90 client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) })
91 if err := client.Create(t.Context(), in); err != nil {
92 t.Fatal(err)
93 }
94 if err := client.RecordProducer(t.Context(), in.Target, "p1"); err != nil {
95 t.Fatal(err)
96 }
97 done, err := client.CompleteProducer(t.Context(), in.Target, "p1", ProducerCompletion{})
98 if err != nil || done.Count != 1 {
99 t.Fatalf("completion: %+v %v", done, err)
100 }
101 got, err := client.Read(t.Context(), in.Target)
102 if err != nil || got.Completed != done {
103 t.Fatalf("read: %+v %v", got, err)
104 }
105 if found, err := client.HasHost(t.Context(), in.Target.Host); err != nil || !found {
106 t.Fatalf("has host: %v %v", found, err)
107 }
108 rows, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment")
109 if err != nil || len(rows) != 1 {
110 t.Fatalf("session: %v %v", rows, err)
111 }
112 inv := StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"}
113 if err := inventory.WriteStopInventory(t.Context(), inv); err != nil {
114 t.Fatal(err)
115 }
116 if got, err := inventory.ReadExactStopInventory(t.Context(), in.Target); err != nil || got.Host != inv.Host {
117 t.Fatalf("inventory: %+v %v", got, err)
118 }
119}
120func TestPlan9JournalLateConnectionKeepsDurability(t *testing.T) {
121 s, _, in := journalFixture(t)
122 s.Launches.db.SetMaxIdleConns(0)
123 for i := 0; i < 3; i++ {
124 var level int
125 if err := s.Launches.db.QueryRow("PRAGMA synchronous").Scan(&level); err != nil || level != 2 {
126 t.Fatalf("replacement connection durability: %d %v", level, err)
127 }
128 }
129 if err := s.Launches.Create(t.Context(), in); err != nil {
130 t.Fatal(err)
131 }
132 if _, err := s.Launches.Read(t.Context(), in.Target); err != nil {
133 t.Fatal(err)
134 }
135}
136func TestPlan9RootNamespaceRejectsUntrustedAncestorsAndSymlinks(t *testing.T) {
137 dir := t.TempDir()
138 path := filepath.Join(dir, "root")
139 if err := os.Mkdir(path, 0700); err != nil {
140 t.Fatal(err)
141 }
142 if err := PrepareRootJournalDirectory(path); err == nil {
143 t.Fatal("untrusted ancestor accepted")
144 }
145 link := filepath.Join(dir, "link")
146 if err := os.Symlink(path, link); err != nil {
147 t.Fatal(err)
148 }
149 if err := PrepareRootJournalDirectory(link); err == nil {
150 t.Fatal("symlink namespace accepted")
151 }
152}
153
154// A lost reply leaves the exact request in the uncertain error. Replaying it,
155// even after root restarts, returns the committed result once; rebuilding a
156// fresh request from the same payload is a distinct call and never a second
157// effect.
158func TestPlan9JournalClientCrashReplaysRetainedRequest(t *testing.T) {
159 s, path, in := journalFixture(t)
160 lost := true
161 call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) {
162 out, err := s.Execute(ctx, "box/enrollment", q)
163 if err != nil {
164 return out, err
165 }
166 if q.Op == "create" && lost {
167 lost = false
168 return JournalResponse{}, context.DeadlineExceeded
169 }
170 return out, nil
171 }
172 client, _ := RemoteJournals(call)
173 err := client.Create(t.Context(), in)
174 var uncertain *JournalUncertainError
175 if !errors.As(err, &uncertain) {
176 t.Fatalf("reply was not lost: %v", err)
177 }
178 if err := s.Launches.Close(); err != nil {
179 t.Fatal(err)
180 }
181 j, err := OpenLaunchJournal(path)
182 if err != nil {
183 t.Fatal(err)
184 }
185 defer j.Close()
186 s, err = NewJournalService(j)
187 if err != nil {
188 t.Fatal(err)
189 }
190 client, _ = RemoteJournals(call)
191 if err = client.Create(t.Context(), in); err == nil {
192 t.Fatal("a rebuilt request repeated the create")
193 }
194 out, err := client.ReplayUncertain(t.Context(), uncertain)
195 if err != nil || out.ID != uncertain.Request.ID {
196 t.Fatalf("retained request did not replay: %+v %v", out, err)
197 }
198 var n int
199 if err = s.Launches.db.QueryRow("SELECT count(*) FROM launches").Scan(&n); err != nil || n != 1 {
200 t.Fatalf("duplicate effect: %d %v", n, err)
201 }
202}
203
204func TestPlan9InventoryCrashReplayAndABAUseDurableVersions(t *testing.T) {
205 s, _, _ := journalFixture(t)
206 var writes []string
207 lose := false
208 call := func(ctx context.Context, q JournalRequest) (JournalResponse, error) {
209 out, err := s.Execute(ctx, "box/enrollment", q)
210 if q.Op == "inventory-write" {
211 writes = append(writes, q.ID)
212 if lose && err == nil {
213 lose = false
214 return JournalResponse{}, context.DeadlineExceeded
215 }
216 }
217 return out, err
218 }
219 _, client := RemoteJournals(call)
220 a := StopInventory{Complete: true, Host: "app.example", Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: "enrollment", DirectoryRevision: "1"}
221 b := a
222 b.DirectoryRevision = "2"
223 if err := client.WriteStopInventory(t.Context(), a); err != nil {
224 t.Fatal(err)
225 }
226 lose = true
227 err := client.WriteStopInventory(t.Context(), b)
228 var uncertain *JournalUncertainError
229 if !errors.As(err, &uncertain) {
230 t.Fatalf("reply was not uncertain: %v", err)
231 }
232 replay, restarted := RemoteJournals(call)
233 client = restarted
234 if _, err := replay.ReplayUncertain(t.Context(), uncertain); err != nil {
235 t.Fatal(err)
236 }
237 if writes[1] != writes[2] {
238 t.Fatal("client restart did not recover inventory request")
239 }
240 if err := client.WriteStopInventory(t.Context(), a); err != nil {
241 t.Fatal(err)
242 }
243 if err := client.WriteStopInventory(t.Context(), b); err != nil {
244 t.Fatal(err)
245 }
246 if writes[1] == writes[4] {
247 t.Fatal("ABA reused an older transition ID")
248 }
249 got, err := s.Inventory.ReadStopInventory(t.Context(), a.Host)
250 if err != nil || got.DirectoryRevision != "2" {
251 t.Fatalf("ABA result: %+v %v", got, err)
252 }
253}
254
255func TestPlan9JournalReadsDoNotCreateReceipts(t *testing.T) {
256 s, _, in := journalFixture(t)
257 if err := s.Launches.Create(t.Context(), in); err != nil {
258 t.Fatal(err)
259 }
260 client, inventory := RemoteJournals(func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) })
261 for i := 0; i < 30; i++ {
262 if _, err := client.Read(t.Context(), in.Target); err != nil {
263 t.Fatal(err)
264 }
265 if _, err := client.HasHost(t.Context(), in.Target.Host); err != nil {
266 t.Fatal(err)
267 }
268 if _, err := client.SessionLaunches(t.Context(), in.Target.Host, in.Target.Incarnation, 1, 1, "box", "enrollment"); err != nil {
269 t.Fatal(err)
270 }
271 if _, err := inventory.ReadStopInventory(t.Context(), in.Target.Host); !errors.Is(err, sql.ErrNoRows) {
272 t.Fatal(err)
273 }
274 }
275 if _, err := journalCall(t.Context(), func(ctx context.Context, q JournalRequest) (JournalResponse, error) { return s.Execute(ctx, "peer", q) }, JournalRequest{Op: "inventory-version", Target: in.Target}); err != nil {
276 t.Fatal(err)
277 }
278 var count int
279 if err := s.Launches.db.QueryRow("SELECT count(*) FROM journal_requests").Scan(&count); err != nil || count != 0 {
280 t.Fatalf("read receipt growth: %d %v", count, err)
281 }
282 // A denied write transaction does not prevent a read-only request.
283 if _, err := s.Launches.db.Exec("PRAGMA query_only=ON"); err != nil {
284 t.Fatal(err)
285 }
286 defer s.Launches.db.Exec("PRAGMA query_only=OFF")
287 if _, err := client.Read(t.Context(), in.Target); err != nil {
288 t.Fatal("read attempted a write", err)
289 }
290}
291
292func TestPlan9JournalRootEnforcesFrozenMutationID(t *testing.T) {
293 s, _, in := journalFixture(t)
294 q := plan9Mutation(t, JournalRequest{Op: "create", Launch: in})
295 old := q.ID
296 q.ID = "00112233445566778899aabbccddeeff"
297 if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) {
298 t.Fatalf("caller-selected mutation id: %v", err)
299 }
300 // A v1 (nonce-free) mutation is refused.
301 v1 := JournalRequest{V: 1, Op: "create", Launch: in}
302 v1.ID = journalRequestID(v1)
303 if _, err := s.Execute(t.Context(), "peer", v1); !errors.Is(err, ErrTypedJournal) {
304 t.Fatalf("nonce-free mutation accepted: %v", err)
305 }
306 q.ID = old
307 if _, err := s.Execute(t.Context(), "peer", q); err != nil {
308 t.Fatal(err)
309 }
310 q.ID = ""
311 if got := journalRequestID(q); got != old {
312 t.Fatal("v2 hash depends on the request ID field")
313 }
314 q.ID = old
315 q.Launch.ProcessStart = "changed"
316 if _, err := s.Execute(t.Context(), "peer", q); !errors.Is(err, ErrChanged) {
317 t.Fatal("changed payload retained old identity", err)
318 }
319}
320
321func TestPlan9JournalV1HashGolden(t *testing.T) {
322 q := JournalRequest{Op: "producer", Producer: "p"}
323 if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" {
324 t.Fatalf("v1 identity format changed: %s", got)
325 }
326 q.ID = "ignored"
327 q.Launch.ProducerIDs = []string{}
328 q.Launch.Files = []LaunchFile{}
329 if got := journalRequestID(q); got != "3a33e3aaa02b53d4915b743130c01b2a" {
330 t.Fatalf("equivalent empty fields changed identity: %s", got)
331 }
332}
333