Skip to content

Commit b215341

Browse files
Merge pull request #123 from open-source-cloud/feat/standard-db-ports-and-engines
feat: standard DB host ports, expose-by-default, unified provisioning + up/down fixes
2 parents 0e38a44 + b2d2870 commit b215341

22 files changed

Lines changed: 480 additions & 201 deletions

‎internal/cli/template_new_test.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,15 @@ func TestTemplateNewEngineHasNoBuildTree(t *testing.T) {
123123
t.Setenv("DEVSTACK_HOME", t.TempDir())
124124
dir := t.TempDir()
125125
if out, err := runCmd(t, "template", "new", "--no-input", "--dir", dir,
126-
"--kind", "engine", "--name", "mariadb", "--base-image", "mariadb:11",
127-
"--provides", "mariadb", "--exports", "host,port,user", "--port", "3306"); err != nil {
126+
"--kind", "engine", "--name", "couchdb", "--base-image", "couchdb:3",
127+
"--provides", "couchdb", "--exports", "host,port,user", "--port", "5984"); err != nil {
128128
t.Fatalf("author engine: %v\n%s", err, out)
129129
}
130-
if _, err := os.Stat(filepath.Join(dir, "mariadb", "build")); !os.IsNotExist(err) {
130+
if _, err := os.Stat(filepath.Join(dir, "couchdb", "build")); !os.IsNotExist(err) {
131131
t.Errorf("engine template must have no build/ tree, stat err = %v", err)
132132
}
133-
manifest, _ := os.ReadFile(filepath.Join(dir, "mariadb", "template.yaml"))
134-
for _, want := range []string{"image: mariadb:11", "provides: mariadb"} {
133+
manifest, _ := os.ReadFile(filepath.Join(dir, "couchdb", "template.yaml"))
134+
for _, want := range []string{"image: couchdb:3", "provides: couchdb"} {
135135
if !strings.Contains(string(manifest), want) {
136136
t.Errorf("engine template.yaml missing %q:\n%s", want, manifest)
137137
}

‎internal/cli/up.go‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
3333
noHooks bool
3434
noPreflight bool
3535
noProvision bool
36+
noExpose bool
3637
profiles []string
3738
healthTimeout time.Duration
3839
)
@@ -56,6 +57,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
5657
d.NoHooks = noHooks
5758
d.NoPreflight = noPreflight
5859
d.NoProvision = noProvision
60+
d.NoExpose = noExpose
5961
d.Profiles = profiles
6062
d.HealthTimeout = healthTimeout
6163

@@ -112,6 +114,7 @@ func newUpCmd(g *GlobalOpts) *cobra.Command {
112114
cmd.Flags().BoolVar(&noHooks, "no-hooks", false, "skip lifecycle hooks")
113115
cmd.Flags().BoolVar(&noPreflight, "no-preflight", false, "skip the preflight checks")
114116
cmd.Flags().BoolVar(&noProvision, "no-provision", false, "skip per-project Postgres role/db provisioning")
117+
cmd.Flags().BoolVar(&noExpose, "no-expose", false, "do not auto-publish shared engines on their standard 127.0.0.1 ports")
115118
cmd.Flags().StringArrayVarP(&profiles, "profile", "p", nil,
116119
"service slice(s) to start — repeatable & comma-separated (spec 12); empty → defaultProfile or all")
117120
return cmd
@@ -174,8 +177,39 @@ func newDownCmd(g *GlobalOpts) *cobra.Command {
174177
fmt.Fprintf(w, "[ok] down %s\n", p)
175178
}
176179
}
180+
// Tear down anything now left running: after the project refs are dropped,
181+
// stop every shared service that fell to zero refs (`shared gc --stop`).
182+
// This is what makes `down` actually bring the workspace DOWN instead of
183+
// leaving warm engines behind, while still respecting cross-workspace
184+
// sharing — an engine another workspace still references keeps running.
185+
var stopped []string
186+
if gc, err := d.Manager.GC(ctx, true); err != nil {
187+
if firstErr == nil {
188+
firstErr = err
189+
}
190+
} else {
191+
stopped = gc.Stopped
192+
// When shared services were actually stopped, their saga phases are no
193+
// longer satisfied — clear them so the next `up` restarts the shared
194+
// stack (and re-publishes its ports) instead of skipping.
195+
if len(stopped) > 0 {
196+
_ = lock.WithLock(ctx, d.LockPath, func() error {
197+
for _, ph := range []string{"shared", "provision", "resources"} {
198+
if e := d.DB.ClearPhase(d.Model.Workspace.Name, "", ph); e != nil {
199+
return e
200+
}
201+
}
202+
return nil
203+
})
204+
}
205+
if !g.JSON && !g.Quiet {
206+
for _, s := range stopped {
207+
fmt.Fprintf(w, "[ok] stopped shared %s (0 refs)\n", s)
208+
}
209+
}
210+
}
177211
if g.JSON {
178-
if err := writeJSON(cmd, map[string]any{"down": results}); err != nil {
212+
if err := writeJSON(cmd, map[string]any{"down": results, "sharedStopped": stopped}); err != nil {
179213
return err
180214
}
181215
}
@@ -216,7 +250,13 @@ func downProject(ctx context.Context, d orchestrate.UpDeps, project string) erro
216250
if _, err := d.Manager.RegisterDown(ctx, project); err != nil {
217251
return err
218252
}
219-
return nil
253+
// The compose-up phase is no longer satisfied — its containers were just
254+
// removed. Clear the saga record so the NEXT `up` re-runs it instead of
255+
// skipping on a stale fingerprint (the "re-up after down is a no-op" bug).
256+
// firstRun/hooks are intentionally NOT cleared (they keep run-once semantics).
257+
return lock.WithLock(ctx, d.LockPath, func() error {
258+
return d.DB.ClearPhase(d.Model.Workspace.Name, project, "compose-up")
259+
})
220260
}
221261

222262
// buildUpDeps assembles the up/down dependencies from the current directory. It

‎internal/migrate/migrate.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const APIVersion = "devstack/v1"
3030
// knownEngines maps a devdock template/image keyword to a devstack shared engine.
3131
var knownEngines = map[string]string{
3232
"postgres": "postgres", "postgresql": "postgres", "postgis": "postgres",
33+
"mysql": "mysql", "percona": "mysql",
34+
"mariadb": "mariadb",
35+
"mongo": "mongodb", "mongodb": "mongodb",
36+
"cassandra": "cassandra",
37+
"arango": "arangodb", "arangodb": "arangodb",
3338
"redis": "redis", "valkey": "redis",
3439
"minio": "minio",
3540
}

‎internal/orchestrate/expose.go‎

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ import (
1919
// is an explicit opt-in that, like provisioning, is an UP-TIME compose overlay —
2020
// it never touches the deterministic, golden-asserted generated compose.
2121
//
22-
// Exposure uses its OWN host-port range (55xxx/58xxx…), distinct from the
23-
// provisioning range (45xxx), so the expose overlay and the provision overlay
24-
// never publish the same host port and can both be applied without a duplicate
25-
// binding. Ports are ledger-allocated (FreeHostPort), so the same engine keeps
26-
// the same host port across runs and two terminals never collide.
22+
// Exposure publishes each engine on its OWN WELL-KNOWN host port — the same port
23+
// the template advertises in-network (postgres→5432, mysql→3306, redis→6379, …) —
24+
// so a GUI client's default connection settings just work and there is no gap
25+
// between what the template's `defaultPort` says and what the host sees. That
26+
// deliberately differs from the provisioning range (45xxx): the two overlays map
27+
// different host ports onto the same container port, so both can be applied
28+
// without a duplicate binding. Ports remain ledger-allocated (FreeHostPort) with
29+
// the standard port as the search base, so the same engine keeps the same host
30+
// port across runs, and if a host-native server already holds the standard port
31+
// the allocator transparently falls back to the next free one in the band.
2732

2833
const exposeFile = "compose.expose.yaml"
2934

@@ -37,19 +42,26 @@ type exposePort struct {
3742
}
3843

3944
// exposeEngines maps a shared engine (template name) to the ports `shared expose`
40-
// publishes on 127.0.0.1. Bases sit in the 5xxxx range so they never collide with
41-
// the 4xxxx provisioning overlay. Kafka is the exception: host clients MUST reach
42-
// the broker on 127.0.0.1:49092 (the fixed advertised external listener from the
43-
// template), so it reuses the kafka provision port rather than a 5xxxx one.
45+
// publishes on 127.0.0.1. The search base is the engine's WELL-KNOWN port (equal
46+
// to the in-container port), so clients connect on the port they already expect
47+
// and the allocator only drifts off it when a host-native server already holds it.
48+
// Kafka is the exception: host clients MUST reach the broker on 127.0.0.1:49092
49+
// (the fixed advertised external listener from the template), so it keeps that
50+
// base and reuses the kafka provision port rather than the broker's 19092.
4451
var exposeEngines = map[string][]exposePort{
45-
"postgres": {{5432, "postgres", "pg-expose", 55432, true}},
46-
"redis": {{6379, "redis", "redis-expose", 56379, true}},
47-
"minio": {{9000, "s3", "minio-expose", 59000, true}, {9001, "console", "minio-console-expose", 59001, false}},
48-
"localstack": {{4566, "aws", "localstack-expose", 54566, true}},
49-
"ministack": {{4566, "aws", "ministack-expose", 54567, true}},
50-
"nats": {{4222, "nats", "nats-expose", 54222, true}, {8222, "monitor", "nats-monitor-expose", 58222, false}},
52+
"postgres": {{5432, "postgres", "pg-expose", 5432, true}},
53+
"mysql": {{3306, "mysql", "mysql-expose", 3306, true}},
54+
"mariadb": {{3306, "mariadb", "mariadb-expose", 3306, true}},
55+
"mongodb": {{27017, "mongodb", "mongodb-expose", 27017, true}},
56+
"cassandra": {{9042, "cassandra", "cassandra-expose", 9042, true}},
57+
"arangodb": {{8529, "arangodb", "arangodb-expose", 8529, true}},
58+
"redis": {{6379, "redis", "redis-expose", 6379, true}},
59+
"minio": {{9000, "s3", "minio-expose", 9000, true}, {9001, "console", "minio-console-expose", 9001, false}},
60+
"localstack": {{4566, "aws", "localstack-expose", 4566, true}},
61+
"ministack": {{4566, "aws", "ministack-expose", 4566, true}},
62+
"nats": {{4222, "nats", "nats-expose", 4222, true}, {8222, "monitor", "nats-monitor-expose", 8222, false}},
5163
"kafka": {{19092, "kafka", "kafka-provision", 49092, true}},
52-
"rabbitmq": {{5672, "amqp", "rmq-expose", 55672, true}, {15672, "management", "rmq-mgmt-expose", 55673, false}},
64+
"rabbitmq": {{5672, "amqp", "rmq-expose", 5672, true}, {15672, "management", "rmq-mgmt-expose", 15672, false}},
5365
}
5466

5567
// ExposableEngine reports whether an engine has a defined host-expose port set.
@@ -58,6 +70,95 @@ func ExposableEngine(engine string) bool {
5870
return ok
5971
}
6072

73+
// primaryExposePort returns an engine's PRIMARY host-published port — the one a
74+
// client (and devstack's own host-side provisioning) connects the engine's main
75+
// protocol on. This is the single source of truth for "the host port of engine
76+
// X": provisioning, reset, snapshot and resource ops all resolve their admin
77+
// endpoint from it, so there is exactly ONE host port per engine (the standard
78+
// one), never a separate provisioning band.
79+
func primaryExposePort(engine string) (exposePort, bool) {
80+
for _, ep := range exposeEngines[engine] {
81+
if ep.primary {
82+
return ep, true
83+
}
84+
}
85+
return exposePort{}, false
86+
}
87+
88+
// exposableUnion returns the shared instances to publish: the requested set
89+
// unioned with any already-exposed instance (so writing the overlay never drops
90+
// another instance's ports), filtered to engines that support exposure. Sorted
91+
// for a byte-stable overlay.
92+
func exposableUnion(d UpDeps, want []string) []string {
93+
set := map[string]bool{}
94+
for _, i := range want {
95+
set[i] = true
96+
}
97+
for _, i := range exposedInstances(d.Model.Root) {
98+
set[i] = true
99+
}
100+
var insts []string
101+
for i := range set {
102+
if s, ok := d.Model.Workspace.Shared[i]; ok && ExposableEngine(s.Template) {
103+
insts = append(insts, i)
104+
}
105+
}
106+
sort.Strings(insts)
107+
return insts
108+
}
109+
110+
// exposeOverlayFor allocates the standard host ports for the exposable instances
111+
// among want (unioned with the currently-exposed set) and WRITES the single
112+
// expose overlay, returning its path ("" when there is nothing to expose). It does
113+
// NOT run compose — the caller (the shared phase) folds the returned path into its
114+
// own `compose up` so ports are published as the services come up.
115+
func exposeOverlayFor(ctx context.Context, d UpDeps, want []string) (string, error) {
116+
insts := exposableUnion(d, want)
117+
if len(insts) == 0 {
118+
return "", nil
119+
}
120+
_, pub, err := allocateExposePorts(ctx, d, insts)
121+
if err != nil {
122+
return "", err
123+
}
124+
return writeExposeOverlay(d.Model.Root, pub)
125+
}
126+
127+
// ensureExposed is the unified host-reachability primitive for callers that need
128+
// the ports published NOW (provisioning, reset, snapshot, resource ops): it writes
129+
// the single expose overlay for the exposable instances among want (unioned with
130+
// the already-exposed set, so it never drops another instance's ports) and applies
131+
// it via `compose up`. It is idempotent — the ledger returns the same standard
132+
// ports and the overlay bytes are unchanged, so compose does not recreate the
133+
// container on repeat calls. Returns instance→primary host port. Because both
134+
// auto-expose and every host-side admin op go through this one overlay, they can
135+
// never fight over a container's `ports:`.
136+
func ensureExposed(ctx context.Context, d UpDeps, want []string) (map[string]int, error) {
137+
insts := exposableUnion(d, want)
138+
if len(insts) == 0 {
139+
return map[string]int{}, nil
140+
}
141+
out, pub, err := allocateExposePorts(ctx, d, insts)
142+
if err != nil {
143+
return nil, err
144+
}
145+
overlay, err := writeExposeOverlay(d.Model.Root, pub)
146+
if err != nil {
147+
return nil, err
148+
}
149+
outDir := filepath.Join(d.Model.Root, generate.GenDir, "shared")
150+
if err := composeUpShared(ctx, d, outDir, []string{overlay}, insts); err != nil {
151+
return nil, fmt.Errorf("apply host-port overlay: %w", err)
152+
}
153+
ports := map[string]int{}
154+
for _, ep := range out {
155+
if ep.Primary {
156+
ports[ep.Instance] = ep.Port
157+
}
158+
}
159+
return ports, nil
160+
}
161+
61162
// ExposedPort is one host-published shared-service port with a client-ready
62163
// connection hint (the `--json` schema + the plain-table source).
63164
type ExposedPort struct {
@@ -284,6 +385,18 @@ func connectionURL(engine string, ep exposePort, params map[string]any, port int
284385
user := paramString(params, "rootUser", "devstack")
285386
pass := paramString(params, "rootPassword", "devstack")
286387
return fmt.Sprintf("postgres://%s:%s@%s/postgres?sslmode=disable", user, pass, host)
388+
case "mysql", "mariadb":
389+
user := paramString(params, "rootUser", "devstack")
390+
pass := paramString(params, "rootPassword", "devstack")
391+
return fmt.Sprintf("mysql://%s:%s@%s/%s", user, pass, host, user)
392+
case "mongodb":
393+
user := paramString(params, "rootUser", "devstack")
394+
pass := paramString(params, "rootPassword", "devstack")
395+
return fmt.Sprintf("mongodb://%s:%s@%s/?authSource=admin", user, pass, host)
396+
case "cassandra":
397+
return host // contact point host:9042 (CQL native transport)
398+
case "arangodb":
399+
return "http://" + host // HTTP API + web UI (root / rootPassword)
287400
case "redis":
288401
return "redis://" + host
289402
case "minio":

‎internal/orchestrate/expose_test.go‎

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,21 @@ func TestConnectionURL(t *testing.T) {
9393
port int
9494
want string
9595
}{
96-
{"postgres", exposePort{5432, "postgres", "", 0, true}, pgParams, 55432, "postgres://admin:s3cret@127.0.0.1:55432/postgres?sslmode=disable"},
97-
{"postgres", exposePort{5432, "postgres", "", 0, true}, nil, 55432, "postgres://devstack:devstack@127.0.0.1:55432/postgres?sslmode=disable"},
98-
{"redis", exposePort{6379, "redis", "", 0, true}, nil, 56379, "redis://127.0.0.1:56379"},
99-
{"minio", exposePort{9000, "s3", "", 0, true}, nil, 59000, "http://127.0.0.1:59000"},
100-
{"localstack", exposePort{4566, "aws", "", 0, true}, nil, 54566, "http://127.0.0.1:54566"},
101-
{"nats", exposePort{8222, "monitor", "", 0, false}, nil, 58222, "http://127.0.0.1:58222"},
102-
{"nats", exposePort{4222, "nats", "", 0, true}, nil, 54222, "nats://127.0.0.1:54222"},
96+
{"postgres", exposePort{5432, "postgres", "", 0, true}, pgParams, 5432, "postgres://admin:s3cret@127.0.0.1:5432/postgres?sslmode=disable"},
97+
{"postgres", exposePort{5432, "postgres", "", 0, true}, nil, 5432, "postgres://devstack:devstack@127.0.0.1:5432/postgres?sslmode=disable"},
98+
{"mysql", exposePort{3306, "mysql", "", 0, true}, nil, 3306, "mysql://devstack:devstack@127.0.0.1:3306/devstack"},
99+
{"mariadb", exposePort{3306, "mariadb", "", 0, true}, nil, 3306, "mysql://devstack:devstack@127.0.0.1:3306/devstack"},
100+
{"mongodb", exposePort{27017, "mongodb", "", 0, true}, nil, 27017, "mongodb://devstack:devstack@127.0.0.1:27017/?authSource=admin"},
101+
{"cassandra", exposePort{9042, "cassandra", "", 0, true}, nil, 9042, "127.0.0.1:9042"},
102+
{"arangodb", exposePort{8529, "arangodb", "", 0, true}, nil, 8529, "http://127.0.0.1:8529"},
103+
{"redis", exposePort{6379, "redis", "", 0, true}, nil, 6379, "redis://127.0.0.1:6379"},
104+
{"minio", exposePort{9000, "s3", "", 0, true}, nil, 9000, "http://127.0.0.1:9000"},
105+
{"localstack", exposePort{4566, "aws", "", 0, true}, nil, 4566, "http://127.0.0.1:4566"},
106+
{"nats", exposePort{8222, "monitor", "", 0, false}, nil, 8222, "http://127.0.0.1:8222"},
107+
{"nats", exposePort{4222, "nats", "", 0, true}, nil, 4222, "nats://127.0.0.1:4222"},
103108
{"kafka", exposePort{19092, "kafka", "", 0, true}, nil, 49092, "127.0.0.1:49092"},
104-
{"rabbitmq", exposePort{15672, "management", "", 0, false}, nil, 55673, "http://127.0.0.1:55673"},
105-
{"rabbitmq", exposePort{5672, "amqp", "", 0, true}, nil, 55672, "amqp://devstack@127.0.0.1:55672"},
109+
{"rabbitmq", exposePort{15672, "management", "", 0, false}, nil, 15672, "http://127.0.0.1:15672"},
110+
{"rabbitmq", exposePort{5672, "amqp", "", 0, true}, nil, 5672, "amqp://devstack@127.0.0.1:5672"},
106111
}
107112
for _, tc := range cases {
108113
if got := connectionURL(tc.engine, tc.ep, tc.params, tc.port); got != tc.want {
@@ -111,34 +116,48 @@ func TestConnectionURL(t *testing.T) {
111116
}
112117
}
113118

114-
// TestExposePortsNeverCollideWithProvision is the load-bearing invariant: the
115-
// expose overlay and the provision overlay must never publish the SAME host port
116-
// (base) for the SAME engine, or applying both recreates the container with a
117-
// duplicate binding. Kafka is the deliberate exception — its host clients MUST
118-
// use the fixed advertised 49092, so it reuses the provision port.
119-
func TestExposePortsNeverCollideWithProvision(t *testing.T) {
120-
provBase := map[string]int{}
121-
for engine, ov := range engineOverlays {
122-
provBase[engine] = ov.portBase
123-
}
119+
// TestExposeUsesStandardPorts locks in the unification: there is exactly ONE host
120+
// port per engine — the well-known one — and it equals the in-container port. That
121+
// is what lets provisioning/reset/snapshot and `expose` share a single overlay
122+
// instead of two fighting bands. Kafka is the deliberate exception: its broker
123+
// advertises a fixed 127.0.0.1:49092 external listener, so its host base is 49092
124+
// while the container port is 19092.
125+
func TestExposeUsesStandardPorts(t *testing.T) {
124126
for engine, ports := range exposeEngines {
125127
for _, ep := range ports {
126-
if pb, ok := provBase[engine]; ok && ep.base == pb && engine != "kafka" {
127-
t.Errorf("engine %q expose base %d collides with provision base %d", engine, ep.base, pb)
128+
if engine == "kafka" {
129+
continue
130+
}
131+
if ep.base != ep.container {
132+
t.Errorf("engine %q port %q: host base %d must equal container port %d (standard-port unification)",
133+
engine, ep.label, ep.base, ep.container)
128134
}
129135
}
130136
}
131-
// Every expose base must be unique across all engines/ports (no two services
132-
// fight for the same host port at allocation time either).
133-
seen := map[int]string{}
134-
for _, ports := range exposeEngines {
137+
// Every provisionable engine must have a PRIMARY expose port, since provisioning
138+
// now resolves its host-reachable admin endpoint from that single overlay.
139+
for _, engine := range []string{"postgres", "redis", "minio", "nats", "kafka", "localstack"} {
140+
if _, ok := primaryExposePort(engine); !ok {
141+
t.Errorf("engine %q has no primary expose port — provisioning cannot reach it", engine)
142+
}
143+
}
144+
// Within a SINGLE engine, its ports must not share a base (else a two-port
145+
// engine like minio/nats/rabbitmq would self-collide on the same host port).
146+
for engine, ports := range exposeEngines {
147+
seen := map[int]string{}
135148
for _, ep := range ports {
136-
if prev, ok := seen[ep.base]; ok && prev != ep.purpose {
137-
t.Errorf("expose base %d reused across purposes %q and %q", ep.base, prev, ep.purpose)
149+
if prev, ok := seen[ep.base]; ok {
150+
t.Errorf("engine %q reuses expose base %d across purposes %q and %q", engine, ep.base, prev, ep.purpose)
138151
}
139152
seen[ep.base] = ep.purpose
140153
}
141154
}
155+
// Across DIFFERENT engines the base MAY repeat on purpose: two engines that
156+
// speak the same wire protocol want the same well-known port (mysql/mariadb on
157+
// 3306, localstack/ministack on 4566). That is safe because the ledger's
158+
// AllocatePort skips every already-allocated port (AllocatedPorts spans all
159+
// owners), so a lone engine lands on the standard port and, when both are
160+
// exposed, the second transparently deconflicts to base+1.
142161
}
143162

144163
func TestFileExists(t *testing.T) {

0 commit comments

Comments
 (0)