| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "os/exec" |
| 12 | "path/filepath" |
| 13 | "runtime" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/moves" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | "github.com/carlosframework/platform/internal/seeds" |
| 21 | ) |
| 22 | |
| 23 | // putSeedsFloor replaces the seeds floor object in s directly, with no |
| 24 | // rising check, as an operator's hand or a broken publisher could. |
| 25 | func putSeedsFloor(t *testing.T, s release.Store, f seeds.Floor) { |
| 26 | t.Helper() |
| 27 | body, _ := json.Marshal(f) |
| 28 | if err := s.Delete(context.Background(), seeds.FloorKey); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if _, err := s.Put(context.Background(), seeds.FloorKey, body, ""); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // armLocalSeedsFloor writes the box's two local seeds floor files, as a |
| 37 | // refresh would. |
| 38 | func armLocalSeedsFloor(t *testing.T, dataDir string, f seeds.Floor) { |
| 39 | t.Helper() |
| 40 | if err := withFloorLock(dataDir, true, func(d *movesDir) error { return writeSeedsFloorFiles(d, f) }); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // A deployment that never publishes the seeds floor: no refresh, judgement |
| 46 | // or adopt pass leaves a file behind, so the units and the update path see |
| 47 | // exactly what they saw before the seeds floor existed. |
| 48 | func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) { |
| 49 | ctx := context.Background() |
| 50 | opt, dd := t.TempDir(), t.TempDir() |
| 51 | t.Setenv("CARLOS_OPT_DIR", opt) |
| 52 | t.Setenv("CARLOS_DATA_DIR", dd) |
| 53 | os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) |
| 54 | if err := writePrevVersion(opt, "v0.27.2"); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | s := release.NewDirStore(t.TempDir()) |
| 58 | var said []string |
| 59 | seedsFloorRefresh(ctx, s, dd, func(format string, args ...any) { said = append(said, format) }) |
| 60 | if len(said) != 0 { |
| 61 | t.Fatalf("an unpublished floor was logged: %q", said) |
| 62 | } |
| 63 | if err := judgeSeedsPrev(opt, dd); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) { |
| 70 | t.Fatal("an unarmed deployment grew a .moves directory") |
| 71 | } |
| 72 | if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 73 | t.Fatal("an unarmed deployment has a seeds verdict") |
| 74 | } |
| 75 | if seedsFloorArmed(dd) { |
| 76 | t.Fatal("armed with nothing published") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The seeds floor and the moves floor share a directory and a lock, and |
| 81 | // nothing else: arming one arms neither the other's files nor its verdict. |
| 82 | func TestSeedsFloorLeavesTheMovesFloorAlone(t *testing.T) { |
| 83 | ctx := context.Background() |
| 84 | opt, dd := t.TempDir(), t.TempDir() |
| 85 | os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) |
| 86 | if err := writePrevVersion(opt, "v0.27.2"); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | s := release.NewDirStore(t.TempDir()) |
| 90 | putSeedsFloor(t, s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: "v0.27.0"}) |
| 91 | if _, err := syncLocalSeedsFloor(ctx, s, dd); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if f, err := readLocalFloor(dd); f != nil || err != nil { |
| 95 | t.Fatalf("the moves floor read as %+v, %v", f, err) |
| 96 | } |
| 97 | for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} { |
| 98 | if _, err := os.Stat(filepath.Join(dd, name)); !errors.Is(err, os.ErrNotExist) { |
| 99 | t.Errorf("arming the seeds floor wrote %s", name) |
| 100 | } |
| 101 | } |
| 102 | if err := judgeSeedsPrev(opt, dd); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | if _, err := os.Stat(prevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 106 | t.Fatal("the seeds judgement wrote the moves verdict") |
| 107 | } |
| 108 | // Byte-identical to the floor line, because the units compare them |
| 109 | // with cmp. |
| 110 | v, verr := os.ReadFile(seedsPrevAllowedPath(opt)) |
| 111 | line, lerr := os.ReadFile(filepath.Join(dd, seeds.FloorMinReleaseFile)) |
| 112 | if verr != nil || lerr != nil || !bytes.Equal(v, line) { |
| 113 | t.Fatalf("verdict %q (%v), floor line %q (%v)", v, verr, line, lerr) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // rootJudgePrev runs the seeds half on a box with no moves floor: a box can |
| 118 | // arm the seeds floor first. |
| 119 | func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) { |
| 120 | opt, dd := t.TempDir(), t.TempDir() |
| 121 | t.Setenv("CARLOS_OPT_DIR", opt) |
| 122 | t.Setenv("CARLOS_DATA_DIR", dd) |
| 123 | os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) |
| 124 | if err := writePrevVersion(opt, "v0.27.2"); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 128 | if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" { |
| 132 | t.Fatalf("verdict = %q, %v", line, ok) |
| 133 | } |
| 134 | // The floor rises past carlos.prev: the next pass takes the verdict away. |
| 135 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"}) |
| 136 | if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 140 | t.Fatal("a verdict left for a carlos.prev below the raised floor") |
| 141 | } |
| 142 | // The floor files gone by an operator's hand: a stale verdict still goes. |
| 143 | writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) |
| 144 | os.Remove(filepath.Join(dd, seeds.FloorFile)) |
| 145 | os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile)) |
| 146 | if err := rootJudgePrevWithSource(nil, nil); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 150 | t.Fatal("a verdict left with no seeds floor") |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // The seeds floor only rises on a box, like the moves floor. |
| 155 | func TestSyncLocalSeedsFloorRefusesARegression(t *testing.T) { |
| 156 | ctx := context.Background() |
| 157 | dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) |
| 158 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) |
| 159 | for _, tc := range []struct { |
| 160 | name string |
| 161 | remote seeds.Floor |
| 162 | }{ |
| 163 | {"a lower min_release", seeds.Floor{Min: 2, MinRelease: "v0.27.0"}}, |
| 164 | {"a lower protocol minimum", seeds.Floor{Min: 1, MinRelease: "v0.28.0"}}, |
| 165 | } { |
| 166 | putSeedsFloor(t, s, tc.remote) |
| 167 | if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || *f != (seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) { |
| 168 | t.Fatalf("%s = %+v, %v", tc.name, f, err) |
| 169 | } |
| 170 | if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.28.0" { |
| 171 | t.Fatalf("%s: min-release line = %q", tc.name, line) |
| 172 | } |
| 173 | } |
| 174 | // Raised: both files follow. |
| 175 | putSeedsFloor(t, s, seeds.Floor{Min: 2, MinRelease: "v0.29.0"}) |
| 176 | if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.29.0" { |
| 177 | t.Fatalf("raised = %+v, %v", f, err) |
| 178 | } |
| 179 | if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.29.0" { |
| 180 | t.Fatalf("min-release line after a raise = %q", line) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // A store that cannot be read, or holds a min_release that is not a |
| 185 | // release, leaves the armed floor as it is and says why. |
| 186 | func TestSyncLocalSeedsFloorKeepsWhatIsArmed(t *testing.T) { |
| 187 | ctx := context.Background() |
| 188 | dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) |
| 189 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 190 | if f, err := syncLocalSeedsFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { |
| 191 | t.Fatalf("store error = %+v, %v", f, err) |
| 192 | } |
| 193 | if f, err := syncLocalSeedsFloor(ctx, nil, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { |
| 194 | t.Fatalf("no store = %+v, %v", f, err) |
| 195 | } |
| 196 | for _, bad := range []string{"v0.28.0-rc.1", "v0.28.0\nv0.1.0", ""} { |
| 197 | putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: bad}) |
| 198 | if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { |
| 199 | t.Fatalf("published %q = %+v, %v", bad, f, err) |
| 200 | } |
| 201 | } |
| 202 | var said []string |
| 203 | seedsFloorRefresh(ctx, unreadableStore{s}, dd, func(format string, args ...any) { said = append(said, format) }) |
| 204 | if len(said) == 0 { |
| 205 | t.Fatal("a failed refresh said nothing") |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // A seeds floor file that is there but unusable counts as armed and |
| 210 | // unknown: the judge takes the verdict away and says why, and a usable |
| 211 | // published floor replaces it. |
| 212 | func TestAnUnreadableSeedsFloorIsArmed(t *testing.T) { |
| 213 | ctx := context.Background() |
| 214 | opt, dd := t.TempDir(), t.TempDir() |
| 215 | os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) |
| 216 | if err := writePrevVersion(opt, "v0.27.2"); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte("{not json"), 0o644) |
| 223 | if !seedsFloorArmed(dd) { |
| 224 | t.Fatal("an unreadable floor counted as not armed") |
| 225 | } |
| 226 | writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) |
| 227 | if err := judgeSeedsPrev(opt, dd); err == nil { |
| 228 | t.Fatal("an unreadable floor was judged without a word") |
| 229 | } |
| 230 | if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 231 | t.Fatal("a verdict left beside an unreadable floor") |
| 232 | } |
| 233 | s := release.NewDirStore(t.TempDir()) |
| 234 | putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 235 | if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.27.0" { |
| 236 | t.Fatalf("published over an unreadable floor = %+v, %v", f, err) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // The local seeds floor lives in a directory the sandboxed agent owns, and |
| 241 | // root reads and writes it. Neither may follow a symlink planted there. |
| 242 | func TestLocalSeedsFloorRefusesASymlink(t *testing.T) { |
| 243 | ctx := context.Background() |
| 244 | dd, elsewhere := t.TempDir(), t.TempDir() |
| 245 | if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil { |
| 246 | t.Fatal(err) |
| 247 | } |
| 248 | s := release.NewDirStore(t.TempDir()) |
| 249 | putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 250 | if _, err := syncLocalSeedsFloor(ctx, s, dd); err == nil { |
| 251 | t.Fatal("wrote through a symlinked .moves") |
| 252 | } |
| 253 | if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { |
| 254 | t.Fatalf("the symlink's target was written: %v", entries) |
| 255 | } |
| 256 | os.WriteFile(filepath.Join(elsewhere, filepath.Base(seeds.FloorFile)), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644) |
| 257 | if f, err := readLocalSeedsFloor(dd); err == nil { |
| 258 | t.Fatalf("read through a symlinked .moves: %+v", f) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // The agent's hourly moves floor refresh refreshes the seeds floor too, on |
| 263 | // the same store handle, whether or not a moves floor is published. |
| 264 | func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) { |
| 265 | dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) |
| 266 | putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 267 | movesFloorRefresh(context.Background(), s, dd, func(string, ...any) {}) |
| 268 | if f, err := readLocalSeedsFloor(dd); err != nil || f == nil || f.MinRelease != "v0.27.0" { |
| 269 | t.Fatalf("seeds floor after the refresh = %+v, %v", f, err) |
| 270 | } |
| 271 | if f, _ := readLocalFloor(dd); f != nil { |
| 272 | t.Fatalf("no moves floor published, yet armed %+v", f) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | // TestSystemUnitsSelectPrevByTheSeedsFloor runs each system unit's ExecStart |
| 277 | // shell against a fake /opt/carlos and /data, as |
| 278 | // TestSystemUnitsSelectPrevByTheFloor does for the moves clause. No seeds |
| 279 | // floor file: the choice is exactly what the moves clause alone makes. |
| 280 | // With one, carlos.prev runs only when its seeds verdict names it, byte for |
| 281 | // byte, as carlos.prev's floor gate reads the two files (the stubs hand the |
| 282 | // gate verb to the real Go code), and only when the moves clause allows it |
| 283 | // too. A seeds floor path that is not a regular file never reads as "no |
| 284 | // floor" (#267, as for the moves floor). |
| 285 | func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) { |
| 286 | t.Parallel() |
| 287 | if runtime.GOOS != "linux" { |
| 288 | t.Skip("the floor gate refuses off Linux") |
| 289 | } |
| 290 | self, err := os.Executable() |
| 291 | if err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | const empty = "\x00empty" // a file that is there with nothing in it |
| 295 | for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} { |
| 296 | t.Run(unit, func(t *testing.T) { |
| 297 | b, err := unitsFS.ReadFile("units/" + unit) |
| 298 | if err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | var line string |
| 302 | for _, l := range strings.Split(string(b), "\n") { |
| 303 | if strings.HasPrefix(l, "ExecStart=") { |
| 304 | line = l |
| 305 | } |
| 306 | } |
| 307 | if strings.ContainsAny(line, "%$") { |
| 308 | t.Fatalf("ExecStart carries a %% or a $: %s", line) |
| 309 | } |
| 310 | for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} { |
| 311 | if !strings.Contains(line, path) { |
| 312 | t.Fatalf("ExecStart does not read %s: %s", path, line) |
| 313 | } |
| 314 | } |
| 315 | if !strings.Contains(line, seedsFloorGateCall) { |
| 316 | t.Fatalf("ExecStart does not ask carlos.prev's floor gate about the seeds floor: %s", line) |
| 317 | } |
| 318 | body, ok := strings.CutPrefix(line, "ExecStart=/bin/sh -c '") |
| 319 | body, ok2 := strings.CutSuffix(body, "'") |
| 320 | if !ok || !ok2 || strings.Contains(body, "'") { |
| 321 | t.Fatalf("ExecStart is not one quoted sh -c body: %s", line) |
| 322 | } |
| 323 | for _, tc := range []struct { |
| 324 | name string |
| 325 | noJournal bool |
| 326 | movesFloor, movesOK string |
| 327 | seedsFloor, seedsOK string |
| 328 | seedsKind string // "" a regular file; or symlink, dangling, directory, fifo |
| 329 | prevNoVerb bool // carlos.prev predates the gate and rejects its verb |
| 330 | want string |
| 331 | }{ |
| 332 | {"no seeds floor", false, "", "", "", "", "", false, "carlos.prev"}, |
| 333 | {"no seeds floor, no journal", true, "", "", "", "", "", false, "carlos"}, |
| 334 | {"an empty seeds floor file", false, "", "", empty, "", "", false, "carlos.prev"}, |
| 335 | {"seeds floor and a verdict naming it", false, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"}, |
| 336 | {"seeds floor and no verdict", false, "", "", "v0.27.0\n", "", "", false, "carlos"}, |
| 337 | {"seeds floor and an empty verdict", false, "", "", "v0.27.0\n", empty, "", false, "carlos"}, |
| 338 | {"a seeds verdict for an older floor", false, "", "", "v0.27.0\n", "v0.26.0\n", "", false, "carlos"}, |
| 339 | {"a seeds verdict, no journal", true, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"}, |
| 340 | {"both floors, both verdicts", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"}, |
| 341 | {"both floors, only the moves verdict", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "", false, "carlos"}, |
| 342 | {"both floors, only the seeds verdict", false, "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"}, |
| 343 | {"the moves verdict in the seeds verdict's place", false, "", "v0.27.0\n", "v0.27.0\n", "", "", false, "carlos"}, |
| 344 | // The agent owns /data: anything but a regular file at the |
| 345 | // seeds floor path is refused before the gate runs. |
| 346 | {"symlink seeds floor with matching verdict", false, "", "", "v0.27.0\n", "v0.27.0\n", "symlink", false, "carlos"}, |
| 347 | {"dangling symlink seeds floor", false, "", "", "", "", "dangling", false, "carlos"}, |
| 348 | {"directory seeds floor", false, "", "", "", "", "directory", false, "carlos"}, |
| 349 | {"fifo seeds floor", false, "", "", "", "v0.27.0\n", "fifo", false, "carlos"}, |
| 350 | // A carlos.prev older than the gate answers its verb with an |
| 351 | // unknown-verb exit: a mismatch, so carlos runs. |
| 352 | {"a carlos.prev without the gate verb", false, "", "", "v0.27.0\n", "v0.27.0\n", "", true, "carlos"}, |
| 353 | } { |
| 354 | t.Run(tc.name, func(t *testing.T) { |
| 355 | root := t.TempDir() |
| 356 | opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran") |
| 357 | os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755) |
| 358 | os.MkdirAll(filepath.Join(data, ".moves"), 0o755) |
| 359 | // Each stub answers the gate verb by handing it to this |
| 360 | // test binary (TestFloorGateHelperProcess); anything |
| 361 | // else records the stub as run. |
| 362 | for _, name := range []string{"carlos", "carlos.prev"} { |
| 363 | answer := " shift 2\n" + |
| 364 | " exec " + self + " -test.run='^TestFloorGateHelperProcess$' -- \"$@\"\n" |
| 365 | if name == "carlos.prev" && tc.prevNoVerb { |
| 366 | answer = " echo 'system: unknown verb \"floor-allows-prev\"' >&2\n exit 2\n" |
| 367 | } |
| 368 | sh := "#!/bin/sh\n" + |
| 369 | "if [ \"$1\" = system ] && [ \"$2\" = floor-allows-prev ]; then\n" + |
| 370 | answer + |
| 371 | "fi\n" + |
| 372 | "echo " + name + " > " + ran + "\n" |
| 373 | os.WriteFile(filepath.Join(opt, name), []byte(sh), 0o755) |
| 374 | } |
| 375 | if !tc.noJournal { |
| 376 | os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600) |
| 377 | } |
| 378 | put := func(path, body string) { |
| 379 | switch body { |
| 380 | case "": |
| 381 | case empty: |
| 382 | os.WriteFile(path, nil, 0o644) |
| 383 | default: |
| 384 | os.WriteFile(path, []byte(body), 0o644) |
| 385 | } |
| 386 | } |
| 387 | put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor) |
| 388 | put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK) |
| 389 | seedsPath := filepath.Join(data, seeds.FloorMinReleaseFile) |
| 390 | switch tc.seedsKind { |
| 391 | case "symlink", "dangling": |
| 392 | target := filepath.Join(root, "seeds-floor-target") |
| 393 | if tc.seedsKind == "symlink" { |
| 394 | put(target, tc.seedsFloor) |
| 395 | } |
| 396 | if err := os.Symlink(target, seedsPath); err != nil { |
| 397 | t.Fatal(err) |
| 398 | } |
| 399 | case "directory": |
| 400 | if err := os.Mkdir(seedsPath, 0o755); err != nil { |
| 401 | t.Fatal(err) |
| 402 | } |
| 403 | case "fifo": |
| 404 | if out, err := exec.Command("mkfifo", seedsPath).CombinedOutput(); err != nil { |
| 405 | t.Fatalf("mkfifo: %v: %s", err, out) |
| 406 | } |
| 407 | default: |
| 408 | put(seedsPath, tc.seedsFloor) |
| 409 | } |
| 410 | put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK) |
| 411 | script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/") |
| 412 | cmd := exec.Command("/bin/sh", "-c", script) |
| 413 | cmd.Env = append(os.Environ(), floorGateHelperEnv+"=1") |
| 414 | if out, err := cmd.CombinedOutput(); err != nil { |
| 415 | t.Fatalf("%v: %s", err, out) |
| 416 | } |
| 417 | got, _ := os.ReadFile(ran) |
| 418 | if strings.TrimSpace(string(got)) != tc.want { |
| 419 | t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want) |
| 420 | } |
| 421 | }) |
| 422 | } |
| 423 | }) |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | func TestSeedsActivationRefusal(t *testing.T) { |
| 428 | dd := t.TempDir() |
| 429 | if err := seedsActivationRefusal("v0.20.0", dd); err != nil { |
| 430 | t.Fatalf("no seeds floor: %v", err) |
| 431 | } |
| 432 | os.MkdirAll(filepath.Join(dd, ".moves"), 0o755) |
| 433 | os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644) |
| 434 | if err := seedsActivationRefusal("v0.27.0", dd); err != nil { |
| 435 | t.Fatalf("at the floor: %v", err) |
| 436 | } |
| 437 | err := seedsActivationRefusal("v0.26.9", dd) |
| 438 | var refused activationRefused |
| 439 | if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" { |
| 440 | t.Fatalf("below the floor: %v", err) |
| 441 | } |
| 442 | // A carlos.prev with no recorded version is below any floor. |
| 443 | if err := seedsActivationRefusal("", dd); !errors.As(err, &refused) { |
| 444 | t.Fatalf("no version: %v, want a refusal", err) |
| 445 | } |
| 446 | os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644) |
| 447 | if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) { |
| 448 | t.Fatalf("an unreadable seeds floor: %v, want a refusal", err) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | // Task 2's review: with the seeds floor armed and a carlos.prev that has no |
| 453 | // version sidecar, the seeds verdict is withdrawn (and not written), so the |
| 454 | // units' floor gate fails and the current binary runs. |
| 455 | func TestJudgeSeedsPrevDeniesACarlosPrevWithNoVersion(t *testing.T) { |
| 456 | opt, dd := t.TempDir(), t.TempDir() |
| 457 | os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) |
| 458 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 459 | writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) |
| 460 | if err := judgeSeedsPrev(opt, dd); err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { |
| 464 | t.Fatal("a seeds verdict for a carlos.prev that records no version") |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | // The apply arms the seeds floor from the store before it stages anything, |
| 469 | // and a candidate below it is refused with SD-N1, never staged or run. |
| 470 | func TestSystemApplyArmsTheSeedsFloorBeforeStaging(t *testing.T) { |
| 471 | f, _ := floorFixture(t, "v0.28.0", "v0.29.0") |
| 472 | putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) |
| 473 | staged := filepath.Join(f.a.stagingDir(), "carlos") |
| 474 | run := f.a.ops.run |
| 475 | f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) { |
| 476 | if name == staged { |
| 477 | t.Errorf("a candidate below the seeds floor was staged and run: %s %v", name, args) |
| 478 | } |
| 479 | return run(ctx, d, name, args...) |
| 480 | } |
| 481 | if err := f.a.Run(context.Background(), false); err != nil { |
| 482 | t.Fatal(err) |
| 483 | } |
| 484 | if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" { |
| 485 | t.Fatalf("local seeds floor = %+v, %v", lf, err) |
| 486 | } |
| 487 | r := f.result() |
| 488 | if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.29.0 is below v0.30.0") { |
| 489 | t.Fatalf("result = %+v", r) |
| 490 | } |
| 491 | if f.onDisk() != "v0.28.0" { |
| 492 | t.Fatalf("on disk %q", f.onDisk()) |
| 493 | } |
| 494 | if f, err := readLocalFloor(f.a.dataDir); f != nil || err != nil { |
| 495 | t.Fatalf("the moves floor read as %+v, %v", f, err) |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | // A seeds floor this box could not write down defers the apply; a store |
| 500 | // that cannot be read is a log line and the apply goes on. |
| 501 | func TestArmSeedsFloor(t *testing.T) { |
| 502 | f, _ := floorFixture(t, "v0.28.0", "v0.29.0") |
| 503 | putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) |
| 504 | f.a.store = f.store |
| 505 | // .moves is a file: floor.lock cannot be made beside it. |
| 506 | os.WriteFile(filepath.Join(f.a.dataDir, ".moves"), nil, 0o644) |
| 507 | if err := f.a.armSeedsFloor(context.Background()); !errors.Is(err, errSystemDeferred) || !strings.Contains(err.Error(), "seeds protocol floor") { |
| 508 | t.Fatalf("unwritable: %v, want a deferral", err) |
| 509 | } |
| 510 | os.Remove(filepath.Join(f.a.dataDir, ".moves")) |
| 511 | var said []string |
| 512 | f.a.ops.logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) } |
| 513 | f.a.store = unreadableStore{f.store} |
| 514 | if err := f.a.armSeedsFloor(context.Background()); err != nil { |
| 515 | t.Fatalf("unreadable store: %v", err) |
| 516 | } |
| 517 | if len(said) != 1 || !strings.Contains(said[0], "the seeds floor on this box stays none") { |
| 518 | t.Fatalf("logged %q", said) |
| 519 | } |
| 520 | f.a.store = f.store |
| 521 | if err := f.a.armSeedsFloor(context.Background()); err != nil { |
| 522 | t.Fatal(err) |
| 523 | } |
| 524 | if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.27.0" { |
| 525 | t.Fatalf("local seeds floor = %+v, %v", lf, err) |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | // The install's backup of the running build is judged against the seeds |
| 530 | // floor as well, and a replacement withdraws the old seeds verdict first. |
| 531 | func TestInstallJudgesCarlosPrevByTheSeedsFloor(t *testing.T) { |
| 532 | for _, tc := range []struct { |
| 533 | name, running string |
| 534 | verdict bool |
| 535 | }{ |
| 536 | {"at or above the seeds floor", "v0.31.0", true}, |
| 537 | {"below the seeds floor", "v0.29.0", false}, |
| 538 | } { |
| 539 | t.Run(tc.name, func(t *testing.T) { |
| 540 | f, _ := floorFixture(t, tc.running, "v0.32.0") |
| 541 | putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) |
| 542 | // A verdict from an earlier judgement must not outlive this one. |
| 543 | writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644) |
| 544 | if err := f.a.Run(context.Background(), false); err != nil { |
| 545 | t.Fatal(err) |
| 546 | } |
| 547 | if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" { |
| 548 | t.Fatalf("result = %+v, on disk %q", r, f.onDisk()) |
| 549 | } |
| 550 | if line, ok := readLine(prevVersionPath(f.a.optDir)); !ok || line != tc.running { |
| 551 | t.Fatalf("sidecar = %q, %v", line, ok) |
| 552 | } |
| 553 | b, err := os.ReadFile(seedsPrevAllowedPath(f.a.optDir)) |
| 554 | if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") { |
| 555 | t.Fatalf("seeds verdict = %q, %v; want present %v", b, err, tc.verdict) |
| 556 | } |
| 557 | if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) { |
| 558 | t.Fatal("a moves verdict with no moves floor") |
| 559 | } |
| 560 | }) |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | // Boot recovery runs before the network: the seeds floor it enforces is |
| 565 | // the one on disk, and the version it judges is carlos.prev's sidecar. |
| 566 | func TestBootRecoveryUsesTheArmedSeedsFloorOffline(t *testing.T) { |
| 567 | for _, tc := range []struct { |
| 568 | name, sidecar string |
| 569 | restored bool |
| 570 | }{ |
| 571 | {"below the seeds floor", "v0.29.0", false}, |
| 572 | {"at the seeds floor", "v0.30.0", true}, |
| 573 | {"no sidecar", "", false}, |
| 574 | } { |
| 575 | t.Run(tc.name, func(t *testing.T) { |
| 576 | f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| 577 | f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { |
| 578 | return unreadableStore{f.store}, f.pub, nil |
| 579 | } |
| 580 | armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) |
| 581 | prev := tc.sidecar |
| 582 | if prev == "" { |
| 583 | prev = "v0.30.0" |
| 584 | } |
| 585 | f.brokenBox("v0.31.0", prev, tc.sidecar) |
| 586 | f.edgeStartedThisBoot = false |
| 587 | err := f.a.Run(context.Background(), true) |
| 588 | j, _ := readSystemJournal(f.a.optDir) |
| 589 | if tc.restored { |
| 590 | if err != nil || f.onDisk() != "v0.30.0" || j != nil { |
| 591 | t.Fatalf("err %v, on disk %q, journal %+v", err, f.onDisk(), j) |
| 592 | } |
| 593 | return |
| 594 | } |
| 595 | if err == nil || !strings.Contains(err.Error(), "seeds protocol floor") { |
| 596 | t.Fatalf("recovery err = %v", err) |
| 597 | } |
| 598 | if f.onDisk() != "v0.31.0" || j == nil { |
| 599 | t.Fatalf("a refused recovery changed the box: on disk %q, journal %+v", f.onDisk(), j) |
| 600 | } |
| 601 | }) |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | // A gated journal is finished forward only when its target is at or above |
| 606 | // the seeds floor armed now; below it, recovery goes back to carlos.prev. |
| 607 | func TestForwardRecoveryChecksTheSeedsFloor(t *testing.T) { |
| 608 | for _, tc := range []struct { |
| 609 | name, prev, want string |
| 610 | ok bool |
| 611 | }{ |
| 612 | {"carlos.prev below the seeds floor too", "v0.30.0", "v0.31.0", false}, |
| 613 | {"carlos.prev at the seeds floor", "v0.32.0", "v0.32.0", true}, |
| 614 | } { |
| 615 | t.Run(tc.name, func(t *testing.T) { |
| 616 | f, _ := floorFixture(t, "v0.30.0", "v0.31.0") |
| 617 | f.brokenBox("v0.31.0", tc.prev, tc.prev) |
| 618 | f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: tc.prev, StartedAt: f.now}) |
| 619 | armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.32.0"}) |
| 620 | f.edgeStartedThisBoot = false |
| 621 | err := f.a.Run(context.Background(), true) |
| 622 | if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" { |
| 623 | t.Fatalf("a target below the seeds floor was finished forward: %+v", ap) |
| 624 | } |
| 625 | j, _ := readSystemJournal(f.a.optDir) |
| 626 | if f.onDisk() != tc.want { |
| 627 | t.Fatalf("on disk %q, want %s", f.onDisk(), tc.want) |
| 628 | } |
| 629 | if tc.ok && (err != nil || j != nil) { |
| 630 | t.Fatalf("err %v, journal %+v", err, j) |
| 631 | } |
| 632 | if !tc.ok && (err == nil || !strings.Contains(err.Error(), "seeds protocol floor") || j == nil) { |
| 633 | t.Fatalf("err %v, journal %+v", err, j) |
| 634 | } |
| 635 | // Refused before the forward path stages a plan from the binary |
| 636 | // on disk, not only at the finish's guard. |
| 637 | for _, c := range f.calls { |
| 638 | if strings.HasPrefix(c, "run carlos bootstrap") { |
| 639 | t.Fatalf("the forward path staged a plan for a target below the seeds floor: %s", c) |
| 640 | } |
| 641 | } |
| 642 | }) |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | // Replacing carlos.prev withdraws the old seeds verdict before the copy, so |
| 647 | // a copy that dies part way leaves no seeds verdict on the new bytes. |
| 648 | func TestReplacingCarlosPrevForgetsTheOldSeedsVerdictFirst(t *testing.T) { |
| 649 | f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| 650 | putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) |
| 651 | os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755) |
| 652 | writePrevVersion(f.a.optDir, "v0.30.0") |
| 653 | writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644) |
| 654 | // Prevent publication without depending on the writer's temporary name |
| 655 | // (as TestDecidePrevForgetsTheOldVerdictBeforeTheCopy does). |
| 656 | if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { |
| 657 | t.Fatal(err) |
| 658 | } |
| 659 | if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil { |
| 660 | t.Fatal(err) |
| 661 | } |
| 662 | if err := f.a.Run(context.Background(), false); err != nil { |
| 663 | t.Fatal(err) |
| 664 | } |
| 665 | if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused { |
| 666 | t.Fatalf("result = %+v", r) |
| 667 | } |
| 668 | if _, err := os.Stat(seedsPrevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) { |
| 669 | t.Fatal("the seeds verdict survived a failed replacement of carlos.prev") |
| 670 | } |
| 671 | } |
| 672 | |
| 673 | // An armed seeds floor arms the floor's own steps (floorStep): a failure in |
| 674 | // them is the apply's error, as with the moves floor. With no floor of |
| 675 | // either kind the same failure is a log line and the install goes on. |
| 676 | func TestAnArmedSeedsFloorMakesFloorStepsFatal(t *testing.T) { |
| 677 | for _, armed := range []bool{true, false} { |
| 678 | t.Run(fmt.Sprintf("armed=%v", armed), func(t *testing.T) { |
| 679 | f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| 680 | if armed { |
| 681 | putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) |
| 682 | } |
| 683 | // carlos.prev's lock cannot be opened. |
| 684 | os.MkdirAll(filepath.Join(systemRootDir(f.a.optDir), prevLockFile), 0o755) |
| 685 | f.a.Run(context.Background(), false) |
| 686 | r := f.result() |
| 687 | if armed && (r == nil || r.Outcome == systemOutcomeApplied || f.onDisk() != "v0.31.0") { |
| 688 | t.Fatalf("armed: result %+v, on disk %q", r, f.onDisk()) |
| 689 | } |
| 690 | if !armed && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0") { |
| 691 | t.Fatalf("unarmed: result %+v, on disk %q", r, f.onDisk()) |
| 692 | } |
| 693 | }) |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | // The seeds floor raised while the candidate is staged: the rename's guard |
| 698 | // refuses it. |
| 699 | func TestSeedsFloorIsCheckedAgainAtTheRename(t *testing.T) { |
| 700 | f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| 701 | f.duringStaging(func() { armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.33.0"}) }) |
| 702 | if err := f.a.Run(context.Background(), false); err != nil { |
| 703 | t.Fatal(err) |
| 704 | } |
| 705 | r := f.result() |
| 706 | if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.32.0 is below v0.33.0") { |
| 707 | t.Fatalf("result = %+v", r) |
| 708 | } |
| 709 | if f.onDisk() != "v0.31.0" { |
| 710 | t.Fatalf("on disk %q", f.onDisk()) |
| 711 | } |
| 712 | } |
| 713 | |