package activator import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/registry" ) // exeControl is a root control channel with the generation verbs. Its plain // StartGen refuses, so a test sees which start was used. type exeControl struct { mu sync.Mutex got []instancectl.StagedExe sidecarExes []instancectl.StagedExe plainStarts []instancectl.Role // Start calls: the live-link exe layout tiers []int64 // StartGenExe's memoryMax, per call sidecarTiers []int64 // StartSidecarExe's memoryMax, per call exeErr error // what StartGenExe answers, when set // exeAnswer, when set, is what StartGenExe answers for each staged exe // (in place of exeErr): an empty invocation with no error is a unit // that exited before root observed it. exeAnswer func(instancectl.StagedExe) (string, error) stopGens int // StopGen calls stopGenErr error // what StopGen answers cmdExes []instancectl.StagedExe // Cmd (a zero StagedExe: the live link) and CmdExe calls } func (c *exeControl) Start(_ context.Context, _ string, role instancectl.Role, _ int64) (string, error) { c.mu.Lock() defer c.mu.Unlock() c.plainStarts = append(c.plainStarts, role) return "inv", nil } func (c *exeControl) StartSidecarExe(_ context.Context, _ string, memoryMax int64, x instancectl.StagedExe) (string, error) { c.mu.Lock() defer c.mu.Unlock() c.sidecarExes = append(c.sidecarExes, x) c.sidecarTiers = append(c.sidecarTiers, memoryMax) return "inv-sc", nil } func (c *exeControl) Seed(context.Context, string, int64) (instancectl.SeedResponse, error) { return instancectl.SeedResponse{}, nil } func (c *exeControl) Stop(context.Context, string, instancectl.Role) error { return nil } func (c *exeControl) Reset(context.Context, string, instancectl.Role) error { return nil } func (c *exeControl) Sweep(context.Context) (instancectl.SweepResponse, error) { return instancectl.SweepResponse{}, nil } func (c *exeControl) Ready(context.Context) error { return nil } func (c *exeControl) Cmd(context.Context, string, int64) (string, error) { c.mu.Lock() defer c.mu.Unlock() c.cmdExes = append(c.cmdExes, instancectl.StagedExe{}) return "inv-cmd", nil } func (c *exeControl) CmdExe(_ context.Context, _ string, _ int64, x instancectl.StagedExe) (string, error) { c.mu.Lock() defer c.mu.Unlock() c.cmdExes = append(c.cmdExes, x) return "inv-cmd", nil } func (c *exeControl) CmdStatus(context.Context, string, string) (instancectl.CmdStatus, error) { return instancectl.CmdStatus{State: instancectl.CmdDone}, nil } func (c *exeControl) StartGen(context.Context, string, uint64, int64) (string, error) { return "", errors.New("test: the live-link generation start was used") } func (c *exeControl) StopGen(context.Context, string, uint64, bool) error { c.mu.Lock() defer c.mu.Unlock() c.stopGens++ return c.stopGenErr } func (c *exeControl) ResetGen(context.Context, string, uint64) error { return nil } func (c *exeControl) Parent(context.Context, string, int64) error { return nil } func (c *exeControl) StartGenExe(_ context.Context, _ string, _ uint64, memoryMax int64, x instancectl.StagedExe) (string, error) { c.mu.Lock() defer c.mu.Unlock() c.got = append(c.got, x) c.tiers = append(c.tiers, memoryMax) if c.exeAnswer != nil { return c.exeAnswer(x) } if c.exeErr != nil { return "", c.exeErr } return "inv-7", nil } // A nested staged generation names its executable to root. Without one, // the generation start is plan 2's, on the live link. func TestStartRoleNamesTheStagedExe(t *testing.T) { b := testExecBackend(t) b.GenContainment = true ctl := &exeControl{} b.Control = ctl rt := registry.Route{Host: "n.example.com"} x := instancectl.StagedExe{Path: "/opt/carlos/versions/v2/blog", Version: "v2", Digest: strings.Repeat("a", 64)} inv, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, &x, cgroup.Limit{}) if err != nil || inv != "inv-7" || len(ctl.got) != 1 || ctl.got[0] != x { t.Fatalf("startRole = %q, %v; root was sent %+v", inv, err, ctl.got) } if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil, cgroup.Limit{}); err == nil || !strings.Contains(err.Error(), "live-link") { t.Fatalf("no staged exe: %v, want plan 2's start", err) } } // A nested legacy sidecar is started on the serving generation's captured // staged executable, which root validates (spec §7.5). The live link names // v1 while the generation serves v2, as after a fallback, and the sidecar // still starts, on v2. Task 7's interim nested arm refused it // (errNestedSidecarGen), and today's StartSidecar would bind the live v1. func TestStartSidecarGenNestedNamesTheStagedExe(t *testing.T) { b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) b.GenContainment = true ctl := &exeControl{} b.Control = ctl stageLive(t, b, rt.Host, "v1") if err := os.MkdirAll(filepath.Dir(b.pathsFor(rt).sidecarEnv), 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(b.pathsFor(rt).sidecarEnv, []byte("X=1\n"), 0o600); err != nil { t.Fatal(err) } // The generation's own tier: 1 GiB, while the live link's (b.tier) is // the default 512 MiB. After a fallback the two differ. g := Gen{N: 4, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("b", 64), Tier: cgroup.TierChildren} p, err := b.StartSidecarGen(context.Background(), rt, g) if err != nil { t.Fatalf("StartSidecarGen: %v", err) } defer p.Stop(context.Background()) //nolint:errcheck want := instancectl.StagedExe{Path: g.StagedPath, Version: "v2", Digest: g.Digest} ctl.mu.Lock() defer ctl.mu.Unlock() if len(ctl.sidecarExes) != 1 || ctl.sidecarExes[0] != want { t.Fatalf("root was sent %+v for the sidecar, want %+v", ctl.sidecarExes, want) } if len(ctl.plainStarts) != 0 { t.Fatalf("a live-link start ran too: %v", ctl.plainStarts) } if ctl.sidecarTiers[0] != cgroup.TierChildren.MemoryMax { t.Fatalf("root was sent memory_max %d, want the generation's own %d", ctl.sidecarTiers[0], cgroup.TierChildren.MemoryMax) } } // Root refusing the staged path abandons the swap (the one classifier, // verdictOf, reads instancectl.ErrStagedExe as NoOwnership). It is not a // failure of the build, and the incumbent keeps serving. func TestSwapAbandonsWhenRootRefusesTheStagedExe(t *testing.T) { f := newSwapFixture(t) f.b.genErr = fmt.Errorf("%w: not blog's staged v2", instancectl.ErrStagedExe) res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, "staged executable refused") { t.Fatalf("Swap = %+v, want abandoned as a root refusal", res) } if got := f.appSocket(t); got != f.incumbent.Socket { t.Fatalf("AppSocket = %q, want the incumbent", got) } } // A nested staged generation is returned as soon as root has started its // unit, with no health wait inside (spec §8.2): the socket here is never // bound, and the real 30 s spawnHealthTimeout is left alone. root is sent // the generation's own tier. func TestStartStagedNestedReturnsTheUnitAtOnce(t *testing.T) { b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) b.GenContainment = true ctl := &exeControl{} b.Control = ctl g := Gen{N: 7, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("c", 64), Tier: cgroup.TierChildren} done := make(chan error, 1) var proc Proc go func() { var err error _, proc, err = b.StartStaged(context.Background(), rt, g) done <- err }() select { case err := <-done: if err != nil || proc == nil { t.Fatalf("StartStaged = %v, %v: want the started unit", proc, err) } case <-time.After(5 * time.Second): t.Fatal("StartStaged waited for health: it must return the started unit at once") } defer proc.Stop(context.Background()) //nolint:errcheck ctl.mu.Lock() defer ctl.mu.Unlock() if len(ctl.got) != 1 || ctl.tiers[0] != cgroup.TierChildren.MemoryMax { t.Fatalf("root was sent %+v at %v, want one start at the generation's 1 GiB", ctl.got, ctl.tiers) } } // Root refusing the staged build started nothing, so the agent kills // nothing and holds nothing unproven. The refusal reaches the swap as // ErrStagedExe alone (verdictOf: abandonment), with the generation number // the caller keeps. func TestStartStagedNestedRootRefusalIsNotUnproven(t *testing.T) { b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) b.GenContainment = true ctl := &exeControl{exeErr: fmt.Errorf("%w: not blog's staged v2", instancectl.ErrStagedExe)} b.Control = ctl g := Gen{N: 7, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("c", 64), Tier: cgroup.TierChildren} got, proc, err := b.StartStaged(context.Background(), rt, g) if !errors.Is(err, instancectl.ErrStagedExe) || errors.Is(err, errGenUnproven) || proc != nil || got.N != 7 { t.Fatalf("StartStaged = %+v, %v, %v", got, proc, err) } if ctl.stopGens != 0 { t.Fatalf("the agent killed %d generation(s) root never started", ctl.stopGens) } if verdictOf(err) != gateNoOwnership { t.Fatalf("verdictOf(%v) = %d, want NoOwnership", err, verdictOf(err)) } // Final review T9: the launch file this start wrote (it carries the // instance's environment) is removed after root's definite refusal. launch := filepath.Join(b.pathsFor(rt).dataDir, instancectl.GenLaunchFileName(7)) if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Fatalf("%s after root refused the start: %v, want it removed", launch, err) } } // A nested successor whose start failed and whose kill is unproven says so // (errGenUnproven) and returns its generation number, so the member's stop // can prove it (stopMember) and the operation set is not released. func TestStartStagedNestedUnprovenKeepsTheGenerationNumber(t *testing.T) { b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) b.GenContainment = true ctl := &exeControl{exeErr: errors.New("502 from root"), stopGenErr: errors.New("processes remain")} b.Control = ctl g := Gen{N: 7, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("c", 64), Tier: cgroup.TierChildren} got, proc, err := b.StartStaged(context.Background(), rt, g) if !errors.Is(err, errGenUnproven) || proc != nil || got.N != 7 { t.Fatalf("StartStaged = %+v, %v, %v: want errGenUnproven, no handle, generation 7", got, proc, err) } if ctl.stopGens != 1 { t.Fatalf("StopGen calls = %d, want the one kill", ctl.stopGens) } if verdictOf(err) != gateProceed { t.Fatalf("an unproven start must stay a failure, verdictOf = %d", verdictOf(err)) } } // A nested start with no staged path (a cold wake) is on the live link, as // the flat arm is, and names no executable to root. func TestStartStagedNestedWithoutAStagedPathUsesTheLiveLink(t *testing.T) { b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) b.GenContainment = true ctl := &exeControl{} b.Control = ctl _, _, err := b.StartStaged(context.Background(), rt, Gen{N: 7}) if err == nil || !strings.Contains(err.Error(), "live-link") || len(ctl.got) != 0 { t.Fatalf("StartStaged = %v; root was sent %+v: want plan 2's live-link start", err, ctl.got) } }