Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions internal/cli/gc_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
75 changes: 73 additions & 2 deletions internal/cli/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"

"github.com/spf13/cobra"

Expand All @@ -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",
Expand Down
172 changes: 172 additions & 0 deletions internal/workspace/gc.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading