package instancectl import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "github.com/carlosframework/platform/internal/cgroup" "github.com/carlosframework/platform/internal/provision" ) func legacyTier() string { return strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10) } func legacyClaimFixture(t *testing.T) (*Server, *unitCtl, string, string, string) { t.Helper() u, ctl, in, _ := claimUnitFixture(t) u.UnitRoot, u.DataRoot = t.TempDir(), t.TempDir() if err := os.Chmod(u.UnitRoot, 0700); err != nil { t.Fatal(err) } if err := u.freezeLaunch(t.Context(), in.Host, Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/opt/carlos/app"}}); err != nil { t.Fatal(err) } source, dropin, err := u.launchPaths(in.Host) if err != nil { t.Fatal(err) } ctl.calls = nil s := &Server{ Systemctl: ctl.run, CgroupRoot: u.CgroupRoot, ExeRoot: u.ExeRoot, DropInDir: u.UnitRoot, DataRoot: u.DataRoot, cgroupWaitBudget: u.WaitBudget, } return s, ctl, in.Host, filepath.Join(source, "launch.json"), filepath.Join(dropin, "60-claim-launch.conf") } func legacyRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil)) return rec } func TestRev3ClaimOnFlagOffLegacyStart(t *testing.T) { u, ctl, in, _ := claimUnitFixture(t) u.UnitRoot, u.DataRoot = t.TempDir(), t.TempDir() if err := os.Chmod(u.UnitRoot, 0700); err != nil { t.Fatal(err) } old := Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/old/app", "old-argument"}, Env: []string{"VERSION=old"}} if err := u.freezeLaunch(t.Context(), in.Host, old); err != nil { t.Fatal(err) } source, dropinDir, err := u.launchPaths(in.Host) if err != nil { t.Fatal(err) } snapshot := filepath.Join(source, "launch.json") dropin := filepath.Join(dropinDir, "60-claim-launch.conf") // The claim-on server has an active invocation when the feature flag is // turned off and instance-control is reconstructed without ClaimStop. ctl.state = "active" populate(t, ctl.events, true) newLaunch := Launch{Schema: LaunchSchema, Host: in.Host, Argv: []string{"/new/app", "new-argument"}, Env: []string{"VERSION=new"}} raw, err := json.Marshal(newLaunch) if err != nil { t.Fatal(err) } instanceDir := provision.InstanceDir(u.DataRoot, in.Host) if err := os.MkdirAll(instanceDir, 0755); err != nil { t.Fatal(err) } mutable := filepath.Join(instanceDir, LaunchFileName(RoleRun)) if err := os.WriteFile(mutable, raw, 0644); err != nil { t.Fatal(err) } var provedStopped bool var consumed Launch systemctl := func(ctx context.Context, args ...string) (string, error) { if args[0] == "start" { launchPath := mutable if _, err := os.Stat(dropin); err == nil { launchPath = snapshot } body, err := os.ReadFile(launchPath) if err != nil { return "", err } if err := json.Unmarshal(body, &consumed); err != nil { return "", err } } out, err := ctl.run(ctx, args...) if args[0] == "show" && strings.Contains(args[2], "ActiveState") { for _, path := range []string{snapshot, dropin} { if _, statErr := os.Stat(path); statErr != nil { t.Fatalf("artifact removed before stopped-state proof: %s: %v", path, statErr) } } provedStopped = strings.Contains(out, "ActiveState=inactive") } return out, err } s := &Server{ ClaimStop: nil, Systemctl: systemctl, CgroupRoot: u.CgroupRoot, ExeRoot: u.ExeRoot, DropInDir: u.UnitRoot, DataRoot: u.DataRoot, } if rec := legacyRequest(t, s, "/stop?host="+in.Host+"&role=run"); rec.Code != http.StatusNoContent { t.Fatalf("stop=%d: %s", rec.Code, rec.Body.String()) } if !provedStopped { t.Fatal("legacy stop did not prove the claim invocation inactive") } for _, path := range []string{snapshot, dropin} { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("old claim artifact survived proved stop: %s: %v", path, err) } } if rec := legacyRequest(t, s, "/start?host="+in.Host+"&role=run&memory_max="+legacyTier()); rec.Code != http.StatusOK { t.Fatalf("start=%d: %s", rec.Code, rec.Body.String()) } if strings.Join(consumed.Argv, " ") != strings.Join(newLaunch.Argv, " ") || strings.Join(consumed.Env, " ") != strings.Join(newLaunch.Env, " ") { t.Fatalf("legacy start consumed %+v, want new mutable launch %+v", consumed, newLaunch) } } func TestRev3LegacyStartClearsStoppedClaimLaunchBeforeStarting(t *testing.T) { s, ctl, host, snapshot, dropin := legacyClaimFixture(t) rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusOK { t.Fatalf("start=%d: %s", rec.Code, rec.Body.String()) } for _, path := range []string{snapshot, dropin} { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("claim artifact survived legacy start: %s: %v", path, err) } } calls := ctl.joined() if proof, reload, start := strings.Index(calls, "show -p ActiveState"), strings.Index(calls, "daemon-reload"), strings.Index(calls, "start "); proof < 0 || reload < proof || start < reload { t.Fatalf("cleanup did not precede start:\n%s", calls) } } func TestRev3LegacyClaimCleanupFailsClosed(t *testing.T) { for _, orphan := range []string{"permit.json", "exec.sock"} { t.Run("orphaned "+orphan, func(t *testing.T) { s, ctl, host, snapshot, dropin := legacyClaimFixture(t) source := filepath.Dir(snapshot) for _, path := range []string{snapshot, dropin} { if err := os.Remove(path); err != nil { t.Fatal(err) } } orphanPath := filepath.Join(source, orphan) if err := os.WriteFile(orphanPath, []byte("claim boundary"), 0600); err != nil { t.Fatal(err) } ctl.state = "active" populate(t, ctl.events, true) rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") { t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined()) } if !strings.Contains(ctl.joined(), "show -p ActiveState") { t.Fatalf("orphan did not force stop proof:\n%s", ctl.joined()) } if _, err := os.Stat(orphanPath); err != nil { t.Fatalf("live orphan removed: %v", err) } }) } t.Run("live unit", func(t *testing.T) { s, ctl, host, snapshot, dropin := legacyClaimFixture(t) ctl.state = "active" populate(t, ctl.events, true) rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") { t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined()) } for _, path := range []string{snapshot, dropin} { if _, err := os.Stat(path); err != nil { t.Fatalf("live artifact removed: %s: %v", path, err) } } }) t.Run("proof error after stop", func(t *testing.T) { s, ctl, host, snapshot, dropin := legacyClaimFixture(t) s.Systemctl = func(ctx context.Context, args ...string) (string, error) { if args[0] == "show" { return "", errors.New("bus gone") } return ctl.run(ctx, args...) } rec := legacyRequest(t, s, "/stop?host="+host+"&role=run") if rec.Code != http.StatusBadGateway { t.Fatalf("stop=%d: %s", rec.Code, rec.Body.String()) } for _, path := range []string{snapshot, dropin} { if _, err := os.Stat(path); err != nil { t.Fatalf("artifact removed without proof: %s: %v", path, err) } } }) t.Run("cleanup error", func(t *testing.T) { s, ctl, host, _, dropin := legacyClaimFixture(t) if err := os.Remove(dropin); err != nil { t.Fatal(err) } if err := os.Mkdir(dropin, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dropin, "busy"), nil, 0600); err != nil { t.Fatal(err) } rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") { t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined()) } }) t.Run("reload error", func(t *testing.T) { s, ctl, host, snapshot, dropin := legacyClaimFixture(t) s.Systemctl = func(ctx context.Context, args ...string) (string, error) { if args[0] == "daemon-reload" { return "", errors.New("reload failed") } return ctl.run(ctx, args...) } rec := legacyRequest(t, s, "/start?host="+host+"&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusBadGateway || strings.Contains(ctl.joined(), "start ") { t.Fatalf("start=%d calls:\n%s", rec.Code, ctl.joined()) } for _, path := range []string{snapshot, dropin} { if _, err := os.Stat(path); err != nil { t.Fatalf("artifact removed after reload failure: %s: %v", path, err) } } }) } func TestRev3LegacyStartWithoutClaimArtifactsKeepsOldPath(t *testing.T) { ctl := &fakeCtl{answers: map[string]string{"show -p InvocationID": "new\n"}} s := &Server{Systemctl: ctl.run} rec := legacyRequest(t, s, "/start?host=shop.example&role=run&memory_max="+legacyTier()) if rec.Code != http.StatusOK { t.Fatalf("start=%d: %s", rec.Code, rec.Body.String()) } if calls := ctl.joined(); strings.Contains(calls, "ActiveState") || strings.Contains(calls, "daemon-reload") { t.Fatalf("artifact-free start gained cleanup calls:\n%s", calls) } }