carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "bytes"
5 "context"
6 "io/fs"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16// The route is live on "old" (a console build). The channel is promoted to
17// "new" (a CLI build) shipped by an OLD client, so its manifest has no
18// program field and only the bytes can tell.
19func TestBoxRefusesWrongProgramWithNoManifestField(t *testing.T) {
20 host := "console.example.com"
21 s := NewDirStore(t.TempDir())
22 ctx := context.Background()
23 priv, pub := testKeys(t)
24 opt, bake := t.TempDir(), t.TempDir()
25 reg := testRegistry(t)
26 good, bad := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
27 shipRelease(t, s, "bab", "console", "old", adoptPlatform, good)
28 shipRelease(t, s, "bab", "console", "new", adoptPlatform, bad) // old client: no program
29 if err := StageArtifact(opt, "bab", "old", "console", good); err != nil {
30 t.Fatal(err)
31 }
32 addRoute(t, reg, host, "console", "canary/x", false)
33 if err := PointLive(opt, host, "bab", "old", "console"); err != nil {
34 t.Fatal(err)
35 }
36 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil {
37 t.Fatal(err)
38 }
39 restart := func(h string) error { t.Fatalf("restart consulted for a wrong-program build on %s", h); return nil }
40 var out bytes.Buffer
41 withCapturedStdout(t, &out, func() {
42 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
43 t.Fatalf("Pass: %v", err)
44 }
45 })
46 if v, _ := LiveVersion(opt, host); v != "old" {
47 t.Fatalf("live = %q, want old", v)
48 }
49 want := " console.example.com: new is example.com/carlos, but this route has only run example.com/console. not pointed live (ship a new version with carlos ship --program-change and promote it, if the change is intended)\n"
50 if !strings.Contains(out.String(), want) {
51 t.Fatalf("printed %q, want the program refusal and its way out", out.String())
52 }
53 r, _ := ReadRefusal(bake, host)
54 if r == nil || r.Version != "new" {
55 t.Fatalf("refusal = %+v, want one for new", r)
56 }
57 if r.Kind != RefusalWrongProgram || !r.Recovered || r.RestoredVersion != "old" || r.RecoveryError != "" {
58 t.Fatalf("refusal = %+v, want a recovered wrong-program record still on old", r)
59 }
60 // The history started empty; the refusing pass seeded it from the live
61 // binary, and the seed is on disk, not only in that pass's memory.
62 if got := ReadPrograms(bake, host); !slices.Equal(got, []string{"example.com/console"}) {
63 t.Fatalf("history after the refusing pass = %v, want the live binary's program persisted", got)
64 }
65 // The next pass reads the record at the gate: no --retry (it would loop),
66 // and the way out is named: a NEW version, since this one's manifest is
67 // immutable.
68 out.Reset()
69 withCapturedStdout(t, &out, func() {
70 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
71 t.Fatalf("second Pass: %v", err)
72 }
73 })
74 if !strings.Contains(out.String(), "ship a new version with carlos ship --program-change and promote it, if the change is intended") ||
75 strings.Contains(out.String(), "--retry") || !strings.Contains(out.String(), "still on old") {
76 t.Fatalf("gate line = %q, want a new version with carlos ship --program-change, still on old, and no --retry", out.String())
77 }
78}
79
80// The history is [A] but the route is live on a B binary (a change made before
81// the check existed, or a lost record): a new B build is the same program the
82// route is running and must not be refused.
83func TestBoxRecordsTheLiveProgramEvenWithAHistory(t *testing.T) {
84 host := "console.example.com"
85 s := NewDirStore(t.TempDir())
86 ctx := context.Background()
87 priv, pub := testKeys(t)
88 opt, bake := t.TempDir(), t.TempDir()
89 reg := testRegistry(t)
90 b1, b2 := tinyProgram(t, "example.com/b"), tinyProgram(t, "example.com/b")
91 b2 = append(b2[:len(b2):len(b2)], 0) // distinct bytes, same program
92 shipRelease(t, s, "bab", "console", "vb1", adoptPlatform, b1)
93 shipRelease(t, s, "bab", "console", "vb2", adoptPlatform, b2)
94 if err := StageArtifact(opt, "bab", "vb1", "console", b1); err != nil {
95 t.Fatal(err)
96 }
97 addRoute(t, reg, host, "console", "canary/x", false)
98 if err := PointLive(opt, host, "bab", "vb1", "console"); err != nil {
99 t.Fatal(err)
100 }
101 if err := RecordProgram(bake, host, "example.com/a"); err != nil {
102 t.Fatal(err)
103 }
104 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "vb2"); err != nil {
105 t.Fatal(err)
106 }
107 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil {
108 t.Fatalf("Pass: %v", err)
109 }
110 if v, _ := LiveVersion(opt, host); v != "vb2" {
111 t.Fatalf("live = %q, want vb2", v)
112 }
113 if got := ReadPrograms(bake, host); !slices.Contains(got, "example.com/b") {
114 t.Fatalf("history = %v, want example.com/b recorded", got)
115 }
116}
117
118// Ruling G1 at the box: ship A, then B1 and B2 both with --program-change. A
119// box that adopted A and missed B1 meets B2 with a history of [A]; B2's own
120// manifest carries the consent, so it is adopted.
121func TestBoxAdoptsAConsentedChangeItMissedTheFirstReleaseOf(t *testing.T) {
122 host := "console.example.com"
123 s := NewDirStore(t.TempDir())
124 ctx := context.Background()
125 priv, pub := testKeys(t)
126 opt, bake := t.TempDir(), t.TempDir()
127 reg := testRegistry(t)
128 a, b1 := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
129 for _, r := range []struct {
130 ver string
131 bytes []byte
132 allow bool
133 }{{"va", a, false}, {"vb1", b1, true}, {"vb2", sameProgram(b1), true}} {
134 if err := shipWithProgram(t, s, r.ver, r.bytes, r.allow); err != nil {
135 t.Fatalf("ship %s: %v", r.ver, err)
136 }
137 }
138 addRoute(t, reg, host, "console", "canary/x", false)
139 restart := func(string) error { return nil }
140 // History by adoption only: the box adopts A.
141 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "va"); err != nil {
142 t.Fatal(err)
143 }
144 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
145 t.Fatalf("Pass va: %v", err)
146 }
147 if got := ReadPrograms(bake, host); !slices.Equal(got, []string{"example.com/console"}) {
148 t.Fatalf("history after adopting va = %v, want [example.com/console]", got)
149 }
150 // The channel moves straight to vb2; this box never saw vb1.
151 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "vb2"); err != nil {
152 t.Fatal(err)
153 }
154 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
155 t.Fatalf("Pass vb2: %v", err)
156 }
157 if v, _ := LiveVersion(opt, host); v != "vb2" {
158 t.Fatalf("live = %q, want vb2: its manifest records --program-change", v)
159 }
160 if r, _ := ReadRefusal(bake, host); r != nil {
161 t.Fatalf("refusal = %+v, want none", r)
162 }
163}
164
165// Ruling G3: an authorised B build is already staged, its manifest sibling is
166// missing, and the store cannot answer for the manifest. Whether it carries
167// --program-change is unknown, so the box neither points it live nor writes a
168// refusal; once the store recovers, the next pass reads the manifest and
169// adopts it, with the promotion unchanged.
170func TestBoxDefersAWrongProgramWhoseManifestItCannotRead(t *testing.T) {
171 host := "console.example.com"
172 base := NewDirStore(t.TempDir())
173 ctx := context.Background()
174 priv, pub := testKeys(t)
175 opt, bake := t.TempDir(), t.TempDir()
176 reg := testRegistry(t)
177 a, b := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
178 if err := shipWithProgram(t, base, "va", a, false); err != nil {
179 t.Fatal(err)
180 }
181 if err := shipWithProgram(t, base, "vb", b, true); err != nil {
182 t.Fatal(err)
183 }
184 addRoute(t, reg, host, "console", "canary/x", false)
185 restart := func(string) error { return nil }
186 if err := Promote(ctx, base, priv, "bab", "console", "canary/x", "va"); err != nil {
187 t.Fatal(err)
188 }
189 if err := Pass(ctx, reg, base, pub, opt, restart, BakeDir(bake)); err != nil {
190 t.Fatalf("Pass va: %v", err)
191 }
192 // vb's binary is staged with no manifest sibling (staged by a binary
193 // older than the sibling, or a sibling write that failed).
194 if err := StageArtifact(opt, "bab", "vb", "console", b); err != nil {
195 t.Fatal(err)
196 }
197 if err := Promote(ctx, base, priv, "bab", "console", "canary/x", "vb"); err != nil {
198 t.Fatal(err)
199 }
200 broken := &failingStore{Store: base, failKey: ManifestKey("bab", "console", "vb")}
201 var out bytes.Buffer
202 withCapturedStdout(t, &out, func() {
203 if err := Pass(ctx, reg, broken, pub, opt, restart, BakeDir(bake)); err != nil {
204 t.Fatalf("Pass with the manifest unreadable: %v", err)
205 }
206 })
207 if v, _ := LiveVersion(opt, host); v != "va" {
208 t.Fatalf("live = %q, want va: an unknown override is never pointed live", v)
209 }
210 if !strings.Contains(out.String(), "could not be read to check for --program-change; not pointed live, will retry") {
211 t.Fatalf("printed %q, want the will-retry line", out.String())
212 }
213 if r, err := ReadRefusal(bake, host); err != nil || r != nil {
214 t.Fatalf("refusal = %+v, %v; want none: an unreadable manifest is not a refusal", r, err)
215 }
216 // The store recovers; the same promotion is adopted.
217 if err := Pass(ctx, reg, base, pub, opt, restart, BakeDir(bake)); err != nil {
218 t.Fatalf("Pass after recovery: %v", err)
219 }
220 if v, _ := LiveVersion(opt, host); v != "vb" {
221 t.Fatalf("live = %q, want vb once its manifest (program_change) is readable", v)
222 }
223}
224
225// After root's pass refuses a wrong program, the agent's own passes (BakeDir
226// and OnStageBlocked, the shape internal/host's loops run) must not relay the
227// version to root again: a relay would run a root pass that refuses the same
228// bytes, every tick. The install directory is made unwritable for the agent
229// pass, as carlos-edge.service's sandbox makes it, so any attempt to stage or
230// repoint would be a write refusal that relays.
231func TestAgentPassDoesNotRelayAfterRootsWrongProgramRefusal(t *testing.T) {
232 host := "console.example.com"
233 s := NewDirStore(t.TempDir())
234 ctx := context.Background()
235 priv, pub := testKeys(t)
236 opt, bake := t.TempDir(), t.TempDir()
237 reg := testRegistry(t)
238 good, bad := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
239 shipRelease(t, s, "bab", "console", "old", adoptPlatform, good)
240 shipRelease(t, s, "bab", "console", "new", adoptPlatform, bad) // old client: no program
241 addRoute(t, reg, host, "console", "canary/x", false)
242 restart := func(h string) error { t.Fatalf("restart consulted for a wrong-program build on %s", h); return nil }
243 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "old"); err != nil {
244 t.Fatal(err)
245 }
246 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil {
247 t.Fatalf("root Pass old: %v", err)
248 }
249 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil {
250 t.Fatal(err)
251 }
252 // Root's pass: stages new and refuses it.
253 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
254 t.Fatalf("root Pass new: %v", err)
255 }
256 if r, _ := ReadRefusal(bake, host); r == nil || r.Kind != RefusalWrongProgram {
257 t.Fatalf("refusal = %+v, want root's wrong-program record", r)
258 }
259
260 readOnlyTree(t, opt)
261 relay := func(h, ver string, err error) { t.Fatalf("agent pass relayed %s %s to root: %v", h, ver, err) }
262 var out bytes.Buffer
263 withCapturedStdout(t, &out, func() {
264 for i := 0; i < 2; i++ {
265 if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake), OnStageBlocked(relay)); err != nil {
266 t.Fatalf("agent Pass %d: %v", i, err)
267 }
268 }
269 })
270 if v, _ := LiveVersion(opt, host); v != "old" {
271 t.Fatalf("live = %q, want old", v)
272 }
273 if !strings.Contains(out.String(), "is the wrong program for this route") {
274 t.Fatalf("agent pass printed %q, want the gate line", out.String())
275 }
276}
277
278// readOnlyTree makes every directory under root unwritable until the test
279// ends (then restores it so the temp dir can be removed).
280func readOnlyTree(t *testing.T, root string) {
281 t.Helper()
282 var dirs []string
283 if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
284 if err == nil && d.IsDir() {
285 dirs = append(dirs, p)
286 }
287 return err
288 }); err != nil {
289 t.Fatal(err)
290 }
291 for _, d := range dirs {
292 if err := os.Chmod(d, 0o555); err != nil {
293 t.Fatal(err)
294 }
295 }
296 t.Cleanup(func() {
297 for _, d := range dirs {
298 os.Chmod(d, 0o755)
299 }
300 })
301 if f, err := os.CreateTemp(root, "probe"); err == nil {
302 f.Close()
303 os.Remove(f.Name())
304 t.Skip("directories stay writable (running as root?)")
305 }
306}
307
308// Spec §7: a deliberate A -> B change and the rollback to A, on each route
309// kind that runs a release: an exec child, a carlos-app@ unit (converged with
310// capabilities, as root's hourly pass does) and a named unit. The history is
311// built by adoption alone.
312func TestBoxRollsBackAcrossADeliberateChangeOnEveryRouteKind(t *testing.T) {
313 for _, tc := range []struct {
314 name string
315 rt registry.Route
316 caps bool
317 }{
318 {"exec", registry.Route{}, false},
319 {"carlos-app unit", registry.Route{Backing: registry.BackingUnit}, true},
320 {"named unit", registry.Route{Unit: "console.service"}, false},
321 } {
322 t.Run(tc.name, func(t *testing.T) {
323 host := "console.example.com"
324 s := NewDirStore(t.TempDir())
325 ctx := context.Background()
326 priv, pub := testKeys(t)
327 opt, bake := t.TempDir(), t.TempDir()
328 reg := testRegistry(t)
329 a, b := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
330 if err := shipWithProgram(t, s, "va", a, false); err != nil {
331 t.Fatal(err)
332 }
333 if err := shipWithProgram(t, s, "vb", b, true); err != nil {
334 t.Fatal(err)
335 }
336 rt := tc.rt
337 rt.Host, rt.Kind, rt.App, rt.Channel = host, "instance", "console", "canary/x"
338 if err := reg.Upsert(rt); err != nil {
339 t.Fatal(err)
340 }
341 var restarted []string
342 opts := []PassOption{BakeDir(bake)}
343 if tc.caps {
344 o, _ := capsTestOps(t, newCapsRecorder(opt, host))
345 opts = append(opts, WithCapabilities(o))
346 }
347 restart := func(h string) error { restarted = append(restarted, h); return nil }
348 for _, step := range []struct {
349 ver string
350 hist []string
351 }{
352 {"va", []string{"example.com/console"}},
353 {"vb", []string{"example.com/console", "example.com/carlos"}},
354 {"va", []string{"example.com/console", "example.com/carlos"}},
355 } {
356 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", step.ver); err != nil {
357 t.Fatal(err)
358 }
359 if err := Pass(ctx, reg, s, pub, opt, restart, opts...); err != nil {
360 t.Fatalf("Pass %s: %v", step.ver, err)
361 }
362 if v, _ := LiveVersion(opt, host); v != step.ver {
363 t.Fatalf("live = %q, want %s", v, step.ver)
364 }
365 if got := ReadPrograms(bake, host); !slices.Equal(got, step.hist) {
366 t.Fatalf("history after %s = %v, want %v", step.ver, got, step.hist)
367 }
368 if r, _ := ReadRefusal(bake, host); r != nil {
369 t.Fatalf("refusal after %s = %+v, want none", step.ver, r)
370 }
371 }
372 if len(restarted) < 2 {
373 t.Fatalf("restarted = %v, want the route cycled on each change", restarted)
374 }
375 })
376 }
377}
378
379// RecordProgram keeps one entry per program.
380func TestRecordProgramKeepsOneEntryPerProgram(t *testing.T) {
381 bake := t.TempDir()
382 if got := ReadPrograms(bake, "console.example.com"); len(got) != 0 {
383 t.Fatalf("fresh history = %v", got)
384 }
385 // The seed itself is asserted after a real refusing pass in
386 // TestBoxRefusesWrongProgramWithNoManifestField. Pin the file shape here.
387 if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil {
388 t.Fatal(err)
389 }
390 if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil {
391 t.Fatal(err)
392 }
393 if got := ReadPrograms(bake, "console.example.com"); len(got) != 1 || got[0] != "example.com/console" {
394 t.Fatalf("history = %v, want one entry (no duplicates)", got)
395 }
396}
397
398func TestBoxAcceptsAProgramTheRouteRanBefore(t *testing.T) {
399 host := "console.example.com"
400 s := NewDirStore(t.TempDir())
401 ctx := context.Background()
402 priv, pub := testKeys(t)
403 opt, bake := t.TempDir(), t.TempDir()
404 reg := testRegistry(t)
405 a, b := tinyProgram(t, "example.com/a"), tinyProgram(t, "example.com/b")
406 shipRelease(t, s, "bab", "console", "va", adoptPlatform, a)
407 shipRelease(t, s, "bab", "console", "vb", adoptPlatform, b)
408 if err := StageArtifact(opt, "bab", "vb", "console", b); err != nil {
409 t.Fatal(err)
410 }
411 addRoute(t, reg, host, "console", "canary/x", false)
412 if err := PointLive(opt, host, "bab", "vb", "console"); err != nil {
413 t.Fatal(err)
414 }
415 for _, p := range []string{"example.com/a", "example.com/b"} {
416 if err := RecordProgram(bake, host, p); err != nil {
417 t.Fatal(err)
418 }
419 }
420 if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "va"); err != nil {
421 t.Fatal(err)
422 }
423 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil {
424 t.Fatalf("Pass: %v", err)
425 }
426 if v, _ := LiveVersion(opt, host); v != "va" {
427 t.Fatalf("live = %q, want va: rolling back across a deliberate change must work (codex 7)", v)
428 }
429}
430
431func TestBoxIgnoresNonGoBinaries(t *testing.T) {
432 host := "x.example.com"
433 ctx, reg, s, pub, opt := startUnitFixture(t, host) // "good build"/"bad build" are not Go
434 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(t.TempDir())); err != nil {
435 t.Fatalf("Pass: %v", err)
436 }
437 if v, _ := LiveVersion(opt, host); v != "new" {
438 t.Fatalf("live = %q, want new: non-Go bytes are never refused", v)
439 }
440}
441