Skip to content

Commit f5e8f8e

Browse files
gustavobertoiclaude
andcommitted
feat(workspace,cli): C8 — shared gc + doctor --rebuild-state (spec 09/13)
Complete M2-remainder with the two ledger-maintenance verbs. - Manager.RebuildState: reconstruct the shared_service + service_ref rows from on-disk config intersected with live tool-labelled containers — the recovery path when state.db is lost/corrupt (the ledger is a cache of reality). Under the flock. - Manager.GC: reconcile, then find shared services at zero refs; with stop=true `compose stop` each on the shared stack (label-driven when the generated file is absent) and mark it stopped. Default is a dry-run report — warm DBs are cheap, reclamation is opt-in. Volumes/network never touched. A new injectable Manager.Runner makes the stop path unit-testable. - CLI: `shared gc [--stop]`, `shared doctor` (the self-healing reconcile), and `doctor --rebuild-state` replace their stubs. Unit-tested with a mock client + fake runner + temp ledger: rebuild re-derives exactly the live shared services + refs (excluding the not-live minio), and gc lists then stops a zero-ref service (dry-run runs no compose). CLI tests cover registration, the gc dry-run (daemon-free), and the --rebuild-state flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92769be commit f5e8f8e

6 files changed

Lines changed: 435 additions & 4 deletions

File tree

‎internal/cli/doctor.go‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@ import (
1313
)
1414

1515
func newDoctorCmd(g *GlobalOpts) *cobra.Command {
16-
var fix bool
16+
var (
17+
fix bool
18+
rebuildState bool
19+
)
1720
cmd := &cobra.Command{
1821
Use: "doctor",
1922
Short: "Probe the environment and report capabilities with remediations",
2023
Long: "doctor runs the REAL branch logic (not docs) for the tools and paths devstack\n" +
21-
"depends on, and prints a one-line remediation for anything that isn't OK.",
24+
"depends on, and prints a one-line remediation for anything that isn't OK.\n\n" +
25+
"With --rebuild-state, the shared_service + ref ledger is reconstructed from\n" +
26+
"on-disk config intersected with live container labels (recovery when state.db\n" +
27+
"is lost or corrupt — the ledger is a cache of reality).",
2228
Args: cobra.NoArgs,
2329
RunE: func(cmd *cobra.Command, _ []string) error {
30+
if rebuildState {
31+
return rebuildLedger(cmd, g)
32+
}
2433
checks := runDoctor(cmd)
2534
if g.JSON {
2635
return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"checks": checks})
@@ -38,9 +47,31 @@ func newDoctorCmd(g *GlobalOpts) *cobra.Command {
3847
},
3948
}
4049
cmd.Flags().BoolVar(&fix, "fix", false, "apply safe automatic remediations (M6)")
50+
cmd.Flags().BoolVar(&rebuildState, "rebuild-state", false, "reconstruct the ledger from config + live container labels")
4151
return cmd
4252
}
4353

54+
// rebuildLedger reconstructs the shared_service + ref ledger from on-disk config
55+
// intersected with live container labels (spec 09 §crash-recovery).
56+
func rebuildLedger(cmd *cobra.Command, g *GlobalOpts) error {
57+
mgr, closeFn, err := buildManager(cmd)
58+
if err != nil {
59+
return err
60+
}
61+
defer closeFn()
62+
sum, err := mgr.RebuildState(cmd.Context())
63+
if err != nil {
64+
return err
65+
}
66+
if g.JSON {
67+
return writeJSON(cmd, sum)
68+
}
69+
fmt.Fprintf(cmd.OutOrStdout(),
70+
"rebuilt ledger from live labels: %d shared service(s), %d ref row(s)\n",
71+
len(sum.Shared), sum.Refs)
72+
return nil
73+
}
74+
4475
// runDoctor assembles the full capability matrix. Each probe is independent so a
4576
// single failure never hides the others.
4677
func runDoctor(cmd *cobra.Command) []docker.Check {

‎internal/cli/gc_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cli
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestSharedGcRegistered(t *testing.T) {
10+
root := NewRootCmd(Options{})
11+
c, _, err := root.Find([]string{"shared", "gc"})
12+
if err != nil || c.Name() != "gc" || c.RunE == nil {
13+
t.Fatalf("shared gc not registered as a real command: %v", err)
14+
}
15+
}
16+
17+
func TestSharedGcDryRun(t *testing.T) {
18+
dir := t.TempDir()
19+
mustWrite(t, filepath.Join(dir, "workspace.yaml"),
20+
"apiVersion: devstack/v1\nkind: Workspace\nname: demo\nshared:\n postgres: { template: postgres }\nprojects: []\n")
21+
t.Chdir(dir)
22+
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
23+
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(dir, "run"))
24+
25+
var out strings.Builder
26+
root := NewRootCmd(Options{})
27+
root.SetArgs([]string{"shared", "gc"})
28+
root.SetOut(&out)
29+
root.SetErr(&out)
30+
if err := root.Execute(); err != nil {
31+
t.Fatalf("shared gc (dry-run): %v\n%s", err, out.String())
32+
}
33+
if !strings.Contains(out.String(), "no shared services at zero references") {
34+
t.Errorf("unexpected gc output:\n%s", out.String())
35+
}
36+
}
37+
38+
func TestDoctorRebuildStateFlag(t *testing.T) {
39+
root := NewRootCmd(Options{})
40+
c, _, err := root.Find([]string{"doctor"})
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if c.Flags().Lookup("rebuild-state") == nil {
45+
t.Error("doctor is missing the --rebuild-state flag")
46+
}
47+
}

‎internal/cli/shared.go‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"slices"
78

89
"github.com/spf13/cobra"
910

@@ -24,12 +25,82 @@ func newSharedCmd(g *GlobalOpts) *cobra.Command {
2425
}
2526
cmd.AddCommand(
2627
newSharedStatusCmd(g),
27-
stub("gc", "Reclaim unused shared services", "M2 (up saga)"),
28-
stub("doctor", "Reconcile the ledger against live containers", "M2 (up saga)"),
28+
newSharedGcCmd(g),
29+
newSharedDoctorCmd(g),
2930
)
3031
return cmd
3132
}
3233

34+
// newSharedGcCmd wires `shared gc [--stop]` — find shared services at zero refs
35+
// and (with --stop) stop them. Default is a dry-run report: warm DBs are cheap,
36+
// so reclamation is opt-in (spec 03/09). Volumes are never touched.
37+
func newSharedGcCmd(g *GlobalOpts) *cobra.Command {
38+
var stop bool
39+
cmd := &cobra.Command{
40+
Use: "gc",
41+
Short: "Report (or with --stop, stop) shared services at zero references",
42+
Args: cobra.NoArgs,
43+
RunE: func(cmd *cobra.Command, _ []string) error {
44+
mgr, closeFn, err := buildManager(cmd)
45+
if err != nil {
46+
return err
47+
}
48+
defer closeFn()
49+
res, err := mgr.GC(cmd.Context(), stop)
50+
if err != nil {
51+
return err
52+
}
53+
if g.JSON {
54+
return writeJSON(cmd, res)
55+
}
56+
w := cmd.OutOrStdout()
57+
if len(res.Candidates) == 0 {
58+
fmt.Fprintln(w, "no shared services at zero references")
59+
return nil
60+
}
61+
for _, c := range res.Candidates {
62+
if slices.Contains(res.Stopped, c) {
63+
fmt.Fprintf(w, "stopped %s\n", c)
64+
} else if stop {
65+
fmt.Fprintf(w, "%s (zero refs, left running)\n", c)
66+
} else {
67+
fmt.Fprintf(w, "%s (zero refs — run `shared gc --stop` to stop)\n", c)
68+
}
69+
}
70+
return nil
71+
},
72+
}
73+
cmd.Flags().BoolVar(&stop, "stop", false, "actually stop the zero-ref services (default: report only)")
74+
return cmd
75+
}
76+
77+
// newSharedDoctorCmd wires `shared doctor` — the self-healing reconcile: prune
78+
// ref rows for projects no longer live (the count is derived from reality).
79+
func newSharedDoctorCmd(g *GlobalOpts) *cobra.Command {
80+
return &cobra.Command{
81+
Use: "doctor",
82+
Short: "Reconcile the ledger against live containers (prune dead refs)",
83+
Args: cobra.NoArgs,
84+
RunE: func(cmd *cobra.Command, _ []string) error {
85+
mgr, closeFn, err := buildManager(cmd)
86+
if err != nil {
87+
return err
88+
}
89+
defer closeFn()
90+
pruned, err := mgr.Reconcile(cmd.Context())
91+
if err != nil {
92+
return err
93+
}
94+
if g.JSON {
95+
return writeJSON(cmd, map[string]any{"pruned": pruned})
96+
}
97+
w := cmd.OutOrStdout()
98+
fmt.Fprintf(w, "reconciled: pruned %d stale ref row(s)\n", len(pruned))
99+
return nil
100+
},
101+
}
102+
}
103+
33104
func newSharedStatusCmd(g *GlobalOpts) *cobra.Command {
34105
return &cobra.Command{
35106
Use: "status",

‎internal/workspace/gc.go‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package workspace
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/open-source-cloud/devstack/internal/docker"
10+
"github.com/open-source-cloud/devstack/internal/generate"
11+
"github.com/open-source-cloud/devstack/internal/lock"
12+
"github.com/open-source-cloud/devstack/internal/state"
13+
)
14+
15+
// This file implements `shared gc` (reclaim zero-ref shared services) and
16+
// `doctor --rebuild-state` (reconstruct the ledger from on-disk config + live
17+
// container labels). The ledger is a cache of reality, never the trusted source
18+
// (spec 09 §crash-recovery, spec 13).
19+
20+
// RebuildSummary reports what RebuildState reconstructed.
21+
type RebuildSummary struct {
22+
Shared []string `json:"shared"` // shared_service rows re-derived (aliases)
23+
Refs int `json:"refs"` // service_ref rows re-derived
24+
}
25+
26+
// RebuildState reconstructs the shared_service + service_ref ledger rows from the
27+
// on-disk workspace config intersected with live tool-labelled containers — the
28+
// recovery path when state.db is lost or corrupt. Acquires the lock.
29+
func (m *Manager) RebuildState(ctx context.Context) (RebuildSummary, error) {
30+
cs, err := m.Docker.ListManaged(ctx, map[string]string{generate.LabelManaged: "true"})
31+
if err != nil {
32+
return RebuildSummary{}, fmt.Errorf("rebuild-state: list containers: %w", err)
33+
}
34+
liveProjects := map[string]bool{}
35+
liveShared := map[string]bool{}
36+
for _, c := range cs {
37+
if !c.Running() {
38+
continue
39+
}
40+
if p := c.Labels[generate.LabelProject]; p != "" {
41+
liveProjects[p] = true
42+
}
43+
if s := c.Labels[generate.LabelShared]; s != "" {
44+
liveShared[s] = true
45+
}
46+
}
47+
48+
instances, err := m.SharedInstances()
49+
if err != nil {
50+
return RebuildSummary{}, err
51+
}
52+
53+
var sum RebuildSummary
54+
err = lock.WithLock(ctx, m.LockPath, func() error {
55+
for _, name := range sortedKeys(instances) {
56+
if !liveShared[name] {
57+
continue
58+
}
59+
inst := instances[name]
60+
if err := m.DB.UpsertSharedService(state.SharedService{
61+
Name: inst.Alias, Engine: inst.Engine, MajorVersion: inst.Major, Status: "running",
62+
}); err != nil {
63+
return err
64+
}
65+
sum.Shared = append(sum.Shared, inst.Alias)
66+
}
67+
for _, project := range sortedKeys(liveProjects) {
68+
for _, inst := range m.instancesUsedBy(project, instances) {
69+
if !liveShared[inst.Name] {
70+
continue
71+
}
72+
for _, c := range m.consumersOf(inst.Name) {
73+
if c.project != project {
74+
continue
75+
}
76+
if err := m.DB.AddRef(c.project, c.service, inst.Alias); err != nil {
77+
return err
78+
}
79+
sum.Refs++
80+
}
81+
}
82+
}
83+
return nil
84+
})
85+
if err == nil {
86+
m.DB.LogEvent("rebuild-state", m.Model.Workspace.Name,
87+
fmt.Sprintf("re-derived %d shared + %d refs from live labels", len(sum.Shared), sum.Refs))
88+
}
89+
return sum, err
90+
}
91+
92+
// GCResult reports the zero-ref shared services and which were stopped.
93+
type GCResult struct {
94+
Candidates []string `json:"candidates"` // zero-ref shared aliases
95+
Stopped []string `json:"stopped"` // actually stopped (when stop=true)
96+
}
97+
98+
// GC reconciles the ledger, then finds shared services at zero refs. With
99+
// stop=false it only reports candidates (the safe default — warm DBs are cheap).
100+
// With stop=true it `compose stop`s each candidate on the shared stack and marks
101+
// it stopped. The external network and volumes are never touched (spec 03/09).
102+
func (m *Manager) GC(ctx context.Context, stop bool) (GCResult, error) {
103+
if _, err := m.Reconcile(ctx); err != nil {
104+
// Reconcile is best-effort; a down daemon shouldn't block a dry-run report.
105+
if stop {
106+
return GCResult{}, err
107+
}
108+
}
109+
shared, err := m.DB.ListSharedServices()
110+
if err != nil {
111+
return GCResult{}, err
112+
}
113+
instances, err := m.SharedInstances()
114+
if err != nil {
115+
return GCResult{}, err
116+
}
117+
aliasToName := map[string]string{}
118+
for name, inst := range instances {
119+
aliasToName[inst.Alias] = name
120+
}
121+
122+
var res GCResult
123+
var zero []state.SharedService
124+
for _, s := range shared {
125+
n, err := m.DB.RefCount(s.Name)
126+
if err != nil {
127+
return GCResult{}, err
128+
}
129+
if n == 0 {
130+
res.Candidates = append(res.Candidates, s.Name)
131+
zero = append(zero, s)
132+
}
133+
}
134+
if !stop || len(zero) == 0 {
135+
return res, nil
136+
}
137+
138+
cp := m.sharedCompose()
139+
for _, s := range zero {
140+
name := aliasToName[s.Name]
141+
if name == "" {
142+
continue // ledger alias has no current config service; skip (orphan)
143+
}
144+
if err := cp.Stop(ctx, name); err != nil {
145+
return res, fmt.Errorf("stop shared %s: %w", s.Name, err)
146+
}
147+
if err := lock.WithLock(ctx, m.LockPath, func() error {
148+
return m.DB.SetSharedStatus(s.Engine, s.MajorVersion, "stopped")
149+
}); err != nil {
150+
return res, err
151+
}
152+
m.DB.LogEvent("shared-gc", s.Name, "stopped (0 refs)")
153+
res.Stopped = append(res.Stopped, s.Name)
154+
}
155+
return res, nil
156+
}
157+
158+
// sharedCompose builds the compose driver for the shared stack. The compose file
159+
// is used when present; otherwise the label-driven `-p devstack-shared` form
160+
// drives stop/down (so gc works even if generated artifacts were cleaned).
161+
func (m *Manager) sharedCompose() docker.Compose {
162+
outDir := filepath.Join(m.Model.Root, generate.GenDir, "shared")
163+
file := filepath.Join(outDir, generate.ComposeFile)
164+
if _, err := os.Stat(file); err != nil {
165+
file = ""
166+
}
167+
runner := m.Runner
168+
if runner == nil {
169+
runner = docker.ExecRunner{}
170+
}
171+
return docker.Compose{Project: generate.SharedStackName, File: file, Dir: outDir, Runner: runner}
172+
}

0 commit comments

Comments
 (0)