yamine 0.15.2 → 0.16.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.
@@ -25,9 +25,24 @@ module Yamine
25
25
  module_function
26
26
 
27
27
  # Per-checkout config that git does not carry: a fresh worktree
28
- # boots without these (missing secrets, no proxy config) — the
29
- # single largest follow-up cost of a new worktree.
30
- LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets].freeze
28
+ # boots without these — missing secrets, no proxy config, and (for
29
+ # credentials apps) no decryption key, which stops Rails before it
30
+ # can even read database.yml. The single largest follow-up cost of
31
+ # a new worktree, now carried automatically.
32
+ LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets config/master.key].freeze
33
+ # Rails 7.1+ per-env credentials: development.key & co are
34
+ # gitignored exactly like master.key and equally fatal without.
35
+ CREDENTIALS_KEY_GLOB = "config/credentials/*.key"
36
+
37
+ # The static list plus whatever key files this checkout has, plus
38
+ # the worktree marker: everything yamine writes or copies that
39
+ # must never read as uncommitted work.
40
+ def local_config_files(dir)
41
+ keys = Dir.glob(File.join(dir, CREDENTIALS_KEY_GLOB)).map do |path|
42
+ path.sub(%r{\A#{Regexp.escape(dir.chomp("/"))}/?}, "")
43
+ end
44
+ LOCAL_CONFIG_FILES + keys + [Database::MARKER_FILE]
45
+ end
31
46
 
32
47
  def run(ctx, args)
33
48
  sub = args.first
@@ -52,7 +67,10 @@ module Yamine
52
67
  default_branch = Worktrees.default_branch(repo)
53
68
  Worktrees.list(repo).each do |entry|
54
69
  next if entry.bare
55
- puts " #{entry_line(entry, claim_for(map, entry.path), repo, default_branch)}"
70
+
71
+ claim = claim_for(map, entry.path)
72
+ puts " #{entry_line(entry, claim, repo, default_branch)}"
73
+ print_claim_databases(claim)
56
74
  printed = true
57
75
  end
58
76
  end
@@ -66,14 +84,17 @@ module Yamine
66
84
  def entry_line(entry, claim, repo, default_branch)
67
85
  name = entry.main? ? "main" : (entry.branch || "(detached)")
68
86
  line = "#{name} #{entry.path}"
69
- line += " db #{claim[0]}" if claim
87
+ if claim
88
+ names = claim[1].is_a?(Hash) ? claim[1]["names"] : nil
89
+ line += names && !names.empty? ? " db #{names.size} databases" : " db #{claim[0]}"
90
+ end
70
91
 
71
92
  flags = []
72
93
  flags << "main checkout" if entry.main?
73
94
  flags << "detached" if entry.detached && !entry.main?
74
95
  flags << "dir gone" unless entry.exists?
75
96
  if !entry.main? && entry.exists?
76
- flags << "dirty" if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
97
+ flags << "dirty" if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path))
77
98
  if entry.detached
78
99
  flags << "unmerged"
79
100
  else
@@ -85,6 +106,18 @@ module Yamine
85
106
  line
86
107
  end
87
108
 
109
+ # Every database a claim stands for, one per line under the row —
110
+ # five names inline would unreadable, and hiding them would
111
+ # re-create the "which database is this worktree even using"
112
+ # question the claim exists to answer.
113
+ def print_claim_databases(claim)
114
+ info = claim && claim[1]
115
+ names = info.is_a?(Hash) ? info["names"] : nil
116
+ return unless names.is_a?(Hash) && !names.empty?
117
+
118
+ names.each_value { |db| puts " db: #{db}" }
119
+ end
120
+
88
121
  # yamine worktree add <name> [--dir <path>] [--no-install]
89
122
  #
90
123
  # `name` is the branch (feature/login stays feature/login — the
@@ -135,13 +168,16 @@ module Yamine
135
168
 
136
169
  def copy_local_config(from_dir, to_dir)
137
170
  copied = []
138
- LOCAL_CONFIG_FILES.each do |rel|
171
+ local_config_files(from_dir).each do |rel|
139
172
  src = File.join(from_dir, rel)
140
173
  next unless File.file?(src)
141
174
 
142
175
  dst = File.join(to_dir, rel)
143
176
  FileUtils.mkdir_p(File.dirname(dst))
144
177
  FileUtils.cp(src, dst)
