local-development-gateway 0.1.0 → 0.1.2

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: d72f55ac50271b2247bd8eb5189f4b6c25c30a62a8cb0d451cecc38b050ea98e
4
- data.tar.gz: ea4e0fda3f4f88cb39666580be77960eeab97994adfd599e3b4e2db02c96f5cc
3
+ metadata.gz: 6f3e2ce5cd6347553a1923006ebc26ff3ae5178e67515d6741014dc229ca115d
4
+ data.tar.gz: 3059913ab2b89ae89442c80112fc49701d31690b46c5ca62bf981042c765700f
5
5
  SHA512:
6
- metadata.gz: 6bbf54e670547b4ff3a4b332094493ad1c825df43a50d29079b6ebfe88d85922ad6a1d73877a8de28ce22fcf2974b48db28c70250b3389d77a062a6fd14a19d9
7
- data.tar.gz: 74cfbf75708d8e46a47e19ffc4a94b732e502372edfc565702c98e7ab8177e98208e543d3be45c822a6ed76bbdd83cb56bf3b50931efb334c2d3516dff4228e7
6
+ metadata.gz: 6190a1c1563636f2a0a6ca2a08c8acb10c0a674e24551a01cac437a02c70c766ff7f6ec81251d10c086a08f414a43c445cff09095ff5f3a57cadab3580b880d3
7
+ data.tar.gz: 6f04fd1c800964cb0d44e65dc792116ece3fd622cfee945f8a2d49bf7e23165bff95050422e826dad0a4f1e4497d90797c417e87f6569da639c9833c7dd98ca1
data/README.md CHANGED
@@ -8,10 +8,11 @@ It lets several local projects use stable hostnames instead of competing for bro
8
8
 
9
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
10
 
11
- With this gateway running, a project can publish its internal web service through a generated hostname instead:
11
+ With this gateway running, a project routes its internal web service through a
12
+ checkout-derived hostname instead:
12
13
 
13
14
  ```text
14
- http://port-35053.traditional-knowledge.localhost
15
+ http://traditional-knowledge.localhost
15
16
  |
16
17
  v
17
18
  Local Development Gateway on 127.0.0.1:80
@@ -20,9 +21,13 @@ Local Development Gateway on 127.0.0.1:80
20
21
  Traditional Knowledge web container on internal port 8080
21
22
  ```
22
23
 
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
+ The checkout folder identifies the project. A separately named worktree such as
25
+ `issue-123` uses `http://issue-123.traditional-knowledge.localhost`; the browser
26
+ reaches the web service through the gateway on port `80`, not a published
27
+ application port.
24
28
 
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.
29
+ These local `*.localhost` hostnames resolve to loopback and do not require a
30
+ public domain, external DNS provider, or internet-facing listener.
26
31
 
27
32
  ## Start once per day
28
33
 
@@ -61,9 +66,11 @@ Consuming projects do not need a gateway checkout. Add the released gem to
61
66
  their bundle and pin the compatible minor version:
62
67
 
63
68
  ```ruby
64
- gem "local-development-gateway", "~> 0.1.0"
69
+ gem "local-development-gateway", "~> 0.1"
65
70
  ```
66
71
 
72
+ Published gem: [`local-development-gateway`](https://rubygems.org/gems/local-development-gateway)
73
+
67
74
  The gem packages the gateway Compose file and pinned Traefik configuration.
68
75
  Its executable provides the same lifecycle commands:
69
76
 
@@ -76,21 +83,56 @@ bundle exec local-development-gateway down
76
83
 
77
84
  `local-development-gateway` uses the installed asset path, the
78
85
  `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.
86
+ version declared by the gem. The `~> 0.1` constraint follows the consuming
87
+ project's published Gemfile.
88
+ The repository's `bin/dev` is only a thin wrapper around this executable API.
82
89
 
83
- ## Use case: Traditional Knowledge beside WRAP
90
+ ### Ruby API lifecycle
91
+
92
+ Wrap a command that needs the gateway with the block-oriented helper:
93
+
94
+ ```ruby
95
+ require "local_development_gateway"
96
+
97
+ result = LocalDevelopmentGateway.with_running do
98
+ run_development_command
99
+ end
100
+ ```
101
+
102
+ The helper starts or reuses the gateway before yielding, returns the block's
103
+ result, and conditionally stops an unused gateway afterward. Cleanup also runs
104
+ when the block raises without replacing the original exception. For a shutdown
105
+ command that must not start a missing gateway, pass
106
+ `ensure_running: false`; the block still runs and unused-gateway cleanup is
107
+ attempted.
108
+
109
+ ## Development checks
110
+
111
+ Install the locked development tools with `bundle install`. Run the Ruby
112
+ formatter check and tests with:
113
+
114
+ ```sh
115
+ bundle exec ruby "$(bundle show syntax_tree)/exe/stree" check \
116
+ Gemfile \
117
+ Rakefile \
118
+ lib/local_development_gateway.rb \
119
+ lib/local_development_gateway/version.rb \
120
+ bin/dev \
121
+ bin/local-development-gateway \
122
+ test/local_development_gateway_test.rb
123
+ rake test
124
+ ```
84
125
 
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.
126
+ ## Use case: Traditional Knowledge beside WRAP
90
127
 
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.
128
+ 1. Use the published gateway gem; it starts or reuses the shared gateway when Traditional Knowledge starts.
129
+ 2. Run `bundle install` in Traditional Knowledge so the published gateway gem is available.
130
+ 3. Run `dev up --watch` in Traditional Knowledge. The gateway is the browser entrypoint; the base checkout uses `http://traditional-knowledge.localhost`, with the API at `http://api.traditional-knowledge.localhost` and MailDev at `http://mail.traditional-knowledge.localhost`.
131
+ 4. A separately named checkout or worktree derives its own hostname, such as `http://issue-123.traditional-knowledge.localhost`. The API and MailDev hosts use the corresponding `api.` and `mail.` prefixes.
132
+ 5. Run `dev down` to stop the application services. It stops the shared gateway only when no other project is attached.
133
+ 6. When WRAP adopts the same contract, it can use its own checkout-derived hostnames through this shared gateway.
92
134
 
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.
135
+ The gateway owns the single browser port on `127.0.0.1:80`. Participating projects keep service ports internal to Docker and route browser traffic with explicit Traefik labels.
94
136
 
95
137
  ## Contract for participating projects
96
138
 
@@ -101,15 +143,7 @@ The gateway:
101
143
  - discovers only services explicitly labelled for routing;
102
144
  - has the Docker label `local-gateway=true` so a project can detect it.
103
145
 
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`:
146
+ A participating project joins routed services to `local-gateway` and supplies a checkout-derived hostname plus explicit service labels:
113
147
 
114
148
  ```yaml
115
149
  services:
@@ -120,10 +154,9 @@ services:
120
154
  labels:
121
155
  - "traefik.enable=true"
122
156
  - "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"
157
+ - "traefik.http.routers.<compose-project>-web.rule=Host(`<web-hostname>`)"
158
+ - "traefik.http.routers.<compose-project>-web.entrypoints=web"
159
+ - "traefik.http.services.<compose-project>-web.loadbalancer.server.port=8080"
127
160
 
128
161
  networks:
129
162
  local-gateway:
@@ -131,19 +164,9 @@ networks:
131
164
  name: local-gateway
132
165
  ```
133
166
 
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
- ```
167
+ Use a project- and service-prefixed router/service identifier. Never derive a destination port from the hostname; each routed service must name one explicit internal destination port.
145
168
 
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.
169
+ 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.
147
170
 
148
171
  ## Authentication callbacks
149
172
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LocalDevelopmentGateway
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -12,7 +12,8 @@ module LocalDevelopmentGateway
12
12
  COMPOSE_FILE = File.join(ASSET_ROOT, "docker-compose.yml")
13
13
  TRAEFIK_CONFIG_FILE = File.join(ASSET_ROOT, "config", "traefik.yml")
14
14
 
15
- class Error < StandardError; end
15
+ class Error < StandardError
16
+ end
16
17
 
17
18
  class DockerError < Error
18
19
  attr_reader :command, :output
@@ -29,7 +30,9 @@ module LocalDevelopmentGateway
29
30
  command = ["docker", *args]
30
31
  if capture
31
32
  stdout, stderr, status = Open3.capture3(*command)
32
- raise DockerError.new(command, stderr.empty? ? stdout : stderr) unless status.success?
33
+ unless status.success?
34
+ raise DockerError.new(command, stderr.empty? ? stdout : stderr)
35
+ end
33
36
 
34
37
  stdout
35
38
  elsif system(*command)
@@ -41,7 +44,8 @@ module LocalDevelopmentGateway
41
44
  end
42
45
 
43
46
  class Client
44
- CONTAINER_FORMAT = "{{.ID}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Label \"com.docker.compose.service\"}}\t{{.Label \"local-gateway\"}}"
47
+ CONTAINER_FORMAT =
48
+ "{{.ID}}\t{{.Label \"com.docker.compose.project\"}}\t{{.Label \"com.docker.compose.service\"}}\t{{.Label \"local-gateway\"}}"
45
49
 
46
50
  def initialize(
47
51
  runner: Docker.new,
@@ -68,7 +72,10 @@ module LocalDevelopmentGateway
68
72
  alias start ensure_running
69
73
 
70
74
  def ready?
71
- network_exists? && gateway_container_ids(status: "running").any? { |id| healthy_container?(id) }
75
+ network_exists? &&
76
+ gateway_container_ids(status: "running").any? do |id|
77
+ healthy_container?(id)
78
+ end
72
79
  end
73
80
 
74
81
  def status
@@ -87,13 +94,29 @@ module LocalDevelopmentGateway
87
94
  :stopped
88
95
  end
89
96
 
97
+ def with_running(ensure_running: true)
98
+ begin
99
+ self.ensure_running if ensure_running
100
+ yield
101
+ ensure
102
+ block_error = $!
103
+ begin
104
+ stop_if_unused
105
+ rescue StandardError => cleanup_error
106
+ raise cleanup_error unless block_error
107
+ end
108
+ end
109
+ end
110
+
90
111
  def compose(*args, capture: true)
91
112
  @runner.call(
92
113
  "compose",
93
- "--project-name", PROJECT_NAME,
94
- "--file", COMPOSE_FILE,
114
+ "--project-name",
115
+ PROJECT_NAME,
116
+ "--file",
117
+ COMPOSE_FILE,
95
118
  *args,
96
- capture: capture,
119
+ capture: capture
97
120
  )
98
121
  end
99
122
 
@@ -102,21 +125,26 @@ module LocalDevelopmentGateway
102
125
  def wait_until_ready
103
126
  deadline = @clock.call + @timeout
104
127
  until ready?
105
- raise Error, "Local Development Gateway did not become ready" if @clock.call >= deadline
128
+ if @clock.call >= deadline
129
+ raise Error, "Local Development Gateway did not become ready"
130
+ end
106
131
 
107
132
  @sleeper.call(@poll_interval)
108
133
  end
109
134
  end
110
135
 
111
136
  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}" }
137
+ labels =
138
+ @runner.call(
139
+ "network",
140
+ "inspect",
141
+ NETWORK_NAME,
142
+ "--format",
143
+ "{{index .Labels \"com.docker.compose.project\"}}\t{{index .Labels \"com.docker.compose.network\"}}"
144
+ )
145
+ labels.lines.any? do |line|
146
+ line.strip == "#{PROJECT_NAME}\t#{NETWORK_NAME}"
147
+ end
120
148
  rescue DockerError
121
149
  false
122
150
  end
@@ -128,30 +156,44 @@ module LocalDevelopmentGateway
128
156
  def gateway_container_ids(status: nil)
129
157
  args = ["ps"]
130
158
  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
- ])
159
+ args.concat(
160
+ [
161
+ "--filter",
162
+ "network=#{NETWORK_NAME}",
163
+ "--filter",
164
+ "label=com.docker.compose.project=#{PROJECT_NAME}",
165
+ "--filter",
166
+ "label=com.docker.compose.service=#{SERVICE_NAME}",
167
+ "--filter",
168
+ "label=#{GATEWAY_LABEL}=true"
169
+ ]
170
+ )
137
171
  args.push("--filter", "status=#{status}") if status
