yamine 0.12.0 → 0.13.1

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: 43ea000326072db8f0173360b3ef58a6cce132e20f372b65f0e777bafbb97e9d
4
+ data.tar.gz: 3e878d2cae8afffd1dca255a006a5863e12dbd4471a4b6214656dda5bc97d37f
5
5
  SHA512:
6
- metadata.gz: 7306ce065ecb6ff40ee10465f3fffc5108d6786a596c2783194459ec143e42649026f7b14ba170e3bbc9561d14738a613b0da8654ed59a335ba2e54e818a6345
7
- data.tar.gz: fb1603c3b379c8067ce51a9aec0baf51a669aec3a9a74002493d9d750e7d224ebb9c8feeb46d786a84f1c0f035d590960e4943feb56e0fb9da7c3c8da1b1b186
6
+ metadata.gz: 272dae53dfac6b0521c09c00ae096cdc10620dcabf8064a286a0fe25813a61738b3fefc4bb34cf8fdfdd8dd1467a0064ec4e8ceb6ec7b262a8ed638b66101bda
7
+ data.tar.gz: e16540d1830850c50a54451ec5f0bfb0ad87fd770b111a0b4276aad9a6916dadeeed4c106b61392e5fc5794e6276f003f718a5e53135885f36cc142944d1a12f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.1] — 2026-09-12
4
+
5
+ ### Fixed
6
+
7
+ - **`worktree clean` no longer reports "could not drop — remove manually"
8
+ for a database that never existed.** `dropdb` fails on a missing
9
+ database, and the old code collapsed that into a generic failure. Now
10
+ `Database.drop` returns an honest three-state outcome — `:dropped`,
11
+ `:missing` (nothing to drop, not an error), `:failed` (server
12
+ unreachable or drop refused) — by splitting "server reachable" from
13
+ "database present". Clean prints each outcome (`dropped N`,
14
+ `N did not exist — nothing to drop`) plus a summary line, exits 1 only
15
+ when something really failed, and **keeps the claim of a failed drop**
16
+ so the next clean can retry it (the old code deleted all claims
17
+ regardless, losing track of databases it failed to drop).
18
+ - `db drop` and `db create` resolve the DATABASE_URL template the way
19
+ boot does (ENV, then config env.clear) instead of ENV-only, so they
20
+ work from a shell without the variable exported.
21
+
22
+ ## [0.13.0] — 2026-09-11
23
+
24
+ ### Changed
25
+
26
+ - **The subdomain fallback is now opt-in: `proxy.subdomains`.** A route
27
+ used to answer its own subdomains unconditionally, so *any* label under
28
+ a live app silently resolved to that app. The expensive case is a
29
+ worktree: once `<branch>.myapp.localhost` became the worktree URL, the
30
+ same hostname with that worktree **stopped** answered as the main
31
+ checkout — HTTP 200, a real app, the wrong code, and nothing to
32
+ distinguish it from the right answer. You had to notice the branch
33
+ was running to know you were looking at someone else's build.
34
+
35
+ Unregistered hostnames now 404 instead, and the page names the parent
36
+ app and its directory: "`myapp.localhost` is running in `/code/myapp`.
37
+ If `my-branch.myapp.localhost` is a worktree or branch, start it there
38
+ (`yamine start`)." The failure states its own fix.
39
+
40
+ Opting in is one line per app that genuinely wants it:
41
+
42
+ ```yaml
43
+ proxy:
44
+ subdomains: true # this app answers *.myapp.localhost
45
+ ```
46
+
47
+ `yamine alias <name> <port> --wildcard` is the same opt-in for a
48
+ single ad-hoc route. `yamine status` now reports which mode an app is
49
+ in, since it changes what an unregistered label resolves to.
50
+
51
+ Nothing depended on the ambient behavior: no sibling app config used
52
+ subdomains, and the only artifact was one test, now the opt-in test.
53
+ Route files written before this need no migration — the flag is absent
54
+ unless set, and absent means exact hostname only.
55
+
56
+ ### Fixed
57
+
58
+ - **`proxy: false` on a process named `web` no longer fails validation.**
59
+ The EXAMPLE can only name one class per key (`true` → TrueClass), so a
60
+ literal `false` was compared as falseclass and rejected with the
61
+ self-contradictory "expected a boolean, got falseclass". Booleans are
62
+ now checked as booleans.
63
+
3
64
  ## [0.12.0] — 2026-09-11
4
65
 
5
66
  ### Fixed
@@ -84,10 +145,10 @@
84
145
 
85
146
  - **Ruby, git, and curl now trust the local CA — so an app can call
86
147
  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
148
+ gap surfaced when anywaye (a Rails app) tried to reach a second
149
+ checkout of anymark at its `.localhost` URL: `certificate verify
150
+ failed`. The CA was trusted everywhere a human looks (Safari, Chrome,
151
+ curl) and nowhere a child process looks — only Node was covered, via
91
152
  `NODE_EXTRA_CA_CERTS`.
