package release import ( "bytes" "context" "io/fs" "os" "path/filepath" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/registry" ) // The route is live on "old" (a console build). The channel is promoted to // "new" (a CLI build) shipped by an OLD client, so its manifest has no // program field and only the bytes can tell. func TestBoxRefusesWrongProgramWithNoManifestField(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) good, bad := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") shipRelease(t, s, "bab", "console", "old", adoptPlatform, good) shipRelease(t, s, "bab", "console", "new", adoptPlatform, bad) // old client: no program if err := StageArtifact(opt, "bab", "old", "console", good); err != nil { t.Fatal(err) } addRoute(t, reg, host, "console", "canary/x", false) if err := PointLive(opt, host, "bab", "old", "console"); err != nil { t.Fatal(err) } if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil { t.Fatal(err) } restart := func(h string) error { t.Fatalf("restart consulted for a wrong-program build on %s", h); return nil } var out bytes.Buffer withCapturedStdout(t, &out, func() { if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass: %v", err) } }) if v, _ := LiveVersion(opt, host); v != "old" { t.Fatalf("live = %q, want old", v) } 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" if !strings.Contains(out.String(), want) { t.Fatalf("printed %q, want the program refusal and its way out", out.String()) } r, _ := ReadRefusal(bake, host) if r == nil || r.Version != "new" { t.Fatalf("refusal = %+v, want one for new", r) } if r.Kind != RefusalWrongProgram || !r.Recovered || r.RestoredVersion != "old" || r.RecoveryError != "" { t.Fatalf("refusal = %+v, want a recovered wrong-program record still on old", r) } // The history started empty; the refusing pass seeded it from the live // binary, and the seed is on disk, not only in that pass's memory. if got := ReadPrograms(bake, host); !slices.Equal(got, []string{"example.com/console"}) { t.Fatalf("history after the refusing pass = %v, want the live binary's program persisted", got) } // The next pass reads the record at the gate: no --retry (it would loop), // and the way out is named: a NEW version, since this one's manifest is // immutable. out.Reset() withCapturedStdout(t, &out, func() { if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("second Pass: %v", err) } }) if !strings.Contains(out.String(), "ship a new version with carlos ship --program-change and promote it, if the change is intended") || strings.Contains(out.String(), "--retry") || !strings.Contains(out.String(), "still on old") { t.Fatalf("gate line = %q, want a new version with carlos ship --program-change, still on old, and no --retry", out.String()) } } // The history is [A] but the route is live on a B binary (a change made before // the check existed, or a lost record): a new B build is the same program the // route is running and must not be refused. func TestBoxRecordsTheLiveProgramEvenWithAHistory(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) b1, b2 := tinyProgram(t, "example.com/b"), tinyProgram(t, "example.com/b") b2 = append(b2[:len(b2):len(b2)], 0) // distinct bytes, same program shipRelease(t, s, "bab", "console", "vb1", adoptPlatform, b1) shipRelease(t, s, "bab", "console", "vb2", adoptPlatform, b2) if err := StageArtifact(opt, "bab", "vb1", "console", b1); err != nil { t.Fatal(err) } addRoute(t, reg, host, "console", "canary/x", false) if err := PointLive(opt, host, "bab", "vb1", "console"); err != nil { t.Fatal(err) } if err := RecordProgram(bake, host, "example.com/a"); err != nil { t.Fatal(err) } if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "vb2"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil { t.Fatalf("Pass: %v", err) } if v, _ := LiveVersion(opt, host); v != "vb2" { t.Fatalf("live = %q, want vb2", v) } if got := ReadPrograms(bake, host); !slices.Contains(got, "example.com/b") { t.Fatalf("history = %v, want example.com/b recorded", got) } } // Ruling G1 at the box: ship A, then B1 and B2 both with --program-change. A // box that adopted A and missed B1 meets B2 with a history of [A]; B2's own // manifest carries the consent, so it is adopted. func TestBoxAdoptsAConsentedChangeItMissedTheFirstReleaseOf(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) a, b1 := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") for _, r := range []struct { ver string bytes []byte allow bool }{{"va", a, false}, {"vb1", b1, true}, {"vb2", sameProgram(b1), true}} { if err := shipWithProgram(t, s, r.ver, r.bytes, r.allow); err != nil { t.Fatalf("ship %s: %v", r.ver, err) } } addRoute(t, reg, host, "console", "canary/x", false) restart := func(string) error { return nil } // History by adoption only: the box adopts A. if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "va"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass va: %v", err) } if got := ReadPrograms(bake, host); !slices.Equal(got, []string{"example.com/console"}) { t.Fatalf("history after adopting va = %v, want [example.com/console]", got) } // The channel moves straight to vb2; this box never saw vb1. if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "vb2"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass vb2: %v", err) } if v, _ := LiveVersion(opt, host); v != "vb2" { t.Fatalf("live = %q, want vb2: its manifest records --program-change", v) } if r, _ := ReadRefusal(bake, host); r != nil { t.Fatalf("refusal = %+v, want none", r) } } // Ruling G3: an authorised B build is already staged, its manifest sibling is // missing, and the store cannot answer for the manifest. Whether it carries // --program-change is unknown, so the box neither points it live nor writes a // refusal; once the store recovers, the next pass reads the manifest and // adopts it, with the promotion unchanged. func TestBoxDefersAWrongProgramWhoseManifestItCannotRead(t *testing.T) { host := "console.example.com" base := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) a, b := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") if err := shipWithProgram(t, base, "va", a, false); err != nil { t.Fatal(err) } if err := shipWithProgram(t, base, "vb", b, true); err != nil { t.Fatal(err) } addRoute(t, reg, host, "console", "canary/x", false) restart := func(string) error { return nil } if err := Promote(ctx, base, priv, "bab", "console", "canary/x", "va"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, base, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass va: %v", err) } // vb's binary is staged with no manifest sibling (staged by a binary // older than the sibling, or a sibling write that failed). if err := StageArtifact(opt, "bab", "vb", "console", b); err != nil { t.Fatal(err) } if err := Promote(ctx, base, priv, "bab", "console", "canary/x", "vb"); err != nil { t.Fatal(err) } broken := &failingStore{Store: base, failKey: ManifestKey("bab", "console", "vb")} var out bytes.Buffer withCapturedStdout(t, &out, func() { if err := Pass(ctx, reg, broken, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass with the manifest unreadable: %v", err) } }) if v, _ := LiveVersion(opt, host); v != "va" { t.Fatalf("live = %q, want va: an unknown override is never pointed live", v) } if !strings.Contains(out.String(), "could not be read to check for --program-change; not pointed live, will retry") { t.Fatalf("printed %q, want the will-retry line", out.String()) } if r, err := ReadRefusal(bake, host); err != nil || r != nil { t.Fatalf("refusal = %+v, %v; want none: an unreadable manifest is not a refusal", r, err) } // The store recovers; the same promotion is adopted. if err := Pass(ctx, reg, base, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("Pass after recovery: %v", err) } if v, _ := LiveVersion(opt, host); v != "vb" { t.Fatalf("live = %q, want vb once its manifest (program_change) is readable", v) } } // After root's pass refuses a wrong program, the agent's own passes (BakeDir // and OnStageBlocked, the shape internal/host's loops run) must not relay the // version to root again: a relay would run a root pass that refuses the same // bytes, every tick. The install directory is made unwritable for the agent // pass, as carlos-edge.service's sandbox makes it, so any attempt to stage or // repoint would be a write refusal that relays. func TestAgentPassDoesNotRelayAfterRootsWrongProgramRefusal(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) good, bad := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") shipRelease(t, s, "bab", "console", "old", adoptPlatform, good) shipRelease(t, s, "bab", "console", "new", adoptPlatform, bad) // old client: no program addRoute(t, reg, host, "console", "canary/x", false) restart := func(h string) error { t.Fatalf("restart consulted for a wrong-program build on %s", h); return nil } if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "old"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil { t.Fatalf("root Pass old: %v", err) } if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil { t.Fatal(err) } // Root's pass: stages new and refuses it. if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil { t.Fatalf("root Pass new: %v", err) } if r, _ := ReadRefusal(bake, host); r == nil || r.Kind != RefusalWrongProgram { t.Fatalf("refusal = %+v, want root's wrong-program record", r) } readOnlyTree(t, opt) relay := func(h, ver string, err error) { t.Fatalf("agent pass relayed %s %s to root: %v", h, ver, err) } var out bytes.Buffer withCapturedStdout(t, &out, func() { for i := 0; i < 2; i++ { if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake), OnStageBlocked(relay)); err != nil { t.Fatalf("agent Pass %d: %v", i, err) } } }) if v, _ := LiveVersion(opt, host); v != "old" { t.Fatalf("live = %q, want old", v) } if !strings.Contains(out.String(), "is the wrong program for this route") { t.Fatalf("agent pass printed %q, want the gate line", out.String()) } } // readOnlyTree makes every directory under root unwritable until the test // ends (then restores it so the temp dir can be removed). func readOnlyTree(t *testing.T, root string) { t.Helper() var dirs []string if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err == nil && d.IsDir() { dirs = append(dirs, p) } return err }); err != nil { t.Fatal(err) } for _, d := range dirs { if err := os.Chmod(d, 0o555); err != nil { t.Fatal(err) } } t.Cleanup(func() { for _, d := range dirs { os.Chmod(d, 0o755) } }) if f, err := os.CreateTemp(root, "probe"); err == nil { f.Close() os.Remove(f.Name()) t.Skip("directories stay writable (running as root?)") } } // Spec ยง7: a deliberate A -> B change and the rollback to A, on each route // kind that runs a release: an exec child, a carlos-app@ unit (converged with // capabilities, as root's hourly pass does) and a named unit. The history is // built by adoption alone. func TestBoxRollsBackAcrossADeliberateChangeOnEveryRouteKind(t *testing.T) { for _, tc := range []struct { name string rt registry.Route caps bool }{ {"exec", registry.Route{}, false}, {"carlos-app unit", registry.Route{Backing: registry.BackingUnit}, true}, {"named unit", registry.Route{Unit: "console.service"}, false}, } { t.Run(tc.name, func(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) a, b := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos") if err := shipWithProgram(t, s, "va", a, false); err != nil { t.Fatal(err) } if err := shipWithProgram(t, s, "vb", b, true); err != nil { t.Fatal(err) } rt := tc.rt rt.Host, rt.Kind, rt.App, rt.Channel = host, "instance", "console", "canary/x" if err := reg.Upsert(rt); err != nil { t.Fatal(err) } var restarted []string opts := []PassOption{BakeDir(bake)} if tc.caps { o, _ := capsTestOps(t, newCapsRecorder(opt, host)) opts = append(opts, WithCapabilities(o)) } restart := func(h string) error { restarted = append(restarted, h); return nil } for _, step := range []struct { ver string hist []string }{ {"va", []string{"example.com/console"}}, {"vb", []string{"example.com/console", "example.com/carlos"}}, {"va", []string{"example.com/console", "example.com/carlos"}}, } { if err := Promote(ctx, s, priv, "bab", "console", "canary/x", step.ver); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, restart, opts...); err != nil { t.Fatalf("Pass %s: %v", step.ver, err) } if v, _ := LiveVersion(opt, host); v != step.ver { t.Fatalf("live = %q, want %s", v, step.ver) } if got := ReadPrograms(bake, host); !slices.Equal(got, step.hist) { t.Fatalf("history after %s = %v, want %v", step.ver, got, step.hist) } if r, _ := ReadRefusal(bake, host); r != nil { t.Fatalf("refusal after %s = %+v, want none", step.ver, r) } } if len(restarted) < 2 { t.Fatalf("restarted = %v, want the route cycled on each change", restarted) } }) } } // RecordProgram keeps one entry per program. func TestRecordProgramKeepsOneEntryPerProgram(t *testing.T) { bake := t.TempDir() if got := ReadPrograms(bake, "console.example.com"); len(got) != 0 { t.Fatalf("fresh history = %v", got) } // The seed itself is asserted after a real refusing pass in // TestBoxRefusesWrongProgramWithNoManifestField. Pin the file shape here. if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil { t.Fatal(err) } if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil { t.Fatal(err) } if got := ReadPrograms(bake, "console.example.com"); len(got) != 1 || got[0] != "example.com/console" { t.Fatalf("history = %v, want one entry (no duplicates)", got) } } func TestBoxAcceptsAProgramTheRouteRanBefore(t *testing.T) { host := "console.example.com" s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) opt, bake := t.TempDir(), t.TempDir() reg := testRegistry(t) a, b := tinyProgram(t, "example.com/a"), tinyProgram(t, "example.com/b") shipRelease(t, s, "bab", "console", "va", adoptPlatform, a) shipRelease(t, s, "bab", "console", "vb", adoptPlatform, b) if err := StageArtifact(opt, "bab", "vb", "console", b); err != nil { t.Fatal(err) } addRoute(t, reg, host, "console", "canary/x", false) if err := PointLive(opt, host, "bab", "vb", "console"); err != nil { t.Fatal(err) } for _, p := range []string{"example.com/a", "example.com/b"} { if err := RecordProgram(bake, host, p); err != nil { t.Fatal(err) } } if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "va"); err != nil { t.Fatal(err) } if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil { t.Fatalf("Pass: %v", err) } if v, _ := LiveVersion(opt, host); v != "va" { t.Fatalf("live = %q, want va: rolling back across a deliberate change must work (codex 7)", v) } } func TestBoxIgnoresNonGoBinaries(t *testing.T) { host := "x.example.com" ctx, reg, s, pub, opt := startUnitFixture(t, host) // "good build"/"bad build" are not Go if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(t.TempDir())); err != nil { t.Fatalf("Pass: %v", err) } if v, _ := LiveVersion(opt, host); v != "new" { t.Fatalf("live = %q, want new: non-Go bytes are never refused", v) } }