@@ -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
2833const 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.
4451var 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).
63164type 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" :
0 commit comments