92
153
 
93
154
  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
@@ -544,13 +546,7 @@ module Yamine
544
546
  end
545
547
 
546
548
  def database_template_from_config(resolved)
547
- top = resolved.env.is_a?(Hash) ? resolved.env["clear"] : nil
548
- from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
549
- return from_top if from_top && !from_top.to_s.strip.empty?
550
-
551
- env = resolved.processes.values.map { |e| e["env"] || {} }
552
- clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
553
- clear["DATABASE_URL"]
549
+ Database.template_for(resolved)
554
550
  end
555
551
 
556
552
  # Schema-load on first boot uses the app's own command when declared
@@ -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}" }
@@ -893,9 +893,9 @@ module Yamine
893
893
  resolved = Resolver.resolve(Dir.pwd)
894
894
  name = Database.name_for(Dir.pwd, env: boot_env_name,
895
895
  state_dir: ctx.store.dir)
896
- template = ENV["DATABASE_URL"]
896
+ template = drop_template
897
897
  unless template && !template.strip.empty?
898
- $stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
898
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml)."
899
899
  exit 1
900
900
  end
901
901
  if Database.ensure_exists(name, template)
@@ -917,38 +917,41 @@ module Yamine
917
917
  $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
918
918
  exit 1
919
919
  end
920
- if drop_database(ctx, name)
920
+ template = drop_template
921
+ unless template && !template.strip.empty?
922
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
923
+ exit 1
924
+ end
925
+ case Database.drop(name, template)
926
+ when :dropped
921
927
  map.delete(name)
922
928
  Database.save_map(ctx.store.dir, map)
923
929
  puts "Dropped #{name}."
924
- else
925
- $stderr.puts "Error: could not drop #{name}."
930
+ when :missing
931
+ map.delete(name)
932
+ Database.save_map(ctx.store.dir, map)
933
+ puts "#{name} did not exist — nothing to drop (claim removed)."
934
+ when :failed
935
+ $stderr.puts "Error: could not drop #{name} — is the database server running? The claim is kept."
926
936
  exit 1
927
937
  end
928
938
  end
929
939
 
930
- def drop_database(_ctx, name)
931
- template = ENV["DATABASE_URL"]
932
- return false unless template && !template.strip.empty?
940
+ # Template for reaching the database server outside of boot:
941
+ # ENV first, then the app's config (env.clear). Runs from the
942
+ # main checkout, so the config's own template is the usual source.
943
+ def drop_template
944
+ env = ENV["DATABASE_URL"]
945
+ return env if env && !env.strip.empty?
933
946
 
934
- case Database.adapter_for(template)
935
- when :postgres
936
- uri = URI.parse(template)
937
- _out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
938
- status.success?
939
- when :mysql
940
- uri = URI.parse(template)
941
- args = ["-h", uri.host || "127.0.0.1", "-u",
942
- URI.decode_www_form_component(uri.user || "root")]
943
- _out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
944
- status.success?
945
- else
946
- false
947
- end
948
- rescue SystemCallError
949
- false
947
+ config = Config.load(Dir.pwd)
948
+ return nil unless config
949
+
950
+ Database.template_for(TemplateSource.new(config.env_config, config.processes))
950
951
  end
951
952
 
953
+ TemplateSource = Struct.new(:env, :processes)
954
+
952
955
  def boot_env_name
953
956
  ENV.fetch("RAILS_ENV", "development")
954
957
  end
@@ -974,16 +977,31 @@ module Yamine
974
977
  puts "No orphaned worktree databases."
975
978
  return
976
979
  end
980
+ template = drop_template
981
+ unless template && !template.strip.empty?
982
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot drop the orphaned databases."
983
+ exit 1
984
+ end
985
+ dropped, missing, failed = [], [], []
977
986
  orphaned.each do |name, _info|
978
- if drop_database(ctx, name)
979
- puts "Dropped orphaned #{name}."
980
- else
981
- $stderr.puts "Could not drop #{name} — remove manually."
987
+ case Database.drop(name, template)
988
+ when :dropped then dropped << name
989
+ when :missing then missing << name
990
+ when :failed then failed << name
982
991
  end
983
992
  end
993
+ dropped.each { |n| puts " dropped #{n}" }
994
+ missing.each { |n| puts " #{n} did not exist — nothing to drop" }
995
+ failed.each { |n| warn " could not drop #{n} — is the server running? Claim kept for a retry." }
996
+ # Forget only the claims we actually handled; a failed drop
997
+ # stays recorded so the next clean can retry it.
984
998
  map = Database.load_map(ctx.store.dir)
985
- orphaned.each_key { |name| map.delete(name) }
999
+ (dropped + missing).each { |n| map.delete(n) }
986
1000
  Database.save_map(ctx.store.dir, map)
1001
+ summary = "Cleaned #{orphaned.length} orphaned claim(s): #{dropped.length} dropped, #{missing.length} already gone"
1002
+ summary += ", #{failed.length} failed" if failed.any?
1003
+ puts "#{summary}."
1004
+ exit 1 if failed.any?
987
1005
  else
988
1006
  raise Error, "Usage: yamine worktree [list|clean]"
989
1007
  end
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
@@ -100,6 +100,28 @@ module Yamine
100
100
  nil
101
101
  end
102
102
 
103
+ # The DATABASE_URL template per-worktree names derive from: ENV
104
+ # first, then the config's top-level env.clear, then per-process
105
+ # env.clear (configs written before top-level env existed). Shared
106
+ # by boot, `db create/drop`, and `worktree clean` so they all agree.
107
+ # Takes anything responding to .env (resolver Result / Config) —
108
+ # pass env_config for a bare Config.
109
+ def template_for(resolved)
110
+ source = if resolved.respond_to?(:env)
111
+ resolved.env
112
+ else
113
+ resolved.env_config
114
+ end
115
+ top = source.is_a?(Hash) ? source["clear"] : nil
116
+ from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
117
+ return from_top if from_top && !from_top.to_s.strip.empty?
118
+
119
+ processes = resolved.respond_to?(:processes) ? resolved.processes : {}
120
+ env = processes.values.map { |e| e["env"] || {} }
121
+ clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
122
+ clear["DATABASE_URL"]
123
+ end
124
+
103
125
  # Databases whose worktree dirs no longer exist (for `worktree clean`).
104
126
  def orphaned(state_dir)
105
127
  load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
@@ -146,6 +168,73 @@ module Yamine
146
168
  end
147
169
  end
148
170
 
171
+ # Drop a database with an honest outcome. Returns :dropped,
172
+ # :missing (the database did not exist — nothing to do, NOT an
173
+ # error), or :failed (server unreachable, drop refused). The
174
+ # distinction matters: `worktree clean` used to print "could not
175
+ # drop — remove manually" for a database that never existed,
176
+ # sending the user hunting for locks and permissions that were
177
+ # never the problem.
178
+ def drop(name, database_url)
179
+ case adapter_for(database_url)
180
+ when :postgres then pg_drop(name, database_url)
181
+ when :mysql then mysql_drop(name, database_url)
182
+ else :failed
183
+ end
184
+ end
185
+
186
+ def pg_drop(name, database_url)
187
+ reachable, exists = pg_state(name, database_url)
188
+ return :missing if reachable && !exists
189
+ return :failed unless reachable
190
+
191
+ uri = URI.parse(database_url)
192
+ _out, status = Open3.capture2("dropdb", name, env: pg_env(uri))
193
+ return :dropped if status.success?
194
+
195
+ reachable, exists = pg_state(name, database_url)
196
+ reachable && exists ? :failed : :dropped
197
+ rescue SystemCallError
198
+ :failed
199
+ end
200
+
201
+ def mysql_drop(name, database_url)
202
+ reachable, exists = mysql_state(name, database_url)
203
+ return :missing if reachable && !exists
204
+ return :failed unless reachable
205
+
206
+ uri = URI.parse(database_url)
207
+ _out, status = Open3.capture2("mysqladmin", *mysql_args(uri), "drop", name, "-f")
208
+ return :dropped if status.success?
209
+
210
+ reachable, exists = mysql_state(name, database_url)
211
+ reachable && exists ? :failed : :dropped
212
+ rescue SystemCallError
213
+ :failed
214
+ end
215
+
216
+ # [server_reachable, database_exists] — the split that lets drop
217
+ # tell "nothing to drop" apart from "could not reach the server".
218
+ def pg_state(name, database_url)
219
+ uri = URI.parse(database_url)
220
+ out, status = Open3.capture2(pg_env(uri), "psql", "-lqt")
221
+ return [false, false] unless status.success?
222
+
223
+ [true, out.split("\n").any? { |l| l.split("|").first.to_s.strip == name }]
224
+ rescue SystemCallError
225
+ [false, false]
226
+ end
227
+
228
+ def mysql_state(name, database_url)
229
+ uri = URI.parse(database_url)
230
+ out, status = Open3.capture2("mysql", *mysql_args(uri), "-e", "SHOW DATABASES;")
231
+ return [false, false] unless status.success?
232
+
233
+ [true, out.split("\n").include?(name)]
234
+ rescue SystemCallError
235
+ [false, false]
236
+ end
237
+
149
238
  # Create the database if missing. Uses createdb/mysqladmin when
150
239
  # available; returns true/false, never raises (boot reports, not dies).
151
240
  def ensure_exists(name, database_url)
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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto