Skip to content

Commit bb77e22

Browse files
fix(up): start shared-only (hub) workspaces and self-heal the network (#91)
Two drift bugs made `devstack up` a silent no-op that started nothing — the exact symptom hit on a `devstack init` starter workspace (shared pg/redis/minio, no projects): 1. Selective-up derived the shared set only from projects' `uses` (profile.sharedUsedBy). A hub / shared-only workspace has no project referencing its shared services, so `active.Shared` was empty and the shared phase brought up nothing. Now a project-less workspace that declares `shared:` brings up its full declared shared stack. 2. The network phase had a constant fingerprint, so once satisfied it was skipped forever — even after the external `devstack_shared` network was removed out-of-band (docker network prune, a Docker Desktop / WSL restart). Every subsequent compose-up then failed with "network devstack_shared declared as external, but could not be found". Marked the phase AlwaysRun: EnsureNetwork is a cheap create-if-missing under the lock, so re-running every time is correct and self-healing. Tests: a hub workspace brings up all declared shared services (and no compose-up phase); the network re-ensures after out-of-band removal; the happy-path re-run now expects network to re-run (AlwaysRun) rather than skip. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd90f84 commit bb77e22

2 files changed

Lines changed: 160 additions & 4 deletions

File tree

internal/orchestrate/up.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ func BuildUp(d UpDeps) ([]Phase, error) {
125125
}
126126
projects = activeProjects
127127

128+
// Shared services to bring up. Normally only the instances the active projects
129+
// transitively `uses` (spec 12 selective-up). But a hub / shared-only workspace
130+
// — one that declares `shared:` services and has NO projects referencing them
131+
// (exactly what `devstack init` scaffolds: warm pg/redis/minio for many repos) —
132+
// must still bring up its full declared shared stack, or `up` is a silent no-op
133+
// that starts nothing.
134+
sharedNames := active.Shared
135+
if len(d.Model.Projects) == 0 && len(d.Model.Workspace.Shared) > 0 {
136+
sharedNames = sortedSharedNames(d.Model)
137+
}
138+
128139
gen, err := generate.New(d.Model, d.Source, generate.WithEnv(d.Env), generate.WithProfile(d.Profile))
129140
if err != nil {
130141
return nil, err
@@ -157,7 +168,7 @@ func BuildUp(d UpDeps) ([]Phase, error) {
157168
generatePhase(d, gen),
158169
secretsPhase(d, projects, secretEnv),
159170
trustPhase(d),
160-
sharedPhase(d, projects, active.Shared, provInstances),
171+
sharedPhase(d, projects, sharedNames, provInstances),
161172
)
162173
if len(targets) > 0 {
163174
phases = append(phases, provisionPhase(d, targets))
@@ -299,10 +310,19 @@ func preflightPhase(d UpDeps) Phase {
299310

300311
// network — idempotent ensure of the pinned external bridge (must precede any
301312
// compose up). Mutating but never auto-removed (shared by other workspaces).
313+
//
314+
// AlwaysRun (not fingerprint-cached): the external network can disappear
315+
// out-of-band — a `docker network prune`, a Docker Desktop/WSL restart, or
316+
// another tool removing it — and a constant-fingerprint skip would leave `up`
317+
// unable to recreate it, failing every compose-up with "network devstack_shared
318+
// declared as external, but could not be found". EnsureNetwork is a cheap
319+
// create-if-missing under the lock, so re-running every time is correct and
320+
// self-healing.
302321
func networkPhase(d UpDeps) Phase {
303322
return Phase{
304-
Name: "network",
305-
Mutating: true,
323+
Name: "network",
324+
Mutating: true,
325+
AlwaysRun: true,
306326
Fingerprint: func(context.Context) (string, error) {
307327
return Fingerprint(generate.SharedNetwork), nil
308328
},
@@ -607,6 +627,15 @@ func sortedProjects(m *config.Model) []string {
607627
return out
608628
}
609629

630+
// sortedSharedNames returns the declared shared-service names, sorted — the full
631+
// shared stack a hub / shared-only workspace brings up when no project selects a
632+
// subset (see BuildUp).
633+
func sortedSharedNames(m *config.Model) []string {
634+
out := m.SharedNames()
635+
sort.Strings(out)
636+
return out
637+
}
638+
610639
// configFingerprint hashes the workspace.yaml + every project's devstack.yaml so
611640
// any config edit re-arms generate.
612641
func configFingerprint(m *config.Model) (string, error) {

internal/orchestrate/up_test.go

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func TestBuildUpHappyPath(t *testing.T) {
218218
}
219219
for _, r := range recs2 {
220220
switch r.Phase {
221-
case "preflight", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases
221+
case "preflight", "network", "secrets", "trust", "preUp", "postUp": // AlwaysRun phases
222222
if r.Status != StatusOK {
223223
t.Errorf("%s should re-run ok, got %q", r.Phase, r.Status)
224224
}
@@ -230,6 +230,133 @@ func TestBuildUpHappyPath(t *testing.T) {
230230
}
231231
}
232232

233+
// hubFixture builds a shared-only (hub) workspace: declared shared services and
234+
// NO projects — exactly what `devstack init` scaffolds. The mock client has a
235+
// running+healthy container per shared service so the health gate passes.
236+
func hubFixture(t *testing.T) (UpDeps, *fakeRunner, *state.DB) {
237+
t.Helper()
238+
root := t.TempDir()
239+
ws := "apiVersion: devstack/v1\nkind: Workspace\nname: hub\n" +
240+
"shared:\n minio: { template: minio }\n" +
241+
" postgres: { template: postgres, params: { version: \"16\" } }\n" +
242+
" redis: { template: redis }\n"
243+
if err := os.WriteFile(filepath.Join(root, "workspace.yaml"), []byte(ws), 0o644); err != nil {
244+
t.Fatal(err)
245+
}
246+
m, err := config.LoadAt(root)
247+
if err != nil {
248+
t.Fatalf("load: %v", err)
249+
}
250+
db, err := state.Open(context.Background(), filepath.Join(root, "state"), "ctx")
251+
if err != nil {
252+
t.Fatalf("state: %v", err)
253+
}
254+
t.Cleanup(func() { db.Close() })
255+
ctr := func(id, name, shared string) docker.Container {
256+
return docker.Container{ID: id, Name: name, State: "running",
257+
Labels: map[string]string{generate.LabelManaged: "true", generate.LabelShared: shared}}
258+
}
259+
mc := &docker.MockClient{
260+
Containers: []docker.Container{
261+
ctr("mn1", "devstack-shared-minio-1", "minio"),
262+
ctr("pg1", "devstack-shared-postgres-1", "postgres"),
263+
ctr("rd1", "devstack-shared-redis-1", "redis"),
264+
},
265+
Details: map[string]docker.ContainerDetails{
266+
"mn1": {ID: "mn1", State: "running", Running: true, Health: docker.HealthHealthy},
267+
"pg1": {ID: "pg1", State: "running", Running: true, Health: docker.HealthHealthy},
268+
"rd1": {ID: "rd1", State: "running", Running: true, Health: docker.HealthHealthy},
269+
},
270+
}
271+
src := template.NewFSSource(templates.FS)
272+
lockPath := filepath.Join(root, "lock")
273+
mgr := &workspace.Manager{Model: m, DB: db, Docker: mc, Source: src, LockPath: lockPath}
274+
fr := &fakeRunner{}
275+
d := UpDeps{
276+
Model: m, DB: db, Docker: mc, Manager: mgr, Source: src,
277+
LockPath: lockPath, Runner: fr, Env: map[string]string{}, PgConnect: okPgConnect,
278+
}
279+
return d, fr, db
280+
}
281+
282+
// A hub / shared-only workspace (no projects) must bring up its full declared
283+
// shared stack — otherwise `up` is a silent no-op that starts nothing. This is
284+
// the live regression: `devstack init` scaffolds shared pg/redis/minio with no
285+
// projects, and selective-up derived the shared set only from projects' `uses`.
286+
func TestBuildUpHubWorkspaceBringsUpAllShared(t *testing.T) {
287+
d, fr, db := hubFixture(t)
288+
phases, err := BuildUp(d)
289+
if err != nil {
290+
t.Fatalf("BuildUp: %v", err)
291+
}
292+
// No project ⇒ no compose-up phases, but the shared phase must be present.
293+
sawShared := false
294+
for _, p := range phases {
295+
if p.Name == "compose-up" {
296+
t.Errorf("unexpected compose-up phase in a project-less workspace")
297+
}
298+
if p.Name == "shared" {
299+
sawShared = true
300+
}
301+
}
302+
if !sawShared {
303+
t.Fatal("no shared phase built for a hub workspace")
304+
}
305+
saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}
306+
recs, err := saga.Run(context.Background(), phases)
307+
if err != nil {
308+
t.Fatalf("saga: %v\n%+v", err, recs)
309+
}
310+
if AnyFailed(recs) {
311+
t.Fatalf("a phase failed: %+v", recs)
312+
}
313+
// The shared stack was up'd with ALL three declared services.
314+
svcs := fr.upServices(generate.SharedStackName)
315+
for _, want := range []string{"minio", "postgres", "redis"} {
316+
if !slices.Contains(svcs, want) {
317+
t.Errorf("shared up did not include %q (got %v)", want, svcs)
318+
}
319+
}
320+
// The shared network was ensured.
321+
if ok, _ := d.Docker.(*docker.MockClient).NetworkExists(context.Background(), generate.SharedNetwork); !ok {
322+
t.Error("shared network was not ensured")
323+
}
324+
}
325+
326+
// The network phase is AlwaysRun: if the external network vanishes out-of-band
327+
// (a `docker network prune`, a Docker Desktop / WSL restart), a later `up` must
328+
// re-create it rather than skip on a stale fingerprint and fail every compose-up
329+
// with "network devstack_shared declared as external, but could not be found".
330+
func TestNetworkPhaseSelfHeals(t *testing.T) {
331+
d, _, db := hubFixture(t)
332+
mc := d.Docker.(*docker.MockClient)
333+
phases, err := BuildUp(d)
334+
if err != nil {
335+
t.Fatalf("BuildUp: %v", err)
336+
}
337+
saga := &Saga{Workspace: d.Model.Workspace.Name, DB: db, LockPath: d.LockPath}
338+
if _, err := saga.Run(context.Background(), phases); err != nil {
339+
t.Fatalf("first up: %v", err)
340+
}
341+
if ok, _ := mc.NetworkExists(context.Background(), generate.SharedNetwork); !ok {
342+
t.Fatal("network not ensured on first up")
343+
}
344+
// Simulate out-of-band removal, then re-run: the network must come back.
345+
delete(mc.Networks, generate.SharedNetwork)
346+
recs, err := saga.Run(context.Background(), phases)
347+
if err != nil {
348+
t.Fatalf("second up: %v", err)
349+
}
350+
for _, r := range recs {
351+
if r.Phase == "network" && r.Status != StatusOK {
352+
t.Errorf("network re-run status = %q, want ok (AlwaysRun self-heal)", r.Status)
353+
}
354+
}
355+
if ok, _ := mc.NetworkExists(context.Background(), generate.SharedNetwork); !ok {
356+
t.Error("network was not re-ensured after out-of-band removal")
357+
}
358+
}
359+
233360
func TestBuildUpCompensatesOnProjectFailure(t *testing.T) {
234361
d, fr, db := upFixture(t)
235362
// Fail the PROJECT compose up (not the shared one).

0 commit comments

Comments
 (0)