138
172
  output = @runner.call(*args, "--quiet")
139
173
  output.lines.map(&:strip).reject(&:empty?)
140
174
  end
141
175
 
142
176
  def healthy_container?(id)
143
- @runner.call("inspect", "--format", "{{.State.Health.Status}}", id).strip == "healthy"
177
+ @runner.call(
178
+ "inspect",
179
+ "--format",
180
+ "{{.State.Health.Status}}",
181
+ id
182
+ ).strip == "healthy"
144
183
  rescue DockerError
145
184
  false
146
185
  end
147
186
 
148
187
  def attached_containers
149
- output = @runner.call(
150
- "ps",
151
- "--all",
152
- "--filter", "network=#{NETWORK_NAME}",
153
- "--format", CONTAINER_FORMAT,
154
- )
188
+ output =
189
+ @runner.call(
190
+ "ps",
191
+ "--all",
192
+ "--filter",
193
+ "network=#{NETWORK_NAME}",
194
+ "--format",
195
+ CONTAINER_FORMAT
196
+ )
155
197
  output.lines.filter_map do |line|
156
198
  id, project, service, label = line.strip.split("\t", -1)
157
199
  next if id.nil? || id.empty?
@@ -161,15 +203,12 @@ module LocalDevelopmentGateway
161
203
  end
