package activator import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) func TestPrepareAtAGenerationKeysItsFilesByGeneration(t *testing.T) { ctl := &fakeControl{} b, rt := nestedBackend(t, ctl, &fakeUnit{}) b.GenContainment = true p := b.pathsFor(rt) if err := os.MkdirAll(p.runDir, 0o750); err != nil { t.Fatal(err) } for _, name := range []string{"exit.run.aaa.json", "exit.gen.7.bbb.json", "exit.gen.8.ccc.json"} { if err := os.WriteFile(filepath.Join(p.runDir, name), []byte("{}"), 0o600); err != nil { t.Fatal(err) } } p2, ctlL, logs, _, err := b.prepareAt(rt, instancectl.RoleRun, nestedGenSocketIn(p.runDir, 7), 7) if err != nil { t.Fatal(err) } defer logs.Close() if ctlL != nil { defer ctlL.Close() } for name, want := range map[string]bool{"exit.run.aaa.json": true, "exit.gen.7.bbb.json": false, "exit.gen.8.ccc.json": true} { if _, err := os.Stat(filepath.Join(p.runDir, name)); (err == nil) != want { t.Errorf("%s present=%v, want %v", name, err == nil, want) } } wantSock := filepath.Join(b.RunDirOverride, "logs", provision.InstanceID(rt.Host)+"@7.run.sock") if got := b.logSocketFor(rt.Host, instancectl.RoleRun, 7); got != wantSock { t.Errorf("log socket = %q, want %q", got, wantSock) } if _, err := os.Stat(wantSock); err != nil { t.Errorf("generation log listener not bound: %v", err) } if err := b.writeLaunchFor(p2, instancectl.RoleRun, 7, rt.Host, []string{"/opt/carlos/live/" + rt.Host}, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(p2.dataDir, "launch.gen.7.json")); err != nil { t.Errorf("launch.gen.7.json: %v", err) } if _, err := os.Stat(filepath.Join(p2.dataDir, "launch.run.json")); !os.IsNotExist(err) { t.Errorf("a generation wrote the legacy launch file: %v", err) } } func TestGenerationUnitProcStopsAndWatchesItsOwnUnit(t *testing.T) { ctl := &fakeControl{} unit := &fakeUnit{} b, rt := nestedBackend(t, ctl, unit) b.GenContainment = true b.Control = fakeGenControl{ctl} var mu sync.Mutex var seen []string b.UnitState = func(ctx context.Context, name string) (string, string, error) { mu.Lock() seen = append(seen, name) mu.Unlock() return unit.state(ctx, name) } unit.set("active", "inv7", true, true) ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "inv7", false, false) } up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, b.pathsFor(rt), "inv7", nil, nil) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := up.Stop(ctx); err != nil { t.Fatal(err) } if got := ctl.joined(); !strings.Contains(got, "stopgen h.example.com 7 kill=false") || strings.Contains(got, "stop h.example.com run") { t.Errorf("calls =\n%s", got) } want := instancectl.GenUnitName(rt.Host, 7) if up.unit != want { t.Errorf("unit = %q, want %q", up.unit, want) } mu.Lock() defer mu.Unlock() if len(seen) == 0 { t.Fatal("the observer never asked about the unit") } for _, name := range seen { if name != want { t.Errorf("the observer asked about %s, want %s", name, want) } } } func TestGenerationUnitProcConsumesOnlyItsOwnRecordAndLaunchFile(t *testing.T) { t.Parallel() ctl := &fakeControl{} unit := &fakeUnit{} b, rt := nestedBackend(t, ctl, unit) b.GenContainment = true b.Control = fakeGenControl{ctl} p := b.pathsFor(rt) for _, d := range []string{p.runDir, p.dataDir} { if err := os.MkdirAll(d, 0o750); err != nil { t.Fatal(err) } } rec, _ := json.Marshal(instancectl.ExitRecord{Invocation: "inv7", Result: "success", Code: "exited", Status: "0"}) for dir, names := range map[string][]string{ p.runDir: {"exit.gen.7.inv7.json", "exit.run.inv7.json"}, p.dataDir: {"launch.gen.7.json", "launch.gen.8.json"}, } { for _, name := range names { if err := os.WriteFile(filepath.Join(dir, name), rec, 0o600); err != nil { t.Fatal(err) } } } unit.set("active", "inv7", true, true) up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, p, "inv7", nil, nil) unit.set("inactive", "inv7", false, false) waitFor(t, "the generation to exit", up.Exited) for path, want := range map[string]bool{ filepath.Join(p.runDir, "exit.gen.7.inv7.json"): false, filepath.Join(p.runDir, "exit.run.inv7.json"): true, filepath.Join(p.dataDir, "launch.gen.7.json"): false, filepath.Join(p.dataDir, "launch.gen.8.json"): true, } { if _, err := os.Stat(path); (err == nil) != want { t.Errorf("%s present=%v, want %v", path, err == nil, want) } } } func TestStartRoleDispatchesByGeneration(t *testing.T) { ctl := &fakeControl{inv: "inv"} b, rt := nestedBackend(t, ctl, &fakeUnit{}) b.Control = fakeGenControl{ctl} if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 0, nil, cgroup.Limit{}); err != nil { t.Fatal(err) } if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil, cgroup.Limit{}); err != nil { t.Fatal(err) } if want := "start h.example.com run\nstartgen h.example.com 7 536870912"; ctl.joined() != want { t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want) } } func TestUnitGen(t *testing.T) { b := testExecBackend(t) if n, err := b.unitGen(7); n != 0 || err != nil { t.Errorf("containment off: %d %v, want 0", n, err) } b.GenContainment = true if n, err := b.unitGen(7); n != 7 || err != nil { t.Errorf("containment on: %d %v, want 7", n, err) } if _, err := b.unitGen(0); err == nil { t.Error("generation 0 accepted") } } // Flag off: a nested wake starts the original template, with the original // launch file, log socket and unit name. func TestStartUnitWithContainmentOffKeepsTheOriginalTemplate(t *testing.T) { t.Parallel() ctl := &fakeControl{inv: "inv1"} unit := &fakeUnit{} b, rt := nestedBackend(t, ctl, unit) var stopServing func() ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) } defer func() { if stopServing != nil { stopServing() } }() unit.set("active", "inv1", true, true) p, _, err := b.Start(context.Background(), rt) if err != nil { t.Fatalf("Start: %v", err) } up := p.(*unitProc) if up.unit != "carlos-instance@h.example.com.service" || up.gen != 0 { t.Errorf("unit = %q gen %d", up.unit, up.gen) } if strings.Contains(ctl.joined(), "startgen") { t.Errorf("a flag-off wake used the generation verb:\n%s", ctl.joined()) } if _, err := os.Stat(b.logSocket(rt.Host, instancectl.RoleRun)); err != nil { t.Errorf("legacy log socket: %v", err) } unit.set("inactive", "inv1", false, false) waitFor(t, "the unit to exit", p.Exited) } // genStartFixture is a nested backend with containment on, ready for a // generation-7 start whose answer the test chooses. func genStartFixture(t *testing.T, ctl *fakeControl) (*ExecBackend, registry.Route, string, string) { t.Helper() b, rt := nestedBackend(t, ctl, &fakeUnit{}) b.GenContainment = true b.Control = fakeGenControl{ctl} launch := filepath.Join(b.pathsFor(rt).dataDir, instancectl.GenLaunchFileName(7)) return b, rt, launch, b.logSocketFor(rt.Host, instancectl.RoleRun, 7) } // A generation start that failed may have started the unit (its answer // was lost): the backend kills it through StopGen on its own budget, and // only then closes its listeners and removes its launch file. func TestStartGenFailureKillsTheGenerationBeforeReleasingIt(t *testing.T) { ctl := &fakeControl{startErr: errors.New("instance control /start: 502: show: bus timeout")} b, rt, launch, logSock := genStartFixture(t, ctl) var launchAtStop bool ctl.stopHook = func(string, instancectl.Role) { _, err := os.Stat(launch); launchAtStop = err == nil } _, _, err := b.StartGen(context.Background(), rt, 7, false) if err == nil || errors.Is(err, errGenUnproven) { t.Fatalf("StartGen = %v, want an ordinary start failure", err) } if want := "startgen h.example.com 7 536870912\nstopgen h.example.com 7 kill=true"; ctl.joined() != want { t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want) } if !launchAtStop { t.Error("the launch file was gone before the kill") } if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Errorf("launch file left after a proven stop: %v", err) } if _, err := os.Stat(logSock); !os.IsNotExist(err) { t.Errorf("log listener still bound after a proven stop: %v", err) } } // The kill that follows a failed start fails too: termination is // unproven, so the generation's files stay and the error says so. func TestStartGenFailureWithAnUnprovenKillKeepsTheGeneration(t *testing.T) { ctl := &fakeControl{startErr: errors.New("instance control socket: context deadline exceeded"), stopGenErr: errors.New("instance control /stop: 502: stop: processes remain")} b, rt, launch, logSock := genStartFixture(t, ctl) _, _, err := b.StartGen(context.Background(), rt, 7, false) if !errors.Is(err, errGenUnproven) { t.Fatalf("StartGen = %v, want errGenUnproven", err) } if _, err := os.Stat(launch); err != nil { t.Errorf("launch file removed while termination is unproven: %v", err) } if _, err := os.Stat(logSock); err != nil { t.Errorf("log listener released while termination is unproven: %v", err) } } // A start that answered no invocation is just as uncertain for a // generation: it is killed and proven gone before its files go. func TestStartGenWithNoInvocationKillsTheGeneration(t *testing.T) { ctl := &fakeControl{} b, rt, launch, _ := genStartFixture(t, ctl) if _, _, err := b.StartGen(context.Background(), rt, 7, false); err == nil { t.Fatal("a start with no invocation succeeded") } if !strings.Contains(ctl.joined(), "stopgen h.example.com 7 kill=true") { t.Errorf("calls =\n%s", ctl.joined()) } if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Errorf("launch file left after a proven stop: %v", err) } } // genProcFixture is a generation-7 unitProc over fakes, with its launch // file on disk. func genProcFixture(t *testing.T) (*fakeControl, *fakeUnit, *ExecBackend, registry.Route, string) { t.Helper() ctl, unit := &fakeControl{}, &fakeUnit{} b, rt := nestedBackend(t, ctl, unit) b.GenContainment = true b.Control = fakeGenControl{ctl} p := b.pathsFor(rt) if err := os.MkdirAll(p.dataDir, 0o750); err != nil { t.Fatal(err) } launch := filepath.Join(p.dataDir, instancectl.GenLaunchFileName(7)) if err := os.WriteFile(launch, []byte("{}"), 0o600); err != nil { t.Fatal(err) } return ctl, unit, b, rt, launch } // A generation that exits on its own is finalised once observe proves it // gone: its launch file here, and the control service's drop-in and exe // layout through a stop of the inactive unit. A later Stop sends nothing. func TestGenerationNaturalExitFinalisesItsFiles(t *testing.T) { t.Parallel() ctl, unit, b, rt, launch := genProcFixture(t) unit.set("active", "inv7", true, true) up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, b.pathsFor(rt), "inv7", nil, nil) unit.set("inactive", "inv7", false, false) waitFor(t, "the generation to exit", up.Exited) if want := "stopgen h.example.com 7 kill=false"; ctl.joined() != want { t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want) } if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Errorf("launch file left after a natural exit: %v", err) } if err := up.Stop(context.Background()); err != nil { t.Fatal(err) } if want := "stopgen h.example.com 7 kill=false"; ctl.joined() != want { t.Errorf("a stop after the exit sent more:\n%s", ctl.joined()) } } // A generation restarted outside the activator is stopped, and once that // is proven its files go too. The stop that proved it already removed the // control service's files, so it is not asked twice. func TestGenerationForeignInvocationExitFinalisesItsFiles(t *testing.T) { t.Parallel() ctl, unit, b, rt, launch := genProcFixture(t) ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "", false, false) } unit.set("active", "inv7", true, true) up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, b.pathsFor(rt), "inv7", nil, nil) unit.set("active", "someone-else", true, true) waitFor(t, "the generation to exit", up.Exited) if want := "stopgen h.example.com 7 kill=false"; ctl.joined() != want { t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want) } if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Errorf("launch file left after a foreign invocation was stopped: %v", err) } } // With no generation channel StartGen is never sent, so nothing ran: the // launch file written for it goes. func TestStartGenRefusedBeforeSendingRemovesTheLaunchFile(t *testing.T) { ctl := &fakeControl{inv: "inv7"} b, rt, launch, _ := genStartFixture(t, ctl) b.Control = ctl // a Control without the generation verbs if _, _, err := b.StartGen(context.Background(), rt, 7, false); !errors.Is(err, errNoGenControl) { t.Fatalf("StartGen = %v, want errNoGenControl", err) } if _, err := os.Stat(launch); !os.IsNotExist(err) { t.Errorf("launch file left by a start never sent: %v", err) } }