yamine 0.15.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +79 -6
- data/lib/ask/skills/yamine/SKILL.md +32 -11
- data/lib/yamine/cli/boot.rb +282 -42
- data/lib/yamine/cli/system.rb +105 -26
- data/lib/yamine/cli/worktree.rb +142 -46
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/database.rb +131 -4
- data/lib/yamine/probe.rb +132 -0
- data/lib/yamine/proxy.rb +6 -1
- data/lib/yamine/readiness.rb +27 -2
- data/lib/yamine/runner.rb +18 -10
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
data/lib/yamine/cli/worktree.rb
CHANGED
|
@@ -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
|
|
29
|
-
#
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
-
|
|
208
|
+
claim_key = Database.name_for(dir, env: BootCommand.rails_env,
|
|
170
209
|
state_dir: ctx.store.dir)
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
219
|
+
claim_key
|
|
175
220
|
end
|
|
176
221
|
|
|
177
|
-
def say_ready(ctx, dir, 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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
332
|
-
# entry itself is cleared by the trailing prune.
|
|
333
|
-
#
|
|
334
|
-
#
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
"(#{
|
|
575
|
+
"(#{first_url ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
|
|
485
576
|
return :missing
|
|
486
577
|
end
|
|
487
578
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/yamine/database.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/yamine/probe.rb
ADDED
|
@@ -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
|
-
|
|
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)
|