yamine 0.15.2 → 0.17.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 +130 -0
- data/README.md +96 -6
- data/lib/ask/skills/yamine/SKILL.md +40 -13
- data/lib/yamine/cli/boot.rb +311 -42
- data/lib/yamine/cli/system.rb +105 -26
- data/lib/yamine/cli/worktree.rb +145 -45
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/database.rb +171 -4
- data/lib/yamine/probe.rb +136 -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,258 @@ 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 + .env files 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, .env breaking the
|
|
596
|
+
# app's boot) — those keep the worktree and the claim.
|
|
597
|
+
def provision_multidb(ctx, dir, claim_key, rows)
|
|
598
|
+
env_name = rails_env
|
|
599
|
+
# Re-provisioning a worktree whose .env exists: the app (via
|
|
600
|
+
# dotenv) resolves SUFFIXED names — strip the claim's stored
|
|
601
|
+
# suffix before fitting, or this pass would double it and
|
|
602
|
+
# yamine would provision a set the app never connects to.
|
|
603
|
+
prior = Database.load_map(ctx.store.dir)[claim_key]
|
|
604
|
+
prior_suffix = prior.is_a?(Hash) ? prior["suffix"] : nil
|
|
605
|
+
rows = strip_suffix(rows, prior_suffix)
|
|
606
|
+
# The test environment's database joins the claim: worktree
|
|
607
|
+
# tests run against it (.env.test carries its URL), and
|
|
608
|
+
# teardown must drop it — a leaked test database per worktree
|
|
609
|
+
# is exactly the leftover this exists to prevent. Test configs
|
|
610
|
+
# are flat (implicitly named "primary"), so the claim key is
|
|
611
|
+
# namespaced under "test" to not collide with the dev primary.
|
|
612
|
+
# A test env the app cannot answer (missing test credentials,
|
|
613
|
+
# say) degrades to a dev-only claim; `yamine db create` heals.
|
|
614
|
+
test_rows = Probe.rails_databases(dir, env: "test")
|
|
615
|
+
test_rows = strip_suffix(test_rows, prior_suffix)
|
|
616
|
+
test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
|
|
617
|
+
.map { |r| [env_namespaced_key(r["name"], "test"), r] }
|
|
618
|
+
|
|
619
|
+
bases = rows.to_h { |r| [r["name"], r["url"]] }
|
|
620
|
+
.merge(test_pairs.to_h { |key, r| [key, r["url"]] })
|
|
621
|
+
suffix = Database.suffix_for(claim_key, env_name)
|
|
622
|
+
all_bases = rows.map { |r| r["database"] } + test_pairs.map { |_, r| r["database"] }
|
|
623
|
+
fitted = Database.fit_suffix(suffix, dir, all_bases)
|
|
624
|
+
unless fitted
|
|
625
|
+
longest = all_bases.max_by(&:to_s)&.bytesize
|
|
626
|
+
raise Error,
|
|
627
|
+
"a database name of #{longest} bytes leaves no room for a worktree " \
|
|
628
|
+
"suffix (#{Database::MAX_IDENTIFIER_BYTES}-byte limit): #{all_bases.inspect}\n" \
|
|
629
|
+
" Shorten the base names in the app's database configuration."
|
|
630
|
+
end
|
|
631
|
+
names = rows.to_h { |r| [r["name"], Database.multiname(r["database"], fitted)] }
|
|
632
|
+
.merge(test_pairs.to_h { |key, r| [key, Database.multiname(r["database"], fitted)] })
|
|
633
|
+
|
|
634
|
+
map = Database.load_map(ctx.store.dir)
|
|
635
|
+
map[claim_key] = (map[claim_key] || {}).merge(
|
|
636
|
+
"dir" => File.expand_path(dir),
|
|
637
|
+
"claimed_at" => Time.now.utc.iso8601,
|
|
638
|
+
"suffix" => fitted, "names" => names, "bases" => bases)
|
|
639
|
+
Database.save_map(ctx.store.dir, map)
|
|
640
|
+
# The environment files: what hand-run commands read. Written
|
|
641
|
+
# before creation so the verify probe below can see them.
|
|
642
|
+
dev_env = db_env_for(
|
|
643
|
+
names.reject { |cfg, _| cfg == "test" || cfg.start_with?("test_") }, bases)
|
|
644
|
+
test_url = names["test"] &&
|
|
645
|
+
Database.url_for(names["test"], base_for(bases, "test"))
|
|
646
|
+
Database.write_env_files(dir, dev_env, test_url: test_url)
|
|
647
|
+
Database.exclude_files(dir, Database::ENV_FILES)
|
|
648
|
+
|
|
649
|
+
# An empty test database is worse than none: Rails won't
|
|
650
|
+
# auto-load schema into it (verified — first `rails test`
|
|
651
|
+
# fails on missing schema_migrations, not a clear
|
|
652
|
+
# NoDatabaseError). So when THIS pass created the test DB,
|
|
653
|
+
# prepare it too — after that, tests just run.
|
|
654
|
+
test_needs_schema = names["test"] &&
|
|
655
|
+
!Database.exists?(names["test"], base_for(bases, "test"))
|
|
656
|
+
|
|
657
|
+
Dir.chdir(dir) { setup_database(ctx, Resolver.resolve(dir), claim_key) }
|
|
658
|
+
|
|
659
|
+
missing = names.reject { |cfg, actual| Database.exists?(actual, base_for(bases, cfg)) }
|
|
660
|
+
unless missing.empty?
|
|
661
|
+
raise Error,
|
|
662
|
+
"could not create #{missing.values.join(', ')} — is the database server running?\n" \
|
|
663
|
+
" The worktree and its claim are kept; start postgres/mysql and run `yamine db create` here."
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
if test_needs_schema
|
|
667
|
+
# RAILS_ENV=test, not development: dotenv must load .env.test
|
|
668
|
+
# (PRIMARY_DATABASE_URL = this worktree's test URL) AND the
|
|
669
|
+
# environment-override merge only applies to the CURRENT
|
|
670
|
+
# env's configs — under development the flat test config
|
|
671
|
+
# would resolve to the base name and purge+load the MAIN
|
|
672
|
+
# checkout's test database instead of this worktree's.
|
|
673
|
+
prepared = Dir.chdir(dir) do
|
|
674
|
+
system({ "RAILS_ENV" => "test" }, "sh", "-c", "bin/rails db:test:prepare",
|
|
675
|
+
out: File::NULL, err: File::NULL)
|
|
676
|
+
end
|
|
677
|
+
if prepared
|
|
678
|
+
puts " test database schema ready — tests run as-is"
|
|
679
|
+
else
|
|
680
|
+
warn " WARNING: could not prepare the test database — run `bin/rails db:test:prepare` in #{dir} before running tests"
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
verify_env_loading(dir, names)
|
|
685
|
+
names
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def strip_suffix(rows, suffix)
|
|
689
|
+
return rows if rows.nil? || suffix.nil? || suffix.empty?
|
|
690
|
+
|
|
691
|
+
rows.map do |r|
|
|
692
|
+
if r["database"].end_with?("_#{suffix}")
|
|
693
|
+
r.merge("database" => r["database"].delete_suffix("_#{suffix}"))
|
|
694
|
+
else
|
|
695
|
+
r
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Flat configs (a lone `test:` section) resolve as "primary" —
|
|
701
|
+
# the claim key must not collide with the development primary.
|
|
702
|
+
def env_namespaced_key(cfg_name, env)
|
|
703
|
+
cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# The proof, not the promise: re-probe now that .env exists.
|
|
707
|
+
# Matching names mean something inside the app loads .env
|
|
708
|
+
# (dotenv-rails or any dotenv loader) — hand-run console/test/
|
|
709
|
+
# migrate commands are isolated too. Mismatch is not fatal
|
|
710
|
+
# (supervised boots isolate via injected env regardless), but
|
|
711
|
+
# it is the one gap worth shouting about at the exact moment of
|
|
712
|
+
# creation. A probe that cannot run at all after writing .env
|
|
713
|
+
# IS fatal: the app no longer boots, and .env's own authorship
|
|
714
|
+
# is the likely reason.
|
|
715
|
+
def verify_env_loading(dir, names)
|
|
716
|
+
rows = Probe.rails_databases(dir)
|
|
717
|
+
unless rows
|
|
718
|
+
raise Error,
|
|
719
|
+
"the app no longer boots after writing .env — " \
|
|
720
|
+
"check the generated .env files in #{dir}"
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
actual = rows.to_h { |r| [r["name"], r["database"]] }
|
|
724
|
+
# Keys absent from this probe are env-namespaced (the test
|
|
725
|
+
# database, probed separately) — the dev probe cannot vouch
|
|
726
|
+
# for them either way; skip, don't flag.
|
|
727
|
+
mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
|
|
728
|
+
if mismatched.empty?
|
|
729
|
+
puts " .env loaded — hand-run commands are isolated too"
|
|
730
|
+
else
|
|
731
|
+
cfg, want = mismatched.first
|
|
732
|
+
env_url = rows.first["env_database_url"]
|
|
733
|
+
if env_url && !env_url.empty?
|
|
734
|
+
# The loader DID run — DATABASE_URL reached the app — yet
|
|
735
|
+
# resolution ignored it. database.yml supplies `url:` keys
|
|
736
|
+
# (credentials-style), and for those Rails gives the FILE
|
|
737
|
+
# precedence: merge_db_environment_variables skips configs
|
|
738
|
+
# that are already URL-shaped. No environment channel,
|
|
739
|
+
# spawned or loaded, can redirect this app in that form.
|
|
740
|
+
warn " WARNING: #{cfg} resolves to #{actual[cfg].inspect} even though " \
|
|
741
|
+
"DATABASE_URL says #{env_url.split("/").last.inspect}."
|
|
742
|
+
warn " database.yml's `url:` keys take precedence over the environment in this configuration —"
|
|
743
|
+
warn " neither injected env nor .env can redirect them. Switch development/test to"
|
|
744
|
+
warn " component form (database:, host:, username:) so DATABASE_URL and .env apply —"
|
|
745
|
+
warn " see the yamine README's multi-database section."
|
|
746
|
+
else
|
|
747
|
+
warn " WARNING: this app does not load .env " \
|
|
748
|
+
"(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
|
|
749
|
+
warn " Supervised boots are still isolated via injected env, but `rails console`, " \
|
|
750
|
+
"`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
|
|
751
|
+
warn " Fix once: add `gem \"dotenv-rails\", groups: [:development, :test]` to the Gemfile — " \
|
|
752
|
+
"any dotenv loader works."
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
rescue StandardError => e
|
|
756
|
+
raise Error, e.message if e.is_a?(Error)
|
|
757
|
+
|
|
758
|
+
raise Error,
|
|
759
|
+
"the app no longer boots after writing .env: #{e.message}"
|
|
495
760
|
end
|
|
496
761
|
|
|
497
762
|
# Top-level `db: false` opts out of per-worktree databases.
|
|
@@ -548,13 +813,17 @@ module Yamine
|
|
|
548
813
|
# Schema-load on first boot uses the app's own command when declared
|
|
549
814
|
# (top-level db.schema_load in config/local.yml), else a Rails
|
|
550
815
|
# default when a Rails app is detected, else nothing (frameworks
|
|
551
|
-
# without a schema concept need no step).
|
|
552
|
-
|
|
816
|
+
# without a schema concept need no step). db_env is the whole
|
|
817
|
+
# injection hash — with a multi-database claim, Rails' schema
|
|
818
|
+
# tasks iterate every config and each resolves to its own
|
|
819
|
+
# isolated database. Returns a detail string.
|
|
820
|
+
def run_schema_load(resolved, db_env)
|
|
553
821
|
cmd = db_schema_load(resolved) ||
|
|
554
822
|
("bin/rails db:schema:load" if rails_app?)
|
|
555
823
|
return "no schema-load command" unless cmd
|
|
556
824
|
|
|
557
|
-
|
|
825
|
+
env = { "RAILS_ENV" => rails_env }.merge(db_env || {})
|
|
826
|
+
ok = system(env,
|
|
558
827
|
"sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
|
|
559
828
|
ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
|
|
560
829
|
end
|