diff --git a/internal/orchestrate/up.go b/internal/orchestrate/up.go index 23d953b..639427e 100644 --- a/internal/orchestrate/up.go +++ b/internal/orchestrate/up.go @@ -125,6 +125,17 @@ func BuildUp(d UpDeps) ([]Phase, error) { } projects = activeProjects + // Shared services to bring up. Normally only the instances the active projects + // transitively `uses` (spec 12 selective-up). But a hub / shared-only workspace + // — one that declares `shared:` services and has NO projects referencing them + // (exactly what `devstack init` scaffolds: warm pg/redis/minio for many repos) — + // must still bring up its full declared shared stack, or `up` is a silent no-op + // that starts nothing. + sharedNames := active.Shared + if len(d.Model.Projects) == 0 && len(d.Model.Workspace.Shared) > 0 { + sharedNames = sortedSharedNames(d.Model) + } + gen, err := generate.New(d.Model, d.Source, generate.WithEnv(d.Env), generate.WithProfile(d.Profile)) if err != nil { return nil, err @@ -157,7 +168,7 @@ func BuildUp(d UpDeps) ([]Phase, error) { generatePhase(d, gen), secretsPhase(d, projects, secretEnv), trustPhase(d), - sharedPhase(d, projects, active.Shared, provInstances), + sharedPhase(d, projects, sharedNames, provInstances), ) if len(targets) > 0 { phases = append(phases, provisionPhase(d, targets)) @@ -299,10 +310,19 @@ func preflightPhase(d UpDeps) Phase { // network — idempotent ensure of the pinned external bridge (must precede any // compose up). Mutating but never auto-removed (shared by other workspaces). +// +// AlwaysRun (not fingerprint-cached): the external network can disappear +// out-of-band — a `docker network prune`, a Docker Desktop/WSL restart, or +// another tool removing it — and a constant-fingerprint skip would leave `up` +// unable to recreate it, failing every compose-up with "network devstack_shared +// declared as external, but could not be found". EnsureNetwork is a cheap +// create-if-missing under the lock, so re-running every time is correct and +// self-healing. func networkPhase(d UpDeps) Phase { return Phase{ - Name: "network", - Mutating: true, + Name: "network", + Mutating: true, + AlwaysRun: true, Fingerprint: func(context.Context) (string, error) { return Fingerprint(generate.SharedNetwork), nil }, @@ -607,6 +627,15 @@ func sortedProjects(m *config.Model) []string { return out } +// sortedSharedNames returns the declared shared-service names, sorted — the full +// shared stack a hub / shared-only workspace brings up when no project selects a +// subset (see BuildUp). +func sortedSharedNames(m *config.Model) []string { + out := m.SharedNames() + sort.Strings(out) + return out +} + // configFingerprint hashes the workspace.yaml + every project's devstack.yaml so // any config edit re-arms generate. func configFingerprint(m *config.Model) (string, error) { diff --git a/internal/orchestrate/up_test.go b/internal/orchestrate/up_test.go index 751b510..afafdd8 100644 --- a/internal/orchestrate/up_test.go +++ b/internal/orchestrate/up_test.go @@ -218,7 +218,7 @@ func TestBuildUpHappyPath(t *testing.T) { } for _, r := range recs2 { switch r.Phase { - case "preflight", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases + case "preflight", "network", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases if r.Status != StatusOK { t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status) } @@ -230,6 +230,133 @@ func TestBuildUpHappyPath(t *testing.T) { } } +// hubFixture builds a shared-only (hub) workspace: declared shared services and +// NO projects — exactly what `devstack init` scaffolds. The mock client has a +// running+healthy container per shared service so the health gate passes. +func hubFixture(t *testing.T) (UpDeps, *fakeRunner, *state.DB) { + t.Helper() + root := t.TempDir() + ws := "apiVersion: devstack/v1\nkind: Workspace\nname: hub\n" + + "shared:\n minio: { template: minio }\n" + + " postgres: { template: postgres, params: { version: \"16\" } }\n" + + " redis: { template: redis }\n" + if err := os.WriteFile(filepath.Join(root, "workspace.yaml"), []byte(ws), 0o644); err != nil { + t.Fatal(err) + } + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx") + if err != nil { + t.Fatalf("state: %v", err) + } + t.Cleanup(func() { db.Close() }) + ctr := func(id, name, shared string) docker.Container { + return docker.Container{ID: id, Name: name, State: "running", + Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: shared}} + } + mc := &docker.MockClient{ + Containers: []docker.Container{ + ctr("mn1", "devstack-shared-minio-1", "minio"), + ctr("pg1", "devstack-shared-postgres-1", "postgres"), + ctr("rd1", "devstack-shared-redis-1", "redis"), + }, + Details: map[string]docker.ContainerDetails{ + "mn1": {ID: "mn1", State: "running", Running: true, Health: docker.HealthHealthy}, + "pg1": {ID: "pg1", State: "running", Running: true, Health: docker.HealthHealthy}, + "rd1": {ID: "rd1", State: "running", Running: true, Health: docker.HealthHealthy}, + }, + } + src := template.NewFSSource(templates.FS) + lockPath := filepath.Join(root, "lock") + mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath} + fr := &fakeRunner{} + d := UpDeps{ + Model: m, DB: db, Docker: mc, Manager: mgr, Source: src, + LockPath: lockPath, Runner: fr, Env: map[string]string{}, PgConnect: okPgConnect, + } + return d, fr, db +} + +// A hub / shared-only workspace (no projects) must bring up its full declared +// shared stack — otherwise `up` is a silent no-op that starts nothing. This is +// the live regression: `devstack init` scaffolds shared pg/redis/minio with no +// projects, and selective-up derived the shared set only from projects' `uses`. +func TestBuildUpHubWorkspaceBringsUpAllShared(t *testing.T) { + d, fr, db := hubFixture(t) + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + // No project ⇒ no compose-up phases, but the shared phase must be present. + sawShared := false + for _, p := range phases { + if p.Name == "compose-up" { + t.Errorf("unexpected compose-up phase in a project-less workspace") + } + if p.Name == "shared" { + sawShared = true + } + } + if !sawShared { + t.Fatal("no shared phase built for a hub workspace") + } + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + recs, err := saga.Run(context.Background(), phases) + if err != nil { + t.Fatalf("saga: %v\n%+v", err, recs) + } + if AnyFailed(recs) { + t.Fatalf("a phase failed: %+v", recs) + } + // The shared stack was up'd with ALL three declared services. + svcs := fr.upServices(generate.SharedStackName) + for _, want := range []string{"minio", "postgres", "redis"} { + if !slices.Contains(svcs, want) { + t.Errorf("shared up did not include %q (got %v)", want, svcs) + } + } + // The shared network was ensured. + if ok, _ := d.Docker.(*docker.MockClient).NetworkExists(context.Background(), generate.SharedNetwork); !ok { + t.Error("shared network was not ensured") + } +} + +// The network phase is AlwaysRun: if the external network vanishes out-of-band +// (a `docker network prune`, a Docker Desktop / WSL restart), a later `up` must +// re-create it rather than skip on a stale fingerprint and fail every compose-up +// with "network devstack_shared declared as external, but could not be found". +func TestNetworkPhaseSelfHeals(t *testing.T) { + d, _, db := hubFixture(t) + mc := d.Docker.(*docker.MockClient) + phases, err := BuildUp(d) + if err != nil { + t.Fatalf("BuildUp: %v", err) + } + saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath} + if _, err := saga.Run(context.Background(), phases); err != nil { + t.Fatalf("first up: %v", err) + } + if ok, _ := mc.NetworkExists(context.Background(), generate.SharedNetwork); !ok { + t.Fatal("network not ensured on first up") + } + // Simulate out-of-band removal, then re-run: the network must come back. + delete(mc.Networks, generate.SharedNetwork) + recs, err := saga.Run(context.Background(), phases) + if err != nil { + t.Fatalf("second up: %v", err) + } + for _, r := range recs { + if r.Phase == "network" && r.Status != StatusOK { + t.Errorf("network re-run status = %q, want ok (AlwaysRun self-heal)", r.Status) + } + } + if ok, _ := mc.NetworkExists(context.Background(), generate.SharedNetwork); !ok { + t.Error("network was not re-ensured after out-of-band removal") + } +} + func TestBuildUpCompensatesOnProjectFailure(t *testing.T) { d, fr, db := upFixture(t) // Fail the PROJECT compose up (not the shared one).