package host import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // putFloor publishes the operator's floor object in s. func putFloor(t *testing.T, s release.Store, minRelease string) { t.Helper() body, _ := json.Marshal(moves.Floor{Min: moves.ProtocolVersion, MinRelease: minRelease}) if _, err := s.Put(context.Background(), moves.FloorKey, body, ""); err != nil { t.Fatal(err) } } // armLocalFloor writes the box's two local floor files, as an apply would. func armLocalFloor(t *testing.T, dataDir, minRelease string) { t.Helper() if err := writeLocalFloor(dataDir, moves.Floor{Min: moves.ProtocolVersion, MinRelease: minRelease}); err != nil { t.Fatal(err) } } // The judge's held-row inputs, shaped like registry.WithHoldsFrozen. func noHolds(fn func([]registry.Route) error) error { return fn(nil) } func oneHold(fn func([]registry.Route) error) error { return fn([]registry.Route{{Host: "app.example.com", Status: "moving", HoldOp: "op1"}}) } func unreadableHolds(func([]registry.Route) error) error { return errors.New("locked") } func TestLocalFloorFilesRoundTrip(t *testing.T) { dd := t.TempDir() f, err := readLocalFloor(dd) if err != nil || f != nil { t.Fatalf("no files read as %+v, %v", f, err) } armLocalFloor(t, dd, "v0.30.0") f, err = readLocalFloor(dd) if err != nil || f == nil || f.MinRelease != "v0.30.0" || f.Min != moves.ProtocolVersion { t.Fatalf("armed floor read as %+v, %v", f, err) } line, err := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)) if err != nil || string(line) != "v0.30.0\n" { t.Fatalf("floor.min-release = %q, %v", line, err) } // A floor whose min_release is not a release is never written: the // units would compare garbage, and the door could never match it. if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.31.0\nv0.1.0"}); err == nil { t.Fatal("a two-line min_release was written") } if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("a refused write disturbed the armed floor: %+v", f) } } // The local floor lives in a directory the sandboxed agent owns, and root // reads and writes it. Neither may follow a symlink planted there. func TestLocalFloorRefusesASymlink(t *testing.T) { dd, elsewhere := t.TempDir(), t.TempDir() if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil { t.Fatal(err) } if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.30.0"}); err == nil { t.Fatal("wrote through a symlinked .moves") } if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { t.Fatalf("the symlink's target was written: %v", entries) } os.WriteFile(filepath.Join(elsewhere, "floor.json"), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644) if f, err := readLocalFloor(dd); err == nil { t.Fatalf("read through a symlinked .moves: %+v", f) } } func TestSyncLocalFloorKeepsWhatIsArmed(t *testing.T) { ctx := context.Background() // Absent everywhere: no floor, no files. dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) if f, err := syncLocalFloor(ctx, s, dd); err != nil || f != nil { t.Fatalf("absent everywhere = %+v, %v", f, err) } if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) { t.Fatal("an unarmed deployment grew a .moves directory") } // Published: both local files written. putFloor(t, s, "v0.30.0") if f, err := syncLocalFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("published = %+v, %v", f, err) } if got, _ := readLocalFloor(dd); got == nil || got.MinRelease != "v0.30.0" { t.Fatalf("local floor = %+v", got) } // The store unreadable: the local floor stands, and the error is said. if f, err := syncLocalFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("store error = %+v, %v", f, err) } // The object gone (it is create-only, so an operator removed it): once // armed, the floor stays armed. if err := s.Delete(ctx, moves.FloorKey); err != nil { t.Fatal(err) } if f, err := syncLocalFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("object removed = %+v, %v", f, err) } // Raised: the local files follow. putFloor(t, s, "v0.31.0") if f, _ := syncLocalFloor(ctx, s, dd); f == nil || f.MinRelease != "v0.31.0" { t.Fatalf("raised = %+v", f) } if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" { t.Fatalf("floor.min-release after a raise = %q", b) } // A malformed min_release is never armed over a good one. s.Delete(ctx, moves.FloorKey) putFloor(t, s, "v0.32.0-rc.1") if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.31.0" { t.Fatalf("malformed = %+v, %v", f, err) } } func TestJudgePrevWritesTheVerdictOnlyAtOrAboveTheFloorWithoutAHold(t *testing.T) { setup := func(t *testing.T, floor, prev string) (opt, dd string) { opt, dd = t.TempDir(), t.TempDir() os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) if prev != "" { if err := writePrevVersion(opt, prev); err != nil { t.Fatal(err) } } if floor != "" { armLocalFloor(t, dd, floor) } return opt, dd } verdict := func(opt string) (string, bool) { b, err := os.ReadFile(filepath.Join(opt, moves.PrevAllowedFile)) return string(b), err == nil } t.Run("at the floor", func(t *testing.T) { opt, dd := setup(t, "v0.30.0", "v0.30.0") if err := judgePrevWith(opt, dd, noHolds, nil); err != nil { t.Fatal(err) } v, ok := verdict(opt) line, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)) // Byte-identical, because the units compare them with cmp. if !ok || !bytes.Equal([]byte(v), line) { t.Fatalf("verdict %q (%v), floor line %q", v, ok, line) } }) for _, tc := range []struct { name, floor, prev string holds holdFreezer }{ {"below the floor", "v0.30.0", "v0.29.0", noHolds}, {"a prerelease", "v0.30.0", "v0.30.0-rc.1", noHolds}, {"no sidecar", "v0.30.0", "", noHolds}, {"a held row", "v0.30.0", "v0.31.0", oneHold}, {"the registry unreadable", "v0.30.0", "v0.31.0", unreadableHolds}, } { t.Run(tc.name, func(t *testing.T) { opt, dd := setup(t, tc.floor, tc.prev) // A verdict from an earlier judgement is removed, not left. writeFileDurable(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.floor+"\n"), 0o644) judgePrevWith(opt, dd, tc.holds, nil) if v, ok := verdict(opt); ok { t.Fatalf("verdict %q left for %s", v, tc.name) } }) } t.Run("no carlos.prev", func(t *testing.T) { opt, dd := setup(t, "v0.30.0", "v0.31.0") os.Remove(systemPrevPath(opt)) writeFileDurable(filepath.Join(opt, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644) if err := judgePrevWith(opt, dd, noHolds, nil); err != nil { t.Fatal(err) } if _, ok := verdict(opt); ok { t.Fatal("verdict left with no carlos.prev") } }) t.Run("the sidecar is learned when missing", func(t *testing.T) { opt, dd := setup(t, "v0.30.0", "") asked := 0 if err := judgePrevWith(opt, dd, noHolds, func() string { asked++; return "v0.30.1" }); err != nil { t.Fatal(err) } if b, _ := os.ReadFile(filepath.Join(opt, moves.PrevVersionFile)); string(b) != "v0.30.1\n" || asked != 1 { t.Fatalf("sidecar %q after %d asks", b, asked) } if _, ok := verdict(opt); !ok { t.Fatal("no verdict for a learned version at or above the floor") } }) } // No floor armed: judgePrev consults neither the registry nor carlos.prev, // and leaves no verdict (the units ignore it then anyway). func TestJudgePrevIsDormantWithoutAFloor(t *testing.T) { opt, dd := t.TempDir(), t.TempDir() os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) consulted := false holds := func(fn func([]registry.Route) error) error { consulted = true; return fn(nil) } ran := false if err := judgePrevWith(opt, dd, holds, func() string { ran = true; return "v0.29.0" }); err != nil { t.Fatal(err) } if consulted || ran { t.Fatalf("unarmed judge consulted the registry (%v) or ran carlos.prev (%v)", consulted, ran) } for _, name := range []string{moves.PrevAllowedFile, moves.PrevVersionFile} { if _, err := os.Stat(filepath.Join(opt, name)); err == nil { t.Errorf("unarmed judge wrote %s", name) } } } // The agent's hourly refresh is syncLocalFloor on the agent's own store // handle; the edges file then publishes what it wrote. func TestMovesFloorRefreshArmsFromTheStore(t *testing.T) { dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) var said []string logf := func(format string, args ...any) { said = append(said, format) } movesFloorRefresh(context.Background(), s, dd, logf) if f, _ := readLocalFloor(dd); f != nil { t.Fatalf("no object, yet armed %+v", f) } putFloor(t, s, "v0.30.0") movesFloorRefresh(context.Background(), s, dd, logf) if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("refresh did not arm: %+v", f) } movesFloorRefresh(context.Background(), unreadableStore{s}, dd, logf) if len(said) == 0 { t.Fatal("a failed refresh said nothing") } } // The floor never goes down on its own (fix round 1, finding 3). Lowering a // floor is an operator's manual step on each box: delete the two local files. func TestSyncLocalFloorRefusesARegression(t *testing.T) { ctx := context.Background() dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) armLocalFloor(t, dd, "v0.31.0") putFloor(t, s, "v0.30.0") if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.31.0" { t.Fatalf("a lower min_release = %+v, %v", f, err) } // A lower protocol minimum at the same release is a regression too. if err := writeLocalFloor(dd, moves.Floor{Min: moves.ProtocolVersion + 1, MinRelease: "v0.31.0"}); err != nil { t.Fatal(err) } s.Delete(ctx, moves.FloorKey) putFloor(t, s, "v0.31.0") if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.Min != moves.ProtocolVersion+1 { t.Fatalf("a lower min = %+v, %v", f, err) } if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" { t.Fatalf("floor.min-release = %q", b) } } // racingStore answers an old floor, but only after a newer refresh has run // to completion: an older observation finishing last. type racingStore struct { release.Store newer func() } func (r racingStore) Get(ctx context.Context, key string) ([]byte, string, error) { r.newer() return r.Store.Get(ctx, key) } func TestAnOlderRefreshFinishingLastDoesNotLowerTheFloor(t *testing.T) { ctx := context.Background() dd := t.TempDir() old, newer := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) putFloor(t, old, "v0.30.0") putFloor(t, newer, "v0.31.0") race := racingStore{Store: old, newer: func() { if f, err := syncLocalFloor(ctx, newer, dd); err != nil || f == nil || f.MinRelease != "v0.31.0" { t.Errorf("newer refresh = %+v, %v", f, err) } }} syncLocalFloor(ctx, race, dd) if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.31.0" { t.Fatalf("the older refresh lowered the floor to %+v", f) } if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" { t.Fatalf("floor.min-release = %q", b) } } // Root's apply and the agent's refresh write the floor under one lock file // in .moves, so neither sees the other's half-written pair. func TestFloorWritersTakeTheFloorLock(t *testing.T) { t.Parallel() ctx := context.Background() dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) armLocalFloor(t, dd, "v0.30.0") putFloor(t, s, "v0.31.0") held, free := make(chan struct{}), make(chan struct{}) go withFloorLock(dd, false, func(*movesDir) error { close(held) <-free return nil }) <-held done := make(chan struct{}) go func() { syncLocalFloor(ctx, s, dd); close(done) }() select { case <-done: t.Fatal("a floor write finished while another writer held the lock") case <-time.After(300 * time.Millisecond): } if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("written under another writer's lock: %+v", f) } close(free) <-done if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.31.0" { t.Fatalf("after the lock = %+v", f) } } // Readers trust floor.json alone; floor.min-release is written first, so // after a crash between the two it can only be ahead, and only the units // read it (where ahead means no verdict matches: the current binary runs). func TestFloorReadersTrustOnlyTheJSON(t *testing.T) { dd, opt := t.TempDir(), t.TempDir() armLocalFloor(t, dd, "v0.30.0") os.WriteFile(filepath.Join(dd, moves.FloorMinReleaseFile), []byte("v0.31.0\n"), 0o644) if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" { t.Fatalf("readLocalFloor = %+v", f) } if got := withMovesFields(edgesFile{}, dd, opt).MovesFloorMinRelease; got != "v0.30.0" { t.Fatalf("edges file publishes %q, want the JSON's v0.30.0", got) } }