| 1 | package activator |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "reflect" |
| 11 | "runtime" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | _ "modernc.org/sqlite" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/cgroup" |
| 21 | "github.com/carlosframework/platform/internal/edge" |
| 22 | "github.com/carlosframework/platform/internal/lease" |
| 23 | "github.com/carlosframework/platform/internal/registry" |
| 24 | ) |
| 25 | |
| 26 | // TestExecBackendTierSeam: a nil Tier means the default tier, and a set |
| 27 | // Tier is consulted with the route. The cgroup manager is nil here (no |
| 28 | // delegation in a test), so this pins the seam's contract, not the write. |
| 29 | func TestExecBackendTierSeam(t *testing.T) { |
| 30 | var b ExecBackend |
| 31 | rt := registry.Route{Host: "h.example.com"} |
| 32 | if got := b.tier(rt); got != cgroup.TierDefault { |
| 33 | t.Fatalf("nil Tier: %v, want TierDefault", got) |
| 34 | } |
| 35 | b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } |
| 36 | if got := b.tier(rt); got != cgroup.TierChildren { |
| 37 | t.Fatalf("set Tier: %v, want TierChildren", got) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // TestSpawnInCgroupAppliesTheTier: the seam every spawn site uses really |
| 42 | // writes the route's tier into the host cgroup — a regression that swapped |
| 43 | // a site back to a bare Cgroups.Start(…, cgroup.Limit{}, …) compiles fine |
| 44 | // and this is what catches the class. Real cgroup.SetupAt against a |
| 45 | // temp-dir fixture; the spawn is /bin/true. |
| 46 | func TestSpawnInCgroupAppliesTheTier(t *testing.T) { |
| 47 | root := t.TempDir() |
| 48 | for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| 49 | if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | } |
| 53 | m, err := cgroup.SetupAt(root, t.Logf) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | b := ExecBackend{Cgroups: m, Tier: func(registry.Route) cgroup.Limit { return cgroup.TierChildren }} |
| 58 | rt := registry.Route{Host: "h.example.com"} |
| 59 | cmd, _, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | _ = cmd.Wait() |
| 64 | got, err := os.ReadFile(filepath.Join(root, "carlos-exec@h.example.com", "memory.max")) |
| 65 | if err != nil { |
| 66 | t.Fatalf("memory.max not written: %v", err) |
| 67 | } |
| 68 | if string(got) != "1073741824" { |
| 69 | t.Errorf("memory.max = %q, want the children tier", got) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // The box's placement code reaches every exec child as CARLOS_REGION (issue |
| 74 | // #329) — agent-owned in both directions: set from ExecBackend.Region, and |
| 75 | // STRIPPED when the backend has none, even when a tenant's own env file |
| 76 | // tries to supply it. Same rule and same reason as CARLOS_CONTROL_SOCKET / |
| 77 | // CARLOS_PURGE: an app must not be told it runs somewhere the agent never |
| 78 | // vouched for. |
| 79 | func TestExecBackendStartChildRegion(t *testing.T) { |
| 80 | for _, tc := range []struct { |
| 81 | name, backendRegion, want string |
| 82 | wantSet bool |
| 83 | }{ |
| 84 | {"set from the backend", "uk", "uk", true}, |
| 85 | {"stripped when the backend has none", "", "", false}, |
| 86 | } { |
| 87 | t.Run(tc.name, func(t *testing.T) { |
| 88 | b := testExecBackend(t) |
| 89 | b.Region = tc.backendRegion |
| 90 | host := "region-env.example.com" |
| 91 | _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| 92 | // A tenant-supplied value that must never survive as-is. |
| 93 | if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte("CARLOS_REGION=forged\n"), 0o600); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} |
| 97 | ctx, cancel := recordedCtx(2*time.Second, envPath) |
| 98 | defer cancel() |
| 99 | proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected |
| 100 | if proc != nil { |
| 101 | defer proc.Stop(context.Background()) |
| 102 | } |
| 103 | child := map[string]string{} |
| 104 | for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| 105 | if k, v, ok := strings.Cut(line, "="); ok { |
| 106 | child[k] = v |
| 107 | } |
| 108 | } |
| 109 | got, ok := child["CARLOS_REGION"] |
| 110 | if ok != tc.wantSet || got != tc.want { |
| 111 | t.Fatalf("child CARLOS_REGION = %q (set=%v), want %q (set=%v)", got, ok, tc.want, tc.wantSet) |
| 112 | } |
| 113 | }) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // Every exec child is told which host it is, in its environment, from the |
| 118 | // row (discussion #256): an app that used to read its hostname off its |
| 119 | // socket's file name has nothing to read nested (app.sock). The variable |
| 120 | // is agent-owned: a tenant's own env file cannot make an app another host. |
| 121 | func TestExecBackendStartChildHandsTheHost(t *testing.T) { |
| 122 | b := testExecBackend(t) |
| 123 | host := "host-env.example.com" |
| 124 | _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| 125 | if err := os.WriteFile(filepath.Join(b.DataDir, host+".env"), []byte("CARLOS_HOST=forged.example.com\n"), 0o600); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} |
| 129 | ctx, cancel := recordedCtx(2*time.Second, envPath) |
| 130 | defer cancel() |
| 131 | proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected |
| 132 | if proc != nil { |
| 133 | defer proc.Stop(context.Background()) |
| 134 | } |
| 135 | child := map[string]string{} |
| 136 | for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| 137 | if k, v, ok := strings.Cut(line, "="); ok { |
| 138 | child[k] = v |
| 139 | } |
| 140 | } |
| 141 | if got := child["CARLOS_HOST"]; got != host { |
| 142 | t.Fatalf("child CARLOS_HOST = %q, want %q", got, host) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // The staging fallback (issue #331): a host whose ordinary prefix is empty |
| 147 | // restores from a move's staged copy, promoted server-side and dropped |
| 148 | // after a clean restore. A staged copy present beside an own lineage wins: |
| 149 | // it is promoted over the ordinary prefix (the boomerang case below; |
| 150 | // ruling R-STAGEDREAD-2). |
| 151 | func TestRestoreStagingFallback(t *testing.T) { |
| 152 | ctx := context.Background() |
| 153 | b := testExecBackend(t) |
| 154 | f := newFakeMirrorStore() |
| 155 | b.Mirror = f |
| 156 | |
| 157 | host := "moved.example.com" |
| 158 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 159 | |
| 160 | // restoreReplica is stubbed for the WHOLE test (the real one dials S3): |
| 161 | // it restores — materializing a genuinely valid SQLite file, so CheckDB |
| 162 | // passes — exactly when the fake bucket holds objects under the prefix |
| 163 | // it was pointed at, which is the promotion's observable effect. |
| 164 | var restoredFrom []string |
| 165 | oldRestore := restoreReplica |
| 166 | restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| 167 | objs, _ := f.List(context.Background(), bucket, prefix) |
| 168 | if len(objs) == 0 { |
| 169 | return false, nil |
| 170 | } |
| 171 | restoredFrom = append(restoredFrom, bucket+"/"+prefix) |
| 172 | return true, writeMinimalSQLite(dbPath) |
| 173 | } |
| 174 | defer func() { restoreReplica = oldRestore }() |
| 175 | |
| 176 | // Nothing staged, nothing own: plain fresh boot (restored=false→fresh=true). |
| 177 | fresh, err := b.Restore(ctx, rt) |
| 178 | if err != nil || !fresh { |
| 179 | t.Fatalf("empty everything: fresh=%v err=%v, want fresh boot", fresh, err) |
| 180 | } |
| 181 | |
| 182 | // Staged copy present, own prefix empty: promoted then restored. |
| 183 | f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") |
| 184 | _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| 185 | fresh, err = b.Restore(ctx, rt) |
| 186 | if err != nil { |
| 187 | t.Fatalf("staged restore: %v", err) |
| 188 | } |
| 189 | if fresh { |
| 190 | t.Fatal("staged restore reported a fresh boot") |
| 191 | } |
| 192 | if len(restoredFrom) != 1 || restoredFrom[0] != b.Cfg.Bucket+"/"+b.Cfg.hibernatePrefix(host) { |
| 193 | t.Fatalf("restored from %v, want the promoted ordinary prefix %s", restoredFrom, b.Cfg.hibernatePrefix(host)) |
| 194 | } |
| 195 | own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| 196 | if len(own) != 1 { |
| 197 | t.Fatalf("promotion left own prefix = %v", own) |
| 198 | } |
| 199 | staged, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| 200 | if len(staged) != 0 { |
| 201 | t.Fatalf("staging not dropped after a clean restore: %v", staged) |
| 202 | } |
| 203 | |
| 204 | // STAGING WINS (review round 1): a fresh staged copy beside a stale |
| 205 | // own lineage is the boomerang landing — the staged bytes replace the |
| 206 | // own prefix and are what gets restored. |
| 207 | f.put(b.Cfg.Bucket, "moves/"+host+"/generations/b/newer-snapshot", "e9") |
| 208 | _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| 209 | restoredFrom = nil |
| 210 | if _, err := b.Restore(ctx, rt); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | own, _ = f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| 214 | if len(own) != 1 { |
| 215 | t.Fatalf("own prefix after boomerang promote = %v, want exactly the staged copy", own) |
| 216 | } |
| 217 | for k := range own { |
| 218 | if !strings.Contains(k, "newer-snapshot") { |
| 219 | t.Fatalf("own prefix kept the stale lineage: %v", own) |
| 220 | } |
| 221 | } |
| 222 | staged, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| 223 | if len(staged) != 0 { |
| 224 | t.Fatalf("staging not dropped after the boomerang restore: %v", staged) |
| 225 | } |
| 226 | |
| 227 | // A staging-fallback ERROR fails the wake — never a fresh boot + seed |
| 228 | // that would shadow the staged data forever. |
| 229 | f.put(b.Cfg.Bucket, "moves/"+host+"/generations/c/snapshot", "eA") |
| 230 | f.failList = true |
| 231 | _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| 232 | if _, err := b.Restore(ctx, rt); err == nil { |
| 233 | t.Fatal("List failure in the staging fallback did not fail the restore") |
| 234 | } |
| 235 | f.failList = false |
| 236 | } |
| 237 | |
| 238 | // A move's copy receipts live in the SOURCE box's own bucket at |
| 239 | // MoveReceiptsKey, and this box may be that source for an earlier move of |
| 240 | // the same host (a move back). Landing must never take them for data: |
| 241 | // they are not under MovesPrefix, so they are never promoted, and not |
| 242 | // under the hibernate prefix, so they are never restored from or swept. |
| 243 | func TestRestoreIgnoresMoveReceipts(t *testing.T) { |
| 244 | ctx := context.Background() |
| 245 | b := testExecBackend(t) |
| 246 | f := newFakeMirrorStore() |
| 247 | b.Mirror = f |
| 248 | host := "moved.example.com" |
| 249 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 250 | |
| 251 | receipts := MoveReceiptsKey(host) |
| 252 | if strings.HasPrefix(receipts, MovesPrefix(host)) || strings.HasPrefix(receipts, b.Cfg.hibernatePrefix(host)+"/") || |
| 253 | strings.HasPrefix(receipts, b.Cfg.Prefix+"/") { |
| 254 | t.Fatalf("receipts key %q sits under a data prefix", receipts) |
| 255 | } |
| 256 | |
| 257 | var restoredFrom []string |
| 258 | oldRestore := restoreReplica |
| 259 | restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| 260 | objs, _ := f.List(context.Background(), bucket, prefix) |
| 261 | if len(objs) == 0 { |
| 262 | return false, nil |
| 263 | } |
| 264 | restoredFrom = append(restoredFrom, bucket+"/"+prefix) |
| 265 | return true, writeMinimalSQLite(dbPath) |
| 266 | } |
| 267 | defer func() { restoreReplica = oldRestore }() |
| 268 | |
| 269 | // Receipts alone: a fresh boot, nothing promoted, receipts untouched. |
| 270 | f.put(b.Cfg.Bucket, receipts, "r1") |
| 271 | fresh, err := b.Restore(ctx, rt) |
| 272 | if err != nil || !fresh || len(restoredFrom) != 0 { |
| 273 | t.Fatalf("receipts alone: fresh=%v err=%v restored=%v, want a fresh boot", fresh, err, restoredFrom) |
| 274 | } |
| 275 | // Beside a staged copy: only the staged data is promoted. |
| 276 | f.put(b.Cfg.Bucket, "moves/"+host+"/ltx/0/a.ltx", "e1") |
| 277 | _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| 278 | if _, err := b.Restore(ctx, rt); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| 282 | if len(own) != 1 { |
| 283 | t.Fatalf("own prefix after promotion = %v, want only the staged object", own) |
| 284 | } |
| 285 | if _, ok := f.buckets[b.Cfg.Bucket][receipts]; !ok { |
| 286 | t.Fatal("landing deleted the receipts of a move it did not make") |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→object. |
| 291 | // A mirror round copies and deletes side by side, so every method holds mu. |
| 292 | type fakeMirrorStore struct { |
| 293 | mu sync.Mutex |
| 294 | buckets map[string]map[string]lease.MirrorObject |
| 295 | failList bool |
| 296 | } |
| 297 | |
| 298 | func newFakeMirrorStore() *fakeMirrorStore { |
| 299 | return &fakeMirrorStore{buckets: map[string]map[string]lease.MirrorObject{}} |
| 300 | } |
| 301 | func (f *fakeMirrorStore) put(bucket, key, etag string) { |
| 302 | f.mu.Lock() |
| 303 | defer f.mu.Unlock() |
| 304 | f.putLocked(bucket, key, etag) |
| 305 | } |
| 306 | func (f *fakeMirrorStore) putLocked(bucket, key, etag string) { |
| 307 | if f.buckets[bucket] == nil { |
| 308 | f.buckets[bucket] = map[string]lease.MirrorObject{} |
| 309 | } |
| 310 | f.buckets[bucket][key] = lease.MirrorObject{ETag: etag} |
| 311 | } |
| 312 | func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]lease.MirrorObject, error) { |
| 313 | f.mu.Lock() |
| 314 | defer f.mu.Unlock() |
| 315 | if f.failList { |
| 316 | return nil, context.DeadlineExceeded |
| 317 | } |
| 318 | out := map[string]lease.MirrorObject{} |
| 319 | for k, v := range f.buckets[bucket] { |
| 320 | if strings.HasPrefix(k, prefix) { |
| 321 | out[k] = v |
| 322 | } |
| 323 | } |
| 324 | return out, nil |
| 325 | } |
| 326 | func (f *fakeMirrorStore) Copy(_ context.Context, srcBucket, srcKey, srcETag, dstBucket, dstKey string) (string, error) { |
| 327 | f.mu.Lock() |
| 328 | defer f.mu.Unlock() |
| 329 | o, ok := f.buckets[srcBucket][srcKey] |
| 330 | if !ok || (srcETag != "" && o.ETag != srcETag) { |
| 331 | return "", lease.ErrCopySourceChanged |
| 332 | } |
| 333 | if f.buckets[dstBucket] == nil { |
| 334 | f.buckets[dstBucket] = map[string]lease.MirrorObject{} |
| 335 | } |
| 336 | f.buckets[dstBucket][dstKey] = o |
| 337 | return o.ETag, nil |
| 338 | } |
| 339 | func (f *fakeMirrorStore) Get(_ context.Context, bucket, key string) ([]byte, error) { |
| 340 | f.mu.Lock() |
| 341 | defer f.mu.Unlock() |
| 342 | if _, ok := f.buckets[bucket][key]; !ok { |
| 343 | return nil, lease.ErrMirrorNotFound |
| 344 | } |
| 345 | return []byte("{}"), nil |
| 346 | } |
| 347 | func (f *fakeMirrorStore) Put(_ context.Context, bucket, key string, _ []byte) error { |
| 348 | f.mu.Lock() |
| 349 | defer f.mu.Unlock() |
| 350 | f.putLocked(bucket, key, "receipts") |
| 351 | return nil |
| 352 | } |
| 353 | func (f *fakeMirrorStore) Delete(_ context.Context, bucket, key string) error { |
| 354 | f.mu.Lock() |
| 355 | defer f.mu.Unlock() |
| 356 | delete(f.buckets[bucket], key) |
| 357 | return nil |
| 358 | } |
| 359 | |
| 360 | // writeMinimalSQLite creates a genuinely valid empty SQLite database so |
| 361 | // lease.CheckDB passes on the stubbed restore's output. |
| 362 | func writeMinimalSQLite(dbPath string) error { |
| 363 | db, err := sql.Open("sqlite", dbPath) |
| 364 | if err != nil { |
| 365 | return err |
| 366 | } |
| 367 | if _, err := db.Exec("create table t(x)"); err != nil { |
| 368 | return err |
| 369 | } |
| 370 | return db.Close() |
| 371 | } |
| 372 | |
| 373 | func TestLaunchEnvironmentComposesTheSpawnEnvironment(t *testing.T) { |
| 374 | b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} // Cache nil: no purge channel on this box |
| 375 | rt := registry.Route{Host: "a.go.example.com", Socket: filepath.Join(b.DataDir, "a.sock")} |
| 376 | if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("MINE=1\nCARLOS_ADMIN_TOKEN=t\nCARLOS_PURGE=header\nCARLOS_REGION=fake\n"), 0o600); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | t.Setenv("CARLOS_SECRETS_KEY", "never") |
| 380 | t.Setenv("UNRELATED", "yes") |
| 381 | // The rendered configuration file (the path instanceEnv reads first) |
| 382 | // renders MINE=0; the instance's own .env overrides it, so MINE is |
| 383 | // reported as shadowed (spec §7.3 T3). |
| 384 | if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte("MINE=0\nFROM_CONFIG=c\n"), 0o600); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | env, shadowed, err := b.LaunchEnvironment(rt) |
| 388 | if err != nil { |
| 389 | t.Fatal(err) |
| 390 | } |
| 391 | if len(shadowed) != 1 || shadowed[0] != "MINE" || env["FROM_CONFIG"] != "c" { |
| 392 | t.Fatalf("shadowed names %v, want [MINE]; FROM_CONFIG: want set from the config file, got set=%v", shadowed, env["FROM_CONFIG"] != "") |
| 393 | } |
| 394 | if env["MINE"] != "1" || env["UNRELATED"] != "yes" || env["CARLOS_SECRETS_KEY"] != "" || env["CARLOS_ADMIN_TOKEN"] != "t" { |
| 395 | t.Fatalf("launch env: MINE matches the own file: %v; UNRELATED passed through: %v; CARLOS_SECRETS_KEY: want unset, got set=%v; CARLOS_ADMIN_TOKEN matches the own file: %v", |
| 396 | env["MINE"] == "1", env["UNRELATED"] == "yes", env["CARLOS_SECRETS_KEY"] != "", env["CARLOS_ADMIN_TOKEN"] == "t") |
| 397 | } |
| 398 | // The agent-owned layer, exactly as agentOwnedVars composes it: the |
| 399 | // tenant's region and purge values are overridden or removed, and the |
| 400 | // host is the row's (#256). |
| 401 | if env["CARLOS_HOST"] != rt.Host { |
| 402 | t.Fatalf("CARLOS_HOST = %q, want the row's host %q", env["CARLOS_HOST"], rt.Host) |
| 403 | } |
| 404 | if env["CARLOS_REGION"] != "ie" || env["CARLOS_CONTROL_SOCKET"] != rt.Socket+".control" { |
| 405 | t.Fatalf("agent-owned vars: CARLOS_REGION is the backend's: %v; CARLOS_CONTROL_SOCKET is the route's control path: %v", |
| 406 | env["CARLOS_REGION"] == "ie", env["CARLOS_CONTROL_SOCKET"] == rt.Socket+".control") |
| 407 | } |
| 408 | if _, ok := env["CARLOS_PURGE"]; ok { |
| 409 | t.Fatal("CARLOS_PURGE survived on a box without a cache") |
| 410 | } |
| 411 | b.Cache = edge.NewCache(16, 1<<20) |
| 412 | if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_PURGE"] != "header" { |
| 413 | t.Fatalf("CARLOS_PURGE on a box with a cache: want header, got set=%v", env["CARLOS_PURGE"] != "") |
| 414 | } |
| 415 | // A box with no region strips the tenant's value rather than keeping it. |
| 416 | b.Region = "" |
| 417 | if env, _, _ = b.LaunchEnvironment(rt); env["CARLOS_REGION"] != "" { |
| 418 | t.Fatal("CARLOS_REGION: want unset on a box with no region, got set") |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | // An empty CARLOS_INSTANCE_KEY in the instance's own file supplies no key |
| 423 | // (instance-key plan ruling 13): the merge keeps the configured key, and |
| 424 | // the launch report does not list the empty one as shadowing it. A |
| 425 | // non-empty own key still wins and is still reported. |
| 426 | func TestEmptyOwnInstanceKeyShadowsNothing(t *testing.T) { |
| 427 | cfg := map[string]string{"CARLOS_INSTANCE_KEY": "cfg"} |
| 428 | if got := mergeEnv(cfg, map[string]string{"CARLOS_INSTANCE_KEY": ""}); got["CARLOS_INSTANCE_KEY"] != "cfg" { |
| 429 | t.Fatalf("mergeEnv with an empty own key: CARLOS_INSTANCE_KEY = %q, want the configured key", got["CARLOS_INSTANCE_KEY"]) |
| 430 | } |
| 431 | b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} |
| 432 | rt := registry.Route{Host: "k.go.example.com", Socket: filepath.Join(b.DataDir, "k.sock")} |
| 433 | write := func(name, body string) { |
| 434 | t.Helper() |
| 435 | if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+name), []byte(body), 0o600); err != nil { |
| 436 | t.Fatal(err) |
| 437 | } |
| 438 | } |
| 439 | write(".config.env", "CARLOS_INSTANCE_KEY=cfg\nMINE=0\n") |
| 440 | write(".env", "CARLOS_INSTANCE_KEY=\nMINE=1\n") |
| 441 | env, shadowed, err := b.LaunchEnvironment(rt) |
| 442 | if err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | if env["CARLOS_INSTANCE_KEY"] != "cfg" || len(shadowed) != 1 || shadowed[0] != "MINE" { |
| 446 | t.Fatalf("empty own key: CARLOS_INSTANCE_KEY is the configured key: %v; shadowed %v, want [MINE]", env["CARLOS_INSTANCE_KEY"] == "cfg", shadowed) |
| 447 | } |
| 448 | write(".env", "CARLOS_INSTANCE_KEY=own\nMINE=1\n") |
| 449 | if env, shadowed, err = b.LaunchEnvironment(rt); err != nil || env["CARLOS_INSTANCE_KEY"] != "own" || strings.Join(shadowed, ",") != "CARLOS_INSTANCE_KEY,MINE" { |
| 450 | t.Fatalf("non-empty own key: CARLOS_INSTANCE_KEY is the own key: %v; shadowed %v, want [CARLOS_INSTANCE_KEY MINE]; err %v", env["CARLOS_INSTANCE_KEY"] == "own", shadowed, err) |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | // With neither file present the composition is the agent-owned layer over |
| 455 | // the agent's own environment, no error and nothing shadowed. |
| 456 | func TestLaunchEnvironmentNoFiles(t *testing.T) { |
| 457 | b := &ExecBackend{DataDir: t.TempDir(), Region: "ie"} |
| 458 | rt := registry.Route{Host: "b.go.example.com", Socket: filepath.Join(b.DataDir, "b.sock")} |
| 459 | env, shadowed, err := b.LaunchEnvironment(rt) |
| 460 | if err != nil || len(shadowed) != 0 || env["CARLOS_REGION"] != "ie" { |
| 461 | t.Fatalf("CARLOS_REGION is the backend's: %v; shadowed %v; err %v", env["CARLOS_REGION"] == "ie", shadowed, err) |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | // recordingStrict is a strictProc that starts the command for real and |
| 466 | // records each call in order, so a test can see the spawn went through the |
| 467 | // strict handle and what happened to it on exit. |
| 468 | type recordingStrict struct { |
| 469 | mu sync.Mutex |
| 470 | calls []string |
| 471 | deadline bool |
| 472 | started chan struct{} |
| 473 | closed chan struct{} |
| 474 | } |
| 475 | |
| 476 | func newRecordingStrict() *recordingStrict { |
| 477 | return &recordingStrict{started: make(chan struct{}), closed: make(chan struct{})} |
| 478 | } |
| 479 | |
| 480 | func (r *recordingStrict) note(s string) { r.mu.Lock(); r.calls = append(r.calls, s); r.mu.Unlock() } |
| 481 | func (r *recordingStrict) Start(cmd *exec.Cmd) error { |
| 482 | r.note("start") |
| 483 | defer close(r.started) |
| 484 | return cmd.Start() |
| 485 | } |
| 486 | func (r *recordingStrict) KillAndEmpty(ctx context.Context) error { |
| 487 | _, ok := ctx.Deadline() |
| 488 | r.mu.Lock() |
| 489 | r.deadline = ok |
| 490 | r.mu.Unlock() |
| 491 | r.note("kill-and-empty") |
| 492 | return nil |
| 493 | } |
| 494 | func (r *recordingStrict) Close() error { |
| 495 | r.note("close") |
| 496 | close(r.closed) |
| 497 | return nil |
| 498 | } |
| 499 | func (r *recordingStrict) seen() []string { |
| 500 | r.mu.Lock() |
| 501 | defer r.mu.Unlock() |
| 502 | return append([]string(nil), r.calls...) |
| 503 | } |
| 504 | |
| 505 | // TestSpawnUnderAMoveIsStrictOrRefused (moves design §5.3): a host Strict |
| 506 | // names spawns only through the strict handle. A handle that cannot be |
| 507 | // prepared refuses the spawn with ErrStrictRequired and nothing runs; a |
| 508 | // prepared one starts the child and, on exit, is drained (KillAndEmpty |
| 509 | // under a deadline) and then closed. With Strict false or unset the plain |
| 510 | // spawn runs exactly as before and the seam is never asked. |
| 511 | func TestSpawnUnderAMoveIsStrictOrRefused(t *testing.T) { |
| 512 | old := spawnHealthTimeout |
| 513 | spawnHealthTimeout = 300 * time.Millisecond |
| 514 | t.Cleanup(func() { spawnHealthTimeout = old }) |
| 515 | host := "strict.example.com" |
| 516 | // argvPath is the recorder's first file: once it exists and the child |
| 517 | // has exited, the health wait the recorder never answers is over. |
| 518 | start := func(t *testing.T, b *ExecBackend, argvPath string) error { |
| 519 | t.Helper() |
| 520 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 521 | ctx, cancel := recordedCtx(2*time.Second, argvPath) |
| 522 | defer cancel() |
| 523 | proc, _, err := b.Start(ctx, rt) // the fake binary never binds: a health error is expected |
| 524 | if proc != nil { |
| 525 | _ = proc.Stop(context.Background()) |
| 526 | } |
| 527 | return err |
| 528 | } |
| 529 | noSpawn := func(t *testing.T, argvPath string) { |
| 530 | t.Helper() |
| 531 | awaitChildFile(argvPath, 200*time.Millisecond) // until no child is left to write it |
| 532 | if _, err := os.Stat(argvPath); !os.IsNotExist(err) { |
| 533 | t.Fatalf("the child ran: %v", err) |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | t.Run("a handle that cannot be prepared refuses the spawn", func(t *testing.T) { |
| 538 | b := testExecBackend(t) |
| 539 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 540 | b.Strict = func(h string) bool { return h == host } |
| 541 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return nil, cgroup.ErrStrictRefused } |
| 542 | if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { |
| 543 | t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| 544 | } |
| 545 | noSpawn(t, argvPath) |
| 546 | }) |
| 547 | |
| 548 | t.Run("no delegation and no seam refuses the spawn", func(t *testing.T) { |
| 549 | b := testExecBackend(t) // Cgroups nil |
| 550 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 551 | b.Strict = func(string) bool { return true } |
| 552 | if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { |
| 553 | t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| 554 | } |
| 555 | noSpawn(t, argvPath) |
| 556 | }) |
| 557 | |
| 558 | t.Run("a handle whose Start fails refuses the spawn and is closed", func(t *testing.T) { |
| 559 | b := testExecBackend(t) |
| 560 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 561 | b.Strict = func(string) bool { return true } |
| 562 | rec := newRecordingStrict() |
| 563 | failing := &failingStrict{recordingStrict: rec} |
| 564 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return failing, nil } |
| 565 | if err := start(t, b, argvPath); !errors.Is(err, ErrStrictRequired) { |
| 566 | t.Fatalf("Start: %v, want ErrStrictRequired", err) |
| 567 | } |
| 568 | noSpawn(t, argvPath) |
| 569 | if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { |
| 570 | t.Fatalf("handle calls = %s", got) |
| 571 | } |
| 572 | }) |
| 573 | |
| 574 | t.Run("a prepared handle starts the child and is drained then closed on exit", func(t *testing.T) { |
| 575 | b := testExecBackend(t) |
| 576 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 577 | b.Strict = func(string) bool { return true } |
| 578 | rec := newRecordingStrict() |
| 579 | var gotHost string |
| 580 | var gotLimit cgroup.Limit |
| 581 | b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } |
| 582 | b.prepareStrict = func(h string, l cgroup.Limit) (strictProc, error) { gotHost, gotLimit = h, l; return rec, nil } |
| 583 | _ = start(t, b, argvPath) |
| 584 | waitForFile(t, argvPath, time.Second) |
| 585 | select { |
| 586 | case <-rec.closed: |
| 587 | case <-time.After(5 * time.Second): |
| 588 | t.Fatalf("the handle was never closed: %v", rec.seen()) |
| 589 | } |
| 590 | if got := strings.Join(rec.seen(), ","); got != "start,kill-and-empty,close" { |
| 591 | t.Fatalf("handle calls = %s, want start, kill-and-empty, close", got) |
| 592 | } |
| 593 | if !rec.deadline { |
| 594 | t.Fatal("KillAndEmpty was called without a deadline") |
| 595 | } |
| 596 | if gotHost != host || gotLimit != cgroup.TierChildren { |
| 597 | t.Fatalf("prepared %q at %v, want %q at the route's tier", gotHost, gotLimit, host) |
| 598 | } |
| 599 | }) |
| 600 | |
| 601 | for name, strict := range map[string]func(string) bool{ |
| 602 | "Strict false": func(string) bool { return false }, |
| 603 | "Strict unset": nil, |
| 604 | } { |
| 605 | t.Run(name+" runs the plain spawn", func(t *testing.T) { |
| 606 | b := testExecBackend(t) |
| 607 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 608 | b.Strict = strict |
| 609 | asked := false |
| 610 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| 611 | asked = true |
| 612 | return nil, errors.New("must not be asked") |
| 613 | } |
| 614 | _ = start(t, b, argvPath) |
| 615 | waitForFile(t, argvPath, time.Second) |
| 616 | if asked { |
| 617 | t.Fatal("the strict seam was asked for a host not in a move") |
| 618 | } |
| 619 | }) |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | // failingStrict is a recordingStrict whose Start fails without starting |
| 624 | // anything, the way Prepared.Start refuses a populated group. |
| 625 | type failingStrict struct{ *recordingStrict } |
| 626 | |
| 627 | func (f *failingStrict) Start(*exec.Cmd) error { |
| 628 | f.note("start") |
| 629 | return cgroup.ErrStrictRefused |
| 630 | } |
| 631 | |
| 632 | // delegatedManager is a cgroup manager over a temp-dir fixture: delegation |
| 633 | // "exists" (Root() is set), but the test process is not placed in it. |
| 634 | func delegatedManager(t *testing.T) *cgroup.Manager { |
| 635 | t.Helper() |
| 636 | root := t.TempDir() |
| 637 | for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} { |
| 638 | if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { |
| 639 | t.Fatal(err) |
| 640 | } |
| 641 | } |
| 642 | m, err := cgroup.SetupAt(root, t.Logf) |
| 643 | if err != nil { |
| 644 | t.Fatal(err) |
| 645 | } |
| 646 | return m |
| 647 | } |
| 648 | |
| 649 | // TestNextSpawnStrict is S2's input: OK only when the host is in a move |
| 650 | // (Strict), the box has a delegated cgroup subtree, PrepareStrict's own |
| 651 | // runtime preflight passes (cgroup.Manager.StrictPreflight, the same |
| 652 | // function PrepareStrict runs, so S2 cannot approve a box PrepareStrict |
| 653 | // refuses), and no earlier strict group of the host awaits cleanup. |
| 654 | func TestNextSpawnStrict(t *testing.T) { |
| 655 | m := delegatedManager(t) |
| 656 | host := "s2.example.com" |
| 657 | yes := func(string) bool { return true } |
| 658 | ok := func() error { return nil } |
| 659 | for name, tc := range map[string]struct { |
| 660 | strict func(string) bool |
| 661 | cg *cgroup.Manager |
| 662 | preflight func() error |
| 663 | ok bool |
| 664 | inWords string |
| 665 | }{ |
| 666 | "in a move, delegated, preflight passes": {yes, m, ok, true, ""}, |
| 667 | "not in a move": {func(string) bool { return false }, m, ok, false, "not strict"}, |
| 668 | "Strict unset": {nil, m, ok, false, "not strict"}, |
| 669 | "no delegation": {yes, nil, ok, false, "delegated"}, |
| 670 | // The real preflight, no seam: delegation exists, but this test |
| 671 | // process is not placed in the fixture's subtree. |
| 672 | "delegated, agent misplaced": {yes, m, nil, false, "not placed"}, |
| 673 | "preflight says unsupported": {yes, m, func() error { return cgroup.ErrStrictUnsupported }, false, "cannot spawn strictly"}, |
| 674 | } { |
| 675 | t.Run(name, func(t *testing.T) { |
| 676 | if tc.preflight == nil && runtime.GOOS != "linux" { |
| 677 | t.Skip("the real preflight answers unsupported off Linux") |
| 678 | } |
| 679 | b := &ExecBackend{Strict: tc.strict, Cgroups: tc.cg, strictPreflight: tc.preflight} |
| 680 | c := b.NextSpawnStrict(host) |
| 681 | if c.OK != tc.ok || (!c.OK && !strings.Contains(c.Reason, tc.inWords)) || (c.OK && c.Reason != "") { |
| 682 | t.Fatalf("NextSpawnStrict = %+v, want ok=%v naming %q", c, tc.ok, tc.inWords) |
| 683 | } |
| 684 | }) |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | // flakyStrict is a recordingStrict whose KillAndEmpty fails its first |
| 689 | // killFails calls and whose Close fails its first closeFails calls, the |
| 690 | // way a drain that outlives its deadline or a group still settling does. |
| 691 | type flakyStrict struct { |
| 692 | *recordingStrict |
| 693 | killFails, closeFails int |
| 694 | } |
| 695 | |
| 696 | func (f *flakyStrict) KillAndEmpty(ctx context.Context) error { |
| 697 | f.mu.Lock() |
| 698 | fail := f.killFails > 0 |
| 699 | if fail { |
| 700 | f.killFails-- |
| 701 | } |
| 702 | f.mu.Unlock() |
| 703 | if fail { |
| 704 | f.note("kill-and-empty failed") |
| 705 | return cgroup.ErrStrictDeadline |
| 706 | } |
| 707 | return f.recordingStrict.KillAndEmpty(ctx) |
| 708 | } |
| 709 | |
| 710 | func (f *flakyStrict) Close() error { |
| 711 | f.mu.Lock() |
| 712 | fail := f.closeFails > 0 |
| 713 | if fail { |
| 714 | f.closeFails-- |
| 715 | } |
| 716 | f.mu.Unlock() |
| 717 | if fail { |
| 718 | f.note("close failed") |
| 719 | return cgroup.ErrStrictRefused |
| 720 | } |
| 721 | return f.recordingStrict.Close() |
| 722 | } |
| 723 | |
| 724 | // TestStrictCleanupIsRetriedAndRefusesUntilItSucceeds: a strict group whose |
| 725 | // drain or close fails on exit keeps its handle; cleanup is retried on a |
| 726 | // bounded schedule, and until it succeeds the host's strict spawns are |
| 727 | // refused (and S2 says so) without preparing a new group. Once it |
| 728 | // succeeds, strict spawns work again. A drain that succeeded is not |
| 729 | // repeated when only the close failed. |
| 730 | func TestStrictCleanupIsRetriedAndRefusesUntilItSucceeds(t *testing.T) { |
| 731 | old := spawnHealthTimeout |
| 732 | spawnHealthTimeout = 300 * time.Millisecond |
| 733 | t.Cleanup(func() { spawnHealthTimeout = old }) |
| 734 | host := "retry.example.com" |
| 735 | for name, tc := range map[string]struct { |
| 736 | killFails, closeFails int |
| 737 | want string |
| 738 | }{ |
| 739 | "the drain fails once": {1, 0, "start,kill-and-empty failed,kill-and-empty,close"}, |
| 740 | "the close fails once": {0, 1, "start,kill-and-empty,close failed,close"}, |
| 741 | } { |
| 742 | t.Run(name, func(t *testing.T) { |
| 743 | oldBackoff := strictCleanupBackoff |
| 744 | strictCleanupBackoff = []time.Duration{400 * time.Millisecond, 400 * time.Millisecond} |
| 745 | t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| 746 | b := testExecBackend(t) |
| 747 | b.Cgroups = delegatedManager(t) |
| 748 | b.strictPreflight = func() error { return nil } |
| 749 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 750 | b.Strict = func(string) bool { return true } |
| 751 | var mu sync.Mutex |
| 752 | var handles []*flakyStrict |
| 753 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| 754 | mu.Lock() |
| 755 | defer mu.Unlock() |
| 756 | h := &flakyStrict{recordingStrict: newRecordingStrict()} |
| 757 | if len(handles) == 0 { |
| 758 | h.killFails, h.closeFails = tc.killFails, tc.closeFails |
| 759 | } |
| 760 | handles = append(handles, h) |
| 761 | return h, nil |
| 762 | } |
| 763 | prepared := func() int { mu.Lock(); defer mu.Unlock(); return len(handles) } |
| 764 | startOnce := func() error { |
| 765 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 766 | ctx, cancel := recordedCtx(2*time.Second, argvPath) // the recorder ran and exited |
| 767 | defer cancel() |
| 768 | proc, _, err := b.Start(ctx, rt) |
| 769 | if proc != nil { |
| 770 | _ = proc.Stop(context.Background()) |
| 771 | } |
| 772 | return err |
| 773 | } |
| 774 | |
| 775 | _ = startOnce() |
| 776 | waitForFile(t, argvPath, time.Second) |
| 777 | deadline := time.Now().Add(2 * time.Second) |
| 778 | for !b.strictCleanupPending(host) { |
| 779 | if time.Now().After(deadline) { |
| 780 | t.Fatalf("no cleanup pending after a failed drain: %v", handles[0].seen()) |
| 781 | } |
| 782 | time.Sleep(5 * time.Millisecond) |
| 783 | } |
| 784 | if c := b.NextSpawnStrict(host); c.OK || !strings.Contains(c.Reason, "cleaned up") { |
| 785 | t.Fatalf("S2 while cleanup is pending: %+v", c) |
| 786 | } |
| 787 | if err := startOnce(); !errors.Is(err, ErrStrictRequired) { |
| 788 | t.Fatalf("spawn while cleanup is pending: %v, want ErrStrictRequired", err) |
| 789 | } |
| 790 | if n := prepared(); n != 1 { |
| 791 | t.Fatalf("a new strict group was prepared while the old one awaited cleanup (%d)", n) |
| 792 | } |
| 793 | |
| 794 | deadline = time.Now().Add(3 * time.Second) |
| 795 | for b.strictCleanupPending(host) { |
| 796 | if time.Now().After(deadline) { |
| 797 | t.Fatalf("cleanup never succeeded on retry: %v", handles[0].seen()) |
| 798 | } |
| 799 | time.Sleep(5 * time.Millisecond) |
| 800 | } |
| 801 | if got := strings.Join(handles[0].seen(), ","); got != tc.want { |
| 802 | t.Fatalf("first handle's calls = %s, want %s", got, tc.want) |
| 803 | } |
| 804 | if c := b.NextSpawnStrict(host); !c.OK { |
| 805 | t.Fatalf("S2 after cleanup: %+v", c) |
| 806 | } |
| 807 | if err := os.Remove(argvPath); err != nil { |
| 808 | t.Fatal(err) |
| 809 | } |
| 810 | _ = startOnce() |
| 811 | waitForFile(t, argvPath, time.Second) |
| 812 | if n := prepared(); n != 2 { |
| 813 | t.Fatalf("strict groups prepared = %d, want a second one after cleanup", n) |
| 814 | } |
| 815 | }) |
| 816 | } |
| 817 | } |
| 818 | |
| 819 | // TestStrictCleanupGivesUpButStaysRefused: the retry schedule is bounded. |
| 820 | // When it runs out the handle is kept and the host's strict spawns stay |
| 821 | // refused: a group that never drained may still hold a process. |
| 822 | func TestStrictCleanupGivesUpButStaysRefused(t *testing.T) { |
| 823 | oldBackoff := strictCleanupBackoff |
| 824 | strictCleanupBackoff = []time.Duration{10 * time.Millisecond, 10 * time.Millisecond} |
| 825 | t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| 826 | host := "stuck.example.com" |
| 827 | b := testExecBackend(t) |
| 828 | h := &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100} |
| 829 | b.Strict = func(string) bool { return true } |
| 830 | b.settleStrict(host, b.ownStrictForTest(host, h)) |
| 831 | time.Sleep(200 * time.Millisecond) |
| 832 | if got := strings.Count(strings.Join(h.seen(), ","), "kill-and-empty failed"); got != 3 { |
| 833 | t.Fatalf("drain attempts = %d, want the first and two retries", got) |
| 834 | } |
| 835 | if !b.strictCleanupPending(host) { |
| 836 | t.Fatal("the handle was dropped after the schedule ran out") |
| 837 | } |
| 838 | b.Strict = func(string) bool { return true } |
| 839 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| 840 | t.Fatal("a strict group was prepared for a host awaiting cleanup") |
| 841 | return nil, nil |
| 842 | } |
| 843 | if _, _, err := b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }); !errors.Is(err, ErrStrictRequired) { |
| 844 | t.Fatalf("spawn: %v, want ErrStrictRequired", err) |
| 845 | } |
| 846 | } |
| 847 | |
| 848 | // ownStrictForTest registers sp as host's strict owner, as a strict spawn |
| 849 | // does, so a test can drive settleStrict directly. |
| 850 | func (b *ExecBackend) ownStrictForTest(host string, sp strictProc) *strictOwner { |
| 851 | g := b.gate(host) |
| 852 | g.mu.Lock() |
| 853 | defer g.mu.Unlock() |
| 854 | g.owner = &strictOwner{sp: sp} |
| 855 | return g.owner |
| 856 | } |
| 857 | |
| 858 | // dirStrict is a strict handle whose identity is a real directory: the |
| 859 | // host's group directory under a temp-dir cgroup fixture. Like |
| 860 | // cgroup.Prepared's identityOK, once that directory is removed (or |
| 861 | // replaced) the handle can neither drain nor close again, for ever. It |
| 862 | // models what an exit-path CleanupHost would do to a retained handle. |
| 863 | type dirStrict struct { |
| 864 | *recordingStrict |
| 865 | dir string |
| 866 | id os.FileInfo |
| 867 | killFails, closeFails int |
| 868 | } |
| 869 | |
| 870 | func newDirStrict(t *testing.T, dir string) *dirStrict { |
| 871 | t.Helper() |
| 872 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 873 | t.Fatal(err) |
| 874 | } |
| 875 | id, err := os.Stat(dir) |
| 876 | if err != nil { |
| 877 | t.Fatal(err) |
| 878 | } |
| 879 | return &dirStrict{recordingStrict: newRecordingStrict(), dir: dir, id: id} |
| 880 | } |
| 881 | |
| 882 | func (d *dirStrict) identityOK() bool { |
| 883 | now, err := os.Stat(d.dir) |
| 884 | return err == nil && os.SameFile(now, d.id) |
| 885 | } |
| 886 | |
| 887 | func (d *dirStrict) KillAndEmpty(ctx context.Context) error { |
| 888 | if !d.identityOK() { |
| 889 | d.note("kill refused: group gone") |
| 890 | return cgroup.ErrStrictRefused |
| 891 | } |
| 892 | d.mu.Lock() |
| 893 | fail := d.killFails > 0 |
| 894 | if fail { |
| 895 | d.killFails-- |
| 896 | } |
| 897 | d.mu.Unlock() |
| 898 | if fail { |
| 899 | d.note("kill-and-empty timed out") |
| 900 | return cgroup.ErrStrictDeadline |
| 901 | } |
| 902 | return d.recordingStrict.KillAndEmpty(ctx) |
| 903 | } |
| 904 | |
| 905 | func (d *dirStrict) Close() error { |
| 906 | if !d.identityOK() { |
| 907 | d.note("close refused: group gone") |
| 908 | return cgroup.ErrStrictRefused |
| 909 | } |
| 910 | d.mu.Lock() |
| 911 | fail := d.closeFails > 0 |
| 912 | if fail { |
| 913 | d.closeFails-- |
| 914 | } |
| 915 | d.mu.Unlock() |
| 916 | if fail { |
| 917 | d.note("close failed") |
| 918 | return cgroup.ErrStrictRefused |
| 919 | } |
| 920 | return d.recordingStrict.Close() |
| 921 | } |
| 922 | |
| 923 | // TestPendingStrictCleanupOwnsTheGroup is the invariant: while a host has |
| 924 | // pending strict cleanup, nothing else may use or remove that host's |
| 925 | // cgroup. The exit path does not remove the group directory under a |
| 926 | // retained handle (with a handle whose identity is that directory, a |
| 927 | // removal would strand it for ever), so a drain that timed out before the |
| 928 | // group emptied, and a transient Close failure on an empty group, both |
| 929 | // recover on retry; the directory goes only once the handle is closed, and |
| 930 | // strict spawns work afterwards. Meanwhile any spawn of the host, an |
| 931 | // ordinary one included (Strict switched off), is refused and never runs. |
| 932 | func TestPendingStrictCleanupOwnsTheGroup(t *testing.T) { |
| 933 | old := spawnHealthTimeout |
| 934 | spawnHealthTimeout = 300 * time.Millisecond |
| 935 | t.Cleanup(func() { spawnHealthTimeout = old }) |
| 936 | host := "owned.example.com" |
| 937 | for name, tc := range map[string]struct { |
| 938 | killFails, closeFails int |
| 939 | want string |
| 940 | }{ |
| 941 | "a drain timeout, then the group empties": {1, 0, "start,kill-and-empty timed out,kill-and-empty,close"}, |
| 942 | "a transient close failure on an empty group": {0, 1, "start,kill-and-empty,close failed,close"}, |
| 943 | } { |
| 944 | t.Run(name, func(t *testing.T) { |
| 945 | oldBackoff := strictCleanupBackoff |
| 946 | strictCleanupBackoff = []time.Duration{500 * time.Millisecond, 500 * time.Millisecond} |
| 947 | t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| 948 | b := testExecBackend(t) |
| 949 | b.Cgroups = delegatedManager(t) |
| 950 | b.strictPreflight = func() error { return nil } |
| 951 | groupDir := filepath.Join(b.Cgroups.Root(), cgroup.ExecName(host)) |
| 952 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 953 | var strict atomic.Bool |
| 954 | strict.Store(true) |
| 955 | b.Strict = func(string) bool { return strict.Load() } |
| 956 | var mu sync.Mutex |
| 957 | var handles []*dirStrict |
| 958 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| 959 | mu.Lock() |
| 960 | defer mu.Unlock() |
| 961 | h := newDirStrict(t, groupDir) |
| 962 | if len(handles) == 0 { |
| 963 | h.killFails, h.closeFails = tc.killFails, tc.closeFails |
| 964 | } |
| 965 | handles = append(handles, h) |
| 966 | return h, nil |
| 967 | } |
| 968 | startOnce := func() error { |
| 969 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 970 | ctx, cancel := recordedCtx(2*time.Second, argvPath) // the recorder ran and exited |
| 971 | defer cancel() |
| 972 | proc, _, err := b.Start(ctx, rt) |
| 973 | if proc != nil { |
| 974 | _ = proc.Stop(context.Background()) |
| 975 | } |
| 976 | return err |
| 977 | } |
| 978 | |
| 979 | _ = startOnce() |
| 980 | waitForFile(t, argvPath, time.Second) |
| 981 | deadline := time.Now().Add(2 * time.Second) |
| 982 | for !b.strictCleanupPending(host) { |
| 983 | if time.Now().After(deadline) { |
| 984 | t.Fatalf("no cleanup pending: %v", handles[0].seen()) |
| 985 | } |
| 986 | time.Sleep(5 * time.Millisecond) |
| 987 | } |
| 988 | // The child has exited and its exit path has run: the group |
| 989 | // directory is still there, owned by the retained handle. |
| 990 | time.Sleep(50 * time.Millisecond) |
| 991 | if _, err := os.Stat(groupDir); err != nil { |
| 992 | t.Fatalf("the exit path removed the group under a pending handle: %v", err) |
| 993 | } |
| 994 | // An ordinary spawn of the host is refused while cleanup is |
| 995 | // pending, and nothing runs. |
| 996 | strict.Store(false) |
| 997 | if err := os.Remove(argvPath); err != nil { |
| 998 | t.Fatal(err) |
| 999 | } |
| 1000 | if err := startOnce(); !errors.Is(err, ErrStrictCleanupPending) { |
| 1001 | t.Fatalf("ordinary spawn during pending cleanup: %v, want ErrStrictCleanupPending", err) |
| 1002 | } |
| 1003 | awaitChildFile(argvPath, 100*time.Millisecond) // until no child is left to write it |
| 1004 | if _, err := os.Stat(argvPath); !os.IsNotExist(err) { |
| 1005 | t.Fatalf("an ordinary child ran in a group awaiting cleanup: %v", err) |
| 1006 | } |
| 1007 | strict.Store(true) |
| 1008 | |
| 1009 | deadline = time.Now().Add(4 * time.Second) |
| 1010 | for b.strictCleanupPending(host) { |
| 1011 | if time.Now().After(deadline) { |
| 1012 | t.Fatalf("cleanup never succeeded: %v", handles[0].seen()) |
| 1013 | } |
| 1014 | time.Sleep(5 * time.Millisecond) |
| 1015 | } |
| 1016 | if got := strings.Join(handles[0].seen(), ","); got != tc.want { |
| 1017 | t.Fatalf("first handle's calls = %s, want %s", got, tc.want) |
| 1018 | } |
| 1019 | if _, err := os.Stat(groupDir); !os.IsNotExist(err) { |
| 1020 | t.Fatalf("the group directory outlived its handle's cleanup: %v", err) |
| 1021 | } |
| 1022 | _ = startOnce() |
| 1023 | waitForFile(t, argvPath, time.Second) |
| 1024 | mu.Lock() |
| 1025 | n := len(handles) |
| 1026 | mu.Unlock() |
| 1027 | if n != 2 { |
| 1028 | t.Fatalf("strict groups prepared = %d, want a second one after cleanup", n) |
| 1029 | } |
| 1030 | }) |
| 1031 | } |
| 1032 | } |
| 1033 | |
| 1034 | // TestALiveStrictGroupRefusesOrdinarySpawns: while a strict child of a |
| 1035 | // host is alive, an ordinary spawn of the same host (Strict switched off |
| 1036 | // meanwhile) is refused rather than placed in the strict group, where the |
| 1037 | // strict child's exit drain would kill it. |
| 1038 | func TestALiveStrictGroupRefusesOrdinarySpawns(t *testing.T) { |
| 1039 | host := "live.example.com" |
| 1040 | b := testExecBackend(t) |
| 1041 | var strict atomic.Bool |
| 1042 | strict.Store(true) |
| 1043 | b.Strict = func(string) bool { return strict.Load() } |
| 1044 | rec := newRecordingStrict() |
| 1045 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return rec, nil } |
| 1046 | rt := registry.Route{Host: host} |
| 1047 | cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("sleep", "5") }) |
| 1048 | if err != nil { |
| 1049 | t.Fatalf("strict spawn: %v", err) |
| 1050 | } |
| 1051 | strict.Store(false) |
| 1052 | ran := false |
| 1053 | if _, _, err := b.spawnInCgroup(rt, func() *exec.Cmd { ran = true; return exec.Command("/bin/true") }); !errors.Is(err, ErrStrictGroupInUse) || ran { |
| 1054 | t.Fatalf("ordinary spawn beside a live strict child: %v (ran %v), want ErrStrictGroupInUse", err, ran) |
| 1055 | } |
| 1056 | _ = cmd.Process.Kill() |
| 1057 | _ = cmd.Wait() |
| 1058 | settle() |
| 1059 | if _, settle2, err := b.spawnInCgroup(rt, func() *exec.Cmd { return exec.Command("/bin/true") }); err != nil { |
| 1060 | t.Fatalf("ordinary spawn after the strict child settled: %v", err) |
| 1061 | } else { |
| 1062 | settle2() |
| 1063 | } |
| 1064 | } |
| 1065 | |
| 1066 | // hookAt returns a strictHook that calls fn at point and does nothing at |
| 1067 | // any other point. |
| 1068 | func hookAt(point string, fn func()) func(string) { |
| 1069 | return func(p string) { |
| 1070 | if p == point { |
| 1071 | fn() |
| 1072 | } |
| 1073 | } |
| 1074 | } |
| 1075 | |
| 1076 | // TestAnOlderExitCannotRemoveANewOwnersGroup: the exit path's removal and |
| 1077 | // a strict spawn's ownership are decided under the same host gate. An exit |
| 1078 | // that has checked "no owner" holds the gate through its removal, so a |
| 1079 | // strict spawn arriving meanwhile waits, then owns a group it created |
| 1080 | // itself; an exit that runs once any owner exists, live or pending, removes |
| 1081 | // nothing. A strict child whose cleanup then fails keeps its directory. |
| 1082 | func TestAnOlderExitCannotRemoveANewOwnersGroup(t *testing.T) { |
| 1083 | oldBackoff := strictCleanupBackoff |
| 1084 | strictCleanupBackoff = []time.Duration{time.Hour} |
| 1085 | t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| 1086 | host := "race.example.com" |
| 1087 | b := testExecBackend(t) |
| 1088 | b.Cgroups = delegatedManager(t) |
| 1089 | groupDir := filepath.Join(b.Cgroups.Root(), cgroup.ExecName(host)) |
| 1090 | b.Strict = func(string) bool { return true } |
| 1091 | var handle *dirStrict |
| 1092 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { |
| 1093 | handle = newDirStrict(t, groupDir) |
| 1094 | handle.killFails = 100 |
| 1095 | return handle, nil |
| 1096 | } |
| 1097 | if err := os.MkdirAll(groupDir, 0o755); err != nil { |
| 1098 | t.Fatal(err) |
| 1099 | } |
| 1100 | |
| 1101 | // The older exit has found no owner and is about to remove the group. |
| 1102 | // A strict spawn starts right then and must wait for the gate. |
| 1103 | spawned := make(chan error, 1) |
| 1104 | var settle func() |
| 1105 | var cmd *exec.Cmd |
| 1106 | b.strictHook = hookAt("exit-cleanup-checked", func() { |
| 1107 | go func() { |
| 1108 | var err error |
| 1109 | cmd, settle, err = b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 1110 | spawned <- err |
| 1111 | }() |
| 1112 | select { |
| 1113 | case err := <-spawned: |
| 1114 | t.Errorf("a strict spawn took the host while an exit held its gate: %v", err) |
| 1115 | spawned <- err |
| 1116 | case <-time.After(150 * time.Millisecond): |
| 1117 | } |
| 1118 | }) |
| 1119 | b.cleanupHost(host) |
| 1120 | b.strictHook = nil |
| 1121 | if err := <-spawned; err != nil { |
| 1122 | t.Fatalf("strict spawn after the exit: %v", err) |
| 1123 | } |
| 1124 | if _, err := os.Stat(groupDir); err != nil { |
| 1125 | t.Fatalf("the new owner has no group directory: %v", err) |
| 1126 | } |
| 1127 | // Any owner, live as here, keeps the directory from an exit. |
| 1128 | b.cleanupHost(host) |
| 1129 | if _, err := os.Stat(groupDir); err != nil { |
| 1130 | t.Fatalf("an exit removed a live owner's group: %v", err) |
| 1131 | } |
| 1132 | _ = cmd.Wait() |
| 1133 | settle() // the drain fails: the owner turns pending |
| 1134 | if !b.strictCleanupPending(host) { |
| 1135 | t.Fatal("cleanup is not pending after a failed drain") |
| 1136 | } |
| 1137 | b.cleanupHost(host) |
| 1138 | if _, err := os.Stat(groupDir); err != nil { |
| 1139 | t.Fatalf("an exit removed a pending owner's group: %v", err) |
| 1140 | } |
| 1141 | if !handle.identityOK() { |
| 1142 | t.Fatal("the retained handle lost its group") |
| 1143 | } |
| 1144 | } |
| 1145 | |
| 1146 | // TestAPendingTransitionRacesARefusal: a spawn refused while the owner is |
| 1147 | // between its failed cleanup attempt and turning pending sees a live owner |
| 1148 | // (ErrStrictGroupInUse); once pending, ErrStrictCleanupPending. pending is |
| 1149 | // only ever read and written under the host gate, so -race stays quiet |
| 1150 | // however the two interleave. |
| 1151 | func TestAPendingTransitionRacesARefusal(t *testing.T) { |
| 1152 | oldBackoff := strictCleanupBackoff |
| 1153 | strictCleanupBackoff = []time.Duration{time.Hour} |
| 1154 | t.Cleanup(func() { strictCleanupBackoff = oldBackoff }) |
| 1155 | host := "transition.example.com" |
| 1156 | b := testExecBackend(t) |
| 1157 | b.Strict = func(string) bool { return false } |
| 1158 | o := b.ownStrictForTest(host, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) |
| 1159 | spawn := func() error { |
| 1160 | _, _, err := b.spawnInCgroup(registry.Route{Host: host}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 1161 | return err |
| 1162 | } |
| 1163 | var during error |
| 1164 | b.strictHook = hookAt("cleanup-failed", func() { during = spawn() }) |
| 1165 | b.settleStrict(host, o) |
| 1166 | b.strictHook = nil |
| 1167 | if !errors.Is(during, ErrStrictGroupInUse) { |
| 1168 | t.Fatalf("refusal before the owner turned pending: %v, want ErrStrictGroupInUse", during) |
| 1169 | } |
| 1170 | if err := spawn(); !errors.Is(err, ErrStrictCleanupPending) { |
| 1171 | t.Fatalf("refusal after: %v, want ErrStrictCleanupPending", err) |
| 1172 | } |
| 1173 | |
| 1174 | // The same transition against concurrent refusals, for -race. |
| 1175 | host2 := "transition2.example.com" |
| 1176 | o2 := b.ownStrictForTest(host2, &flakyStrict{recordingStrict: newRecordingStrict(), killFails: 100}) |
| 1177 | var wg sync.WaitGroup |
| 1178 | stop := make(chan struct{}) |
| 1179 | var looping sync.WaitGroup |
| 1180 | for i := 0; i < 8; i++ { |
| 1181 | wg.Add(1) |
| 1182 | looping.Add(1) |
| 1183 | go func() { |
| 1184 | defer wg.Done() |
| 1185 | first := true |
| 1186 | for { |
| 1187 | _, _, err := b.spawnInCgroup(registry.Route{Host: host2}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 1188 | if !errors.Is(err, ErrStrictGroupInUse) && !errors.Is(err, ErrStrictCleanupPending) { |
| 1189 | t.Errorf("spawn beside an owner: %v", err) |
| 1190 | } |
| 1191 | _ = b.NextSpawnStrict(host2) |
| 1192 | if first { |
| 1193 | looping.Done() |
| 1194 | first = false |
| 1195 | } |
| 1196 | select { |
| 1197 | case <-stop: |
| 1198 | return |
| 1199 | default: |
| 1200 | } |
| 1201 | } |
| 1202 | }() |
| 1203 | } |
| 1204 | looping.Wait() |
| 1205 | b.settleStrict(host2, o2) // turns pending while the refusals run |
| 1206 | time.Sleep(20 * time.Millisecond) |
| 1207 | close(stop) |
| 1208 | wg.Wait() |
| 1209 | } |
| 1210 | |
| 1211 | // TestAStalledStartBlocksOnlyItsHost: host A's ordinary start stalls inside |
| 1212 | // Cgroups.Start, holding A's gate. Host B's ordinary and strict spawns and |
| 1213 | // its NextSpawnStrict proceed; the global map lock is never held across a |
| 1214 | // start. |
| 1215 | func TestAStalledStartBlocksOnlyItsHost(t *testing.T) { |
| 1216 | b := testExecBackend(t) |
| 1217 | b.Cgroups = delegatedManager(t) |
| 1218 | b.strictPreflight = func() error { return nil } |
| 1219 | b.Strict = func(h string) bool { return h == "b-strict.example.com" } |
| 1220 | b.prepareStrict = func(string, cgroup.Limit) (strictProc, error) { return newRecordingStrict(), nil } |
| 1221 | release := make(chan struct{}) |
| 1222 | entered := make(chan struct{}) |
| 1223 | var once sync.Once |
| 1224 | doneA := make(chan error, 1) |
| 1225 | go func() { |
| 1226 | cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "a.example.com"}, func() *exec.Cmd { |
| 1227 | once.Do(func() { close(entered); <-release }) |
| 1228 | return exec.Command("/bin/true") |
| 1229 | }) |
| 1230 | if err == nil { |
| 1231 | _ = cmd.Wait() |
| 1232 | settle() |
| 1233 | } |
| 1234 | doneA <- err |
| 1235 | }() |
| 1236 | <-entered |
| 1237 | within := func(what string, fn func()) { |
| 1238 | t.Helper() |
| 1239 | done := make(chan struct{}) |
| 1240 | go func() { fn(); close(done) }() |
| 1241 | select { |
| 1242 | case <-done: |
| 1243 | case <-time.After(2 * time.Second): |
| 1244 | t.Fatalf("%s was blocked by host A's stalled start", what) |
| 1245 | } |
| 1246 | } |
| 1247 | within("host B's ordinary spawn", func() { |
| 1248 | cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 1249 | if err != nil { |
| 1250 | t.Errorf("host B: %v", err) |
| 1251 | return |
| 1252 | } |
| 1253 | _ = cmd.Wait() |
| 1254 | settle() |
| 1255 | }) |
| 1256 | within("host B's strict spawn", func() { |
| 1257 | cmd, settle, err := b.spawnInCgroup(registry.Route{Host: "b-strict.example.com"}, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| 1258 | if err != nil { |
| 1259 | t.Errorf("host B strict: %v", err) |
| 1260 | return |
| 1261 | } |
| 1262 | _ = cmd.Wait() |
| 1263 | settle() |
| 1264 | }) |
| 1265 | within("host B's NextSpawnStrict", func() { |
| 1266 | if c := b.NextSpawnStrict("b-strict.example.com"); !c.OK { |
| 1267 | t.Errorf("S2 for host B: %+v", c) |
| 1268 | } |
| 1269 | }) |
| 1270 | within("host B's exit cleanup", func() { b.cleanupHost("b.example.com") }) |
| 1271 | close(release) |
| 1272 | if err := <-doneA; err != nil { |
| 1273 | t.Fatalf("host A: %v", err) |
| 1274 | } |
| 1275 | } |
| 1276 | |
| 1277 | // TestSwitchedOffSpawnsTakeNoNewLock: with Strict nil, a spawn and its exit |
| 1278 | // path are main's: no host gate is created and the global map lock is |
| 1279 | // never taken (held by the test throughout, a spawn that wanted it would |
| 1280 | // hang). |
| 1281 | func TestSwitchedOffSpawnsTakeNoNewLock(t *testing.T) { |
| 1282 | old := spawnHealthTimeout |
| 1283 | spawnHealthTimeout = 300 * time.Millisecond |
| 1284 | t.Cleanup(func() { spawnHealthTimeout = old }) |
| 1285 | b := testExecBackend(t) |
| 1286 | b.Cgroups = delegatedManager(t) |
| 1287 | host := "off.example.com" |
| 1288 | argvPath, _ := stageRecordingLive(t, b, host, "v1") |
| 1289 | b.strictMu.Lock() |
| 1290 | defer b.strictMu.Unlock() |
| 1291 | done := make(chan struct{}) |
| 1292 | go func() { |
| 1293 | defer close(done) |
| 1294 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 1295 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 1296 | defer cancel() |
| 1297 | proc, _, _ := b.Start(ctx, rt) |
| 1298 | if proc != nil { |
| 1299 | _ = proc.Stop(context.Background()) |
| 1300 | } |
| 1301 | }() |
| 1302 | select { |
| 1303 | case <-done: |
| 1304 | case <-time.After(5 * time.Second): |
| 1305 | t.Fatal("a switched-off spawn waited on the strict map lock") |
| 1306 | } |
| 1307 | waitForFile(t, argvPath, time.Second) |
| 1308 | // The exit path's call, again, with the lock held. |
| 1309 | cleaned := make(chan struct{}) |
| 1310 | go func() { b.cleanupHost(host); close(cleaned) }() |
| 1311 | select { |
| 1312 | case <-cleaned: |
| 1313 | case <-time.After(2 * time.Second): |
| 1314 | t.Fatal("a switched-off exit cleanup waited on the strict map lock") |
| 1315 | } |
| 1316 | if b.strictGates != nil { |
| 1317 | t.Fatalf("a switched-off spawn created host gates: %v", b.strictGates) |
| 1318 | } |
| 1319 | } |
| 1320 | |
| 1321 | // Under Phase 1 the staging-wins promotion is gone (spec §5.3, M15): a |
| 1322 | // staging tree beside an empty own prefix is not promoted, the restore is a |
| 1323 | // fresh boot, and the tree is left exactly as found. Off, the legacy |
| 1324 | // promotion runs as TestRestoreStagingFallback pins. |
| 1325 | func TestRestoreDoesNotPromoteStagingUnderPhase1(t *testing.T) { |
| 1326 | ctx := context.Background() |
| 1327 | b := testExecBackend(t) |
| 1328 | f := newFakeMirrorStore() |
| 1329 | b.Mirror = f |
| 1330 | b.Phase1 = true |
| 1331 | host := "moved.example.com" |
| 1332 | rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| 1333 | |
| 1334 | oldRestore := restoreReplica |
| 1335 | restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| 1336 | objs, _ := f.List(context.Background(), bucket, prefix) |
| 1337 | if len(objs) == 0 { |
| 1338 | return false, nil |
| 1339 | } |
| 1340 | return true, writeMinimalSQLite(dbPath) |
| 1341 | } |
| 1342 | defer func() { restoreReplica = oldRestore }() |
| 1343 | |
| 1344 | // The legacy shape and the Phase 1 shape (moves/<host>/<op>/...). |
| 1345 | f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") |
| 1346 | f.put(b.Cfg.Bucket, "moves/"+host+"/mv_abcdefghijklmnopqrstuvwxyz/g-cur/0000/0000000000000001-0000000000000002.ltx", "e2") |
| 1347 | before, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| 1348 | wantStaging := map[string]lease.MirrorObject{ |
| 1349 | "moves/" + host + "/generations/a/snapshot": {ETag: "e1"}, |
| 1350 | "moves/" + host + "/mv_abcdefghijklmnopqrstuvwxyz/g-cur/0000/0000000000000001-0000000000000002.ltx": {ETag: "e2"}, |
| 1351 | } |
| 1352 | if !reflect.DeepEqual(before, wantStaging) { |
| 1353 | t.Fatalf("staging fixture: %v, want %v", before, wantStaging) |
| 1354 | } |
| 1355 | |
| 1356 | fresh, err := b.Restore(ctx, rt) |
| 1357 | if err != nil || !fresh { |
| 1358 | t.Fatalf("Phase 1 restore with only staging: fresh=%v err=%v, want a fresh boot", fresh, err) |
| 1359 | } |
| 1360 | if own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)); len(own) != 0 { |
| 1361 | t.Fatalf("staging was promoted into the own prefix: %v", own) |
| 1362 | } |
| 1363 | after, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| 1364 | if !reflect.DeepEqual(after, before) { |
| 1365 | t.Fatalf("staging changed: before %v after %v", before, after) |
| 1366 | } |
| 1367 | |
| 1368 | // An own lineage restores as ever, and still leaves staging alone. |
| 1369 | f.put(b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)+"/snapshot", "own") |
| 1370 | _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| 1371 | fresh, err = b.Restore(ctx, rt) |
| 1372 | if err != nil || fresh { |
| 1373 | t.Fatalf("own lineage: fresh=%v err=%v, want a restore", fresh, err) |
| 1374 | } |
| 1375 | if after, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/"); !reflect.DeepEqual(after, before) { |
| 1376 | t.Fatalf("staging touched by an own-lineage restore: %v", after) |
| 1377 | } |
| 1378 | } |
| 1379 | |