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
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func NewRootCmd(opts Options) *cobra.Command {
root.AddCommand(
newUpCmd(g),
newDownCmd(g),
newStatusCmd(g),
newDoctorCmd(g),
newConfigCmd(g),
newGenerateCmd(g),
Expand Down
170 changes: 170 additions & 0 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cli

import (
"context"
"fmt"
"sort"

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/generate"
"github.com/open-source-cloud/devstack/internal/git"
"github.com/open-source-cloud/devstack/internal/state"
"github.com/open-source-cloud/devstack/internal/workspace"
)

// newStatusCmd wires `devstack status` — the spec-09 composite view: per-project
// service state + health, a brief git summary, the last saga outcome, and the
// shared-service ref graph. Read-only (lock-free) bar a best-effort reconcile.
// Per-repo git detail lives in `ws status`.
func newStatusCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Service health + last saga outcome + shared-service ref graph",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
mgr, closeFn, err := buildManager(cmd)
if err != nil {
return err
}
defer closeFn()
ctx := cmd.Context()
_, _ = mgr.Reconcile(ctx) // best-effort self-heal

projects := collectProjectStatus(ctx, mgr)
shared, err := mgr.Status()
if err != nil {
return err
}

if g.JSON {
return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared})
}
renderStatus(cmd, projects, shared)
return nil
},
}
}

type serviceStatus struct {
Name string `json:"name"`
State string `json:"state"`
Health string `json:"health,omitempty"`
}

type sagaSummary struct {
Phase string `json:"phase"`
Status string `json:"status"`
}

type projectStatus struct {
Project string `json:"project"`
Branch string `json:"branch,omitempty"`
Dirty bool `json:"dirty"`
Services []serviceStatus `json:"services"`
LastSaga *sagaSummary `json:"lastSaga,omitempty"`
}

// collectProjectStatus assembles the per-project view from live containers, the
// saga ledger, and a tolerant git summary.
func collectProjectStatus(ctx context.Context, mgr *workspace.Manager) []projectStatus {
phases, _ := mgr.DB.PhasesFor(mgr.Model.Workspace.Name)
gx, _ := git.New()

out := []projectStatus{}
for _, name := range sortedProjectNames(mgr.Model) {
ps := projectStatus{Project: name}

// Git summary (tolerant: many projects are local, non-repo paths).
if gx != nil {
if dir := mgr.Model.ProjectDir(name); dir != "" && gx.IsRepo(ctx, dir) {
if st, err := gx.Status(ctx, dir); err == nil {
ps.Branch = st.Branch
ps.Dirty = st.Dirty()
}
}
}

// Live services from tool-labelled containers (+ health via inspect).
cs, _ := mgr.Docker.ListManaged(ctx, map[string]string{
generate.LabelManaged: "true", generate.LabelProject: name,
})
for _, c := range cs {
svc := c.Labels[generate.LabelService]
if svc == "" {
svc = c.Name
}
ss := serviceStatus{Name: svc, State: c.State}
if det, err := mgr.Docker.ContainerInspect(ctx, c.ID); err == nil && det.HasHealthcheck() {
ss.Health = string(det.Health)
}
ps.Services = append(ps.Services, ss)
}
sort.Slice(ps.Services, func(i, j int) bool { return ps.Services[i].Name < ps.Services[j].Name })

ps.LastSaga = lastSaga(phases, name)
out = append(out, ps)
}
return out
}

// lastSaga summarizes a project's saga outcome: a failed phase if any, else the
// terminal compose-up status.
func lastSaga(phases []state.SagaPhase, project string) *sagaSummary {
var composeUp *sagaSummary
for _, p := range phases {
if p.Scope != project {
continue
}
if p.Status == state.PhaseFailed {
return &sagaSummary{Phase: p.Phase, Status: p.Status}
}
if p.Phase == "compose-up" {
composeUp = &sagaSummary{Phase: p.Phase, Status: p.Status}
}
}
return composeUp
}

func renderStatus(cmd *cobra.Command, projects []projectStatus, shared []workspace.SharedStatus) {
w := cmd.OutOrStdout()
fmt.Fprintln(w, "PROJECTS")
if len(projects) == 0 {
fmt.Fprintln(w, " (none)")
}
for _, p := range projects {
git := ""
if p.Branch != "" {
git = " [" + p.Branch + dirtyMark(p.Dirty) + "]"
}
saga := ""
if p.LastSaga != nil {
saga = fmt.Sprintf(" saga:%s=%s", p.LastSaga.Phase, p.LastSaga.Status)
}
fmt.Fprintf(w, " %s%s%s\n", p.Project, git, saga)
for _, s := range p.Services {
h := ""
if s.Health != "" {
h = " (" + s.Health + ")"
}
fmt.Fprintf(w, " %-16s %s%s\n", s.Name, s.State, h)
}
if len(p.Services) == 0 {
fmt.Fprintln(w, " (no running services)")
}
}
fmt.Fprintln(w, "SHARED")
if len(shared) == 0 {
fmt.Fprintln(w, " (none)")
}
for _, s := range shared {
fmt.Fprintf(w, " %-20s %-10s refs=%d %v\n", s.Alias, s.Status, s.RefCount, s.Projects)
}
}

func dirtyMark(dirty bool) string {
if dirty {
return "*"
}
return ""
}
66 changes: 66 additions & 0 deletions internal/cli/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cli

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/open-source-cloud/devstack/internal/state"
)

func TestStatusRegistered(t *testing.T) {
if !findCmd(t, "status") {
t.Error("status is not registered as a real RunE command")
}
}

func TestStatusRunsInWorkspace(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 - { name: app, path: app }\n")
mustWrite(t, filepath.Join(dir, "app", "devstack.yaml"),
"apiVersion: devstack/v1\nkind: Project\nname: app\nservices:\n web: { template: node.vite, uses: [workspace.shared.postgres] }\n")
t.Chdir(dir)
// Isolate the ledger so the test never touches the real ~/.local/share.
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{"status"})
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("status: %v\n%s", err, out.String())
}
if !strings.Contains(out.String(), "app") {
t.Errorf("status output should name project app:\n%s", out.String())
}
}

func TestLastSaga(t *testing.T) {
phases := []state.SagaPhase{
{Scope: "app", Phase: "compose-up", Status: state.PhaseSatisfied},
{Scope: "other", Phase: "compose-up", Status: state.PhaseFailed},
}
if s := lastSaga(phases, "app"); s == nil || s.Status != state.PhaseSatisfied {
t.Errorf("app lastSaga = %+v, want satisfied compose-up", s)
}
if s := lastSaga(phases, "other"); s == nil || s.Status != state.PhaseFailed {
t.Errorf("other lastSaga = %+v, want failed", s)
}
if s := lastSaga(phases, "ghost"); s != nil {
t.Errorf("unknown project lastSaga = %+v, want nil", s)
}
}

func mustWrite(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
1 change: 0 additions & 1 deletion internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ func rootName(c *cobra.Command) string { return c.Root().Name() }
// alias and version are real; everything else is a milestone-tagged placeholder.
func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
root.AddCommand(
stub("status", "Multi-repo git + service health table", "M3"),
stub("shell", "Open a shell in a service container", "M2"),
stub("logs", "Stream service logs", "M2"),
stub("secrets", "Secrets providers (login, keygen)", "M4",
Expand Down
Loading