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/boot.rb
CHANGED
|
@@ -116,27 +116,29 @@ module Yamine
|
|
|
116
116
|
say opts, "yamine (#{service})"
|
|
117
117
|
say opts, "--"
|
|
118
118
|
|
|
119
|
-
# Per-worktree
|
|
120
|
-
# agents never share tables
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
119
|
+
# Per-worktree databases: one isolated set per directory so
|
|
120
|
+
# concurrent agents never share tables — every database the app
|
|
121
|
+
# declares (a Rails multi-database app may have five). The
|
|
122
|
+
# claim records the whole set; setup creates what's missing,
|
|
123
|
+
# loads schemas on first creation, and hands back the env to
|
|
124
|
+
# inject into every process. SQLite needs nothing (relative
|
|
125
|
+
# paths already isolate); the branch plays no part (dirs are
|
|
126
|
+
# stable, branches hop).
|
|
125
127
|
db_name = Database.name_for(Dir.pwd, env: rails_env,
|
|
126
128
|
state_dir: ctx.store.dir)
|
|
127
|
-
db_created,
|
|
128
|
-
events&.note("[db] #{db_name}#{db_created ? " (created)" : ""}") if
|
|
129
|
+
db_created, db_env = setup_database(ctx, resolved, db_name, events: events)
|
|
130
|
+
events&.note("[db] #{db_name}#{db_created ? " (created)" : ""}") if db_env && !db_env.empty?
|
|
129
131
|
|
|
130
132
|
with_wait = !opts[:no_wait]
|
|
131
|
-
spawn_plan = collect_spawns(ctx, runner, resolved, opts,
|
|
133
|
+
spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_env, children)
|
|
132
134
|
|
|
133
135
|
begin
|
|
134
136
|
if with_wait
|
|
135
137
|
boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
|
|
136
|
-
routes_registered,
|
|
138
|
+
routes_registered, db_env, db_name, db_created, events: events)
|
|
137
139
|
else
|
|
138
140
|
boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
|
|
139
|
-
routes_registered,
|
|
141
|
+
routes_registered, db_env, events: events)
|
|
140
142
|
end
|
|
141
143
|
rescue StandardError
|
|
142
144
|
# A raise mid-boot (route conflict, quota, unexpected error)
|
|
@@ -198,14 +200,14 @@ module Yamine
|
|
|
198
200
|
# (no route to wait on), HTTP processes become a spawn plan the
|
|
199
201
|
# boot mode executes. Compound-line refusal happens here so both
|
|
200
202
|
# paths share one rule.
|
|
201
|
-
def collect_spawns(ctx, runner, resolved, opts,
|
|
203
|
+
def collect_spawns(ctx, runner, resolved, opts, db_env, children)
|
|
202
204
|
plan = []
|
|
203
205
|
processes = resolved.processes
|
|
204
206
|
processes.each do |proc_name, entry|
|
|
205
207
|
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
206
208
|
if entry["proxy"] == false
|
|
207
209
|
boot_background(ctx, runner, resolved, proc_name, entry, opts,
|
|
208
|
-
children,
|
|
210
|
+
children, db_env)
|
|
209
211
|
next
|
|
210
212
|
end
|
|
211
213
|
next unless hostname
|
|
@@ -226,19 +228,20 @@ module Yamine
|
|
|
226
228
|
|
|
227
229
|
# Default: sequential boot, route registered per process as before.
|
|
228
230
|
def boot_sequential(ctx, runner, resolved, opts, plan, children,
|
|
229
|
-
routes_registered,
|
|
231
|
+
routes_registered, db_env, events: nil)
|
|
230
232
|
plan.each do |item|
|
|
231
233
|
say opts, " [#{item[:name]}] #{item[:url]}"
|
|
232
234
|
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
233
235
|
# this env var when not a Rails app — safe for every framework).
|
|
234
|
-
#
|
|
235
|
-
# frameworks that honor
|
|
236
|
-
# boot_run registers the route + backend
|
|
237
|
-
# (register: true default); no separate adopt
|
|
236
|
+
# db_env points every process at this worktree's own databases;
|
|
237
|
+
# non-Ruby frameworks that honor DATABASE_URL get isolation for
|
|
238
|
+
# free, others ignore it. boot_run registers the route + backend
|
|
239
|
+
# sidecar itself (register: true default); no separate adopt
|
|
240
|
+
# step needed.
|
|
238
241
|
app = runner.boot_run(name: item[:name], hostname: item[:hostname],
|
|
239
242
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
240
243
|
port: item[:port], force: opts[:force],
|
|
241
|
-
rails_dev_host: item[:hostname],
|
|
244
|
+
rails_dev_host: item[:hostname], database_env: db_env,
|
|
242
245
|
subdomains: resolved.subdomains,
|
|
243
246
|
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
244
247
|
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
@@ -253,14 +256,14 @@ module Yamine
|
|
|
253
256
|
# never leak into the proxy. A backend that dies before its route
|
|
254
257
|
# registers is cleaned up like any other failed process.
|
|
255
258
|
def boot_concurrent(ctx, runner, resolved, opts, plan, children,
|
|
256
|
-
routes_registered,
|
|
259
|
+
routes_registered, db_env, db_name, db_created, events: nil)
|
|
257
260
|
apps = {}
|
|
258
261
|
plan.each do |item|
|
|
259
262
|
say opts, " [#{item[:name]}] #{item[:url]}"
|
|
260
263
|
app = runner.spawn_http(name: item[:name], hostname: item[:hostname],
|
|
261
264
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
262
265
|
port: item[:port], rails_dev_host: item[:hostname],
|
|
263
|
-
|
|
266
|
+
database_env: db_env, force: opts[:force],
|
|
264
267
|
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
265
268
|
# Tracked before registration so any later failure (a raise
|
|
266
269
|
# during adopt, a crash while another process waits) can
|
|
@@ -285,7 +288,7 @@ module Yamine
|
|
|
285
288
|
end
|
|
286
289
|
end
|
|
287
290
|
finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
|
|
288
|
-
children, db_name,
|
|
291
|
+
children, db_name, db_env, db_created, json: opts[:json])
|
|
289
292
|
end
|
|
290
293
|
|
|
291
294
|
# --wait epilogue. Success: the summary line and, with --json, the
|
|
@@ -294,13 +297,14 @@ module Yamine
|
|
|
294
297
|
# and removed, the failed phase + that process's log tail is
|
|
295
298
|
# reported, and start exits 1 — no half-booted routes left behind.
|
|
296
299
|
def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
|
|
297
|
-
children, db_name,
|
|
300
|
+
children, db_name, db_env, db_created, json: false)
|
|
298
301
|
urls = apps.transform_values { |slot| slot[:item][:url] }
|
|
299
302
|
summary = "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
|
|
300
303
|
|
|
301
304
|
if failed.empty?
|
|
302
305
|
payload = Yamine::WaitPayload.success(resolved, wait_result, urls: urls,
|
|
303
|
-
db_name: db_name, db_url:
|
|
306
|
+
db_name: db_name, db_url: db_env && db_env["DATABASE_URL"],
|
|
307
|
+
created: db_created)
|
|
304
308
|
if json
|
|
305
309
|
$stderr.puts summary
|
|
306
310
|
puts JSON.generate(payload)
|
|
@@ -349,10 +353,10 @@ module Yamine
|
|
|
349
353
|
|
|
350
354
|
# A background process (proxy: false) is spawned, logged, and
|
|
351
355
|
# supervised exactly like an HTTP one — it just gets no route.
|
|
352
|
-
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children,
|
|
356
|
+
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_env)
|
|
353
357
|
cmd = entry["cmd"].to_s
|
|
354
358
|
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
355
|
-
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — split it into separate processes, or wrap in a script and point cmd: at it"
|
|
359
|
+
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — split it into separate processes, or wrap it in a script and point cmd: at it"
|
|
356
360
|
return
|
|
357
361
|
end
|
|
358
362
|
port = opts[:app_port] || Ports.find_free
|
|
@@ -360,7 +364,7 @@ module Yamine
|
|
|
360
364
|
app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
|
|
361
365
|
url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
|
|
362
366
|
force: opts[:force], rails_dev_host: nil, register: false,
|
|
363
|
-
|
|
367
|
+
database_env: db_env,
|
|
364
368
|
extra_env: build_env(resolved, entry, proc_name: proc_name))
|
|
365
369
|
children << { name: proc_name, pid: app.pid }
|
|
366
370
|
say opts, " [#{proc_name}] background (pid #{app.pid})"
|
|
@@ -447,23 +451,41 @@ module Yamine
|
|
|
447
451
|
ENV.fetch("RAILS_ENV", "development")
|
|
448
452
|
end
|
|
449
453
|
|
|
450
|
-
# Resolve this
|
|
451
|
-
# the app's schema-load command on first creation. Returns
|
|
452
|
-
# [created,
|
|
454
|
+
# Resolve this checkout's databases, create what's missing, and
|
|
455
|
+
# run the app's schema-load command on first creation. Returns
|
|
456
|
+
# [created, env]: created is true only when this boot created a
|
|
453
457
|
# database (so --wait can report provenance without a second
|
|
454
|
-
# probe),
|
|
458
|
+
# probe), env is the hash of variables to inject into every
|
|
459
|
+
# process (DATABASE_URL for primary, NAME_DATABASE_URL for the
|
|
460
|
+
# rest — Rails' own convention) or nil when there is nothing to
|
|
461
|
+
# inject.
|
|
455
462
|
#
|
|
456
|
-
#
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
#
|
|
463
|
+
# Three shapes, decided in order:
|
|
464
|
+
# * a multi-database claim (worktree recorded at add time) —
|
|
465
|
+
# the whole set is ensured and injected;
|
|
466
|
+
# * the main checkout with no template — nothing to isolate:
|
|
467
|
+
# the app's own databases ARE its databases. Silence, not
|
|
468
|
+
# the old false alarm;
|
|
469
|
+
# * a DATABASE_URL template (ENV or config env.clear) — the
|
|
470
|
+
# single-database path, unchanged. Injected DATABASE_URL
|
|
471
|
+
# overrides database.yml/credentials at connection time
|
|
472
|
+
# (ActiveRecord merges env over file config), so credentials
|
|
473
|
+
# apps work — they just need the template declared in config.
|
|
462
474
|
def setup_database(ctx, resolved, db_name, events: nil)
|
|
463
475
|
return [false, nil] if db_opted_out?(resolved)
|
|
464
476
|
|
|
477
|
+
claim = Database.multi_claim(ctx.store.dir, db_name)
|
|
478
|
+
return setup_multidb(resolved, claim, events: events) if claim
|
|
479
|
+
|
|
465
480
|
template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
|
|
466
481
|
if template.nil? || template.strip.empty?
|
|
482
|
+
if Database.main_checkout?(Dir.pwd) && rails_app?
|
|
483
|
+
Readiness.phase(:db, "main checkout — the app's own databases", sink: events) do
|
|
484
|
+
"unsuffixed; worktrees get isolated sets at `yamine worktree add`"
|
|
485
|
+
end
|
|
486
|
+
return [false, nil]
|
|
487
|
+
end
|
|
488
|
+
|
|
467
489
|
warn_missing_template(resolved)
|
|
468
490
|
return [false, nil]
|
|
469
491
|
end
|
|
@@ -483,15 +505,229 @@ module Yamine
|
|
|
483
505
|
end
|
|
484
506
|
created = db_event.detail.to_s.start_with?("created")
|
|
485
507
|
|
|
508
|
+
db_env = { "DATABASE_URL" => url }
|
|
486
509
|
schema_event = Readiness.phase(:schema, "load #{db_name}", sink: events) do
|
|
487
510
|
next "skipped (already exists)" unless created
|
|
488
511
|
|
|
489
|
-
run_schema_load(resolved,
|
|
512
|
+
run_schema_load(resolved, db_env)
|
|
490
513
|
end
|
|
491
514
|
unless schema_event.status == "ok"
|
|
492
515
|
$stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
|
|
493
516
|
end
|
|
494
|
-
[created,
|
|
517
|
+
[created, db_env]
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Multi-database worktree: the claim carries every database name
|
|
521
|
+
# plus the app's own URL for each. Create what's missing, hand
|
|
522
|
+
# back the env for the whole set, load schemas once on first
|
|
523
|
+
# creation.
|
|
524
|
+
#
|
|
525
|
+
# On creation failure the env is STILL handed back. A boot
|
|
526
|
+
# pointed at missing databases fails loudly at connect time,
|
|
527
|
+
# while a boot falling back to the shared base databases would
|
|
528
|
+
# silently run another checkout's tables — the exact bug
|
|
529
|
+
# isolation exists to kill. Loud and broken beats quiet and
|
|
530
|
+
# wrong.
|
|
531
|
+
def setup_multidb(resolved, claim, events: nil)
|
|
532
|
+
names = claim["names"]
|
|
533
|
+
bases = claim["bases"] || {}
|
|
534
|
+
env = db_env_for(names, bases)
|
|
535
|
+
created = false
|
|
536
|
+
|
|
537
|
+
db_event = Readiness.phase(:db, "ensure #{names.size} databases", sink: events) do
|
|
538
|
+
missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
|
|
539
|
+
failed = missing.filter_map do |cfg, actual|
|
|
540
|
+
actual unless Database.ensure_exists(actual, base_for(bases, cfg))
|
|
541
|
+
end
|
|
542
|
+
raise "could not create #{failed.join(', ')} — is postgres/mysql running?" unless failed.empty?
|
|
543
|
+
|
|
544
|
+
created = missing.any?
|
|
545
|
+
created ? "created #{missing.size}" : "already exist"
|
|
546
|
+
end
|
|
547
|
+
unless db_event.status == "ok"
|
|
548
|
+
$stderr.puts " [db] WARNING: #{db_event.detail}."
|
|
549
|
+
$stderr.puts " The isolated URLs stay in effect — the app cannot connect until they exist."
|
|
550
|
+
$stderr.puts " Start the database server and re-run `yamine start`."
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
if created
|
|
554
|
+
schema_event = Readiness.phase(:schema, "load #{names.size} schemas", sink: events) do
|
|
555
|
+
run_schema_load(resolved, env)
|
|
556
|
+
end
|
|
557
|
+
unless schema_event.status == "ok"
|
|
558
|
+
$stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
[created, env]
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# The env for a whole claim: DATABASE_URL for primary (the name
|
|
565
|
+
# every non-Rails framework reads too), NAME_DATABASE_URL per
|
|
566
|
+
# config — Rails resolves those over database.yml all by
|
|
567
|
+
# themselves, so even an app whose database.yml never heard of
|
|
568
|
+
# yamine gets full isolation in supervised processes.
|
|
569
|
+
def db_env_for(names, bases)
|
|
570
|
+
names.each_with_object({}) do |(cfg, actual), env|
|
|
571
|
+
url = Database.url_for(actual, base_for(bases, cfg))
|
|
572
|
+
next unless url
|
|
573
|
+
|
|
574
|
+
env["DATABASE_URL"] = url if cfg == "primary"
|
|
575
|
+
env["#{cfg.upcase}_DATABASE_URL"] = url
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# All of an app's databases live on one server in every layout
|
|
580
|
+
# yamine has seen; a per-config base with a missing entry falls
|
|
581
|
+
# back to the first.
|
|
582
|
+
def base_for(bases, cfg)
|
|
583
|
+
bases[cfg] || bases.values.first
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Worktree-add (and `yamine db create` in a worktree): turn a
|
|
587
|
+
# probe of the app's real databases into a provisioned,
|
|
588
|
+
# self-describing claim. Names come from the app, the suffix
|
|
589
|
+
# from the directory; claim + marker are written BEFORE any
|
|
590
|
+
# database is created, so a failure mid-way leaves a state the
|
|
591
|
+
# next `yamine db create` or boot can finish from — never a
|
|
592
|
+
# half-named set with no record of it.
|
|
593
|
+
#
|
|
594
|
+
# Returns the final names; raises Error only for conditions the
|
|
595
|
+
# user must fix (name budget, server down, app broken by its own
|
|
596
|
+
# suffix hook) — those keep the worktree and the claim.
|
|
597
|
+
def provision_multidb(ctx, dir, claim_key, rows)
|
|
598
|
+
env_name = rails_env
|
|
599
|
+
# A marker from a previous run means the probe answered with
|
|
600
|
+
# names the app already suffixed — strip that suffix before
|
|
601
|
+
# fitting, or this pass would double it and yamine would
|
|
602
|
+
# provision a set the app never connects to.
|
|
603
|
+
marker = Database.read_marker(dir)
|
|
604
|
+
if marker && !marker.empty?
|
|
605
|
+
rows = strip_marker_suffix(rows, marker)
|
|
606
|
+
end
|
|
607
|
+
# The test environment's database joins the claim: worktree
|
|
608
|
+
# tests run against it (the same marker suffixes it), and
|
|
609
|
+
# teardown must drop it — a leaked test database per worktree
|
|
610
|
+
# is exactly the leftover this exists to prevent. Test configs
|
|
611
|
+
# are flat (implicitly named "primary"), so the claim key is
|
|
612
|
+
# namespaced under "test" to not collide with the dev primary.
|
|
613
|
+
# A test env the app cannot answer (missing test credentials,
|
|
614
|
+
# say) degrades to a dev-only claim; `yamine db create` heals.
|
|
615
|
+
test_rows = Probe.rails_databases(dir, env: "test")
|
|
616
|
+
test_rows = strip_marker_suffix(test_rows, marker) if test_rows && marker && !marker.empty?
|
|
617
|
+
test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
|
|
618
|
+
.map { |r| [env_namespaced_key(r["name"], "test"), r] }
|
|
619
|
+
|
|
620
|
+
bases = rows.to_h { |r| [r["name"], r["url"]] }
|
|
621
|
+
.merge(test_pairs.to_h { |key, r| [key, r["url"]] })
|
|
622
|
+
suffix = Database.suffix_for(claim_key, env_name)
|
|
623
|
+
all_bases = rows.map { |r| r["database"] } + test_pairs.map { |_, r| r["database"] }
|
|
624
|
+
fitted = Database.fit_suffix(suffix, dir, all_bases)
|
|
625
|
+
unless fitted
|
|
626
|
+
longest = all_bases.max_by(&:to_s)&.bytesize
|
|
627
|
+
raise Error,
|
|
628
|
+
"a database name of #{longest} bytes leaves no room for a worktree " \
|
|
629
|
+
"suffix (#{Database::MAX_IDENTIFIER_BYTES}-byte limit): #{all_bases.inspect}\n" \
|
|
630
|
+
" Shorten the base names in the app's database configuration."
|
|
631
|
+
end
|
|
632
|
+
names = rows.to_h { |r| [r["name"], Database.multiname(r["database"], fitted)] }
|
|
633
|
+
.merge(test_pairs.to_h { |key, r| [key, Database.multiname(r["database"], fitted)] })
|
|
634
|
+
|
|
635
|
+
map = Database.load_map(ctx.store.dir)
|
|
636
|
+
map[claim_key] = (map[claim_key] || {}).merge(
|
|
637
|
+
"dir" => File.expand_path(dir),
|
|
638
|
+
"claimed_at" => Time.now.utc.iso8601,
|
|
639
|
+
"suffix" => fitted, "names" => names, "bases" => bases)
|
|
640
|
+
Database.save_map(ctx.store.dir, map)
|
|
641
|
+
Database.write_marker(dir, fitted)
|
|
642
|
+
Database.exclude_marker(dir)
|
|
643
|
+
|
|
644
|
+
# An empty test database is worse than none: Rails won't
|
|
645
|
+
# auto-load schema into it (verified — first `rails test`
|
|
646
|
+
# fails on missing schema_migrations, not a clear
|
|
647
|
+
# NoDatabaseError). So when THIS pass created the test DB,
|
|
648
|
+
# prepare it too — after that, tests just run.
|
|
649
|
+
test_needs_schema = names["test"] &&
|
|
650
|
+
!Database.exists?(names["test"], base_for(bases, "test"))
|
|
651
|
+
|
|
652
|
+
Dir.chdir(dir) { setup_database(ctx, Resolver.resolve(dir), claim_key) }
|
|
653
|
+
|
|
654
|
+
missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
|
|
655
|
+
unless missing.empty?
|
|
656
|
+
raise Error,
|
|
657
|
+
"could not create #{missing.values.join(', ')} — is the database server running?\n" \
|
|
658
|
+
" The worktree and its claim are kept; start postgres/mysql and run `yamine db create` here."
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
if test_needs_schema
|
|
662
|
+
prepared = Dir.chdir(dir) do
|
|
663
|
+
system({ "RAILS_ENV" => rails_env }, "sh", "-c", "bin/rails db:test:prepare",
|
|
664
|
+
out: File::NULL, err: File::NULL)
|
|
665
|
+
end
|
|
666
|
+
if prepared
|
|
667
|
+
puts " test database schema ready — tests run as-is"
|
|
668
|
+
else
|
|
669
|
+
warn " WARNING: could not prepare the test database — run `bin/rails db:test:prepare` in #{dir} before running tests"
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
verify_marker_conformance(dir, names)
|
|
674
|
+
names
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def strip_marker_suffix(rows, marker)
|
|
678
|
+
return rows if marker.nil? || marker.empty?
|
|
679
|
+
|
|
680
|
+
rows.map do |r|
|
|
681
|
+
if r["database"].end_with?("_#{marker}")
|
|
682
|
+
r.merge("database" => r["database"].delete_suffix("_#{marker}"))
|
|
683
|
+
else
|
|
684
|
+
r
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Flat configs (a lone `test:` section) resolve as "primary" —
|
|
690
|
+
# the claim key must not collide with the development primary.
|
|
691
|
+
def env_namespaced_key(cfg_name, env)
|
|
692
|
+
cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# The proof, not the promise: re-probe now that the marker file
|
|
696
|
+
# is in place. Matching names mean database.yml reads it —
|
|
697
|
+
# hand-run console/test/migrate commands are isolated too.
|
|
698
|
+
# Mismatch is not fatal (supervised boots still isolate via
|
|
699
|
+
# env), but it is the one gap worth shouting about at the exact
|
|
700
|
+
# moment of creation. A probe that cannot run at all after
|
|
701
|
+
# writing the marker IS fatal: the app no longer boots, and the
|
|
702
|
+
# marker's own authorship is the likely reason.
|
|
703
|
+
def verify_marker_conformance(dir, names)
|
|
704
|
+
rows = Probe.rails_databases(dir)
|
|
705
|
+
unless rows
|
|
706
|
+
raise Error,
|
|
707
|
+
"the app no longer boots after writing #{Database::MARKER_FILE} — " \
|
|
708
|
+
"check the suffix hook at the top of config/database.yml"
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
actual = rows.to_h { |r| [r["name"], r["database"]] }
|
|
712
|
+
# Keys absent from this probe are env-namespaced (the test
|
|
713
|
+
# database, probed separately) — the dev probe cannot vouch
|
|
714
|
+
# for them either way; skip, don't flag.
|
|
715
|
+
mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
|
|
716
|
+
if mismatched.empty?
|
|
717
|
+
puts " database.yml reads #{Database::MARKER_FILE} — hand-run commands are isolated too"
|
|
718
|
+
else
|
|
719
|
+
cfg, want = mismatched.first
|
|
720
|
+
warn " WARNING: config/database.yml does not read #{Database::MARKER_FILE} " \
|
|
721
|
+
"(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
|
|
722
|
+
warn " Supervised boots are still isolated via env, but `rails console`, " \
|
|
723
|
+
"`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
|
|
724
|
+
warn " Add the suffix hook from the yamine README to database.yml, then re-run `yamine db create`."
|
|
725
|
+
end
|
|
726
|
+
rescue StandardError => e
|
|
727
|
+
raise Error, e.message if e.is_a?(Error)
|
|
728
|
+
|
|
729
|
+
raise Error,
|
|
730
|
+
"the app no longer boots after writing #{Database::MARKER_FILE}: #{e.message}"
|
|
495
731
|
end
|
|
496
732
|
|
|
497
733
|
# Top-level `db: false` opts out of per-worktree databases.
|
|
@@ -548,13 +784,17 @@ module Yamine
|
|
|
548
784
|
# Schema-load on first boot uses the app's own command when declared
|
|
549
785
|
# (top-level db.schema_load in config/local.yml), else a Rails
|
|
550
786
|
# default when a Rails app is detected, else nothing (frameworks
|
|
551
|
-
# without a schema concept need no step).
|
|
552
|
-
|
|
787
|
+
# without a schema concept need no step). db_env is the whole
|
|
788
|
+
# injection hash — with a multi-database claim, Rails' schema
|
|
789
|
+
# tasks iterate every config and each resolves to its own
|
|
790
|
+
# isolated database. Returns a detail string.
|
|
791
|
+
def run_schema_load(resolved, db_env)
|
|
553
792
|
cmd = db_schema_load(resolved) ||
|
|
554
793
|
("bin/rails db:schema:load" if rails_app?)
|
|
555
794
|
return "no schema-load command" unless cmd
|
|
556
795
|
|
|
557
|
-
|
|
796
|
+
env = { "RAILS_ENV" => rails_env }.merge(db_env || {})
|
|
797
|
+
ok = system(env,
|
|
558
798
|
"sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
|
|
559
799
|
ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
|
|
560
800
|
end
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -861,18 +861,24 @@ module Yamine
|
|
|
861
861
|
puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
|
|
862
862
|
end
|
|
863
863
|
|
|
864
|
-
# Per-worktree databases: list/create/drop
|
|
865
|
-
# `db list` shows every
|
|
866
|
-
# `db create`
|
|
867
|
-
#
|
|
868
|
-
#
|
|
864
|
+
# Per-worktree databases: list/create/drop/describe.
|
|
865
|
+
# `db list` shows every claim and every database under it.
|
|
866
|
+
# `db create` asks the app what it has and provisions it — the
|
|
867
|
+
# suffixed set in a worktree, the base set in the main checkout
|
|
868
|
+
# (self-healing: run it after adding a database to the app).
|
|
869
|
+
# `db drop <name>` drops a claim's whole set — by claim key or
|
|
870
|
+
# by any actual database name (refuses without --force when its
|
|
871
|
+
# worktree still exists).
|
|
872
|
+
# `db describe` asks the app what it has and prints it — the
|
|
873
|
+
# probe behind create, exposed for debugging.
|
|
869
874
|
def db(ctx, args)
|
|
870
875
|
sub = args.first
|
|
871
876
|
case sub
|
|
872
877
|
when "list", nil then db_list(ctx)
|
|
873
878
|
when "create" then db_create(ctx)
|
|
874
879
|
when "drop" then db_drop(ctx, args[1..] || [])
|
|
875
|
-
|
|
880
|
+
when "describe" then db_describe(ctx)
|
|
881
|
+
else raise Error, "Usage: yamine db [list|create|drop <name> [--force]|describe]"
|
|
876
882
|
end
|
|
877
883
|
end
|
|
878
884
|
|
|
@@ -880,32 +886,64 @@ module Yamine
|
|
|
880
886
|
map = Database.load_map(ctx.store.dir)
|
|
881
887
|
if map.empty?
|
|
882
888
|
puts "No worktree databases claimed yet."
|
|
883
|
-
puts "
|
|
889
|
+
puts "Run `yamine worktree add <branch>` — or `yamine db create` — to claim one."
|
|
884
890
|
return
|
|
885
891
|
end
|
|
886
892
|
map.each do |name, info|
|
|
887
893
|
alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
|
|
888
894
|
puts " #{name} -> #{info["dir"]}#{alive}"
|
|
895
|
+
names = info.is_a?(Hash) ? info["names"] : nil
|
|
896
|
+
next unless names.is_a?(Hash) && !names.empty?
|
|
897
|
+
|
|
898
|
+
names.each_value { |db| puts " db: #{db}" }
|
|
889
899
|
end
|
|
890
900
|
end
|
|
891
901
|
|
|
892
902
|
def db_create(ctx)
|
|
893
|
-
|
|
894
|
-
name = Database.name_for(Dir.pwd, env: boot_env_name,
|
|
903
|
+
claim_key = Database.name_for(Dir.pwd, env: boot_env_name,
|
|
895
904
|
state_dir: ctx.store.dir)
|
|
905
|
+
rows = Probe.rails_databases(Dir.pwd)
|
|
906
|
+
server = rows && Probe.server_backed(rows)
|
|
907
|
+
|
|
908
|
+
if server && !server.empty?
|
|
909
|
+
if Database.main_checkout?(Dir.pwd)
|
|
910
|
+
db_create_main(server)
|
|
911
|
+
else
|
|
912
|
+
names = BootCommand.provision_multidb(ctx, Dir.pwd, claim_key, server)
|
|
913
|
+
puts "Database set ready (#{names.size}):"
|
|
914
|
+
names.each_value { |db| puts " #{db}" }
|
|
915
|
+
end
|
|
916
|
+
return
|
|
917
|
+
end
|
|
918
|
+
|
|
896
919
|
template = drop_template
|
|
897
920
|
unless template && !template.strip.empty?
|
|
898
921
|
$stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml)."
|
|
899
922
|
exit 1
|
|
900
923
|
end
|
|
901
|
-
if Database.ensure_exists(
|
|
902
|
-
puts "Database #{
|
|
924
|
+
if Database.ensure_exists(claim_key, template)
|
|
925
|
+
puts "Database #{claim_key} ready."
|
|
903
926
|
else
|
|
904
|
-
$stderr.puts "Error: could not create #{
|
|
927
|
+
$stderr.puts "Error: could not create #{claim_key}. Check the database server is running."
|
|
905
928
|
exit 1
|
|
906
929
|
end
|
|
907
930
|
end
|
|
908
931
|
|
|
932
|
+
# Main checkout: the databases ARE the app's own — no suffix, no
|
|
933
|
+
# marker, no claim names. Just make sure they exist.
|
|
934
|
+
def db_create_main(rows)
|
|
935
|
+
missing = rows.reject { |r| Database.exists?(r["database"], r["url"]) }
|
|
936
|
+
failed = missing.filter_map do |r|
|
|
937
|
+
r["database"] unless Database.ensure_exists(r["database"], r["url"])
|
|
938
|
+
end
|
|
939
|
+
unless failed.empty?
|
|
940
|
+
$stderr.puts "Error: could not create #{failed.join(', ')}. Check the database server."
|
|
941
|
+
exit 1
|
|
942
|
+
end
|
|
943
|
+
puts "Base databases ready (#{rows.size}):"
|
|
944
|
+
rows.each { |r| puts " #{r["database"]}" }
|
|
945
|
+
end
|
|
946
|
+
|
|
909
947
|
def db_drop(ctx, args)
|
|
910
948
|
force = args.delete("--force")
|
|
911
949
|
name = args.first
|
|
@@ -913,28 +951,69 @@ module Yamine
|
|
|
913
951
|
|
|
914
952
|
map = Database.load_map(ctx.store.dir)
|
|
915
953
|
info = map[name]
|
|
916
|
-
|
|
954
|
+
unless info
|
|
955
|
+
# A name from inside a claim identifies the claim — the set
|
|
956
|
+
# is the unit; half a suffixed set is orphan territory.
|
|
957
|
+
key, found = map.find { |_k, i| i.is_a?(Hash) && i.dig("names")&.value?(name) }
|
|
958
|
+
name, info = key, found if info.nil? && found
|
|
959
|
+
end
|
|
960
|
+
raise Error, "no claim named #{name.inspect} here — `yamine db list` shows what exists" unless info
|
|
961
|
+
|
|
962
|
+
if File.directory?(info["dir"]) && !force
|
|
917
963
|
$stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
|
|
918
964
|
exit 1
|
|
919
965
|
end
|
|
966
|
+
|
|
967
|
+
pairs = Database.claim_pairs(info) || begin
|
|
968
|
+
template = drop_template
|
|
969
|
+
unless template && !template.strip.empty?
|
|
970
|
+
$stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
|
|
971
|
+
exit 1
|
|
972
|
+
end
|
|
973
|
+
[[name, template]]
|
|
974
|
+
end
|
|
920
975
|
template = drop_template
|
|
921
|
-
|
|
922
|
-
|
|
976
|
+
pairs = pairs.map { |db, tmpl| [db, (tmpl || template)] }
|
|
977
|
+
|
|
978
|
+
failed = false
|
|
979
|
+
pairs.each do |db, tmpl|
|
|
980
|
+
case Database.drop(db, tmpl)
|
|
981
|
+
when :dropped then puts " dropped #{db}"
|
|
982
|
+
when :missing then puts " #{db} did not exist — nothing to drop"
|
|
983
|
+
when :failed then failed = true
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
if failed
|
|
987
|
+
$stderr.puts "Error: could not drop part of #{name} — is the database server running? The claim is kept."
|
|
923
988
|
exit 1
|
|
924
989
|
end
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
990
|
+
|
|
991
|
+
map.delete(name)
|
|
992
|
+
Database.save_map(ctx.store.dir, map)
|
|
993
|
+
puts "Dropped #{name}."
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
# The probe exposed: what THIS app, right here, resolves to —
|
|
997
|
+
# database.yml + credentials and all, asked inside the app so no
|
|
998
|
+
# key or parser is needed outside it. Passwords masked; this
|
|
999
|
+
# prints to terminals and CI logs.
|
|
1000
|
+
def db_describe(_ctx)
|
|
1001
|
+
rows = Probe.rails_databases(Dir.pwd)
|
|
1002
|
+
unless rows
|
|
1003
|
+
$stderr.puts "Error: could not ask this app for its databases."
|
|
1004
|
+
$stderr.puts " Is it a Rails app with a working `bin/rails runner`? (probe timed out or exited non-zero)"
|
|
936
1005
|
exit 1
|
|
937
1006
|
end
|
|
1007
|
+
rows.each do |r|
|
|
1008
|
+
kind = %i[postgres mysql].include?(Database.adapter_for(r["url"])) ? "server" : "local"
|
|
1009
|
+
puts " #{r["name"]} (#{kind})"
|
|
1010
|
+
puts " database: #{r["database"]}"
|
|
1011
|
+
puts " url: #{mask_url(r["url"])}"
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def mask_url(url)
|
|
1016
|
+
url.to_s.sub(%r{\A([a-z0-9+]+://[^:/@]+):[^@]*@}, '\1:***@')
|
|
938
1017
|
end
|
|
939
1018
|
|
|
940
1019
|
# Template for reaching the database server outside of boot:
|