local-development-gateway 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8baffa29acc52adfcc3112cacc9be936b24528915f86fbc94c323df081e05fd5
4
- data.tar.gz: 4ab5156e3dc8dae143345b3c58271be2afd4b3485fb92c6bfd18153dd1958734
3
+ metadata.gz: c8606a508df46ba2018a8cd768c86e6a41ce38a5ef7caedff7c5832017d34e38
4
+ data.tar.gz: ebd5a8310bcb5b235d0c00c717813873b73e6061d6333f89ba188818636b3162
5
5
  SHA512:
6
- metadata.gz: 8ed0938cfb210bda2c320f6c481af5ddabf9f32d8f04fcac7ac94f60bbc9df8da98fe71f7929581d54ab5a20b648c339b92cd78f799b50fb74f3300e64997e0c
7
- data.tar.gz: b1acdaefc1c2b69b1e101fb5516ee8de7de866e9d9b1d11bd2390d14a860204a869ca8d90a10123a9b304b954fb03297c26b80482743105bee5a53f3bad06c22
6
+ metadata.gz: cb84da27199e8af6e78189f9025240ae090ae84372e3ccbd3f1d25240e18a4710a3eb16f32793b2e1411bc0bb9d9f0535f9a4ff5955815cc1b0eda322680fdd8
7
+ data.tar.gz: e0e3a2a24e3f039d516a150496b37527ff98d6549aafaeeb0aca5b36116f68ed24bb5f3948c35ab3555efeca1c40267d334f233d50a46aa7dbf55c50333b62e5
data/README.md CHANGED
@@ -106,25 +106,123 @@ command that must not start a missing gateway, pass
106
106
  `ensure_running: false`; the block still runs and unused-gateway cleanup is
107
107
  attempted.
108
108
 
109
+ ### Installed versus active versions
110
+
111
+ `bundle exec local-development-gateway status` reports the invoking gem's installed version separately from each running shared service's active version, provenance, and mounted asset path. Healthy CLI `up`/`ensure` reuse also prints these diagnostics without recreating containers. Ruby callers can inspect `LocalDevelopmentGateway.status`; `ensure_running` still returns `:reused` for healthy services, and `ready?` remains a health check, not an artifact-version check.
112
+
113
+ For the database router, the active version comes from a record written by the Ruby process after loading the library at startup. For the HTTP gateway, it is the gateway configuration artifact version recorded in a container-creation label, not the Traefik image version. Missing records or labels are `unknown`. A legacy mount path containing a gem version is useful diagnostic evidence but does not prove which Ruby code or configuration the running process loaded. Neither version record is a content hash or proof that bind-mounted files have remained unchanged.
114
+
115
+ Installing a newer gem does not update already-running services. A known difference is reported as `mismatch`; legacy `unknown` provenance cannot establish that an upgrade is active. Coordinate restart timing with all consumers before explicitly updating only the database router:
116
+
117
+ ```sh
118
+ bundle exec local-development-gateway up --no-deps --force-recreate database-router
119
+ bundle exec local-development-gateway status
120
+ ```
121
+
122
+ Recreating the shared database router interrupts existing database client sessions across every consuming project/worktree. This command leaves the HTTP gateway and consumer database containers/volumes alone. If the HTTP gateway itself needs an update, separately coordinate and explicitly select `gateway` instead. Do not use `down` for a router-only update. After updating, confirm the selected service's active version is current and exercise an encrypted connection to the intended database; readiness alone is insufficient release verification.
123
+
109
124
  ## Worktree database services
110
125
 
111
126
  The gateway includes protocol drivers for encrypted SQL Server and PostgreSQL
112
127
  connections. No host agent, DNS server, administrator privileges, or
113
128
  `/etc/hosts` changes are required.
114
129
 
115
- A participating project only joins its existing database service to
116
- `local-gateway` and adds three labels:
130
+ ### Three connection paths
131
+
132
+ | Caller | Database hostname | Routing |
133
+ | --- | --- | --- |
134
+ | Application/API, background job, test container | `${COMPOSE_PROJECT_NAME}-<database-service>` | Qualified alias on that checkout's private `default` network; internal database port |
135
+ | Desktop client, such as DBeaver | `db.<worktree>.<project>.localhost` | Encrypted, hostname-labelled gateway endpoint on loopback |
136
+ | Database-local console, backup, clone command | `localhost` | Explicitly select the intended database container first |
137
+
138
+ Do not use bare `db` or `db_trav_com` for application connections in the current
139
+ gateway topology. An API attached to both `default` and `local-gateway` can see
140
+ other projects' database service aliases on the shared network. Docker DNS or
141
+ network ordering cannot reliably choose its own database. Separate database
142
+ volumes do not prevent connecting to the wrong server.
143
+
144
+ The qualified alias belongs **only on the private `default` network**. Compose
145
+ project names must be distinct for concurrent checkouts. `.localhost` desktop
146
+ names resolve to loopback, not to an internal database container; they are not
147
+ substitutes for private aliases. Database cloning is independent of this
148
+ hostname correction: it selects source/destination containers explicitly and
149
+ connects locally.
150
+
151
+ ### Canonical development and test Compose pattern
152
+
153
+ Merge these entries into the project's existing development base and gateway
154
+ overlay. Keep its database images, credentials, volumes, and application
155
+ commands. The base must not publish database host ports. Use the driver's
156
+ existing configuration keys: `AZURE_SQL_HOST` is a SQL Server application
157
+ example; `PGHOST` is the PostgreSQL/libpq equivalent. An application with a
158
+ different PostgreSQL configuration key must set that key instead.
159
+
160
+ For example, a wrapper-derived `COMPOSE_PROJECT_NAME=issue-123-example` gives
161
+ private hosts `issue-123-example-db` and `issue-123-example-db_trav_com`.
162
+ `GATEWAY_HOSTNAME=issue-123.example.localhost` separately identifies desktop
163
+ routes. This requires no new Ruby API, generic database environment variable,
164
+ or consumer runtime.
117
165
 
118
166
  ```yaml
167
+ # docker-compose.development.yml: also safe without the gateway overlay.
119
168
  services:
169
+ api:
170
+ environment:
171
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
172
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
173
+ jobs:
174
+ environment:
175
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
176
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
177
+ test_api:
178
+ environment:
179
+ AZURE_SQL_HOST: "${COMPOSE_PROJECT_NAME}-db"
180
+ PGHOST: "${COMPOSE_PROJECT_NAME}-db_trav_com"
120
181
  db:
182
+ networks:
183
+ default:
184
+ aliases:
185
+ - "${COMPOSE_PROJECT_NAME}-db"
186
+ db_trav_com:
187
+ networks:
188
+ default:
189
+ aliases:
190
+ - "${COMPOSE_PROJECT_NAME}-db_trav_com"
191
+ ```
192
+
193
+ The example's driver assignments are illustrative. In
194
+ [Travel Authorization's overlay](https://github.com/icefoganalytics/travel-authorization/blob/main/docker-compose.development.gateway.yml),
195
+ `db` is PostgreSQL and `db_trav_com` is SQL Server: its PostgreSQL host setting
196
+ must use `${COMPOSE_PROJECT_NAME}-db`, and its SQL Server host setting must use
197
+ `${COMPOSE_PROJECT_NAME}-db_trav_com`, with labels/ports matching those drivers.
198
+ Apply the rule separately to **every** database service, even when two services
199
+ use the same driver. Preserve each context's existing credentials, internal
200
+ port, and database name; tests must still select the test database rather than
201
+ development data.
202
+
203
+ ```yaml
204
+ # docker-compose.development.gateway.yml
205
+ services:
206
+ api:
121
207
  networks:
122
208
  - default
123
209
  - local-gateway
210
+ db:
211
+ networks:
212
+ default: {}
213
+ local-gateway: {}
124
214
  labels:
125
215
  - "local-gateway.tcp.driver=sql_server"
126
- - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME:-wrap.localhost}"
216
+ - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME}"
127
217
  - "local-gateway.tcp.port=1433"
218
+ db_trav_com:
219
+ networks:
220
+ default: {}
221
+ local-gateway: {}
222
+ labels:
223
+ - "local-gateway.tcp.driver=postgresql"
224
+ - "local-gateway.tcp.hostname=db.${GATEWAY_HOSTNAME}"
225
+ - "local-gateway.tcp.port=5432"
128
226
 
129
227
  networks:
130
228
  local-gateway:
@@ -132,22 +230,64 @@ networks:
132
230
  name: local-gateway
133
231
  ```
134
232
 
135
- For a worktree whose `GATEWAY_HOSTNAME` is `issue-567.wrap.localhost`, DBeaver
136
- connects to `db.issue-567.wrap.localhost` on the driver's standard port:
233
+ Use **mapping-form** database networks in both files. Compose merges
234
+ `default: {}` with the base's `default.aliases`; replacing that mapping with a
235
+ network list can lose the private alias. Do not add qualified aliases to
236
+ `local-gateway`. Jobs/tests need only `default` unless they themselves expose a
237
+ routed service; qualified database names remain safe if an existing overlay
238
+ also attaches them to the shared network.
239
+
240
+ The shared router owns `127.0.0.1:1433` and `127.0.0.1:5432`. No participating
241
+ database publishes either port, so multiple development/test stacks can start
242
+ concurrently. Internal connections use each server's internal port and existing
243
+ encryption policy; they do not pass through the router or require a published
244
+ port.
245
+
246
+ ### Explicit non-gateway fallback
247
+
248
+ Container-only development/tests work with the base alone. If desktop access
249
+ without the gateway is required, select a separate, project-local publishing
250
+ overlay **instead of** the gateway overlay:
251
+
252
+ ```yaml
253
+ # docker-compose.development.ports.yml: opt-in, non-gateway mode only.
254
+ services:
255
+ db:
256
+ ports:
257
+ - "127.0.0.1:1433:1433"
258
+ db_trav_com:
259
+ ports:
260
+ - "127.0.0.1:5432:5432"
261
+ ```
262
+
263
+ These fallback ports must be free; this mode does not promise parallel desktop
264
+ access. Do not combine it with the gateway or leave these publications in the
265
+ base/test files. When migrating a project that retains base publications, its
266
+ gateway overlay must explicitly clear each database's ports with
267
+ `ports: !reset []` (Docker Compose 2.24.4 or newer); simply omitting `ports` or
268
+ setting `ports: []` does not remove inherited publications. Prefer the
269
+ port-free base plus explicit fallback above.
270
+
271
+ Use the project's documented development wrapper to select Compose modes and
272
+ start/test/stop services. Inspect its rendered `config`: gateway mode must have
273
+ no database host publications and must retain the private aliases. A base-only
274
+ test wrapper must use the same qualified hosts.
275
+
276
+ ### Desktop database clients
277
+
278
+ For `GATEWAY_HOSTNAME=issue-123.example.localhost`, connect to
279
+ `db.issue-123.example.localhost` on the driver's standard port:
137
280
 
138
281
  | Driver label | Host port | Client requirement |
139
282
  | --- | ---: | --- |
140
283
  | `sql_server` | `1433` | Encryption enabled and **Trust server certificate** selected |
141
- | `postgresql` | `5432` | SSL mode `require` |
284
+ | `postgresql` | `5432` | SSL mode `require`, SNI enabled, development certificate accepted |
142
285
 
143
286
  Use the database container's actual internal port in
144
- `local-gateway.tcp.port`; it does not need to match the host listener.
145
-
146
- Remove the database's host `ports` publication when using the gateway; the shared
147
- router owns `127.0.0.1:1433` and `127.0.0.1:5432`. Keep the `default` network so
148
- application and test containers still connect directly to `db` on its internal
149
- port, without TLS or a host-published port. A project not using the gateway must
150
- select its own explicit port-publishing fallback.
287
+ `local-gateway.tcp.port`; it need not match the host listener. Different drivers
288
+ can share a hostname because their listeners differ. Two databases using the
289
+ same driver need distinct labels, for example `db.${GATEWAY_HOSTNAME}` and
290
+ `db_trav_com.${GATEWAY_HOSTNAME}`.
151
291
 
152
292
  All `.localhost` names resolve to loopback. The gateway publishes loopback-only
153
293
  listeners, discovers labelled containers through Docker, and uses the database
@@ -165,6 +305,29 @@ Closed sessions lose their cancellation mapping. See
165
305
  for the complete contract and security boundary, including the reason for
166
306
  retaining cancellation-aware PostgreSQL routing despite Traefik's STARTTLS support.
167
307
 
308
+ ### Downstream adoption checklist
309
+
310
+ 1. Give each concurrent checkout a distinct Compose project name and desktop
311
+ route hostname. Qualify every API/job/test database host with
312
+ `${COMPOSE_PROJECT_NAME}-<database-service>` using its existing driver setting.
313
+ 2. Advertise each qualified alias only on its private `default` network;
314
+ preserve it through mapping-form network merges in base-only and gateway modes.
315
+ 3. Remove fixed database publications from development/test stacks; retain
316
+ desktop fallback publications only in an explicitly selected non-gateway file.
317
+ 4. Recreate database and consumer services so changed aliases and environment
318
+ settings take effect. Inspect both rendered Compose configurations.
319
+ 5. Query real, read-only server/database identity from API, job, and test
320
+ contexts and compare it with the explicitly selected local database
321
+ container. Stop that checkout's database while another is available: its
322
+ consumers must fail, not select the other checkout. Record the actual DNS or
323
+ connection error; there is no universal platform error code.
324
+ 6. Verify both encrypted desktop routes, and restore normal gateway attachments
325
+ after test wrappers recreate services using base-only Compose.
326
+
327
+ Shared development networks are not a hostile-container security boundary.
328
+ Qualified private aliases prevent accidental cross-stack selection; they do not
329
+ prevent an attached, malicious container from connecting to another database.
330
+
168
331
  ## Development checks
169
332
 
170
333
  Install the locked development tools with `bundle install`. Run the Ruby
@@ -206,8 +369,8 @@ The gateway owns the single browser port on `127.0.0.1:80`. Participating projec
206
369
 
207
370
  The gateway:
208
371
 
209
- - listens only on `127.0.0.1:80`;
210
- - creates the external Docker network `local-gateway`;
372
+ - listens only on `127.0.0.1:80`, `127.0.0.1:1433`, and `127.0.0.1:5432`;
373
+ - creates the external Docker network `local-gateway` for HTTP and labelled database ingress;
211
374
  - discovers only services explicitly labelled for routing;
212
375
  - has the Docker label `local-gateway=true` so a project can detect it.
213
376
 
@@ -236,6 +399,14 @@ Use a project- and service-prefixed router/service identifier. Never derive a de
236
399
 
237
400
  Without the gateway, a participating project may use its own loopback port-publishing fallback. That fallback is project-specific and is not part of the shared gateway contract.
238
401
 
402
+ Application/job/test database connections follow the
403
+ [private-alias contract](#three-connection-paths), not desktop gateway hostnames
404
+ or bare database service names. Database services retain their private network
405
+ and add gateway labels without publishing host ports. The
406
+ [architecture decision](docs/architecture/local-tcp-routing.md#internal-database-isolation-decision)
407
+ explains why a future HTTP/database ingress split requires coordinated consumer
408
+ migration rather than a transparent gem update.
409
+
239
410
  ## Authentication callbacks
240
411
 
241
412
  A wildcard hostname lets an identity provider allow one local callback pattern instead of every generated port. For Traditional Knowledge, configure:
@@ -263,6 +434,24 @@ It mounts the Docker socket so it can discover labelled containers. A read-only
263
434
  | A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
264
435
  | Authentication rejects the callback | Confirm the generated hostname matches the allowed wildcard callback pattern. |
265
436
 
437
+ ## Release 0.2.3
438
+
439
+ - Status and healthy CLI reuse distinguish the installed gem version from each active shared service, reporting mismatches and unknown legacy provenance without restarting services.
440
+ - Scoped router update instructions explain shared database session interruption and keep restart timing under caller control. Health readiness and Ruby lifecycle return values remain unchanged.
441
+
442
+ ## Release 0.2.2
443
+
444
+ - Canonical SQL Server/PostgreSQL Compose integration supports concurrent
445
+ development/test stacks without per-stack database host publications.
446
+ - Application, job, and test connections use checkout-qualified private aliases
447
+ instead of ambiguous shared-network service names. Base-only mode, explicit
448
+ non-gateway fallback, multiple databases, and adoption checks are documented.
449
+ - The packaged architecture reference records the current private-alias
450
+ decision, real-container Linux validation, and coordinated migration required
451
+ for a future HTTP/database ingress split.
452
+ - Gateway routing and the public Ruby API are unchanged. Consumers must adopt
453
+ the Compose contract; upgrading the gem does not rewrite their configuration.
454
+
266
455
  ## Release 0.2.1
267
456
 
268
457
  - PostgreSQL query cancellation works across concurrent worktrees, including
data/docker-compose.yml CHANGED
@@ -13,6 +13,7 @@ services:
13
13
  start_period: 1s
14
14
  labels:
15
15
  local-gateway: "true"
16
+ local-gateway.version: "${LOCAL_DEVELOPMENT_GATEWAY_VERSION:-unknown}"
16
17
  ports:
17
18
  - "127.0.0.1:80:80"
18
19
  restart: unless-stopped
@@ -29,7 +30,7 @@ services:
29
30
  - -I/app/lib
30
31
  - -rlocal_development_gateway
31
32
  - -e
32
- - LocalDevelopmentGateway::DatabaseRouter.run
33
+ - File.write("/tmp/local-development-gateway-version", LocalDevelopmentGateway::VERSION); LocalDevelopmentGateway::DatabaseRouter.run
33
34
  healthcheck:
34
35
  test:
35
36
  - CMD
@@ -43,6 +44,7 @@ services:
43
44
  start_period: 1s
44
45
  labels:
45
46
  local-gateway: "true"
47
+ local-gateway.version: "${LOCAL_DEVELOPMENT_GATEWAY_VERSION:-unknown}"
46
48
  ports:
47
49
  - "127.0.0.1:1433:1433"
48
50
  - "127.0.0.1:5432:5432"
@@ -0,0 +1,356 @@
1
+ # Local TCP Routing Architecture
2
+
3
+ Status: SQL Server and PostgreSQL protocol routing implemented in PR #22 and
4
+ validated on Linux. Windows Docker Desktop validation remains outstanding.
5
+
6
+ ## Request
7
+
8
+ Extend Local Development Gateway so a participating Docker Compose project can
9
+ expose hostname-routed database services with the same simple declarative
10
+ integration it already uses for HTTP services.
11
+
12
+ For WRAP, database routing should require only a small change to
13
+ `docker-compose.development.gateway.yml`. WRAP must not implement address
14
+ allocation, routing, DNS, host-file management, startup orchestration, or
15
+ cleanup.
16
+
17
+ The initial endpoints are:
18
+
19
+ ```text
20
+ db.<worktree>.wrap.localhost:1433 # SQL Server
21
+ db.<worktree>.wrap.localhost:5432 # PostgreSQL
22
+ ```
23
+
24
+ Each must connect from DBeaver to that worktree's labelled database container.
25
+
26
+ ## Consumer Contract
27
+
28
+ A participating project keeps its databases on its private `default` network
29
+ and adds the existing `local-gateway` network plus three gateway-owned labels:
30
+ driver, desktop hostname, and internal port. The copyable SQL Server and
31
+ PostgreSQL base/overlay examples live in the README's
32
+ [canonical Compose pattern](../../README.md#canonical-development-and-test-compose-pattern).
33
+
34
+ | Connection path | Destination selection |
35
+ | --- | --- |
36
+ | API, background job, test | `${COMPOSE_PROJECT_NAME}-<database-service>` on private Docker DNS, using the driver's existing host setting and internal port |
37
+ | Desktop database client | Encrypted `db.<worktree>.<project>.localhost` endpoint; driver/hostname labels select the backend |
38
+ | Database-local console, backup, clone | Explicitly selected container, then `localhost`; not gateway routing |
39
+
40
+ Each concurrent checkout has a distinct Compose project name. Each database
41
+ advertises its qualified alias only on that project's `default` network.
42
+ Use mapping-form database networks in the base and gateway overlay so Compose
43
+ preserves `default.aliases`. Configure all application/job/test contexts with
44
+ their appropriate aliases, existing credentials, and intended database names;
45
+ test contexts still select their own test database.
46
+
47
+ Gateway-mode development and test stacks publish no database host ports.
48
+ Container consumers work with the base alone. Desktop access without the
49
+ gateway is an explicit project-local port-publishing overlay, not an inherited
50
+ base default and not a parallel-stack guarantee. The README covers migration
51
+ of inherited port publications, fallback selection, and adoption checks.
52
+
53
+ Internal database hostnames and desktop `.localhost` hostnames are separate
54
+ contracts. Internal connections need neither a published host port nor the
55
+ gateway's encrypted-handshake protocol. Database cloning does not depend on
56
+ the internal hostname correction.
57
+
58
+ Requirements:
59
+
60
+ - No consumer Ruby API calls.
61
+ - No consumer-side address allocator.
62
+ - No per-worktree host-port selection.
63
+ - No bind-address environment variable passed from application code.
64
+ - No consumer startup or shutdown hook solely for gateway routing.
65
+ - No consumer cleanup call.
66
+ - No manually maintained DBeaver port per worktree.
67
+ - The consumer supplies only declarative service identity and internal-port metadata.
68
+
69
+ - No administrator or root installation.
70
+ - No host `/etc/hosts`, DNS, resolver, systemd, or Windows-service changes.
71
+ - The same gateway Compose stack must run through Docker on Linux and Windows.
72
+
73
+ ## Internal Database Isolation Decision
74
+
75
+ **Decision:** retain the current shared ingress topology and standardize
76
+ checkout-qualified private aliases. This resolves
77
+ [#23](https://github.com/icefoganalytics/local-development-gateway/issues/23)'s
78
+ parallel-stack contract and
79
+ [#26](https://github.com/icefoganalytics/local-development-gateway/issues/26)'s
80
+ internal database selection contract without new runtime machinery or public
81
+ Ruby lifecycle changes.
82
+
83
+ Bare `db` is not safe for an API attached to both its private bridge and
84
+ `local-gateway`: other attached databases advertise the same Compose service
85
+ alias on shared ingress. Docker DNS/network ordering, container IDs, separate
86
+ volumes, and gateway TLS/SNI cannot disambiguate an ordinary internal connection.
87
+ The qualified alias identifies only its own private endpoint. If that endpoint
88
+ is absent, its consumers must fail rather than connect to another checkout.
89
+ Do not advertise the qualified alias on shared ingress.
90
+
91
+ An HTTP/database ingress split is a possible future centralization, not a
92
+ transparent gem-only patch:
93
+
94
+ - HTTP services/APIs: private checkout network plus existing HTTP ingress.
95
+ - Databases: private checkout network plus a distinct gateway-owned database
96
+ ingress network. APIs/jobs/tests must not join database ingress.
97
+ - Traefik: HTTP ingress only. Database router: database ingress only.
98
+ - Route discovery must read backend addresses on database ingress instead of
99
+ `DockerRoutes::NETWORK_NAME`'s current `local-gateway`.
100
+ - Gateway readiness/discovery must check the appropriate networks and services.
101
+ Unused-gateway shutdown must retain running consumers on either ingress,
102
+ including database-only stacks, without counting exited containers.
103
+ - Consumer network membership, installed gem versions, and already-running
104
+ shared gateways require coordinated migration. Old consumers still
105
+ advertising database aliases on HTTP ingress remain ambiguous after a
106
+ gateway-only update.
107
+
108
+ That alternative requires packaged install/readiness, database-only lifecycle,
109
+ multi-project startup/stop/replacement, SQL Server routing, PostgreSQL routing
110
+ and cancellation, mixed-version migration, and unchanged HTTP-routing evidence
111
+ before adoption. No structural split is implemented in this release; downstream
112
+ qualified-alias mitigations must remain until a verified replacement is adopted.
113
+
114
+ These development networks are not hostile-container security boundaries.
115
+ Private qualified aliases prevent accidental selection, not deliberate
116
+ connections by another attached container.
117
+
118
+ ## Gateway Responsibilities
119
+
120
+ All implementation belongs in this repository. The gateway must:
121
+
122
+ 1. Discover participating containers and their routing metadata through Docker.
123
+ 2. Derive or read the requested hostname and target internal port.
124
+ 3. Distinguish concurrent worktrees even when they expose the same internal TCP port.
125
+ 4. Make `db.<worktree>.wrap.localhost` resolve or connect to the correct worktree route on the local machine.
126
+ 5. Forward the raw TCP stream to the labelled container.
127
+ 6. Add, update, and remove routes automatically as containers start, restart, and stop.
128
+ 7. Preserve stable routing for a worktree while it remains active.
129
+ 8. Keep all listeners and generated routes local to the developer machine.
130
+ 9. Preserve the existing HTTP routing behavior.
131
+
132
+ ## Implemented Routing Shape
133
+
134
+ Every `.localhost` name intentionally resolves to loopback on both Linux and
135
+ Windows. A shared port therefore requires a protocol-level hostname. The
136
+ gateway uses one Docker-label discovery path and a small driver for each
137
+ supported database handshake:
138
+
139
+ ```text
140
+ DBeaver
141
+ -> db.<worktree>.wrap.localhost:<driver port>
142
+ -> Docker publishes the database router on loopback
143
+ -> driver reads the encrypted handshake hostname
144
+ -> router selects the matching Docker label
145
+ -> router forwards the database stream to the labelled container
146
+ ```
147
+
148
+ The `sql_server` driver mediates Tabular Data Stream (TDS) PRELOGIN, reads SNI
149
+ from the wrapped TLS ClientHello, then preserves the encrypted client/backend
150
+ stream. The
151
+ `postgresql` driver accepts PostgreSQL's SSLRequest, terminates the client TLS
152
+ session to obtain SNI, and forwards the plaintext PostgreSQL stream on the
153
+ private Docker network.
154
+
155
+ Traefik remains responsible for HTTP. It cannot inspect TLS SNI wrapped inside
156
+ TDS PRELOGIN. It **does** support PostgreSQL SSLRequest/STARTTLS and subsequent
157
+ TLS-SNI routing, as documented in its
158
+ [TCP TLS reference](https://doc.traefik.io/traefik/reference/routing-configuration/tcp/tls/).
159
+
160
+ STARTTLS alone is therefore not a reason to implement PostgreSQL routing here.
161
+ The gateway also owns PostgreSQL query cancellation: `psql` and JDBC can open
162
+ a separate plaintext CancelRequest connection containing only a backend PID and
163
+ secret, with no hostname. A stateless SNI router cannot select that backend.
164
+ Keeping negotiation and cancellation-aware session forwarding together preserves
165
+ the existing three-label consumer contract without per-route internal listeners,
166
+ generated Traefik configuration, or another discovery/control plane.
167
+
168
+ Normal database sessions require encrypted handshakes so the requested hostname
169
+ is present as TLS SNI. PostgreSQL cancellation is a bounded control-message
170
+ exception, not support for plaintext database sessions. The router parses
171
+ PostgreSQL backend messages only until BackendKeyData, replaces that key, then
172
+ forwards the remainder without interpreting query results.
173
+
174
+ ## Code Organization
175
+
176
+ `DatabaseRouter` owns listener concurrency and backend connection lifecycle.
177
+ `Wire` owns bounded handshake I/O and bidirectional forwarding, including
178
+ shutdown that wakes both relay directions. `DockerApi` and `DockerRoutes` form
179
+ the external Docker boundary and produce validated `Route` values.
180
+ Behavior-bearing classes each have one file, and directories map to Ruby
181
+ namespaces: drivers live under `DatabaseRouter::Drivers`, TDS framing and TLS
182
+ parsing under `DatabaseRouter::Tds`, and PostgreSQL session-key forwarding and
183
+ cancellation lookup under `DatabaseRouter::PostgreSql`. Tiny immutable `Route`,
184
+ `Connection`, cancellation destination, and TDS `Packet` records stay with
185
+ their owners.
186
+
187
+ Source and test imports resolve from the gem's `lib` load path and start at
188
+ `local_development_gateway`; domain files never traverse sibling paths with
189
+ `require_relative`.
190
+
191
+ The focused tests mirror those boundaries under `test/database_router/`.
192
+ Protocol scenarios keep their literal setup beside the behavior they verify
193
+ instead of sharing a generic fixture layer.
194
+
195
+ ## Hostname Contract
196
+
197
+ - Keep the established WRAP hostname family.
198
+ - HTTP remains:
199
+ - `<worktree>.wrap.localhost`
200
+ - `api.<worktree>.wrap.localhost`
201
+ - `mail.<worktree>.wrap.localhost`
202
+ - SQL Server is:
203
+ - `db.<worktree>.wrap.localhost`
204
+ - PostgreSQL uses the same hostname on port `5432`.
205
+ - Do not migrate consumers to `.test`, `.alt`, `.local`, `home.arpa`, or an externally registered domain as part of this issue.
206
+ - Do not require host resolver configuration; `.localhost` must retain its native loopback behavior.
207
+
208
+ ## Security Boundary
209
+
210
+ - No routed service may be reachable from another machine.
211
+ - Bind host listeners only to loopback addresses.
212
+ - Do not publish generated names through public DNS.
213
+ - Do not expose SQL Server on `0.0.0.0` or a LAN interface.
214
+ - Do not require privileged host integration.
215
+ - Do not grant a long-running container unrestricted write access to arbitrary host files.
216
+ - The database router receives the same read-only Docker socket already required by Traefik so it can resolve labels to current container addresses.
217
+ - SQL Server remains encrypted end to end. PostgreSQL is plaintext only inside the private Docker network after the gateway terminates client TLS.
218
+
219
+ ## Lifecycle
220
+
221
+ - Normal session selection reads current Docker metadata; starting, restarting,
222
+ or stopping a labelled container requires no stored route configuration or
223
+ consumer cleanup.
224
+ - Containers outside `local-gateway` are excluded before label validation.
225
+ Invalid participating routes are warned about and quarantined individually.
226
+ - Duplicate active driver/hostname identities reject every route for that
227
+ identity, not unrelated identities or database drivers.
228
+ - A connection snapshot never changes backend after selection.
229
+ - PostgreSQL rewrites BackendKeyData with a random, collision-checked virtual
230
+ identity whose PID is positive for older client compatibility. The active
231
+ mapping retains the exact backend address, port, and original cancellation key.
232
+ - Cancellation uses that mapping without Docker discovery or hostname
233
+ negotiation. Unknown and closed-session identities fail closed, and malformed
234
+ cancellation lengths never fall through to normal backend forwarding.
235
+ - Backend EOF, client disconnect, or relay failure removes the session mapping.
236
+ Gateway restart closes sessions and clears their cancellation identities;
237
+ normal route configuration is reconstructed from Docker metadata.
238
+ - Handshakes and active sessions have separate bounded admission pools.
239
+ Cancellation and closed health probes do not consume an active-session slot,
240
+ so cancellation remains available when the normal session limit is reached.
241
+ - SQL Server provisional attempts have a bounded sub-deadline within the shared
242
+ handshake deadline. Connection establishment uses only the remaining budget;
243
+ stalled provisional sockets are closed before a healthy alternative is tried.
244
+
245
+ ## Compatibility
246
+
247
+ - DBeaver connects to `db.<worktree>.wrap.localhost` on `1433` for SQL Server
248
+ or `5432` for PostgreSQL.
249
+ - SQL Server clients must enable encryption and trust the development server
250
+ certificate.
251
+ - PostgreSQL clients must use SSL mode `require`, send SNI, and permit the
252
+ gateway's generated development certificate.
253
+ - Concurrent SQL Server routes must use compatible PRELOGIN encryption
254
+ settings because the client receives a provisional backend's negotiation
255
+ response before SNI identifies the final backend.
256
+ - PostgreSQL backends must accept a plaintext connection from the private
257
+ `local-gateway` Docker network.
258
+ - PostgreSQL cancellation PIDs reported by client APIs are virtual. Use
259
+ `SELECT pg_backend_pid()` when inspecting the physical backend in server views.
260
+ - Existing browser routes through Traefik on `127.0.0.1:80` remain unchanged.
261
+ - Multiple active worktrees can use each database driver's standard port
262
+ simultaneously.
263
+ - Adding another protocol requires another explicit driver; ports are never
264
+ inferred from labels.
265
+
266
+ ## Acceptance Scenarios
267
+
268
+ 1. Start worktree A and worktree B with the same database driver and internal
269
+ port.
270
+ 2. Connect a DBeaver-equivalent client to
271
+ `db.<A>.wrap.localhost:<driver port>`; observe bytes reaching only A.
272
+ 3. Connect to `db.<B>.wrap.localhost:<driver port>`; observe bytes reaching
273
+ only B.
274
+ 4. Repeat the routing check for both `sql_server` and `postgresql`.
275
+ 5. Keep both connections possible concurrently without alternate host ports.
276
+ 6. Recreate A's database container; the same hostname routes to its replacement.
277
+ 7. Stop A; A's route disappears while B remains available.
278
+ 8. Verify existing HTTP hostnames still use Traefik.
279
+ 9. Verify every host listener is loopback-only.
280
+ 10. Demonstrate consumer integration as a Compose-only diff.
281
+
282
+ ## Non-Goals
283
+
284
+ - Changing consumer application code, adding new development wrapper runtimes,
285
+ or automatically mutating consumer networks. Existing Compose connection
286
+ settings and file selection must adopt the documented contract.
287
+ - Requiring consumers to run DNS, a proxy, or host installation.
288
+ - Assigning a different database port to each worktree.
289
+ - Publishing development routes outside the machine.
290
+ - Replacing existing HTTP routing.
291
+ - Building a generic service mesh or full database proxy.
292
+ - Supporting plaintext database sessions, PostgreSQL direct TLS negotiation, or
293
+ database protocols without an implemented driver. Plaintext PostgreSQL
294
+ cancellation control packets are supported.
295
+
296
+ ## Verification Evidence
297
+
298
+ PR #22 demonstrated:
299
+
300
+ 1. Two exact `db.<worktree>.wrap.localhost` names selected different labelled
301
+ containers for SQL Server and PostgreSQL.
302
+ 2. Docker-label discovery updated routes without consumer lifecycle code;
303
+ stopped routes were rejected while surviving routes remained reachable.
304
+ 3. Fragmented and oversized handshakes, stalled clients, duplicate routes,
305
+ unsupported drivers, and unreachable provisional backends failed safely.
306
+ 4. Linux validation required no host resolver, administrator/root, or
307
+ operating-system service setup. Windows Docker Desktop was unavailable for
308
+ equivalent validation.
309
+ 5. Existing HTTP routes remained unchanged and every published listener was
310
+ loopback-only.
311
+
312
+ Issue #24's corrective release additionally demonstrated:
313
+
314
+ 1. PostgreSQL 14.4 `psql` cancellation stopped A's query while B stayed active.
315
+ 2. DBeaver's Java runtime with PostgreSQL JDBC 42.7.13 and 42.2.27 returned
316
+ SQLSTATE `57014` for A while B completed its query normally.
317
+ 3. Two SQL Server 2022 backends remained reachable with encrypted `sqlcmd`
318
+ connections despite a first labelled backend that accepted TCP but never
319
+ answered PRELOGIN.
320
+ 4. Focused regressions cover invalid-route isolation, cancellation identity
321
+ collisions after PID normalization, variable backend keys, session cleanup,
322
+ malformed cancellation packets, and cancellation at full session capacity.
323
+
324
+ Issues #23 and #26's documentation release demonstrated on Linux with Docker
325
+ Compose 5.6.0:
326
+
327
+ 1. Two isolated Compose projects, each with SQL Server 2022 `db` and PostgreSQL
328
+ 17 `db_trav_com`, started concurrently without database host publications.
329
+ The repository's `bin/dev` Compose passthrough selected the temporary
330
+ consumer fixtures; the existing shared gateway was reused.
331
+ 2. Real SQL/PG client containers representing API, job, and test contexts joined
332
+ both private and shared networks. `getent hosts` selected only their private
333
+ qualified aliases. Read-only SQL Server `@@SERVERNAME` / `DB_NAME()` and
334
+ PostgreSQL `cluster_name` / `current_database()` / `inet_server_addr()`
335
+ identified their own servers. Test contexts selected `qa_test`; explicit
336
+ database-container `localhost` queries matched the consumer identities.
337
+ 3. Stopping A's two databases made every A context fail while B remained
338
+ usable. `getent hosts` returned exit 1; SQL Server reported login timeout,
339
+ TCP Provider `0x2AFA`, and server-not-found/not-accessible; PostgreSQL reported
340
+ `could not translate host name ... to address: Try again`. These are observed
341
+ platform errors, not required portable error codes.
342
+ 4. Both projects were recreated using base-only Compose. All consumer identity
343
+ checks passed with private-only attachments. Recreating with the gateway
344
+ overlay restored shared attachments and preserved private aliases and test
345
+ database selection.
346
+ 5. Desktop-equivalent host-network clients selected both labelled worktrees:
347
+ `sqlcmd` 18.6 with `-Nm -C` (mandatory encryption, trusted development
348
+ certificate), and PostgreSQL 17 `psql` with `sslmode=require`, SNI, hostname,
349
+ and explicit loopback `hostaddr`. SQL Server strict/direct TLS is outside the
350
+ supported TDS PRELOGIN contract.
351
+ 6. Docker inspection confirmed the shared gateway publishes only
352
+ `127.0.0.1:80`, `127.0.0.1:1433`, and `127.0.0.1:5432`.
353
+
354
+ This validates real database client contexts, not an automatic downstream
355
+ application migration. Consumers still need to adopt the README checklist.
356
+ Windows Docker Desktop validation remains outstanding.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocalDevelopmentGateway
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "json"
4
5
  require "local_development_gateway/version"
5
6
 
6
7
  module LocalDevelopmentGateway
@@ -29,16 +30,16 @@ module LocalDevelopmentGateway
29
30
  end
30
31
 
31
32
  class Docker
32
- def call(*args, capture: true)
33
+ def call(*args, capture: true, env: {})
33
34
  command = ["docker", *args]
34
35
  if capture
35
- stdout, stderr, status = Open3.capture3(*command)
36
+ stdout, stderr, status = Open3.capture3(env, *command)
36
37
  unless status.success?
37
38
  raise DockerError.new(command, stderr.empty? ? stdout : stderr)
38
39
  end
39
40
 
40
41
  stdout
41
- elsif system(*command)
42
+ elsif system(env, *command)
42
43
  true
43
44
  else
44
45
  raise DockerError.new(command, "")
@@ -85,7 +86,17 @@ module LocalDevelopmentGateway
85
86
  end
86
87
 
87
88
  def status
88
- compose("ps", "--all")
89
+ output = compose("ps", "--all")
90
+ output << "\nInstalled gateway version: #{VERSION}\n"
91
+ SERVICE_NAMES.each do |service|
92
+ ids = gateway_container_ids(service: service, status: "running")
93
+ if ids.empty?
94
+ output << "#{service}: not running; active=unknown\n"
95
+ else
96
+ ids.each { |id| output << service_version_status(service, id) }
97
+ end
98
+ end
99
+ output
89
100
  end
90
101
 
91
102
  def logs(*args, follow: false)
@@ -122,12 +133,56 @@ module LocalDevelopmentGateway
122
133
  "--file",
123
134
  COMPOSE_FILE,
124
135
  *args,
125
- capture: capture
136
+ capture: capture,
137
+ env: {
138
+ "LOCAL_DEVELOPMENT_GATEWAY_VERSION" => VERSION
139
+ }
126
140
  )
127
141
  end
128
142
 
129
143
  private
130
144
 
145
+ def service_version_status(service, id)
146
+ container = JSON.parse(@runner.call("inspect", id)).fetch(0)
147
+ if service == "database-router"
148
+ version = router_version(id)
149
+ provenance = "process-start record"
150
+ asset_target = "/app/lib"
151
+ else
152
+ version = container.dig("Config", "Labels", "local-gateway.version")
153
+ provenance = "container-creation label"
154
+ asset_target = "/etc/traefik/traefik.yml"
155
+ end
156
+ version = "unknown" if version.nil? || version.empty?
157
+ comparison = version_comparison(version)
158
+ mount =
159
+ container
160
+ .fetch("Mounts", [])
161
+ .find { |entry| entry["Destination"] == asset_target }
162
+ source = "unknown"
163
+ source = mount.fetch("Source") if mount
164
+ "#{service} (#{id[0, 12]}): active=#{version}; #{comparison}; " \
165
+ "provenance=#{provenance}; mounted assets=#{source}\n"
166
+ end
167
+
168
+ def router_version(id)
169
+ @runner.call(
170
+ "exec",
171
+ id,
172
+ "cat",
173
+ "/tmp/local-development-gateway-version"
174
+ ).strip
175
+ rescue DockerError
176
+ nil
177
+ end
178
+
179
+ def version_comparison(version)
180
+ return "unknown" if version == "unknown"
181
+ return "current" if version == VERSION
182
+
183
+ "mismatch (installed=#{VERSION}); explicit service recreation required"
184
+ end
185
+
131
186
  def wait_until_ready
132
187
  deadline = @clock.call + @timeout
133
188
  until ready?
@@ -233,7 +288,7 @@ module LocalDevelopmentGateway
233
288
 
234
289
  Commands:
235
290
  up, ensure Start or reuse the shared gateway
236
- status Show the gateway Compose status
291
+ status Show Compose status and installed/active gateway versions
237
292
  ready Check gateway readiness
238
293
  logs Follow gateway logs
239
294
  down, stop Stop the gateway only when unused
@@ -260,7 +315,8 @@ module LocalDevelopmentGateway
260
315
 
261
316
  case command
262
317
  when "up", "ensure"
263
- @client.ensure_running(*@argv)
318
+ result = @client.ensure_running(*@argv)
319
+ @output.write(@client.status) if result == :reused
264
320
  when "status"
265
321
  @output.write(@client.status)
266
322
  when "ready"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: local-development-gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-08 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby API and CLI for starting, monitoring, and conditionally stopping
14
14
  the shared Docker gateway.
@@ -23,6 +23,7 @@ files:
23
23
  - bin/local-development-gateway
24
24
  - config/traefik.yml
25
25
  - docker-compose.yml
26
+ - docs/architecture/local-tcp-routing.md
26
27
  - lib/local_development_gateway.rb
27
28
  - lib/local_development_gateway/database_router.rb
28
29
  - lib/local_development_gateway/database_router/docker_api.rb