diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index bdbeb24..3a4d227 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -13,14 +13,23 @@ import ( ) func newDoctorCmd(g *GlobalOpts) *cobra.Command { - var fix bool + var ( + fix bool + rebuildState bool + ) cmd := &cobra.Command{ Use: "doctor", Short: "Probe the environment and report capabilities with remediations", Long: "doctor runs the REAL branch logic (not docs) for the tools and paths devstack\n" + - "depends on, and prints a one-line remediation for anything that isn't OK.", + "depends on, and prints a one-line remediation for anything that isn't OK.\n\n" + + "With --rebuild-state, the shared_service + ref ledger is reconstructed from\n" + + "on-disk config intersected with live container labels (recovery when state.db\n" + + "is lost or corrupt — the ledger is a cache of reality).", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + if rebuildState { + return rebuildLedger(cmd, g) + } checks := runDoctor(cmd) if g.JSON { return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"checks": checks}) @@ -38,9 +47,31 @@ func newDoctorCmd(g *GlobalOpts) *cobra.Command { }, } cmd.Flags().BoolVar(&fix, "fix", false, "apply safe automatic remediations (M6)") + cmd.Flags().BoolVar(&rebuildState, "rebuild-state", false, "reconstruct the ledger from config + live container labels") return cmd } +// rebuildLedger reconstructs the shared_service + ref ledger from on-disk config +// intersected with live container labels (spec 09 §crash-recovery). +func rebuildLedger(cmd *cobra.Command, g *GlobalOpts) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + sum, err := mgr.RebuildState(cmd.Context()) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, sum) + } + fmt.Fprintf(cmd.OutOrStdout(), + "rebuilt ledger from live labels: %d shared service(s), %d ref row(s)\n", + len(sum.Shared), sum.Refs) + return nil +} + // runDoctor assembles the full capability matrix. Each probe is independent so a // single failure never hides the others. func runDoctor(cmd *cobra.Command) []docker.Check { diff --git a/internal/cli/gc_test.go b/internal/cli/gc_test.go new file mode 100644 index 0000000..b98e662 --- /dev/null +++ b/internal/cli/gc_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestSharedGcRegistered(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"shared", "gc"}) + if err != nil || c.Name() != "gc" || c.RunE == nil { + t.Fatalf("shared gc not registered as a real command: %v", err) + } +} + +func TestSharedGcDryRun(t *testing.T) { + dir := t.TempDir() + mustWrite(t, filepath.Join(dir, "workspace.yaml"), + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\nshared:\n postgres: { template: postgres }\nprojects: []\n") + t.Chdir(dir) + t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data")) + t.Setenv("XDG_RUNTIME_DIR", filepath.Join(dir, "run")) + + var out strings.Builder + root := NewRootCmd(Options{}) + root.SetArgs([]string{"shared", "gc"}) + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("shared gc (dry-run): %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "no shared services at zero references") { + t.Errorf("unexpected gc output:\n%s", out.String()) + } +} + +func TestDoctorRebuildStateFlag(t *testing.T) { + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"doctor"}) + if err != nil { + t.Fatal(err) + } + if c.Flags().Lookup("rebuild-state") == nil { + t.Error("doctor is missing the --rebuild-state flag") + } +} diff --git a/internal/cli/shared.go b/internal/cli/shared.go index 6edb004..41a4928 100644 --- a/internal/cli/shared.go +++ b/internal/cli/shared.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "github.com/spf13/cobra" @@ -24,12 +25,82 @@ func newSharedCmd(g *GlobalOpts) *cobra.Command { } cmd.AddCommand( newSharedStatusCmd(g), - stub("gc", "Reclaim unused shared services", "M2 (up saga)"), - stub("doctor", "Reconcile the ledger against live containers", "M2 (up saga)"), + newSharedGcCmd(g), + newSharedDoctorCmd(g), ) return cmd } +// newSharedGcCmd wires `shared gc [--stop]` — find shared services at zero refs +// and (with --stop) stop them. Default is a dry-run report: warm DBs are cheap, +// so reclamation is opt-in (spec 03/09). Volumes are never touched. +func newSharedGcCmd(g *GlobalOpts) *cobra.Command { + var stop bool + cmd := &cobra.Command{ + Use: "gc", + Short: "Report (or with --stop, stop) shared services at zero references", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + res, err := mgr.GC(cmd.Context(), stop) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, res) + } + w := cmd.OutOrStdout() + if len(res.Candidates) == 0 { + fmt.Fprintln(w, "no shared services at zero references") + return nil + } + for _, c := range res.Candidates { + if slices.Contains(res.Stopped, c) { + fmt.Fprintf(w, "stopped %s\n", c) + } else if stop { + fmt.Fprintf(w, "%s (zero refs, left running)\n", c) + } else { + fmt.Fprintf(w, "%s (zero refs — run `shared gc --stop` to stop)\n", c) + } + } + return nil + }, + } + cmd.Flags().BoolVar(&stop, "stop", false, "actually stop the zero-ref services (default: report only)") + return cmd +} + +// newSharedDoctorCmd wires `shared doctor` — the self-healing reconcile: prune +// ref rows for projects no longer live (the count is derived from reality). +func newSharedDoctorCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Reconcile the ledger against live containers (prune dead refs)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + pruned, err := mgr.Reconcile(cmd.Context()) + if err != nil { + return err + } + if g.JSON { + return writeJSON(cmd, map[string]any{"pruned": pruned}) + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "reconciled: pruned %d stale ref row(s)\n", len(pruned)) + return nil + }, + } +} + func newSharedStatusCmd(g *GlobalOpts) *cobra.Command { return &cobra.Command{ Use: "status", diff --git a/internal/workspace/gc.go b/internal/workspace/gc.go new file mode 100644 index 0000000..a355021 --- /dev/null +++ b/internal/workspace/gc.go @@ -0,0 +1,172 @@ +package workspace + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/state" +) + +// This file implements `shared gc` (reclaim zero-ref shared services) and +// `doctor --rebuild-state` (reconstruct the ledger from on-disk config + live +// container labels). The ledger is a cache of reality, never the trusted source +// (spec 09 §crash-recovery, spec 13). + +// RebuildSummary reports what RebuildState reconstructed. +type RebuildSummary struct { + Shared []string `json:"shared"` // shared_service rows re-derived (aliases) + Refs int `json:"refs"` // service_ref rows re-derived +} + +// RebuildState reconstructs the shared_service + service_ref ledger rows from the +// on-disk workspace config intersected with live tool-labelled containers — the +// recovery path when state.db is lost or corrupt. Acquires the lock. +func (m *Manager) RebuildState(ctx context.Context) (RebuildSummary, error) { + cs, err := m.Docker.ListManaged(ctx, map[string]string{generate.LabelManaged: "true"}) + if err != nil { + return RebuildSummary{}, fmt.Errorf("rebuild-state: list containers: %w", err) + } + liveProjects := map[string]bool{} + liveShared := map[string]bool{} + for _, c := range cs { + if !c.Running() { + continue + } + if p := c.Labels[generate.LabelProject]; p != "" { + liveProjects[p] = true + } + if s := c.Labels[generate.LabelShared]; s != "" { + liveShared[s] = true + } + } + + instances, err := m.SharedInstances() + if err != nil { + return RebuildSummary{}, err + } + + var sum RebuildSummary + err = lock.WithLock(ctx, m.LockPath, func() error { + for _, name := range sortedKeys(instances) { + if !liveShared[name] { + continue + } + inst := instances[name] + if err := m.DB.UpsertSharedService(state.SharedService{ + Name: inst.Alias, Engine: inst.Engine, MajorVersion: inst.Major, Status: "running", + }); err != nil { + return err + } + sum.Shared = append(sum.Shared, inst.Alias) + } + for _, project := range sortedKeys(liveProjects) { + for _, inst := range m.instancesUsedBy(project, instances) { + if !liveShared[inst.Name] { + continue + } + for _, c := range m.consumersOf(inst.Name) { + if c.project != project { + continue + } + if err := m.DB.AddRef(c.project, c.service, inst.Alias); err != nil { + return err + } + sum.Refs++ + } + } + } + return nil + }) + if err == nil { + m.DB.LogEvent("rebuild-state", m.Model.Workspace.Name, + fmt.Sprintf("re-derived %d shared + %d refs from live labels", len(sum.Shared), sum.Refs)) + } + return sum, err +} + +// GCResult reports the zero-ref shared services and which were stopped. +type GCResult struct { + Candidates []string `json:"candidates"` // zero-ref shared aliases + Stopped []string `json:"stopped"` // actually stopped (when stop=true) +} + +// GC reconciles the ledger, then finds shared services at zero refs. With +// stop=false it only reports candidates (the safe default — warm DBs are cheap). +// With stop=true it `compose stop`s each candidate on the shared stack and marks +// it stopped. The external network and volumes are never touched (spec 03/09). +func (m *Manager) GC(ctx context.Context, stop bool) (GCResult, error) { + if _, err := m.Reconcile(ctx); err != nil { + // Reconcile is best-effort; a down daemon shouldn't block a dry-run report. + if stop { + return GCResult{}, err + } + } + shared, err := m.DB.ListSharedServices() + if err != nil { + return GCResult{}, err + } + instances, err := m.SharedInstances() + if err != nil { + return GCResult{}, err + } + aliasToName := map[string]string{} + for name, inst := range instances { + aliasToName[inst.Alias] = name + } + + var res GCResult + var zero []state.SharedService + for _, s := range shared { + n, err := m.DB.RefCount(s.Name) + if err != nil { + return GCResult{}, err + } + if n == 0 { + res.Candidates = append(res.Candidates, s.Name) + zero = append(zero, s) + } + } + if !stop || len(zero) == 0 { + return res, nil + } + + cp := m.sharedCompose() + for _, s := range zero { + name := aliasToName[s.Name] + if name == "" { + continue // ledger alias has no current config service; skip (orphan) + } + if err := cp.Stop(ctx, name); err != nil { + return res, fmt.Errorf("stop shared %s: %w", s.Name, err) + } + if err := lock.WithLock(ctx, m.LockPath, func() error { + return m.DB.SetSharedStatus(s.Engine, s.MajorVersion, "stopped") + }); err != nil { + return res, err + } + m.DB.LogEvent("shared-gc", s.Name, "stopped (0 refs)") + res.Stopped = append(res.Stopped, s.Name) + } + return res, nil +} + +// sharedCompose builds the compose driver for the shared stack. The compose file +// is used when present; otherwise the label-driven `-p devstack-shared` form +// drives stop/down (so gc works even if generated artifacts were cleaned). +func (m *Manager) sharedCompose() docker.Compose { + outDir := filepath.Join(m.Model.Root, generate.GenDir, "shared") + file := filepath.Join(outDir, generate.ComposeFile) + if _, err := os.Stat(file); err != nil { + file = "" + } + runner := m.Runner + if runner == nil { + runner = docker.ExecRunner{} + } + return docker.Compose{Project: generate.SharedStackName, File: file, Dir: outDir, Runner: runner} +} diff --git a/internal/workspace/gc_test.go b/internal/workspace/gc_test.go new file mode 100644 index 0000000..f705a1a --- /dev/null +++ b/internal/workspace/gc_test.go @@ -0,0 +1,107 @@ +package workspace + +import ( + "context" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/docker" + "github.com/open-source-cloud/devstack/internal/generate" + "github.com/open-source-cloud/devstack/internal/state" +) + +type fakeRunner struct{ cmds [][]string } + +func (f *fakeRunner) Run(_ context.Context, _ []string, _, name string, args ...string) error { + f.cmds = append(f.cmds, append([]string{name}, args...)) + return nil +} +func (f *fakeRunner) Output(_ context.Context, _ []string, _, name string, args ...string) ([]byte, error) { + f.cmds = append(f.cmds, append([]string{name}, args...)) + return nil, nil +} +func (f *fakeRunner) saw(sub string) bool { + for _, c := range f.cmds { + if strings.Contains(strings.Join(c, " "), sub) { + return true + } + } + return false +} + +func TestRebuildStateFromLabels(t *testing.T) { + // Live containers: shared postgres+redis, project services api and web. + mock := &docker.MockClient{Containers: []docker.Container{ + {ID: "s1", State: "running", Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "postgres"}}, + {ID: "s2", State: "running", Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: "redis"}}, + {ID: "a1", State: "running", Labels: map[string]string{generate.LabelManaged: "true", generate.LabelProject: "api", generate.LabelService: "api"}}, + {ID: "w1", State: "running", Labels: map[string]string{generate.LabelManaged: "true", generate.LabelProject: "web", generate.LabelService: "web"}}, + }} + m := newManager(t, mock) + + sum, err := m.RebuildState(context.Background()) + if err != nil { + t.Fatalf("RebuildState: %v", err) + } + // minio has no live container → not re-derived; postgres + redis are. + if len(sum.Shared) != 2 { + t.Errorf("rebuilt shared = %v, want 2 (postgres, redis)", sum.Shared) + } + // Refs: postgres ← api/api, web/web (2); redis ← api/api (1) = 3. + if sum.Refs != 3 { + t.Errorf("rebuilt refs = %d, want 3", sum.Refs) + } + if n, _ := m.DB.RefCount(generate.SharedAlias("postgres")); n != 2 { + t.Errorf("shared-postgres refs = %d, want 2", n) + } + if n, _ := m.DB.RefCount(generate.SharedAlias("redis")); n != 1 { + t.Errorf("shared-redis refs = %d, want 1", n) + } + rows, _ := m.DB.ListSharedServices() + if len(rows) != 2 { + t.Errorf("shared_service rows = %d, want 2 (minio excluded — not live)", len(rows)) + } +} + +func TestGCListsAndStopsZeroRef(t *testing.T) { + fr := &fakeRunner{} + m := newManager(t, &docker.MockClient{}) + m.Runner = fr + // A shared service with no refs (zero-ref candidate). + if err := m.DB.UpsertSharedService(state.SharedService{ + Name: generate.SharedAlias("postgres"), Engine: "postgres", MajorVersion: "16", Status: "running", + }); err != nil { + t.Fatal(err) + } + + // Dry run: lists the candidate, stops nothing. + res, err := m.GC(context.Background(), false) + if err != nil { + t.Fatal(err) + } + if len(res.Candidates) != 1 || res.Candidates[0] != generate.SharedAlias("postgres") { + t.Fatalf("candidates = %v, want [shared-postgres]", res.Candidates) + } + if len(res.Stopped) != 0 { + t.Errorf("dry run stopped %v, want none", res.Stopped) + } + if len(fr.cmds) != 0 { + t.Errorf("dry run must not run compose; got %v", fr.cmds) + } + + // With stop: composes stop the service and marks it stopped. + res, err = m.GC(context.Background(), true) + if err != nil { + t.Fatal(err) + } + if len(res.Stopped) != 1 { + t.Fatalf("stopped = %v, want [shared-postgres]", res.Stopped) + } + if !fr.saw("stop postgres") { + t.Errorf("did not compose stop the service; cmds=%v", fr.cmds) + } + s, ok, _ := m.DB.GetSharedService("postgres", "16") + if !ok || s.Status != "stopped" { + t.Errorf("status after gc = %+v, want stopped", s) + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 63be274..137b524 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -31,6 +31,9 @@ type Manager struct { Docker docker.Client Source template.TemplateSource LockPath string + // Runner drives the compose CLI for shared-stack lifecycle verbs (gc stop). + // nil → docker.ExecRunner. Injectable so gc is unit-testable without a daemon. + Runner docker.Runner } // SharedInstance is a resolved shared service: its config name, the DNS alias /