package instancectl import ( "context" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/provision" ) // unitCtl is a stateful systemd stand-in for one run unit. type unitCtl struct { mu sync.Mutex calls []string state, invocation string pid int group string startInvocation string stopLeavesActive bool failStart error events string } func (u *unitCtl) run(ctx context.Context, args ...string) (string, error) { u.mu.Lock() defer u.mu.Unlock() u.calls = append(u.calls, strings.Join(args, " ")) if err := ctx.Err(); err != nil { return "", err } switch args[0] { case "start": if u.failStart != nil { return "failed", u.failStart } u.state, u.invocation, u.pid = "active", u.startInvocation, 4242 u.setPopulated(true) case "stop": if !u.stopLeavesActive { u.state = "inactive" u.setPopulated(false) } case "show": var out []string for _, p := range strings.Split(args[2], ",") { switch p { case "ActiveState": out = append(out, "ActiveState="+u.state) case "InvocationID": out = append(out, "InvocationID="+u.invocation) case "MainPID": out = append(out, "MainPID="+strconv.Itoa(u.pid)) case "ControlGroup": out = append(out, "ControlGroup="+u.group) } } return strings.Join(out, "\n") + "\n", nil } return "", nil } func (u *unitCtl) setPopulated(on bool) { v := "populated 0\n" if on { v = "populated 1\n" } _ = os.WriteFile(filepath.Join(u.events, "cgroup.events"), []byte(v), 0o644) } func (u *unitCtl) joined() string { u.mu.Lock() defer u.mu.Unlock() return strings.Join(u.calls, "\n") } func claimUnitFixture(t *testing.T) (ClaimUnits, *unitCtl, ClaimUnit, string) { t.Helper() host := "shop.example" root := t.TempDir() events := filepath.Join(root, parentSlices, SliceName(host), UnitName(RoleRun, host)) if err := os.MkdirAll(events, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(events, "cgroup.events"), []byte("populated 0\nfrozen 0\n"), 0o644); err != nil { t.Fatal(err) } ctl := &unitCtl{state: "inactive", invocation: "old-invocation", startInvocation: "new-invocation", group: "/" + ClaimUnitControlGroup(host), events: events} build := filepath.Join(t.TempDir(), "versions", "v1", "app") if err := os.MkdirAll(filepath.Dir(build), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(build, []byte("app"), 0o555); err != nil { t.Fatal(err) } u := ClaimUnits{Systemctl: ctl.run, CgroupRoot: root, ExeRoot: filepath.Join(t.TempDir(), "exe"), WaitBudget: time.Second, Poll: time.Millisecond} return u, ctl, ClaimUnit{Host: host, Executable: build, MemoryMax: cgroup.TierDefault.MemoryMax}, events } func populate(t *testing.T, events string, on bool) { t.Helper() v := "populated 0\n" if on { v = "populated 1\n" } if err := os.WriteFile(filepath.Join(events, "cgroup.events"), []byte(v), 0o644); err != nil { t.Fatal(err) } } func TestClaimUnitStartsMainsIsolatedRunUnitOnThePinnedBuild(t *testing.T) { u, ctl, in, _ := claimUnitFixture(t) got, err := u.Start(context.Background(), in) if err != nil { t.Fatal(err) } unit := UnitName(RoleRun, in.Host) if got.Unit != unit || got.InvocationID != "new-invocation" || got.MainPID != 4242 || got.ControlGroup != ClaimUnitControlGroup(in.Host) { t.Fatalf("started=%+v", got) } calls := ctl.joined() want := []string{"set-property --runtime " + SliceName(in.Host) + " MemoryMax=" + strconv.FormatInt(in.MemoryMax, 10), "start " + unit} at := 0 for _, w := range want { i := strings.Index(calls[at:], w) if i < 0 { t.Fatalf("missing %q in order:\n%s", w, calls) } at += i + len(w) } // The unit's /opt/carlos/app source is the exact build root verified, // not the host's retargetable live link. iid := provision.InstanceID(in.Host) if target, err := os.Readlink(filepath.Join(u.ExeRoot, iid, "app")); err != nil || target != in.Executable { t.Fatalf("app source=%q %v", target, err) } if target, err := os.Readlink(filepath.Join(u.ExeRoot, iid, "live", in.Host)); err != nil || target != "../app" { t.Fatalf("live source=%q %v", target, err) } } func TestClaimUnitRefusesBeforeAnyEffect(t *testing.T) { for name, setup := range map[string]func(*ClaimUnits, *unitCtl, *ClaimUnit, string){ "active unit": func(_ *ClaimUnits, c *unitCtl, _ *ClaimUnit, _ string) { c.state = "active" }, "activating unit": func(_ *ClaimUnits, c *unitCtl, _ *ClaimUnit, _ string) { c.state = "activating" }, "populated group": func(_ *ClaimUnits, _ *unitCtl, _ *ClaimUnit, ev string) { populate(t, ev, true) }, "not a tier": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.MemoryMax = 12345 }, "relative build": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Executable = "versions/app" }, "unclean build": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Executable = "/opt/carlos/../x" }, "not a host": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Host = "../etc" }, "disk full": func(u *ClaimUnits, _ *unitCtl, _ *ClaimUnit, _ string) { u.Room = func() error { return ErrDiskFull } }, "no exe root": func(u *ClaimUnits, _ *unitCtl, _ *ClaimUnit, _ string) { u.ExeRoot = "" }, "no cgroup root": func(u *ClaimUnits, _ *unitCtl, _ *ClaimUnit, _ string) { u.CgroupRoot = "" }, "no systemctl": func(u *ClaimUnits, _ *unitCtl, _ *ClaimUnit, _ string) { u.Systemctl = nil }, "platform binary": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Executable = SystemBinary }, "host live link": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Executable = filepath.Join(LiveDir, in.Host) }, "probe build view": func(_ *ClaimUnits, _ *unitCtl, in *ClaimUnit, _ string) { in.Host = ProbeDirName }, } { u, ctl, in, events := claimUnitFixture(t) setup(&u, ctl, &in, events) if _, err := u.Start(context.Background(), in); err == nil { t.Fatalf("%s started", name) } if ctl != nil && strings.Contains(ctl.joined(), "start ") { t.Fatalf("%s reached systemctl start:\n%s", name, ctl.joined()) } } } func TestClaimUnitStopsAnInvocationItCannotVouchFor(t *testing.T) { for name, setup := range map[string]func(*unitCtl){ "same invocation": func(c *unitCtl) { c.startInvocation = "old-invocation" }, "empty invocation": func(c *unitCtl) { c.startInvocation = "" }, "other group": func(c *unitCtl) { c.group = "/carlos.slice/elsewhere.service" }, "start failed": func(c *unitCtl) { c.failStart = errors.New("boom") }, } { u, ctl, in, _ := claimUnitFixture(t) setup(ctl) if _, err := u.Start(context.Background(), in); err == nil { t.Fatalf("%s accepted", name) } if !strings.Contains(ctl.joined(), "stop "+UnitName(RoleRun, in.Host)) { t.Fatalf("%s left the unit running:\n%s", name, ctl.joined()) } } } func TestClaimUnitStopAndGoneProveTheInvocationEnded(t *testing.T) { ctx := context.Background() u, ctl, in, events := claimUnitFixture(t) if _, err := u.Start(ctx, in); err != nil { t.Fatal(err) } populate(t, events, true) if err := u.Gone(ctx, in.Host, "new-invocation"); err == nil { t.Fatal("running invocation reported gone") } if err := u.Gone(ctx, in.Host, "older-invocation"); err != nil { t.Fatalf("a later invocation means the older one ended: %v", err) } ctl.stopLeavesActive = true stopCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) if err := u.Stop(stopCtx, in.Host); err == nil { t.Fatal("stop returned while the unit stayed active") } cancel() ctl.stopLeavesActive = false populate(t, events, false) if err := u.Stop(ctx, in.Host); err != nil { t.Fatal(err) } if err := u.Gone(ctx, in.Host, "new-invocation"); err != nil { t.Fatalf("stopped invocation: %v", err) } if err := u.Gone(ctx, in.Host, ""); err != nil { t.Fatalf("idle unit with no named invocation: %v", err) } populate(t, events, true) if err := u.Gone(ctx, in.Host, ""); err == nil { t.Fatal("populated group reported gone") } } func TestClaimLaunchRouteExistsOnlyInClaimMode(t *testing.T) { legacy := (&Server{Systemctl: func(context.Context, ...string) (string, error) { return "", nil }}).Handler() rec := httptest.NewRecorder() legacy.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/launch", strings.NewReader("{}"))) if rec.Code != http.StatusNotFound { t.Fatalf("flag-off mux answered /claim/launch with %d", rec.Code) } reached := "" claim := (&Server{ClaimStop: http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { reached = r.URL.Path })}).Handler() rec = httptest.NewRecorder() claim.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/launch", strings.NewReader("{}"))) if reached != "/claim/launch" { t.Fatalf("claim mux did not route /claim/launch: %q", reached) } rec = httptest.NewRecorder() claim.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/claim/launch", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("GET /claim/launch=%d", rec.Code) } } // TestClaimHeldReleaseRouteExistsOnlyInClaimMode (moves Task 7b): the // root's held-release route is the claim handler's, and a flag-off mux // has none, so an agent's request there reads as unavailable. func TestClaimHeldReleaseRouteExistsOnlyInClaimMode(t *testing.T) { legacy := (&Server{Systemctl: func(context.Context, ...string) (string, error) { return "", nil }}).Handler() rec := httptest.NewRecorder() legacy.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/held-release", strings.NewReader("{}"))) if rec.Code != http.StatusNotFound { t.Fatalf("flag-off mux answered /claim/held-release with %d", rec.Code) } // Claim mode with moves off (whole-branch fixes, item 6): the route is // not registered, so main's 404 stands rather than the claim handler's // refusal. reached := "" stop := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { reached = r.URL.Path }) claimOnly := (&Server{ClaimStop: stop}).Handler() rec = httptest.NewRecorder() claimOnly.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/held-release", strings.NewReader("{}"))) if rec.Code != http.StatusNotFound || reached != "" { t.Fatalf("claim mode with moves off answered /claim/held-release with %d (reached %q)", rec.Code, reached) } // Both flags on: the held-release handler is registered and routed. claim := (&Server{ClaimStop: stop, ClaimHeldRelease: stop}).Handler() rec = httptest.NewRecorder() claim.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/claim/held-release", strings.NewReader("{}"))) if reached != "/claim/held-release" { t.Fatalf("claim mux did not route /claim/held-release: %q", reached) } rec = httptest.NewRecorder() claim.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/claim/held-release", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("GET /claim/held-release=%d", rec.Code) } }