178
+ # Decryption keys stay as private in the worktree as they
179
+ # were in the checkout — FileUtils.cp does not carry modes.
180
+ File.chmod(0o600, dst) if rel.end_with?(".key")
145
181
  copied << rel
146
182
  end
147
183
  copied
@@ -158,27 +194,36 @@ module Yamine
158
194
  puts " bundle install did not finish — run it in #{dir} before booting" unless $?.success?
159
195
  end
160
196
 
161
- # Claim the per-worktree database and create it now (with the
197
+ # Claim this worktree's databases and create them now (with the
162
198
  # app's schema) so server problems surface at creation time, not
163
- # at first boot. Boot reuses an existing database idempotently, so
164
- # this is never wasted work. SQLite and template-less apps need
165
- # nothing; setup_database says so itself.
199
+ # at first boot. Rails apps are ASKED what they have (the probe
200
+ # resolves database.yml + credentials inside the app itself) and
201
+ # get the whole suffixed set — claim, marker file, databases,
202
+ # schema. Boot reuses an existing set idempotently, so this is
203
+ # never wasted work. SQLite and non-Rails apps fall through to
204
+ # the single-database path, which setup_database narrates.
166
205
  def prepare_database(ctx, dir)
167
206
  return nil unless Config.load(dir)
168
207
 
169
- db_name = Database.name_for(dir, env: BootCommand.rails_env,
208
+ claim_key = Database.name_for(dir, env: BootCommand.rails_env,
170
209
  state_dir: ctx.store.dir)
171
- Dir.chdir(dir) do
172
- BootCommand.setup_database(ctx, Resolver.resolve(dir), db_name)
210
+ rows = Probe.rails_databases(dir)
211
+ server = rows && Probe.server_backed(rows)
212
+ if server && !server.empty?
213
+ BootCommand.provision_multidb(ctx, dir, claim_key, server)
214
+ else
215
+ Dir.chdir(dir) do
216
+ BootCommand.setup_database(ctx, Resolver.resolve(dir), claim_key)
217
+ end
173
218
  end
174
- db_name
219
+ claim_key
175
220
  end
176
221
 
177
- def say_ready(ctx, dir, name, db_name)
222
+ def say_ready(ctx, dir, name, db_key)
178
223
  puts "Worktree ready:"
179
224
  puts " dir #{dir}"
180
225
  puts " branch #{name}"
181
- puts " db #{db_name}" if db_name
226
+ print_ready_databases(ctx, db_key)
182
227
  if (resolved = resolved_quietly(dir))
183
228
  Resolver.urls(resolved, port: ctx.proxy_port, tls: ctx.proxy_tls)
184
229
  .each { |u| puts " url #{u}" }
@@ -186,6 +231,18 @@ module Yamine
186
231
  puts " boot it: cd #{dir} && yamine start"
187
232
  end
188
233
 
234
+ def print_ready_databases(ctx, db_key)
235
+ return unless db_key
236
+
237
+ names = Database.load_map(ctx.store.dir).dig(db_key, "names")
238
+ if names.is_a?(Hash) && !names.empty?
239
+ puts " db #{names.size} databases:"
240
+ names.each_value { |db| puts " #{db}" }
241
+ else
242
+ puts " db #{db_key}"
243
+ end
244
+ end
245
+
189
246
  def resolved_quietly(dir)
190
247
  Resolver.resolve(dir)
191
248
  rescue StandardError
@@ -214,7 +271,7 @@ module Yamine
214
271
  return
215
272
  end
216
273
 
217
- if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES) && !opts[:force]
274
+ if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path)) && !opts[:force]
218
275
  $stderr.puts "Error: #{entry.path} has uncommitted changes."
219
276
  $stderr.puts " Commit or stash them, or re-run with --force to discard them."
220
277
  exit 1
@@ -309,7 +366,7 @@ module Yamine
309
366
  plan << Plan.new(:prune, entry)
310
367
  next
311
368
  end
312
- if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
369
+ if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path))
313
370
  plan << Plan.new(:keep, entry.path, "uncommitted changes are never cleaned automatically")
314
371
  next
315
372
  end
@@ -328,29 +385,55 @@ module Yamine
328
385
  end
329
386
 
330
387
  # An admin entry whose directory vanished (rm -rf, a crashed
331
- # agent): drop the database it claimed, forget the claim. The git
332
- # entry itself is cleared by the trailing prune. A template with
333
- # no server behind it (sqlite) means there is nothing to drop and
334
- # no retry that could help — the claim is simply forgotten.
388
+ # agent): drop every database it claimed, forget the claim. The
389
+ # git entry itself is cleared by the trailing prune. The claim
390
+ # itself carries the names AND the server coordinates — an
391
+ # orphan's config is unreadable by definition, and the app must
392
+ # not have to boot (or even still bundle) for cleanup to work.
393
+ # A claim with no server behind it (sqlite) means there is
394
+ # nothing to drop and no retry that could help — the claim is
395
+ # simply forgotten. A LEGACY claim with no reachable template
396
+ # anywhere (credentials apps pre-multi-database never declared
397
+ # one) is also forgotten, with the database name said out loud:
398
+ # erroring forever would leave `clean` permanently red for a
399
+ # claim no future command can ever resolve.
335
400
  def drop_orphan(ctx, name, _dir)
401
+ info = Database.load_map(ctx.store.dir)[name]
402
+ pairs = Database.claim_pairs(info)
336
403
  template = orphan_template(ctx)
337
- unless template
338
- $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in " \
339
- "config/local.yml) — cannot drop #{name}."
340
- return false
404
+
405
+ if pairs.nil?
406
+ unless template
407
+ warn " no DATABASE_URL template found (ENV or env.clear in config/local.yml) — " \
408
+ "cannot reach #{name}; claim forgotten."
409
+ warn " If a database named #{name} still exists on a server, drop it manually."
410
+ forget_claim(ctx, name)
411
+ return true
412
+ end
413
+ pairs = [[name, template]]
414
+ else
415
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || template)] }
341
416
  end
342
- unless %i[postgres mysql].include?(Database.adapter_for(template))
417
+
418
+ first_url = pairs.first&.[](1)
419
+ unless %i[postgres mysql].include?(first_url && Database.adapter_for(first_url))
343
420
  puts " #{name} has no server database (sqlite/unknown adapter) — claim forgotten"
344
421
  forget_claim(ctx, name)
345
422
  return true
346
423
  end
347
- case Database.drop(name, template)
348
- when :dropped then puts " dropped #{name}"
349
- when :missing then puts " #{name} did not exist — nothing to drop"
350
- when :failed
351
- warn " could not drop #{name} — is the server running? Claim kept for a retry."
352
- return false
424
+
425
+ failed = false
426
+ pairs.each do |db, tmpl|
427
+ case Database.drop(db, tmpl)
428
+ when :dropped then puts " dropped #{db}"
429
+ when :missing then puts " #{db} did not exist — nothing to drop"
430
+ when :failed
431
+ warn " could not drop #{db} — is the server running? Claim kept for a retry."
432
+ failed = true
433
+ end
353
434
  end
435
+ return false if failed
436
+
354
437
  forget_claim(ctx, name)
355
438
  true
356
439
  end
@@ -419,7 +502,7 @@ module Yamine
419
502
  stop_routes(ctx, dir)
420
503
 
421
504
  if claim
422
- outcome = drop_database(repo, dir, claim[0])
505
+ outcome = drop_database(repo, dir, claim[0], claim[1])
423
506
  if outcome == :failed
424
507
  warn " kept #{dir} — database drop failed; fix the server and re-run"
425
508
  return false
@@ -476,22 +559,35 @@ module Yamine
476
559
  end
477
560
  end
478
561
 
479
- def drop_database(repo, dir, name)
480
- template = drop_template_for(repo, dir)
481
- adapter = template && Database.adapter_for(template)
482
- unless %i[postgres mysql].include?(adapter)
562
+ # Drop every database a claim stands for: the whole suffixed set
563
+ # for a multi claim, the one key-named database for a legacy
564
+ # single claim. Any :failed aborts the whole teardown (kept for
565
+ # a retry) — a half-dropped set with a removed worktree is
566
+ # orphan territory, and orphans are the bug this guards.
567
+ def drop_database(repo, dir, name, info = nil)
568
+ fallback = drop_template_for(repo, dir)
569
+ pairs = Database.claim_pairs(info) || [[name, fallback]]
570
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || fallback)] }
571
+
572
+ first_url = pairs.first&.[](1)
573
+ unless %i[postgres mysql].include?(first_url && Database.adapter_for(first_url))
483
574
  puts " no server database to drop for #{name} " \
484
- "(#{template ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
575
+ "(#{first_url ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
485
576
  return :missing
486
577
  end
487
578
 
488
- outcome = Database.drop(name, template)
489
- case outcome
490
- when :dropped then puts " dropped #{name}"
491
- when :missing then puts " #{name} did not exist — nothing to drop"
492
- when :failed then puts " could not drop #{name} — is the database server running?"
579
+ outcomes = pairs.map do |db, tmpl|
580
+ outcome = Database.drop(db, tmpl)
581
+ case outcome
582
+ when :dropped then puts " dropped #{db}"
583
+ when :missing then puts " #{db} did not exist — nothing to drop"
584
+ when :failed then puts " could not drop #{db} — is the database server running?"
585
+ end
586
+ outcome
493
587
  end
494
- outcome
588
+ return :failed if outcomes.include?(:failed)
589
+
590
+ outcomes.include?(:dropped) ? :dropped : :missing
495
591
  end
496
592
 
497
593
  # Reach the database server from the worktree's own config (read
data/lib/yamine/cli.rb CHANGED
@@ -94,7 +94,7 @@ module Yamine
94
94
  yamine trust Add local CA to trust store
95
95
  yamine clean Remove state and hosts entries
96
96
  yamine prune Remove stale routes
97
- yamine db list|create|drop Per-worktree databases
97
+ yamine db list|create|drop|describe Per-worktree databases (multi-database aware)
98
98
  yamine worktree list|add|remove|clean Worktrees: create, inspect, full cleanup
99
99
  yamine skills install Install yamine skill into ~/.agents/skills/ (or --local)
100
100
  yamine proxy start|stop Control the proxy
@@ -32,6 +32,12 @@ module Yamine
32
32
 
33
33
  MAX_IDENTIFIER_BYTES = 63
34
34
  STATE_FILE = "databases.json"
35
+ # Written into a worktree at add time; the app's database.yml
36
+ # reads it and suffixes every database URL, so hand-run commands
37
+ # (console, test, db:migrate) land on the worktree's own
38
+ # databases too — env injection only ever reaches processes
39
+ # yamine spawns.
40
+ MARKER_FILE = ".yamine-db-suffix"
35
41
 
36
42
  # Database name for a worktree dir + env (development/test).
37
43
  # Main checkout (no worktree marker) keeps the bare name.
@@ -77,7 +83,12 @@ module Yamine
77
83
  if map.key?(name) && map[name]["dir"] != dir
78
84
  return nil
79
85
  end
80
- map[name] = { "dir" => dir, "claimed_at" => Time.now.utc.iso8601 }
86
+ # Merge, don't replace: boot re-claims on every run, and a
87
+ # multi-database claim's suffix/names/bases are stored under
88
+ # this key — a fresh hash would erase them and orphan the whole
89
+ # set on the next drop.
90
+ map[name] = (map[name] || {}).merge("dir" => dir,
91
+ "claimed_at" => Time.now.utc.iso8601)
81
92
  save_map(state_dir, map)
82
93
  name
83
94
  rescue SystemCallError
@@ -93,13 +104,99 @@ module Yamine
93
104
  {}
94
105
  end
95
106
 
107
+ # Claim URLs carry database passwords (the app's own resolved
108
+ # config, probed at worktree-add time), so the state file is
109
+ # private — it held no secrets before multi-database claims.
96
110
  def save_map(state_dir, map)
97
111
  FileUtils.mkdir_p(state_dir)
98
- File.write(File.join(state_dir, STATE_FILE), JSON.pretty_generate(map))
112
+ path = File.join(state_dir, STATE_FILE)
113
+ File.write(path, JSON.pretty_generate(map))
114
+ File.chmod(0o600, path)
99
115
  rescue SystemCallError
100
116
  nil
101
117
  end
102
118
 
119
+ # ── multi-database claims ─────────────────────────────────────
120
+ #
121
+ # A multi-database claim carries the whole set beside the usual
122
+ # dir/claimed_at:
123
+ # suffix the token every database name gains — also the marker
124
+ # file's contents, the one string yamine and the app's
125
+ # database.yml must agree on, byte for byte
126
+ # names config name -> actual database name
127
+ # bases config name -> the app's own URL (server coordinates
128
+ # for create/drop; where the suffix gets inserted)
129
+ # Single-database claims predate this and have none of the three;
130
+ # every reader falls back to the claim key as the one name.
131
+
132
+ # The suffix for a worktree: the claim key minus the env. Base
133
+ # names already carry the environment (myrr_markdown_development),
134
+ # so appending "_development" a second time only burns identifier
135
+ # budget. The claim key keeps the env (and the collision guard);
136
+ # the suffix is what has to fit inside a database name.
137
+ def suffix_for(claim_name, env)
138
+ stripped = claim_name.delete_suffix("_#{env}")
139
+ stripped.empty? ? claim_name : stripped
140
+ end
141
+
142
+ # Fit the suffix so `base + "_" + suffix` stays within the
143
+ # identifier cap for ANY base: budget against the longest one.
144
+ # Fitting happens once, here, because the marker file holds the
145
+ # suffix and the app only concatenates — a hand-run `rails
146
+ # console` must arrive at byte-identical names with no truncation
147
+ # logic of its own. Returns nil when even a hash-shortened suffix
148
+ # cannot fit (a base name so long no suffix follows it); callers
149
+ # name the problem instead of guessing.
150
+ def fit_suffix(suffix, dir, base_databases)
151
+ longest = base_databases.map { |d| d.to_s.bytesize }.max || 0
152
+ budget = MAX_IDENTIFIER_BYTES - longest - 1
153
+ return suffix if budget.positive? && suffix.bytesize <= budget
154
+ return nil if budget < 8
155
+
156
+ keep = budget - 7
157
+ "#{suffix.byteslice(0, keep).gsub(/_+\z/, "")}_#{short_hash(dir)}"
158
+ end
159
+
160
+ # The actual database name for one base under a suffix. Raises
161
+ # rather than truncating: silent truncation would diverge from
162
+ # what the marker file produces inside the app, and yamine names
163
+ # and app names disagreeing is exactly the bug this prevents.
164
+ # fit_suffix makes this unreachable for fitted suffixes; the
165
+ # raise guards programmatic misuse.
166
+ def multiname(base_database, suffix)
167
+ name = "#{base_database}_#{suffix}"
168
+ raise ArgumentError,
169
+ "database name exceeds #{MAX_IDENTIFIER_BYTES} bytes: #{name}" if name.bytesize > MAX_IDENTIFIER_BYTES
170
+
171
+ name
172
+ end
173
+
174
+ def write_marker(dir, suffix)
175
+ File.write(File.join(dir, MARKER_FILE), "#{suffix}\n")
176
+ end
177
+
178
+ def read_marker(dir)
179
+ path = File.join(dir, MARKER_FILE)
180
+ File.file?(path) ? File.read(path).strip : nil
181
+ end
182
+
183
+ # Keep the marker out of `git status` without touching the
184
+ # committed .gitignore: git's exclude file lives in the shared
185
+ # git dir, so one entry covers every worktree of the repo.
186
+ def exclude_marker(dir)
187
+ out, status = Open3.capture2("git", "-C", dir, "rev-parse", "--git-path", "info/exclude")
188
+ return unless status.success?
189
+
190
+ path = out.strip
191
+ path = File.expand_path(path, dir) unless path.start_with?("/")
192
+ return if File.file?(path) && File.read(path).lines.any? { |l| l.strip == MARKER_FILE }
193
+
194
+ FileUtils.mkdir_p(File.dirname(path))
195
+ File.open(path, "a") { |f| f.puts MARKER_FILE }
196
+ rescue SystemCallError, IOError
197
+ nil
198
+ end
199
+
103
200
  # The DATABASE_URL template per-worktree names derive from: ENV
104
201
  # first, then the config's top-level env.clear, then per-process
105
202
  # env.clear (configs written before top-level env existed). Shared
@@ -127,6 +224,29 @@ module Yamine
127
224
  load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
128
225
  end
129
226
 
227
+ # The multi-database claim under `key`, or nil — single-database
228
+ # claims (and foreign keys) simply have no names, and every reader
229
+ # falls back to the key itself as the one database name.
230
+ def multi_claim(state_dir, key)
231
+ info = load_map(state_dir)[key]
232
+ info if info.is_a?(Hash) && info["names"].is_a?(Hash) && !info["names"].empty?
233
+ end
234
+
235
+ # Every [database_name, server_url] pair a claim stands for: N
236
+ # pairs for a multi claim, nil for a legacy single claim (caller
237
+ # falls back to the key + its own template lookup). bases may lag
238
+ # names on older claims; a missing base falls back to the first —
239
+ # all of an app's databases live on one server in every layout
240
+ # yamine has seen.
241
+ def claim_pairs(info)
242
+ names = info.is_a?(Hash) ? info["names"] : nil
243
+ return nil if names.nil? || names.empty?
244
+
245
+ bases = info["bases"] || {}
246
+ default = bases.values.first
247
+ names.filter_map { |cfg, db| [db, (bases[cfg] || default)] }
248
+ end
249
+
130
250
  # Adapter detection from DATABASE_URL scheme. Returns :postgres,
131
251
  # :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
132
252
  # DATABASE_URL treatment; sqlite is left alone (relative paths
@@ -189,7 +309,11 @@ module Yamine
189
309
  return :failed unless reachable
190
310
 
191
311
  uri = URI.parse(database_url)
192
- _out, status = Open3.capture2("dropdb", name, env: pg_env(uri))
312
+ # env as the FIRST positional (like ensure_postgres): Open3
313
+ # forwards kwargs to spawn, and spawn has no env: option — as a
314
+ # kwarg this raised ArgumentError on every real drop, crashing
315
+ # teardown after the routes were already stopped.
316
+ _out, status = Open3.capture2(pg_env(uri), "dropdb", name)
193
317
  return :dropped if status.success?
194
318
 
195
319
  reachable, exists = pg_state(name, database_url)
@@ -293,9 +417,12 @@ module Yamine
293
417
  args
294
418
  end
295
419
 
420
+ # PGHOST only when the URL names a host: an empty authority
421
+ # (postgres:///dbname) means the unix socket, and forcing
422
+ # 127.0.0.1 there would reach the wrong server — or none.
296
423
  def pg_env(uri)
297
424
  {
298
- "PGHOST" => uri.host || "127.0.0.1",
425
+ "PGHOST" => (uri.host unless uri.host.to_s.empty?),
299
426
  "PGPORT" => (uri.port || 5432).to_s,
300
427
  "PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
301
428
  "PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+
7
+ module Yamine
8
+ # What databases an app actually has — asked, never guessed.
9
+ #
10
+ # A Rails multi-database app keeps its per-database URLs wherever it
11
+ # likes (credentials, ENV, hard-coded); yamine must not parse
12
+ # database.yml or touch decryption keys to learn them. The app
13
+ # itself answers: `bin/rails runner` prints the resolved
14
+ # configuration for the environment, so whatever the app would
15
+ # really connect to is exactly what yamine provisions, names, and
16
+ # drops. Credentials decrypt inside that process — the probe never
17
+ # sees a key.
18
+ #
19
+ # The runner emits one `YAMINE_DBS=<json>` line; Rails boot noise
20
+ # shares stdout, so the marker (never the last line) finds the
21
+ # payload. Any failure — no bin/rails, boot error, timeout — returns
22
+ # nil: the caller falls back to the single-database path. A probe is
23
+ # an enhancement, not a requirement, and it must never take a boot
24
+ # down with it.
25
+ module Probe
26
+ module_function
27
+
28
+ MARKER = "YAMINE_DBS="
29
+ TIMEOUT = 90
30
+
31
+ # Runs inside the app (bin/rails runner), so configuration_hash is
32
+ # fully resolved: Rails has already parsed any `url:` into
33
+ # components and decrypted credentials. The URL is rebuilt here
34
+ # from those components — a password travels from credentials to
35
+ # the claim file without yamine ever parsing config itself.
36
+ # SQLite-family adapters keep their scheme and skip the host part;
37
+ # relative paths already isolate per-worktree, so they never enter
38
+ # the multi-database claim.
39
+ SCRIPT = <<~'RUBY'
40
+ require "json"
41
+ require "uri"
42
+ rows = ActiveRecord::Base.configurations
43
+ .configs_for(env_name: ENV.fetch("RAILS_ENV", "development"))
44
+ .map do |c|
45
+ h = c.configuration_hash
46
+ db = h[:database].to_s
47
+ url = h[:url].to_s
48
+ if url.empty?
49
+ scheme = { "postgresql" => "postgres", "postgres" => "postgres",
50
+ "postgis" => "postgres", "mysql2" => "mysql",
51
+ "trilogy" => "mysql" }[h[:adapter].to_s]
52
+ if scheme
53
+ info = +""
54
+ if h[:username]
55
+ info << URI.encode_www_form_component(h[:username].to_s)
56
+ if h[:password]
57
+ info << ":" << URI.encode_www_form_component(h[:password].to_s)
58
+ end
59
+ info << "@"
60
+ end
61
+ host = h[:host].to_s
62
+ authority = host.empty? ? "#{info}" : "#{info}#{host}#{h[:port] ? ":#{h[:port]}" : ""}"
63
+ url = "#{scheme}://#{authority}/#{db}"
64
+ else
65
+ url = "#{h[:adapter]}:#{db}"
66
+ end
67
+ end
68
+ { "name" => c.name, "database" => db, "url" => url }
69
+ end
70
+ puts "YAMINE_DBS=#{JSON.generate(rows)}"
71
+ RUBY
72
+
73
+ # [{ "name", "database", "url" }, ...] for the environment, or nil
74
+ # when the app cannot answer (not Rails, boot failure, timeout).
75
+ def rails_databases(dir, env: nil)
76
+ env ||= ENV.fetch("RAILS_ENV", "development")
77
+ bin = File.join(dir, "bin", "rails")
78
+ return nil unless File.file?(bin)
79
+
80
+ out = +""
81
+ status = nil
82
+ # A stray DATABASE_URL (or NAME_DATABASE_URL) in the caller's
83
+ # environment would win over database.yml inside the resolver —
84
+ # the probe must see the app's own configuration. spawn MERGES
85
+ # the env hash over the inherited one, so merely omitting the
86
+ # keys would leave them in place: pass them as nil to unset.
87
+ scrub = ENV.keys
88
+ .select { |k| k == "DATABASE_URL" || k.end_with?("_DATABASE_URL") }
89
+ .to_h { |k| [k, nil] }
90
+ child_env = scrub.merge("RAILS_ENV" => env)
91
+ Open3.popen2e(child_env, bin, "runner", SCRIPT, chdir: dir) do |stdin, stdout, wait|
92
+ stdin.close
93
+ begin
94
+ Timeout.timeout(TIMEOUT) { out = stdout.read }
95
+ rescue Timeout::Error
96
+ Process.kill("KILL", wait.pid) rescue nil
97
+ wait.value rescue nil
98
+ return nil
99
+ end
100
+ status = wait.value
101
+ end
102
+ return nil unless status&.success?
103
+
104
+ parse(out)
105
+ rescue SystemCallError, IOError
106
+ nil
107
+ end
108
+
109
+ # The marker line amidst whatever the boot printed. JSON parse
110
+ # failure returns nil rather than raising: garbage in, "cannot
111
+ # probe" out.
112
+ def parse(output)
113
+ line = output.to_s.lines.find { |l| l.start_with?(MARKER) }
114
+ return nil unless line
115
+
116
+ rows = JSON.parse(line.delete_prefix(MARKER).strip)
117
+ return nil unless rows.is_a?(Array)
118
+
119
+ rows.select { |r| r.is_a?(Hash) && r["name"] && r["url"] && r["database"] }
120
+ .then { |r| r.empty? ? nil : r }
121
+ rescue JSON::ParserError
122
+ nil
123
+ end
124
+
125
+ # The subset that lives on a server (postgres/mysql). SQLite and
126
+ # friends need no provisioning — their relative paths are already
127
+ # per-worktree — so they never enter a claim.
128
+ def server_backed(rows)
129
+ rows.select { |r| %i[postgres mysql].include?(Yamine::Database.adapter_for(r["url"])) }
130
+ end
131
+ end
132
+ end
data/lib/yamine/proxy.rb CHANGED
@@ -485,7 +485,12 @@ module Yamine
485
485
  items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
486
486
  body = "<h1>No app registered for #{escape(bare)}</h1>" \
487
487
  "#{hint}<ul>#{items}</ul>"
488
- respond(sock, 404, body)
488
+ # 503, not 404: 404 says the app answered and has nothing at that path,
489
+ # and a client that believes it goes looking for a bug in the app. This
490
+ # is the proxy saying the app is not there — the same answer a dead
491
+ # backend gets (502 Bad Gateway), so no caller has to read the body to
492
+ # tell "not running" from "the app said no".
493
+ respond(sock, 503, body)
489
494
  end
490
495
 
491
496
  def render_bad_gateway(sock)