local-development-gateway 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d72f55ac50271b2247bd8eb5189f4b6c25c30a62a8cb0d451cecc38b050ea98e
4
+ data.tar.gz: ea4e0fda3f4f88cb39666580be77960eeab97994adfd599e3b4e2db02c96f5cc
5
+ SHA512:
6
+ metadata.gz: 6bbf54e670547b4ff3a4b332094493ad1c825df43a50d29079b6ebfe88d85922ad6a1d73877a8de28ce22fcf2974b48db28c70250b3389d77a062a6fd14a19d9
7
+ data.tar.gz: 74cfbf75708d8e46a47e19ffc4a94b732e502372edfc565702c98e7ab8177e98208e543d3be45c822a6ed76bbdd83cb56bf3b50931efb334c2d3516dff4228e7
data/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # Local Development Gateway
2
+
3
+ One shared loopback-only gateway for browser-facing Docker development services.
4
+
5
+ It lets several local projects use stable hostnames instead of competing for browser ports. It is intended to run once at the beginning of a development day and remain running while projects start and stop.
6
+
7
+ ## What problem this solves
8
+
9
+ Without a gateway, two projects commonly both try to publish a web service on port `8080` and a backend service on port `3000`. Only one can start.
10
+
11
+ With this gateway running, a project can publish its internal web service through a generated hostname instead:
12
+
13
+ ```text
14
+ http://port-35053.traditional-knowledge.localhost
15
+ |
16
+ v
17
+ Local Development Gateway on 127.0.0.1:80
18
+ |
19
+ v
20
+ Traditional Knowledge web container on internal port 8080
21
+ ```
22
+
23
+ `port-35053` is derived from Traditional Knowledge's generated `WEB_PORT` value. It identifies the project instance; the browser reaches the web service through the gateway on port `80`, not directly on port `35053`.
24
+
25
+ The generated hostname is a local `*.localhost` name. It resolves to loopback and does not require a public domain, external DNS provider, or internet-facing listener.
26
+
27
+ ## Start once per day
28
+
29
+ Use the small repository wrapper for the common lifecycle commands:
30
+
31
+ ```sh
32
+ bin/dev up
33
+ ```
34
+
35
+ Check the gateway:
36
+
37
+ ```sh
38
+ bin/dev status
39
+ ```
40
+
41
+ Follow its logs:
42
+
43
+ ```sh
44
+ bin/dev logs
45
+ ```
46
+
47
+ Stop it only when no participating local project needs it:
48
+
49
+ ```sh
50
+ bin/dev down
51
+ ```
52
+
53
+ The wrapper also passes unrecognized arguments directly to `docker compose`, so
54
+ `bin/dev config` and `bin/dev up --force-recreate` remain available.
55
+
56
+ Port `80` on `127.0.0.1` must be free before starting. The gateway never binds to a non-loopback address.
57
+
58
+ ## Install as a gem
59
+
60
+ Consuming projects do not need a gateway checkout. Add the released gem to
61
+ their bundle and pin the compatible minor version:
62
+
63
+ ```ruby
64
+ gem "local-development-gateway", "~> 0.1.0"
65
+ ```
66
+
67
+ The gem packages the gateway Compose file and pinned Traefik configuration.
68
+ Its executable provides the same lifecycle commands:
69
+
70
+ ```sh
71
+ bundle exec local-development-gateway up
72
+ bundle exec local-development-gateway status
73
+ bundle exec local-development-gateway logs
74
+ bundle exec local-development-gateway down
75
+ ```
76
+
77
+ `local-development-gateway` uses the installed asset path, the
78
+ `local-gateway` Compose project and network labels, and the pinned minimum Ruby
79
+ version declared by the gem. Upgrade the version constraint when a new
80
+ compatible release is published. The repository's `bin/dev` is only a thin
81
+ wrapper around this executable API.
82
+
83
+ ## Use case: Traditional Knowledge beside WRAP
84
+
85
+ 1. Start this gateway once.
86
+ 2. Run `dev up` in Traditional Knowledge. It writes role-based generated values to `.env.development.local`, including `WEB_PORT`, `BACKEND_PORT`, and `WEB_HOSTNAME=port-<WEB_PORT>.traditional-knowledge.localhost`.
87
+ 3. With the gateway running, Traditional Knowledge's web container joins `local-gateway`, its direct web publishing is removed, and the generated hostname routes through the gateway on `127.0.0.1:80`.
88
+ 4. Open `WEB_HOSTNAME` from `.env.development.local`.
89
+ 5. When WRAP adopts the same contract, start it normally. It can then use its own generated route labels and hostname through this same gateway.
90
+
91
+ Each project receives an independent hostname, while the gateway owns the single browser port. The generated `WEB_PORT` is an identity seed in gateway mode, not the browser-facing port.
92
+
93
+ Without the gateway, Traditional Knowledge publishes the same generated values directly, including `localhost:<WEB_PORT>` for the web service and `localhost:<BACKEND_PORT>` for the backend. `dev up` regenerates unavailable assignments; deleting `.env.development.local` forces a fresh assignment.
94
+
95
+ ## Contract for participating projects
96
+
97
+ The gateway:
98
+
99
+ - listens only on `127.0.0.1:80`;
100
+ - creates the external Docker network `local-gateway`;
101
+ - discovers only services explicitly labelled for routing;
102
+ - has the Docker label `local-gateway=true` so a project can detect it.
103
+
104
+ A participating project writes role-based generated values to its local `.env.development.local`:
105
+
106
+ ```dotenv
107
+ WEB_PORT=35053
108
+ BACKEND_PORT=38261
109
+ WEB_HOSTNAME=port-35053.traditional-knowledge.localhost
110
+ ```
111
+
112
+ With the gateway available, its web service joins `local-gateway` and supplies explicit route labels. Use a project-prefixed identifier containing the published `BACKEND_PORT` for the internal router/service name; do not generate a separate `STACK_IDENTIFIER`:
113
+
114
+ ```yaml
115
+ services:
116
+ web:
117
+ networks:
118
+ - default
119
+ - local-gateway
120
+ labels:
121
+ - "traefik.enable=true"
122
+ - "traefik.docker.network=local-gateway"
123
+ - "traefik.http.routers.<project>-<backend-port>.rule=Host(`<web-hostname>`)"
124
+ - "traefik.http.routers.<project>-<backend-port>.entrypoints=web"
125
+ - "traefik.http.routers.<project>-<backend-port>.service=<project>-<backend-port>"
126
+ - "traefik.http.services.<project>-<backend-port>.loadbalancer.server.port=8080"
127
+
128
+ networks:
129
+ local-gateway:
130
+ external: true
131
+ name: local-gateway
132
+ ```
133
+
134
+ Without the gateway, publish the same generated values directly:
135
+
136
+ ```yaml
137
+ services:
138
+ web:
139
+ ports:
140
+ - "127.0.0.1:${WEB_PORT}:8080"
141
+ backend:
142
+ ports:
143
+ - "127.0.0.1:${BACKEND_PORT}:3000"
144
+ ```
145
+
146
+ Use a unique generated `WEB_PORT` and `BACKEND_PORT` for every running project instance. Never derive a destination port from the hostname; each route must name one explicit Docker service and internal port.
147
+
148
+ ## Authentication callbacks
149
+
150
+ A wildcard hostname lets an identity provider allow one local callback pattern instead of every generated port. For Traditional Knowledge, configure:
151
+
152
+ ```text
153
+ Allowed Callback URLs: http://*.traditional-knowledge.localhost/callback
154
+ Allowed Logout URLs: http://*.traditional-knowledge.localhost
155
+ Allowed Web Origins: http://*.traditional-knowledge.localhost
156
+ ```
157
+
158
+ Keep existing production and staging entries. Verify that the identity provider accepts wildcard hostnames in each field before relying on this pattern.
159
+
160
+ ## Security boundary
161
+
162
+ The gateway publishes port `80` only to the local machine. It does not create a public endpoint.
163
+
164
+ It mounts the Docker socket so it can discover labelled containers. A read-only socket mount still exposes broad Docker metadata and control-plane access. Treat anyone who can modify this repository or its running gateway container as having significant access to local Docker. Do not run untrusted gateway images or route untrusted containers through it.
165
+
166
+ ## Troubleshooting
167
+
168
+ | Symptom | Check |
169
+ | --- | --- |
170
+ | Gateway will not start | Confirm `127.0.0.1:80` is free. |
171
+ | Generated hostname returns `404` | Confirm the web container has the route labels and is attached to `local-gateway`. |
172
+ | A project uses normal ports unexpectedly | Confirm the gateway container is running and has `local-gateway=true`. |
173
+ | Authentication rejects the callback | Confirm the generated hostname matches the allowed wildcard callback pattern. |
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "local_development_gateway"
5
+
6
+ exit LocalDevelopmentGateway::CLI.run(ARGV)
@@ -0,0 +1,11 @@
1
+ # Pinned with the gateway gem so every consumer uses the same routing contract.
2
+ entryPoints:
3
+ web:
4
+ address: ":80"
5
+
6
+ ping: {}
7
+
8
+ providers:
9
+ docker:
10
+ exposedByDefault: false
11
+ network: local-gateway
@@ -0,0 +1,27 @@
1
+ name: local-gateway
2
+
3
+ services:
4
+ gateway:
5
+ image: traefik:v3.7.8
6
+ command:
7
+ - --configFile=/etc/traefik/traefik.yml
8
+ healthcheck:
9
+ test: ["CMD", "traefik", "healthcheck", "--ping"]
10
+ interval: 1s
11
+ timeout: 3s
12
+ retries: 30
13
+ start_period: 1s
14
+ labels:
15
+ local-gateway: "true"
16
+ ports:
17
+ - "127.0.0.1:80:80"
18
+ restart: unless-stopped
19
+ volumes:
20
+ - ./config/traefik.yml:/etc/traefik/traefik.yml:ro
21
+ - /var/run/docker.sock:/var/run/docker.sock:ro
22
+ networks:
23
+ - local-gateway
24
+
25
+ networks:
26
+ local-gateway:
27
+ name: local-gateway
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LocalDevelopmentGateway
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "local_development_gateway/version"
5
+
6
+ module LocalDevelopmentGateway
7
+ PROJECT_NAME = "local-gateway"
8
+ NETWORK_NAME = "local-gateway"
9
+ SERVICE_NAME = "gateway"
10
+ GATEWAY_LABEL = "local-gateway"
11
+ ASSET_ROOT = File.expand_path("..", __dir__)
12
+ COMPOSE_FILE = File.join(ASSET_ROOT, "docker-compose.yml")
13
+ TRAEFIK_CONFIG_FILE = File.join(ASSET_ROOT, "config", "traefik.yml")
14
+
15
+ class Error < StandardError; end
16
+
17
+ class DockerError < Error
18
+ attr_reader :command, :output
19
+
20
+ def initialize(command, output)
21
+ @command = command
22
+ @output = output
23
+ super("Docker command failed: #{command.join(" ")}\n#{output}")
24
+ end
25
+ end
26
+
27
+ class Docker
28
+ def call(*args, capture: true)
29
+ command = ["docker", *args]
30
+ if capture
31
+ stdout, stderr, status = Open3.capture3(*command)
32
+ raise DockerError.new(command, stderr.empty? ? stdout : stderr) unless status.success?
33
+
34
+ stdout
35
+ elsif system(*command)
36
+ true
37
+ else
38
+ raise DockerError.new(command, "")
39
+ end
40
+ end
41
+ end
42
+
43
+ class Client
44
+ CONTAINER_FORMAT = "{{.ID}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Label \"com.docker.compose.service\"}}\t{{.Label \"local-gateway\"}}"
45
+
46
+ def initialize(
47
+ runner: Docker.new,
48
+ timeout: 30,
49
+ poll_interval: 0.25,
50
+ sleeper: ->(seconds) { sleep seconds },
51
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
52
+ )
53
+ @runner = runner
54
+ @timeout = timeout
55
+ @poll_interval = poll_interval
56
+ @sleeper = sleeper
57
+ @clock = clock
58
+ end
59
+
60
+ def ensure_running(*compose_args)
61
+ return :reused if compose_args.empty? && ready?
62
+
63
+ compose("up", "-d", "--remove-orphans", *compose_args)
64
+ wait_until_ready
65
+ :started
66
+ end
67
+
68
+ alias start ensure_running
69
+
70
+ def ready?
71
+ network_exists? && gateway_container_ids(status: "running").any? { |id| healthy_container?(id) }
72
+ end
73
+
74
+ def status
75
+ compose("ps", "--all")
76
+ end
77
+
78
+ def logs(*args, follow: false)
79
+ compose("logs", *(follow ? ["--follow"] : []), *args, capture: false)
80
+ end
81
+
82
+ def stop_if_unused
83
+ return :not_running unless gateway_exists?
84
+ return :in_use if non_gateway_containers_attached?
85
+
86
+ compose("down", "--remove-orphans")
87
+ :stopped
88
+ end
89
+
90
+ def compose(*args, capture: true)
91
+ @runner.call(
92
+ "compose",
93
+ "--project-name", PROJECT_NAME,
94
+ "--file", COMPOSE_FILE,
95
+ *args,
96
+ capture: capture,
97
+ )
98
+ end
99
+
100
+ private
101
+
102
+ def wait_until_ready
103
+ deadline = @clock.call + @timeout
104
+ until ready?
105
+ raise Error, "Local Development Gateway did not become ready" if @clock.call >= deadline
106
+
107
+ @sleeper.call(@poll_interval)
108
+ end
109
+ end
110
+
111
+ def network_exists?
112
+ labels = @runner.call(
113
+ "network",
114
+ "inspect",
115
+ NETWORK_NAME,
116
+ "--format",
117
+ "{{index .Labels \"com.docker.compose.project\"}}\t{{index .Labels \"com.docker.compose.network\"}}",
118
+ )
119
+ labels.lines.any? { |line| line.strip == "#{PROJECT_NAME}\t#{NETWORK_NAME}" }
120
+ rescue DockerError
121
+ false
122
+ end
123
+
124
+ def gateway_exists?
125
+ network_exists? && !gateway_container_ids.empty?
126
+ end
127
+
128
+ def gateway_container_ids(status: nil)
129
+ args = ["ps"]
130
+ args << "--all" unless status
131
+ args.concat([
132
+ "--filter", "network=#{NETWORK_NAME}",
133
+ "--filter", "label=com.docker.compose.project=#{PROJECT_NAME}",
134
+ "--filter", "label=com.docker.compose.service=#{SERVICE_NAME}",
135
+ "--filter", "label=#{GATEWAY_LABEL}=true",
136
+ ])
137
+ args.push("--filter", "status=#{status}") if status
138
+ output = @runner.call(*args, "--quiet")
139
+ output.lines.map(&:strip).reject(&:empty?)
140
+ end
141
+
142
+ def healthy_container?(id)
143
+ @runner.call("inspect", "--format", "{{.State.Health.Status}}", id).strip == "healthy"
144
+ rescue DockerError
145
+ false
146
+ end
147
+
148
+ def attached_containers
149
+ output = @runner.call(
150
+ "ps",
151
+ "--all",
152
+ "--filter", "network=#{NETWORK_NAME}",
153
+ "--format", CONTAINER_FORMAT,
154
+ )
155
+ output.lines.filter_map do |line|
156
+ id, project, service, label = line.strip.split("\t", -1)
157
+ next if id.nil? || id.empty?
158
+
159
+ { id: id, project: project, service: service, label: label }
160
+ end
161
+ end
162
+
163
+ def non_gateway_containers_attached?
164
+ attached_containers.any? do |container|
165
+ !gateway_container?(container)
166
+ end
167
+ end
168
+
169
+ def gateway_container?(container)
170
+ container[:project] == PROJECT_NAME &&
171
+ container[:service] == SERVICE_NAME &&
172
+ container[:label] == "true"
173
+ end
174
+ end
175
+
176
+ class CLI
177
+ USAGE = <<~USAGE
178
+ Usage: local-development-gateway COMMAND [OPTIONS]
179
+
180
+ Commands:
181
+ up, ensure Start or reuse the shared gateway
182
+ status Show the gateway Compose status
183
+ ready Check gateway readiness
184
+ logs Follow gateway logs
185
+ down, stop Stop the gateway only when unused
186
+
187
+ Other commands are passed to Docker Compose using the packaged assets.
188
+ USAGE
189
+
190
+ def self.run(argv, client: Client.new, output: $stdout, error: $stderr)
191
+ new(argv, client: client, output: output, error: error).run
192
+ end
193
+
194
+ def initialize(argv, client:, output:, error:)
195
+ @argv = argv.dup
196
+ @client = client
197
+ @output = output
198
+ @error = error
199
+ end
200
+
201
+ def run
202
+ command = @argv.shift
203
+ return print_usage(0) if command.nil? || %w[help --help -h].include?(command)
204
+
205
+ case command
206
+ when "up", "ensure"
207
+ @client.ensure_running(*@argv)
208
+ when "status"
209
+ @output.write(@client.status)
210
+ when "ready"
211
+ return 0 if @client.ready?
212
+
213
+ @error.puts "Local Development Gateway is not ready"
214
+ return 1
215
+ when "logs"
216
+ @client.logs(*@argv, follow: true)
217
+ when "down", "stop"
218
+ report_stop(@client.stop_if_unused)
219
+ else
220
+ @output.write(@client.compose(command, *@argv))
221
+ end
222
+ 0
223
+ rescue Error => error
224
+ @error.puts error.message
225
+ 1
226
+ end
227
+
228
+ private
229
+
230
+ def print_usage(status)
231
+ @output.write(USAGE)
232
+ status
233
+ end
234
+
235
+ def report_stop(result)
236
+ messages = {
237
+ not_running: "Local Development Gateway is not running",
238
+ in_use: "Leaving Local Development Gateway running because another container is attached",
239
+ stopped: "Stopped Local Development Gateway",
240
+ }
241
+ @output.puts messages.fetch(result)
242
+ end
243
+ end
244
+
245
+ class << self
246
+ def ensure_running(*args)
247
+ Client.new.ensure_running(*args)
248
+ end
249
+
250
+ def start(*args)
251
+ ensure_running(*args)
252
+ end
253
+
254
+ def ready?
255
+ Client.new.ready?
256
+ end
257
+
258
+ def status
259
+ Client.new.status
260
+ end
261
+
262
+ def logs(*args)
263
+ Client.new.logs(*args, follow: true)
264
+ end
265
+
266
+ def stop_if_unused
267
+ Client.new.stop_if_unused
268
+ end
269
+ end
270
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: local-development-gateway
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Marlen Brunner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby API and CLI for starting, monitoring, and conditionally stopping
14
+ the shared Docker gateway.
15
+ email:
16
+ - marlen@icefoganalytics.com
17
+ executables:
18
+ - local-development-gateway
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - README.md
23
+ - bin/local-development-gateway
24
+ - config/traefik.yml
25
+ - docker-compose.yml
26
+ - lib/local_development_gateway.rb
27
+ - lib/local_development_gateway/version.rb
28
+ homepage: https://github.com/icefoganalytics/local-development-gateway
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ bug_tracker_uri: https://github.com/icefoganalytics/local-development-gateway/issues
33
+ source_code_uri: https://github.com/icefoganalytics/local-development-gateway
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.2'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.5.22
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Shared loopback gateway lifecycle for local Docker development
53
+ test_files: []