162
204
 
163
205
  def non_gateway_containers_attached?
164
- attached_containers.any? do |container|
165
- !gateway_container?(container)
166
- end
206
+ attached_containers.any? { |container| !gateway_container?(container) }
167
207
  end
168
208
 
169
209
  def gateway_container?(container)
170
210
  container[:project] == PROJECT_NAME &&
171
- container[:service] == SERVICE_NAME &&
172
- container[:label] == "true"
211
+ container[:service] == SERVICE_NAME && container[:label] == "true"
173
212
  end
174
213
  end
175
214
 
@@ -200,7 +239,9 @@ module LocalDevelopmentGateway
200
239
 
201
240
  def run
202
241
  command = @argv.shift
203
- return print_usage(0) if command.nil? || %w[help --help -h].include?(command)
242
+ if command.nil? || %w[help --help -h].include?(command)
243
+ return print_usage(0)
244
+ end
204
245
 
205
246
  case command
206
247
  when "up", "ensure"
@@ -235,8 +276,9 @@ module LocalDevelopmentGateway
235
276
  def report_stop(result)
236
277
  messages = {
237
278
  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",
279
+ in_use:
280
+ "Leaving Local Development Gateway running because another container is attached",
281
+ stopped: "Stopped Local Development Gateway"
240
282
  }
241
283
  @output.puts messages.fetch(result)
242
284
  end
@@ -247,6 +289,10 @@ module LocalDevelopmentGateway
247
289
  Client.new.ensure_running(*args)
248
290
  end
249
291
 
292
+ def with_running(ensure_running: true, &block)
293
+ Client.new.with_running(ensure_running: ensure_running, &block)
294
+ end
295
+
250
296
  def start(*args)
251
297
  ensure_running(*args)
252
298
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: local-development-gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner