yamine 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a9a67fc55d6a6514571970b0a4ee40a682878c28b4a2bdf3a140a7a509715c8
4
- data.tar.gz: e3332534d4123efbc4fea82c06c0af753866d4ca48db335860e921121baf2e12
3
+ metadata.gz: 6f7c7127a262069469056cf55f94b5785de8e1890bc0814d84453159e1a01096
4
+ data.tar.gz: 0001bfbad73772c3caa7e615384a0c70dfa82732987174f55cbfbffac703dd15
5
5
  SHA512:
6
- metadata.gz: 7306ce065ecb6ff40ee10465f3fffc5108d6786a596c2783194459ec143e42649026f7b14ba170e3bbc9561d14738a613b0da8654ed59a335ba2e54e818a6345
7
- data.tar.gz: fb1603c3b379c8067ce51a9aec0baf51a669aec3a9a74002493d9d750e7d224ebb9c8feeb46d786a84f1c0f035d590960e4943feb56e0fb9da7c3c8da1b1b186
6
+ metadata.gz: 702d9b80482ac8f541f16ab5946b8916b0c23750774e398eabe85cd940a5d39306bdc188e010065e17ecf91081feb79b94d021a24c71186978ba8d1e4ede7dba
7
+ data.tar.gz: f0f12d6cc5b32bcc46804ef3009a72ab4fac500f792b6d6ea39f05324857031b4cb0f06fe131ef291a61f2faf1d1fd87eb85520d6698b63a6a5b6dde394e637d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] — 2026-09-11
4
+
5
+ ### Changed
6
+
7
+ - **The subdomain fallback is now opt-in: `proxy.subdomains`.** A route
8
+ used to answer its own subdomains unconditionally, so *any* label under
9
+ a live app silently resolved to that app. The expensive case is a
10
+ worktree: once `<branch>.myapp.localhost` became the worktree URL, the
11
+ same hostname with that worktree **stopped** answered as the main
12
+ checkout — HTTP 200, a real app, the wrong code, and nothing to
13
+ distinguish it from the right answer. You had to notice the branch
14
+ was running to know you were looking at someone else's build.
15
+
16
+ Unregistered hostnames now 404 instead, and the page names the parent
17
+ app and its directory: "`myapp.localhost` is running in `/code/myapp`.
18
+ If `my-branch.myapp.localhost` is a worktree or branch, start it there
19
+ (`yamine start`)." The failure states its own fix.
20
+
21
+ Opting in is one line per app that genuinely wants it:
22
+
23
+ ```yaml
24
+ proxy:
25
+ subdomains: true # this app answers *.myapp.localhost
26
+ ```
27
+
28
+ `yamine alias <name> <port> --wildcard` is the same opt-in for a
29
+ single ad-hoc route. `yamine status` now reports which mode an app is
30
+ in, since it changes what an unregistered label resolves to.
31
+
32
+ Nothing depended on the ambient behavior: no sibling app config used
33
+ subdomains, and the only artifact was one test, now the opt-in test.
34
+ Route files written before this need no migration — the flag is absent
35
+ unless set, and absent means exact hostname only.
36
+
37
+ ### Fixed
38
+
39
+ - **`proxy: false` on a process named `web` no longer fails validation.**
40
+ The EXAMPLE can only name one class per key (`true` → TrueClass), so a
41
+ literal `false` was compared as falseclass and rejected with the
42
+ self-contradictory "expected a boolean, got falseclass". Booleans are
43
+ now checked as booleans.
44
+
3
45
  ## [0.12.0] — 2026-09-11
4
46
 
5
47
  ### Fixed
@@ -84,10 +126,10 @@
84
126
 
85
127
  - **Ruby, git, and curl now trust the local CA — so an app can call
86
128
  another app at `https://<name>.localhost` with no configuration.** The
87
- gap surfaced when anywaye (a Rails app) tried to reach anymark at
88
- `https://anymark-directory.localhost`: `certificate verify failed`. The
89
- CA was trusted everywhere a human looks (Safari, Chrome, curl) and
90
- nowhere a child process looks — only Node was covered, via
129
+ gap surfaced when anywaye (a Rails app) tried to reach a second
130
+ checkout of anymark at its `.localhost` URL: `certificate verify
131
+ failed`. The CA was trusted everywhere a human looks (Safari, Chrome,
132
+ curl) and nowhere a child process looks — only Node was covered, via
91
133
  `NODE_EXTRA_CA_CERTS`.
92
134
 
93
135
  The naive fix is wrong in a way that shows up much later. `SSL_CERT_FILE`
data/README.md CHANGED
@@ -91,6 +91,7 @@ of truth for service name, proxy TLD/host, processes, and env:
91
91
  service: myapp
92
92
  proxy:
93
93
  tld: localhost
94
+ subdomains: false # opt in to answering *.myapp.localhost
94
95
  processes:
95
96
  web:
96
97
  cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
@@ -162,10 +163,25 @@ yamine --variant demo # -> https://demo.myapp.localhos
162
163
  yamine --tld preview.example.com # your own domain (OAuth parity)
163
164
  ```
164
165
 
165
- Because a registered route answers `*.` subdomains of itself, any
166
- `<label>.myapp.localhost` reaches the main checkout until a worktree
167
- registers that exact name — at which point the exact route wins and the
168
- worktree takes over.
166
+ ## Subdomains are opt-in
167
+
168
+ A route answers its exact hostname. `*.myapp.localhost` reaches
169
+ `myapp.localhost` only if that app asked for it:
170
+
171
+ ```yaml
172
+ proxy:
173
+ subdomains: true # this app answers its own subdomains
174
+ ```
175
+
176
+ ```bash
177
+ yamine alias tenant1 4001 --wildcard # one route, its subdomains
178
+ ```
179
+
180
+ Off is the useful default. An unregistered label under a live app is far
181
+ more likely to be a worktree whose stack is stopped than a tenant, and
182
+ handing that label to the parent app means HTTP 200 with the wrong code.
183
+ Instead the request 404s and names the parent app, its directory, and how
184
+ to start it. `yamine status` reports which mode an app is in.
169
185
 
170
186
  ## Commands
171
187
 
@@ -98,6 +98,17 @@ Only an explicit variant ever looks for an overlay file — a branch name
98
98
  that happens to match one on disk is ignored. `yamine status` shows
99
99
  `variant:` and `overlay:` separately.
100
100
 
101
+ ## Subdomains are opt-in
102
+
103
+ A route answers its exact hostname. `*.myapp.localhost` reaches the app
104
+ only if it asked (`proxy.subdomains: true` in `config/local.yml`, or
105
+ `yamine alias <name> <port> --wildcard` for one route).
106
+
107
+ So a worktree whose stack is not running gets a 404 that names the parent
108
+ app, its directory, and `yamine start` — not the parent app's code. If
109
+ you hit a `.localhost` URL that loads but looks wrong, check you started
110
+ the worktree you think you did; `yamine status` in it prints the URL.
111
+
101
112
  ## First time on a machine
102
113
 
103
114
  Run `yamine start` — it does the one-shot CA trust, port 443, and
@@ -243,6 +243,7 @@ module Yamine
243
243
  url: item[:url], dir: Dir.pwd, command: item[:command],
244
244
  port: item[:port], force: opts[:force],
245
245
  rails_dev_host: item[:hostname], database_url: db_url,
246
+ subdomains: resolved.subdomains,
246
247
  extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
247
248
  routes_registered << { hostnames: item[:hostnames], app: app }
248
249
 
@@ -281,7 +282,8 @@ module Yamine
281
282
  if failed.empty?
282
283
  apps.each do |name, slot|
283
284
  runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
284
- spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
285
+ spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name },
286
+ subdomains: resolved.subdomains)
285
287
  routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
286
288
  say opts, " -> #{slot[:item][:url]}"
287
289
  end
@@ -147,14 +147,20 @@ module Yamine
147
147
  puts "Removed alias #{hostname}."
148
148
  return
149
149
  end
150
- name, port_or_url = args
151
- raise Error, "Usage: yamine alias <name> <port|url>" unless name && port_or_url
150
+ name, port_or_url = args.reject { |a| a.start_with?("--") }
151
+ raise Error, "Usage: yamine alias <name> <port|url> [--wildcard]" unless name && port_or_url
152
152
 
153
153
  hostname = alias_hostname(name)
154
154
  target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
155
155
  force = args.include?("--force")
156
- ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force)
157
- puts "#{hostname} -> #{target}"
156
+ # --wildcard is the per-route escape hatch for the tenant case:
157
+ # this alias answers its own subdomains. Config `proxy.subdomains`
158
+ # is the same opt-in for a whole app at boot.
159
+ wildcard = args.include?("--wildcard")
160
+ ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force,
161
+ subdomains: wildcard)
162
+ suffix = wildcard ? " (and its subdomains)" : ""
163
+ puts "#{hostname} -> #{target}#{suffix}"
158
164
  end
159
165
 
160
166
  # A name containing dots is treated as a full hostname (any TLD);
@@ -282,6 +288,7 @@ module Yamine
282
288
  host: resolved.host, host_source: resolved.sources[:host],
283
289
  variant: resolved.variant, variant_source: resolved.sources[:variant],
284
290
  overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
291
+ subdomains: resolved.subdomains,
285
292
  urls: urls,
286
293
  processes: resolved.processes.keys,
287
294
  framework: Framework.detect(Dir.pwd).to_s
@@ -302,6 +309,9 @@ module Yamine
302
309
  # explicit --variant / YAMINE_VARIANT ever sets it, so say so
303
310
  # rather than letting the two look like one setting.
304
311
  puts "overlay: #{payload[:overlay] ? "#{payload[:overlay]} (merged)" : "(none)"}"
312
+ # Off by default; say when it is on, because it changes what an
313
+ # unregistered label under this app resolves to.
314
+ puts "subdomains: #{payload[:subdomains] ? "own subdomains resolve here" : "exact hostname only"}"
305
315
  puts "processes: #{payload[:processes].join(", ")}"
306
316
  puts "urls:"
307
317
  urls.each { |u| puts " #{u}" }
data/lib/yamine/config.rb CHANGED
@@ -51,7 +51,12 @@ module Yamine
51
51
  "service" => "myapp",
52
52
  "proxy" => {
53
53
  "tld" => "localhost",
54
- "host" => "myapp.local.example.com"
54
+ "host" => "myapp.local.example.com",
55
+ # Opt this app into answering its own subdomains. Off by default:
56
+ # an unregistered label under a live app is far more likely to be
57
+ # a stopped worktree than a tenant, and resolving it to the wrong
58
+ # app is worse than not resolving it.
59
+ "subdomains" => false
55
60
  },
56
61
  # db: false opts out of per-worktree databases entirely (exotic
57
62
  # setups: manual establish_connection, shared staging DB, ...).
@@ -178,6 +183,14 @@ module Yamine
178
183
  data[key]
179
184
  end
180
185
 
186
+ # Whether this app answers its own subdomains. Off unless asked: the
187
+ # ambient fallback used to hand any unregistered label to whichever
188
+ # app owned the parent name, which reads as the right app running the
189
+ # wrong code — the worktree case that motivated the opt-in.
190
+ def subdomains?
191
+ proxy_config["subdomains"] == true
192
+ end
193
+
181
194
  private
182
195
 
183
196
  def load_secrets
@@ -274,6 +287,9 @@ module Yamine
274
287
  if value["tld"] && !Sanitize.valid_tld?(value["tld"].downcase)
275
288
  raise ConfigError, "#{context}: invalid tld #{value["tld"].inspect}"
276
289
  end
290
+ if value.key?("subdomains") && ![true, false].include?(value["subdomains"])
291
+ raise ConfigError, "#{context}: subdomains must be a boolean"
292
+ end
277
293
  end
278
294
 
279
295
  def validate_db(value, context)
@@ -364,13 +380,28 @@ module Yamine
364
380
  elsif example_value.is_a?(Array) && value.is_a?(Array)
365
381
  validate_array_of!(value, example_value.first.class) unless example_value.empty?
366
382
  elsif !example_value.nil?
367
- expected = type_description(example_value.class)
368
- unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
369
- raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
383
+ # Booleans are one type, but the example can only name one class
384
+ # (TrueClass for `true`), so a literal `false` compared as
385
+ # falseclass and was rejected: `proxy: false` on a process
386
+ # raised "expected a boolean, got falseclass". Ask the value
387
+ # what it is instead of the example.
388
+ if boolean?(example_value)
389
+ unless boolean?(value)
390
+ raise ConfigError, "#{current_context}: expected a boolean, got #{value.class.name.downcase}"
391
+ end
392
+ else
393
+ expected = type_description(example_value.class)
394
+ unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
395
+ raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
396
+ end
370
397
  end
371
398
  end
372
399
  end
373
400
 
401
+ def boolean?(value)
402
+ value == true || value == false
403
+ end
404
+
374
405
  def validate_array_of!(array, type)
375
406
  array.each_with_index do |value, index|
376
407
  with_context(index) do
data/lib/yamine/proxy.rb CHANGED
@@ -106,6 +106,17 @@ module Yamine
106
106
  end
107
107
 
108
108
  # Pure request-routing core, tested without sockets.
109
+ #
110
+ # Exact hostname first, then an opted-in wildcard. The wildcard is
111
+ # deliberately not ambient: a route only answers its own subdomains
112
+ # when it registered with `subdomains: true` (config
113
+ # `proxy.subdomains`, or `yamine alias --wildcard`).
114
+ #
115
+ # It used to be unconditional, which meant every unregistered label
116
+ # under any live app silently resolved to that app. The expensive
117
+ # case is a worktree: `<branch>.myapp.localhost` answered as the main
118
+ # checkout — a wrong-but-working app, indistinguishable from the
119
+ # right one. An unregistered hostname now 404s, and says so.
109
120
  def route(authority, routes)
110
121
  host = Hostname.strip_port(authority)
111
122
  return nil if host.empty? || host.bytesize > MAX_HOSTNAME_BYTES
@@ -113,8 +124,7 @@ module Yamine
113
124
  exact = routes.find { |r| r["hostname"] == host }
114
125
  return exact if exact
115
126
 
116
- wildcard = routes.find { |r| host.end_with?(".#{r["hostname"]}") }
117
- wildcard
127
+ routes.find { |r| r["subdomains"] && host.end_with?(".#{r["hostname"]}") }
118
128
  end
119
129
 
120
130
  def check_hops(headers)
@@ -452,9 +462,23 @@ module Yamine
452
462
  return respond(sock, 404, "<h1>Not Found</h1>")
453
463
  end
454
464
 
465
+ # A label in front of a live app is almost always a worktree or a
466
+ # branch whose stack is not running. Name the parent it would fall
467
+ # under and where it lives, so "the app loaded but it's the wrong
468
+ # code" becomes "that worktree isn't running" without a search.
469
+ parent = routes.find { |r| bare.end_with?(".#{r["hostname"]}") }
470
+ hint = if parent
471
+ dir = parent.dig("spec", "dir")
472
+ where = dir ? " in #{escape(dir)}" : ""
473
+ "<p><strong>#{escape(parent["hostname"])}</strong> is running#{where}.</p>" \
474
+ "<p>If #{escape(bare)} is a worktree or branch, start it there " \
475
+ "(<code>yamine start</code>), or open " \
476
+ "<strong>#{escape(parent["hostname"])}</strong> instead.</p>"
477
+ end
478
+
455
479
  items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
456
480
  body = "<h1>No app registered for #{escape(bare)}</h1>" \
457
- "<ul>#{items}</ul>"
481
+ "#{hint}<ul>#{items}</ul>"
458
482
  respond(sock, 404, body)
459
483
  end
460
484
 
@@ -9,7 +9,7 @@ module Yamine
9
9
  module_function
10
10
 
11
11
  Result = Struct.new(:app, :tld, :host, :processes, :secrets,
12
- :sources, :variant, :overlay, :db, :env, keyword_init: true)
12
+ :sources, :variant, :overlay, :subdomains, :db, :env, keyword_init: true)
13
13
 
14
14
  # Two axes, and keeping them apart is the whole point:
15
15
  #
@@ -69,6 +69,7 @@ module Yamine
69
69
  sources: sources,
70
70
  variant: variant_name,
71
71
  overlay: overlay,
72
+ subdomains: config.subdomains?,
72
73
  db: config.data["db"],
73
74
  env: config.env_config
74
75
  )
@@ -76,7 +76,12 @@ module Yamine
76
76
  # `agent` records the owner (defaults to Agent.name); foreign-owned
77
77
  # live routes can only be taken with force: true, and the conflict
78
78
  # error names the other agent plus its worktree dir.
79
- def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil)
79
+ #
80
+ # `subdomains` opts this route into answering its own subdomains.
81
+ # Absent (the default, and what every pre-existing route file has)
82
+ # means exact hostname only — see Proxy#route for why.
83
+ def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil,
84
+ subdomains: false)
80
85
  agent ||= Agent.name
81
86
  killed = nil
82
87
  with_lock do
@@ -101,6 +106,7 @@ module Yamine
101
106
  entry = { "hostname" => hostname, "target" => target, "kind" => kind,
102
107
  "pid" => pid, "agent" => agent }
103
108
  entry["spec"] = spec if spec
109
+ entry["subdomains"] = true if subdomains
104
110
  routes << entry
105
111
  save_routes(routes)
106
112
  end
data/lib/yamine/runner.rb CHANGED
@@ -99,7 +99,7 @@ module Yamine
99
99
  # win over it, because those describe the boot rather than the app.
100
100
  def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
101
101
  rails_dev_host: nil, register: true, database_url: nil, spec: nil,
102
- extra_env: nil)
102
+ extra_env: nil, subdomains: false)
103
103
  port ||= Ports.find_free
104
104
  env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
105
105
  database_url: database_url, extra_env: extra_env)
@@ -111,7 +111,7 @@ module Yamine
111
111
  begin
112
112
  spec ||= { "dir" => File.expand_path(dir), "proc" => name }
113
113
  @store.add_route(hostname, target, Process.pid, kind: "tcp",
114
- force: force, spec: spec)
114
+ force: force, spec: spec, subdomains: subdomains)
115
115
  write_backend_pid(hostname, pid)
116
116
  rescue StandardError
117
117
  # Registration refused (quota, conflict): the backend is
@@ -140,9 +140,9 @@ module Yamine
140
140
  end
141
141
 
142
142
  # Register an already-spawned backend: route + sidecar, together.
143
- def adopt(hostname, app, force: false, spec: nil)
143
+ def adopt(hostname, app, force: false, spec: nil, subdomains: false)
144
144
  @store.add_route(hostname, app.target, Process.pid, kind: app.kind,
145
- force: force, spec: spec)
145
+ force: force, spec: spec, subdomains: subdomains)
146
146
  write_backend_pid(hostname, app.pid)
147
147
  nil
148
148
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto