yamine 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c28579a9f53f3078412006a6f697d366cd7f0b2f897e7cf52025b742b2d5ba2
4
- data.tar.gz: e7c826ffece34cf08ac88fcb31d1613418a9f3f5713badb08f6725937d906409
3
+ metadata.gz: 1fef68ea493be93af6dfb3271b446b793536a373d11b030f62e54c0d90438e88
4
+ data.tar.gz: 69c64d3bd1523b05ca6277f76329e20415879f16c95cd0640e30c9adf77f69cf
5
5
  SHA512:
6
- metadata.gz: e1a77d9dad1424f4cdbd0fa351224f626a858e986c6300063a70650dc7c02007f8ea8cbb83e2658a5f06e1a3ff8ba091986ce1480c28e510d9a096c287cc596d
7
- data.tar.gz: 9194bc35c52acdb669a177bb758af0539c2983056c8e7e4dbd700d937ea850a2947708fef0315d8e2d6281b33850bb96a2de4f26abb0878a2b1b8d3db91cd777
6
+ metadata.gz: 2864def84f4afefe2dd79adda834a2381964e964da864f99c8740321c19559669ad48874cce4485222c0a09d5a285b343d99d2fdea5f1029ee54f8dc8cd86a76
7
+ data.tar.gz: fe58b77da7a315e3f6aa3fd59f7c153fe8a0d342ffdbbdf923e4199e8ccba02b039c2cde777a7c9d1855d127563ef3f021171b308c1696f5f7446b9ac068430e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] — Unreleased
4
+
5
+ `yamine start --wait` + boot readiness protocol. The remaining gap from
6
+ “one-storey building” onwards: boot had no definitive answer — agents
7
+ polled, guessed from logs, and raced half-booted routes. Now every phase
8
+ (deps, db, schema, per-process health) has a real event and `--wait`
9
+ delivers one call with a pass/fail payload. Credentials-only DB URLs
10
+ warn loudly instead of sharing silently.
11
+
12
+ ### Added
13
+
14
+ - `Yamine::Readiness`: phased events (`phase`, `action`, `status`,
15
+ `duration_ms`, `detail`), `check_deps` pre-flight, `wait_healthy`
16
+ (healthcheck path or TCP accept), concurrent `wait_all` (one thread
17
+ per process, dead-pid short-circuit so a crash fails fast), per-phase
18
+ timeouts (deps 30s, db 15s, schema 90s, process 45s or per-healthcheck
19
+ timeout). `Yamine::WaitPayload` success/failure payloads for `--json`.
20
+ - `yamine start --wait [--json]`: spawns every process, polls until
21
+ healthy, registers routes only on success, kills everything (including
22
+ background children) and exits 1 with the failed phase + that process's
23
+ own log tail on failure — no half-booted routes. `--wait` is kept as a no-op alias;
24
+ `--no-wait` keeps the old sequential fire-and-forget path.
25
+ `--json` is only meaningful with wait (now default).
26
+ - Per-process `healthcheck: { path: /up, timeout: 30 }` in
27
+ `config/local.yml`, respected by `--wait`. Absent means TCP accept.
28
+ - Boot phases now visible: deps pre-flight, db (`exists?`/`ensure_exists`
29
+ split so provenance doesn't need a second probe), schema, per-process.
30
+ `Runner#spawn_http`/`adopt`/`spawned?` support the concurrent path;
31
+ `boot_run` child output is logged so failure payloads can tail it.
32
+
33
+ ### Fixed
34
+
35
+ - Top-level `db: false` opts out of per-worktree databases (exotic
36
+ setups: manual `establish_connection`, shared staging DB, …). Validated
37
+ as boolean-or-mapping; `x-` extensions still ignored. `db.schema_load`
38
+ moves to top-level `db` (per-process ghost `db` entry was a category
39
+ error). Credentials-stored `DATABASE_URL`s now work when the template is
40
+ declared in `env.clear` (password via `local.secrets`); when no template
41
+ exists but the app looks database-backed (`database.yml` adapter or
42
+ `pg`/`mysql2`/`trilogy` in `Gemfile`) boot warns loudly with the fix
43
+ instead of silently sharing one database. Framework-agnostic: injection
44
+ is still `DATABASE_URL`, no `ask-auth` dep, no Rails load at boot.
45
+
3
46
  ## [0.5.0] — Unreleased
4
47
 
5
48
  Per-worktree databases: every worktree directory gets its own database
data/README.md CHANGED
@@ -91,6 +91,12 @@ processes:
91
91
  proxy: false
92
92
  ```
93
93
 
94
+ Optional top-level `db: false` opts out of per-worktree databases
95
+ (exotic setups — manual `establish_connection`, shared staging DB, …);
96
+ `db.schema_load` overrides the schema-load command. Per-process
97
+ `healthcheck: { path: /up, timeout: 30 }` declares what `--wait` polls
98
+ (TCP accept when absent).
99
+
94
100
  `yamine init` creates the file (migrating an existing Procfile);
95
101
  Rails apps need no extra gem — yamine injects `RAILS_DEVELOPMENT_HOSTS`
96
102
  so the proxied hostname is allowed automatically. `yamine`
@@ -133,19 +139,21 @@ yamine --tld preview.example.com # your own domain (OAuth parity)
133
139
  ## Commands
134
140
 
135
141
  ```bash
136
- yamine # infer name, boot app
142
+ yamine # boot app (waits until healthy, then supervises)
143
+ yamine start --no-wait # fire-and-forget (register routes immediately)
144
+ yamine start --json # machine-readable wait result (--wait default)
137
145
  yamine run -- <cmd> # run explicit command through proxy
138
- yamine <name> <cmd> # explicit app name
139
146
  yamine get <name> # print URL for cross-service wiring
140
147
  yamine alias <name> <port> # static route (e.g. Docker)
141
148
  yamine list [--json] # show active routes (+ backend liveness)
142
149
  yamine status [--json] # show effective naming context here
143
150
  yamine doctor [--json] # machine-readable health checks
144
151
  yamine open [name] # open the app URL in a browser
145
- yamine doctor # read-only health check (state, proxy, routes, DNS, CA)
146
152
  yamine trust # add local CA to system trust store
147
153
  yamine clean # remove state and hosts entries
148
154
  yamine prune # remove stale routes
155
+ yamine db list|create|drop # per-worktree databases
156
+ yamine worktree list|clean # worktree databases + orphan cleanup
149
157
  yamine stop # stop this app's backend + routes
150
158
  yamine restart # touch tmp/restart.txt (managed apps reboot)
151
159
  yamine log [-f] [n] # tail (or follow) this app's backend log
@@ -186,12 +194,18 @@ connection (verified end-to-end: RFC 6455 handshake + frame echo).
186
194
 
187
195
  ## Machine-readable output
188
196
 
189
- `list`, `status`, and `doctor` accept `--json` with stable keys for
197
+ `list`, `status`, `doctor`, and `yamine start --wait` accept `--json` with stable keys for
190
198
  agents and scripts (`DevUrl` in ask-ruby-harness consumes the same data
191
199
  in-process). Hostnames that fall outside the configured TLDs get a bare
192
200
  404 naming nothing — route names never leak to foreign hosts
193
201
  (DNS-rebinding boundary).
194
202
 
203
+ `yamine start` (default `--wait`) exits 0 only once every HTTP route is healthy
204
+ (healthcheck path when declared, TCP accept otherwise). On failure it
205
+ exits 1 with the failed process, its phase, and the tail of its own log
206
+ — no guessing, no polling, no half-booted routes. `--no-wait` keeps the
207
+ old fire-and-forget path.
208
+
195
209
  ## Log rotation
196
210
 
197
211
  `proxy.log` and per-app backend logs rotate at 5MB
@@ -33,11 +33,13 @@ no extra gem — the proxied hostname is allowed automatically via
33
33
  `RAILS_DEVELOPMENT_HOSTS`.
34
34
 
35
35
  ```bash
36
- yamine start # setup if needed, then boot every process
37
- yamine # same as start
38
- yamine stop # stop this app's backend + routes
39
- yamine status # show service, processes, and URLs
40
- yamine log [-f] # tail the web process log
36
+ yamine start # setup if needed, then boot every process (waits until healthy)
37
+ yamine start --no-wait # fire-and-forget (register routes immediately)
38
+ yamine start --json # machine-readable wait result (--wait default)
39
+ yamine # same as start
40
+ yamine stop # stop this app's backend + routes
41
+ yamine status # show service, processes, and URLs
42
+ yamine log [-f] # tail the web process log
41
43
  ```
42
44
 
43
45
  `$PORT` and `YAMINE_URL` are injected per process; HTTP processes get
@@ -96,6 +98,7 @@ proxy:
96
98
  yamine doctor # read-only: proxy, routes, DNS, CA trust
97
99
  yamine list --json # routes as stable JSON
98
100
  yamine prune # clear stale routes from crashed sessions
101
+ yamine start --json # boot readiness payload (pass/fail + log tail)
99
102
  ```
100
103
 
101
104
  If a hostname does not resolve: `yamine hosts sync`. If the browser
@@ -16,7 +16,7 @@ module Yamine
16
16
  # boots every process, supervises the tree, cleans up on exit.
17
17
  def run_inferred(ctx, args)
18
18
  variant = ENV["YAMINE_VARIANT"]
19
- opts = ctx.parse_flags(args, %i[variant tld force])
19
+ opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
20
20
  resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
21
21
  # Ownership gate before any side effects: no proxy spawn, no
22
22
  # port allocation when we'd refuse anyway.
@@ -43,6 +43,7 @@ module Yamine
43
43
  tld = resolved.tld
44
44
  host = resolved.host
45
45
  processes = resolved.processes
46
+ events = opts[:events]
46
47
 
47
48
  if processes.empty?
48
49
  $stderr.puts "Error: no processes in config/local.yml. Add at least one."
@@ -55,6 +56,20 @@ module Yamine
55
56
  exit 1
56
57
  end
57
58
 
59
+ # Pre-flight: verify runtime deps BEFORE spawning anything.
60
+ # A missing bundle fails here in seconds with the fix, instead
61
+ # of a 60s Puma crash-loop ending in "did not boot".
62
+ deps = Readiness.phase(:deps, "check", out: events) do
63
+ ok, fix = Readiness.check_deps(Dir.pwd)
64
+ raise fix unless ok
65
+
66
+ "dependencies satisfied"
67
+ end
68
+ unless deps.status == "ok"
69
+ $stderr.puts "Error: #{deps.detail}"
70
+ exit 1
71
+ end
72
+
58
73
  runner = Runner.new(store: ctx.store)
59
74
  children = []
60
75
  routes_registered = []
@@ -70,59 +85,169 @@ module Yamine
70
85
  # branches hop).
71
86
  db_name = Database.name_for(Dir.pwd, env: rails_env,
72
87
  state_dir: ctx.store.dir)
73
- db_url = setup_database(ctx, resolved, db_name)
74
- puts " [db] #{db_name}" if db_url
88
+ db_created, db_url = setup_database(ctx, resolved, db_name, events: events)
89
+ puts " [db] #{db_name}#{db_created ? " (created)" : ""}" if db_url
90
+
91
+ with_wait = !opts[:no_wait]
92
+ spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
93
+
94
+ if with_wait
95
+ boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
96
+ routes_registered, db_url, db_name, db_created)
97
+ else
98
+ boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
99
+ routes_registered, db_url, events: events)
100
+ end
101
+
102
+ background = processes.select { |_, v| v["proxy"] == false }
103
+ unless background.empty?
104
+ puts " [background] #{background.keys.join(', ')}"
105
+ end
106
+
107
+ puts
108
+ ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
109
+
110
+ # Supervisor: exit when ANY child dies (loud cleanup).
111
+ all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
112
+ all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
113
+ trap_cleanup(ctx, all_hostnames, all_pids)
114
+ supervise_tree(ctx, all_hostnames, all_pids)
115
+ end
75
116
 
117
+ # One pass over config: background processes spawn immediately
118
+ # (no route to wait on), HTTP processes become a spawn plan the
119
+ # boot mode executes. Compound-line refusal happens here so both
120
+ # paths share one rule.
121
+ def collect_spawns(ctx, runner, resolved, opts, db_url, children)
122
+ plan = []
123
+ processes = resolved.processes
76
124
  processes.each do |proc_name, entry|
77
125
  hostname = Resolver.hostname_for(resolved, proc_name)
78
126
  if entry["proxy"] == false
79
- boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
127
+ boot_background(ctx, runner, resolved, proc_name, entry, opts,
128
+ children, db_url)
80
129
  next
81
130
  end
82
131
  next unless hostname
83
132
 
84
133
  url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
85
- hostnames = [hostname]
86
- puts " [#{proc_name}] #{url}"
87
-
88
- # Each process cmd runs through the shell so $PORT (and other
89
- # env refs) expand — same trust boundary as a Procfile line
90
- # (repo code, not user input). Compound lines are refused.
91
134
  cmd = entry["cmd"].to_s
92
135
  if cmd.match?(Yamine::Procfile::COMPOUND)
93
136
  $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
94
137
  next
95
138
  end
96
-
97
139
  port = opts[:app_port] || Ports.find_free
98
- shell_cmd = ["sh", "-c", cmd]
140
+ plan << { name: proc_name, entry: entry, hostname: hostname,
141
+ hostnames: [hostname], url: url, port: port,
142
+ command: ["sh", "-c", cmd] }
143
+ end
144
+ plan
145
+ end
146
+
147
+ # Default: sequential boot, route registered per process as before.
148
+ def boot_sequential(ctx, runner, resolved, opts, plan, children,
149
+ routes_registered, db_url, events: nil)
150
+ plan.each do |item|
151
+ puts " [#{item[:name]}] #{item[:url]}"
99
152
  # Always allow the proxied hostname in Rails dev (Rails ignores
100
153
  # this env var when not a Rails app — safe for every framework).
101
154
  # DATABASE_URL points at this worktree's own database; non-Ruby
102
155
  # frameworks that honor it get isolation for free, others ignore it.
103
- app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
104
- dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
105
- rails_dev_host: hostname, database_url: db_url)
106
- register_all(ctx, hostnames, app, force: opts[:force],
107
- spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
108
- routes_registered << { hostnames: hostnames, app: app }
109
-
110
- puts " -> #{url}"
156
+ # boot_run registers the route + backend sidecar itself
157
+ # (register: true default); no separate adopt step needed.
158
+ app = runner.boot_run(name: item[:name], hostname: item[:hostname],
159
+ url: item[:url], dir: Dir.pwd, command: item[:command],
160
+ port: item[:port], force: opts[:force],
161
+ rails_dev_host: item[:hostname], database_url: db_url)
162
+ routes_registered << { hostnames: item[:hostnames], app: app }
163
+
164
+ puts " -> #{item[:url]}"
111
165
  end
166
+ children
167
+ end
112
168
 
113
- background = processes.select { |_, v| v["proxy"] == false }
114
- unless background.empty?
115
- puts " [background] #{background.keys.join(', ')}"
169
+ # --wait: spawn everything first, then poll every port/path until
170
+ # healthy. No route exists until its backend answers, so half-boots
171
+ # never leak into the proxy. A backend that dies before its route
172
+ # registers is cleaned up like any other failed process.
173
+ def boot_concurrent(ctx, runner, resolved, opts, plan, children,
174
+ routes_registered, db_url, db_name, db_created)
175
+ apps = {}
176
+ plan.each do |item|
177
+ puts " [#{item[:name]}] #{item[:url]}"
178
+ apps[item[:name]] = {
179
+ item: item,
180
+ app: runner.spawn_http(name: item[:name], hostname: item[:hostname],
181
+ url: item[:url], dir: Dir.pwd, command: item[:command],
182
+ port: item[:port], rails_dev_host: item[:hostname],
183
+ database_url: db_url, force: opts[:force])
184
+ }
185
+ end
186
+ wait_result = Readiness.wait_all(apps, tls: ctx.proxy_tls, out: nil)
187
+ failed = wait_result.select { |r| r[:status] != "ok" }
188
+
189
+ if failed.empty?
190
+ apps.each do |name, slot|
191
+ runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
192
+ spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
193
+ routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
194
+ puts " -> #{slot[:item][:url]}"
195
+ end
116
196
  end
197
+ finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
198
+ children, db_name, db_url, db_created, json: opts[:json])
199
+ end
117
200
 
118
- puts
119
- ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
201
+ # --wait epilogue: on success the summary payload (URLs + per-process
202
+ # health) goes to stdout and boot continues into supervision. On
203
+ # failure every spawned child is killed and removed, the failure
204
+ # payload (failed phase + log tail) prints, and start exits 1 —
205
+ # no half-booted routes left behind.
206
+ def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
207
+ children, db_name, db_url, db_created, json: false)
208
+ urls = apps.transform_values { |slot| slot[:item][:url] }
209
+ if failed.empty?
210
+ puts "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
211
+ payload = Yamine::WaitPayload.success(resolved, wait_result, urls: urls,
212
+ db_name: db_name, db_url: db_url, created: db_created) if json
213
+ puts JSON.generate(payload) if json
214
+ return
215
+ end
120
216
 
121
- # Supervisor: exit when ANY child dies (loud cleanup).
122
- all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
123
- all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
124
- trap_cleanup(ctx, all_hostnames, all_pids)
125
- supervise_tree(ctx, all_hostnames, all_pids)
217
+ apps.each_value { |slot| stop_spawned(slot[:app]) }
218
+ children.each { |c| stop_spawned_pid(c[:pid]) }
219
+ first = failed.first
220
+ payload = Yamine::WaitPayload.failure(resolved, wait_result, failed: first,
221
+ log_tail: tail_for(failed.first, apps))
222
+ if json
223
+ puts JSON.generate(payload)
224
+ else
225
+ $stderr.puts "Error: #{first[:name]} failed (#{first[:phase]}): #{first[:detail]}"
226
+ $stderr.puts payload[:log_tail].to_s.lines.last(10).join if payload[:log_tail]
227
+ $stderr.puts "Full log: #{payload[:log_path]}" if payload[:log_path]
228
+ end
229
+ cleanup_routes(ctx, apps.values.map { |slot| slot[:item][:hostname] })
230
+ exit 1
231
+ end
232
+
233
+ def tail_for(failure, apps)
234
+ name = failure[:name]
235
+ slot = apps[name]
236
+ path = slot ? File.expand_path(File.join(Dir.pwd, "log", "yamine-#{name}.log")) : nil
237
+ lines = path && File.file?(path) ? File.readlines(path).last(20).join : "(no log file)"
238
+ { path: path, tail: lines }
239
+ rescue SystemCallError
240
+ { path: path, tail: "(unreadable log)" }
241
+ end
242
+
243
+ def stop_spawned(app)
244
+ stop_spawned_pid(app.pid)
245
+ end
246
+
247
+ def stop_spawned_pid(pid)
248
+ Process.kill("TERM", pid)
249
+ rescue SystemCallError
250
+ nil
126
251
  end
127
252
 
128
253
  # A background process (proxy: false) is spawned, logged, and
@@ -187,51 +312,125 @@ module Yamine
187
312
  end
188
313
 
189
314
  # Resolve this worktree's database, create it if missing, and run
190
- # the app's schema-load command on first creation. Returns the
191
- # DATABASE_URL for injection, or nil when there's nothing to do
192
- # (no template URL, sqlite, or the app opted out via db: false).
193
- def setup_database(ctx, resolved, db_name)
194
- return nil if resolved.processes["db"] == false
315
+ # the app's schema-load command on first creation. Returns
316
+ # [created, url]: created is true only when this boot created the
317
+ # database (so --wait can report provenance without a second
318
+ # probe), url is nil when there is nothing to inject.
319
+ #
320
+ # Template sources: ENV DATABASE_URL, then config env.clear
321
+ # DATABASE_URL (top-level env, falling back to per-process env
322
+ # for configs written before top-level env existed). Injected
323
+ # DATABASE_URL overrides database.yml/credentials at connection
324
+ # time (ActiveRecord merges env over file config), so credentials
325
+ # apps work — they just need the template declared in config.
326
+ def setup_database(ctx, resolved, db_name, events: nil)
327
+ return [false, nil] if db_opted_out?(resolved)
195
328
 
196
329
  template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
197
- return nil if template.nil? || template.strip.empty?
330
+ if template.nil? || template.strip.empty?
331
+ warn_missing_template(resolved)
332
+ return [false, nil]
333
+ end
198
334
 
199
335
  url = Database.url_for(db_name, template)
200
- return nil unless url
336
+ return [false, nil] unless url
337
+
338
+ db_event = Readiness.phase(:db, "ensure #{db_name}", out: events) do
339
+ existed = Database.exists?(db_name, template)
340
+ raise "database server unreachable — is postgres/mysql running?" unless Database.ensure_exists(db_name, template)
341
+
342
+ existed ? "already exists" : "created #{db_name}"
343
+ end
344
+ unless db_event.status == "ok"
345
+ $stderr.puts " [db] WARNING: #{db_event.detail} — processes share the template database."
346
+ return [false, nil]
347
+ end
348
+ created = db_event.detail.to_s.start_with?("created")
349
+
350
+ schema_event = Readiness.phase(:schema, "load #{db_name}", out: events) do
351
+ next "skipped (already exists)" unless created
201
352
 
202
- if Database.ensure_exists(db_name, template)
203
- puts " [db] created #{db_name}" unless database_exists?(db_name, template)
204
353
  run_schema_load(resolved, url)
205
- else
206
- $stderr.puts " [db] WARNING: could not create #{db_name} — processes share the template database."
207
- return nil
208
354
  end
209
- url
355
+ unless schema_event.status == "ok"
356
+ $stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
357
+ end
358
+ [created, url]
210
359
  end
211
360
 
212
- def database_exists?(name, template)
213
- # ensure_exists is idempotent; this second call is cheap (psql -lqt)
214
- # and tells us whether to print "created" vs nothing.
215
- Database.ensure_exists(name, template)
361
+ # Top-level `db: false` opts out of per-worktree databases.
362
+ # (A `db` process entry still boots as a process — only the
363
+ # explicit top-level key opts out.)
364
+ def db_opted_out?(resolved)
365
+ resolved.db == false
366
+ end
367
+
368
+ # The dangerous case made loud: the app looks database-backed
369
+ # (server adapter in database.yml, or pg/mysql2 in the Gemfile)
370
+ # but no template was declared, so every worktree would silently
371
+ # share one database. Name the fix; do not guess.
372
+ def warn_missing_template(resolved)
373
+ return unless database_backed?
374
+
375
+ $stderr.puts " [db] WARNING: app looks database-backed but no DATABASE_URL template found."
376
+ $stderr.puts " Each worktree would share one database. Declare the template in config/local.yml:"
377
+ $stderr.puts " env:"
378
+ $stderr.puts " clear:"
379
+ $stderr.puts " DATABASE_URL: postgres://user@localhost:5432/myapp_development"
380
+ $stderr.puts " (password via config/local.secrets + env.secret), or opt out with top-level `db: false`."
381
+ end
382
+
383
+ # Heuristics, deliberately conservative: only warn when there is
384
+ # positive evidence of a server database. SQLite-only and
385
+ # database-less apps stay silent.
386
+ def database_backed?
387
+ database_yml_server? || gemfile_server_adapter?
388
+ end
389
+
390
+ def database_yml_server?(path = File.join(Dir.pwd, "config", "database.yml"))
391
+ return false unless File.file?(path)
392
+
393
+ content = File.read(path)
394
+ content.match?(/adapter:\s*(postgresql|postgres|postgis|mysql2?|trilogy)/i)
395
+ rescue SystemCallError
396
+ false
397
+ end
398
+
399
+ def gemfile_server_adapter?(path = File.join(Dir.pwd, "Gemfile"))
400
+ return false unless File.file?(path)
401
+
402
+ content = File.read(path)
403
+ content.match?(/gem\s+["'](pg|mysql2|trilogy)["']/)
404
+ rescue SystemCallError
405
+ false
216
406
  end
217
407
 
218
408
  def database_template_from_config(resolved)
409
+ top = resolved.env.is_a?(Hash) ? resolved.env["clear"] : nil
410
+ from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
411
+ return from_top if from_top && !from_top.to_s.strip.empty?
412
+
219
413
  env = resolved.processes.values.map { |e| e["env"] || {} }
220
414
  clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
221
415
  clear["DATABASE_URL"]
222
416
  end
223
417
 
224
418
  # Schema-load on first boot uses the app's own command when declared
225
- # (db.schema_load in config/local.yml), else a Rails default when a
226
- # Rails app is detected, else nothing (frameworks without a
227
- # schema concept need no step).
419
+ # (top-level db.schema_load in config/local.yml), else a Rails
420
+ # default when a Rails app is detected, else nothing (frameworks
421
+ # without a schema concept need no step). Returns a detail string.
228
422
  def run_schema_load(resolved, url)
229
- cmd = resolved.processes.dig("db", "schema_load") ||
423
+ cmd = db_schema_load(resolved) ||
230
424
  ("bin/rails db:schema:load" if rails_app?)
231
- return unless cmd
425
+ return "no schema-load command" unless cmd
232
426
 
233
- system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
427
+ ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
234
428
  "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
429
+ ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
430
+ end
431
+
432
+ def db_schema_load(resolved)
433
+ resolved.db.is_a?(Hash) ? resolved.db["schema_load"] : nil
235
434
  end
236
435
 
237
436
  def rails_app?
@@ -290,21 +489,6 @@ module Yamine
290
489
  end
291
490
  end
292
491
 
293
- def register_all(ctx, hostnames, app, force:, spec: nil)
294
- hostnames[1..].each do |h|
295
- ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
296
- force: force, spec: spec)
297
- write_backend_sidecar(ctx, h, app.pid)
298
- end
299
- end
300
-
301
- def write_backend_sidecar(ctx, hostname, pid)
302
- ctx.store.ensure_dir
303
- File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
304
- rescue SystemCallError
305
- nil
306
- end
307
-
308
492
  def cleanup_routes(ctx, hostnames)
309
493
  hostnames.each do |h|
310
494
  begin
@@ -81,7 +81,7 @@ module Yamine
81
81
  elsif arg.start_with?("--")
82
82
  key = arg.sub(/\A--/, "").tr("-", "_").to_sym
83
83
  if known.include?(key)
84
- if %i[branch force].include?(key)
84
+ if %i[branch force wait no_wait json].include?(key)
85
85
  opts[key] = true
86
86
  i += 1
87
87
  else
@@ -657,18 +657,28 @@ module Yamine
657
657
  def start(ctx, args)
658
658
  if args.include?("--help") || args.include?("-h")
659
659
  puts <<~HELP
660
- Usage: yamine start [name] [cmd...] [options]
660
+ Usage: yamine start [options]
661
661
 
662
662
  One-setup-and-go: if the workstation isn't ready (CA, proxy,
663
663
  hosts), runs the minimal needed setup first, then boots the
664
664
  app in the current directory.
665
665
 
666
- yamine start # infer name, boot -> https://<app>.localhost
667
- yamine start myapp # explicit name
666
+ yamine start # block until every route is healthy, then supervise
667
+ yamine start --no-wait # fire-and-forget (register routes immediately)
668
+ yamine start --json # machine-readable result payload (--wait only)
668
669
  yamine start -- --help # pass --help to the app, not here
669
670
 
670
671
  Options are passed through to the boot path:
671
- --name <name> --service <svc> --variant <v> --tld <tld> --branch
672
+ --variant <v> --tld <tld> --force --app-port <port>
673
+ --wait (alias, default) --no-wait --json
674
+
675
+ Default waits: every process is spawned concurrently, each
676
+ healthcheck (or TCP accept when none is declared) is polled,
677
+ routes are registered only when all are healthy, and `ready:`
678
+ is printed with exit 0. On failure everything spawned is
679
+ killed, the failed process + its log tail is printed, and
680
+ the CLI exits 1 — no half-booted routes. `--no-wait` keeps
681
+ the old fire-and-forget path.
672
682
 
673
683
  Setup failures become hard errors pointing at `yamine setup`;
674
684
  non-interactive CI without a running proxy exits immediately.
data/lib/yamine/cli.rb CHANGED
@@ -94,8 +94,7 @@ module Yamine
94
94
  yamine restart Touch tmp/restart.txt
95
95
  yamine log [-f] [n] Tail (or follow) this app's backend log
96
96
 
97
- Flags: --name, --service, --variant, --tld, --branch, --force,
98
- --app-port, --proc (pick a Procfile process, e.g. --proc web)
97
+ Flags: --variant, --tld, --force, --app-port, --wait (default), --no-wait, --json
99
98
  Env: YAMINE_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, YAMINE_BRANCH=1
100
99
  HELP
101
100
  end
data/lib/yamine/config.rb CHANGED
@@ -53,6 +53,10 @@ module Yamine
53
53
  "tld" => "localhost",
54
54
  "host" => "myapp.local.example.com"
55
55
  },
56
+ # db: false opts out of per-worktree databases entirely (exotic
57
+ # setups: manual establish_connection, shared staging DB, ...).
58
+ # Absent means enabled. A mapping holds future db options.
59
+ "db" => false,
56
60
  "processes" => {
57
61
  "web" => {
58
62
  "cmd" => "bin/rails server -p $PORT",
@@ -238,6 +242,9 @@ module Yamine
238
242
  if data["proxy"]
239
243
  validate_proxy(data["proxy"], "#{@path} proxy")
240
244
  end
245
+ if data.key?("db")
246
+ validate_db(data["db"], "#{@path} db")
247
+ end
241
248
  if data["processes"]
242
249
  validate_processes(data["processes"], "#{@path} processes")
243
250
  end
@@ -269,6 +276,11 @@ module Yamine
269
276
  end
270
277
  end
271
278
 
279
+ def validate_db(value, context)
280
+ return if value == false
281
+ raise ConfigError, "#{context} must be false or a mapping" unless value.is_a?(Hash)
282
+ end
283
+
272
284
  def validate_processes(value, context)
273
285
  raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
274
286
  raise ConfigError, "#{context} must list at least one process" if value.empty?
@@ -136,6 +136,16 @@ module Yamine
136
136
  nil
137
137
  end
138
138
 
139
+ # True when the named database already exists. False covers both
140
+ # missing and unreachable server — ensure_exists decides creation.
141
+ def exists?(name, database_url)
142
+ case adapter_for(database_url)
143
+ when :postgres then pg_exists?(name, database_url)
144
+ when :mysql then mysql_exists?(name, database_url)
145
+ else false
146
+ end
147
+ end
148
+
139
149
  # Create the database if missing. Uses createdb/mysqladmin when
140
150
  # available; returns true/false, never raises (boot reports, not dies).
141
151
  def ensure_exists(name, database_url)
@@ -149,13 +159,7 @@ module Yamine
149
159
  def ensure_postgres(name, database_url)
150
160
  uri = URI.parse(database_url)
151
161
  env = pg_env(uri)
152
- _out, status = Open3.capture2(env, "psql", "-lqt")
153
- return false unless status.success?
154
-
155
- exists = Open3.capture2(env, "psql", "-lqt")[0].split("\n").any? do |line|
156
- line.split("|").first.to_s.strip == name
157
- end
158
- return true if exists
162
+ return true if pg_exists?(name, database_url)
159
163
 
160
164
  _out, status = Open3.capture2(env, "createdb", name)
161
165
  status.success?
@@ -163,24 +167,43 @@ module Yamine
163
167
  false
164
168
  end
165
169
 
166
- def ensure_mysql(name, database_url)
170
+ def pg_exists?(name, database_url)
167
171
  uri = URI.parse(database_url)
168
- args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
169
- "-u", URI.decode_www_form_component(uri.user || "root")]
170
- args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
171
- _out, status = Open3.capture2("mysqladmin", *args, "create", name)
172
- status.success? || already_exists_mysql(args, name)
172
+ out, status = Open3.capture2(pg_env(uri), "psql", "-lqt")
173
+ return false unless status.success?
174
+
175
+ out.split("\n").any? do |line|
176
+ line.split("|").first.to_s.strip == name
177
+ end
173
178
  rescue SystemCallError
174
179
  false
175
180
  end
176
181
 
177
- def already_exists_mysql(args, name)
182
+ def mysql_exists?(name, database_url)
183
+ uri = URI.parse(database_url)
184
+ args = mysql_args(uri)
178
185
  out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
179
186
  status.success? && out.split("\n").include?(name)
180
187
  rescue SystemCallError
181
188
  false
182
189
  end
183
190
 
191
+ def ensure_mysql(name, database_url)
192
+ uri = URI.parse(database_url)
193
+ args = mysql_args(uri)
194
+ _out, status = Open3.capture2("mysqladmin", *args, "create", name)
195
+ status.success? || mysql_exists?(name, database_url)
196
+ rescue SystemCallError
197
+ false
198
+ end
199
+
200
+ def mysql_args(uri)
201
+ args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
202
+ "-u", URI.decode_www_form_component(uri.user || "root")]
203
+ args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
204
+ args
205
+ end
206
+
184
207
  def pg_env(uri)
185
208
  {
186
209
  "PGHOST" => uri.host || "127.0.0.1",
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "openssl"
7
+ require "socket"
8
+ require "timeout"
9
+ require "uri"
10
+
11
+ module Yamine
12
+ # Phased boot readiness: every boot step emits a structured event
13
+ # (phase, action, status, duration_ms, detail) so agents get one call
14
+ # with a definitive answer instead of polling logs and guessing.
15
+ #
16
+ # Phases: deps (bundle check etc) -> db (create) -> schema (load) ->
17
+ # process (spawn + healthcheck per process). Each phase has its own
18
+ # timeout; a timeout reports that phase's logs, not the whole tree's.
19
+ #
20
+ # `start --wait` blocks until every HTTP route is healthy and exits 0
21
+ # with a summary payload, or non-zero with the failed phase + log tail.
22
+ module Readiness
23
+ module_function
24
+
25
+ DEFAULT_TIMEOUTS = {
26
+ deps: 30,
27
+ db: 15,
28
+ schema: 90,
29
+ process: 45
30
+ }.freeze
31
+
32
+ Event = Struct.new(:phase, :action, :status, :duration_ms, :detail,
33
+ keyword_init: true) do
34
+ def to_h
35
+ { phase: phase, action: action, status: status,
36
+ duration_ms: duration_ms, detail: detail }.compact
37
+ end
38
+ end
39
+
40
+ # Run a phase with timing + timeout. Yields; returns the Event.
41
+ # On timeout or raise, status is "fail" with the error as detail.
42
+ def phase(name, action, timeout: nil, out: nil)
43
+ timeout ||= DEFAULT_TIMEOUTS.fetch(name, 30)
44
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ event = nil
46
+ begin
47
+ result = Timeout.timeout(timeout) { yield }
48
+ ms = elapsed_ms(started)
49
+ event = Event.new(phase: name, action: action, status: "ok",
50
+ duration_ms: ms, detail: result.is_a?(String) ? result : nil)
51
+ rescue Timeout::Error
52
+ ms = elapsed_ms(started)
53
+ event = Event.new(phase: name, action: action, status: "timeout",
54
+ duration_ms: ms, detail: "exceeded #{timeout}s")
55
+ rescue StandardError => e
56
+ ms = elapsed_ms(started)
57
+ event = Event.new(phase: name, action: action, status: "fail",
58
+ duration_ms: ms, detail: e.message.lines.first&.strip)
59
+ end
60
+ out&.puts(JSON.generate(event.to_h)) if out
61
+ event
62
+ end
63
+
64
+ def elapsed_ms(started)
65
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
66
+ end
67
+
68
+ # Pre-flight: verify runtime dependencies before spawning anything.
69
+ # Ruby (bundle check), Node (node_modules present). Returns [ok, fix].
70
+ # Fast-fails with the fix instead of letting Puma crash-loop for 60s.
71
+ def check_deps(dir = Dir.pwd)
72
+ gemfile = File.join(dir, "Gemfile")
73
+ if File.file?(gemfile)
74
+ _out, status = Open3.capture2("bundle", "check", chdir: dir,
75
+ err: File::NULL)
76
+ unless status.success?
77
+ return [false, "gems missing — run `bundle install` in #{dir}"]
78
+ end
79
+ end
80
+ package = File.join(dir, "package.json")
81
+ if File.file?(package) && !File.directory?(File.join(dir, "node_modules"))
82
+ return [false, "node_modules missing — run `npm install` in #{dir}"]
83
+ end
84
+ [true, nil]
85
+ rescue SystemCallError => e
86
+ [false, e.message.lines.first&.strip]
87
+ end
88
+
89
+ # Poll an HTTP route until healthy or timeout. Healthy = healthcheck
90
+ # path returns 2xx-3xx, or (no healthcheck) TCP accept on the port.
91
+ # Returns [healthy, detail].
92
+ def wait_healthy(hostname, port:, tls:, path: nil, timeout: 30)
93
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
94
+ last_error = "not yet attempted"
95
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
96
+ ok, detail = probe(hostname, port, tls: tls, path: path)
97
+ return [true, nil] if ok
98
+
99
+ last_error = detail
100
+ sleep 0.5
101
+ end
102
+ [false, last_error]
103
+ end
104
+
105
+ def probe(hostname, port, tls:, path: nil)
106
+ if path
107
+ probe_http(hostname, port, tls: tls, path: path)
108
+ else
109
+ probe_tcp(port)
110
+ end
111
+ end
112
+
113
+ def probe_tcp(port)
114
+ TCPSocket.new("127.0.0.1", port).close
115
+ [true, nil]
116
+ rescue SystemCallError => e
117
+ [false, e.message.lines.first&.strip]
118
+ end
119
+
120
+ def probe_http(hostname, port, tls:, path:)
121
+ http = Net::HTTP.new("127.0.0.1", port)
122
+ http.use_ssl = tls
123
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if tls
124
+ http.open_timeout = 3
125
+ http.read_timeout = 5
126
+ res = http.get(path, { "Host" => hostname })
127
+ if res.code.to_i.between?(200, 399)
128
+ [true, nil]
129
+ else
130
+ [false, "HTTP #{res.code} on #{path}"]
131
+ end
132
+ rescue StandardError => e
133
+ [false, e.message.lines.first&.strip]
134
+ end
135
+
136
+ # Wait for every spawned app concurrently. Each entry: name =>
137
+ # { item: {entry, hostname, port...}, app: <Runner::App> }.
138
+ # Returns an array of result hashes: {name, status, phase, detail,
139
+ # duration_ms}. status is "ok", "fail" (died with log tail hint),
140
+ # or "timeout" (never answered within its healthcheck timeout).
141
+ # Dead-pid short-circuit: a reaped backend fails immediately
142
+ # instead of burning its full timeout on connection-refused.
143
+ def wait_all(apps, tls: true, out: nil)
144
+ threads = apps.map do |name, slot|
145
+ Thread.new do
146
+ Thread.current[:result] = wait_one(name, slot, tls: tls, out: out)
147
+ end
148
+ end
149
+ threads.each(&:join)
150
+ threads.map { |t| t[:result] }
151
+ end
152
+
153
+ def wait_one(name, slot, tls:, out:)
154
+ item = slot[:item]
155
+ app = slot[:app]
156
+ hc = item[:entry]["healthcheck"] || {}
157
+ path = hc["path"]
158
+ timeout = hc["timeout"] || DEFAULT_TIMEOUTS[:process]
159
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ deadline = started + timeout
161
+ last_error = "not yet attempted"
162
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
163
+ unless process_alive?(app.pid)
164
+ ms = elapsed_ms(started)
165
+ return fail_result(name, started, ms,
166
+ "process exited before becoming healthy — see log/yamine-#{name}.log", out)
167
+ end
168
+ ok, detail = probe(item[:hostname], item[:port], tls: tls, path: path)
169
+ if ok
170
+ ms = elapsed_ms(started)
171
+ return ok_result(name, started, ms, path, out)
172
+ end
173
+ last_error = detail
174
+ sleep 0.5
175
+ end
176
+ ms = elapsed_ms(started)
177
+ status = process_alive?(app.pid) ? "timeout" : "fail"
178
+ detail = process_alive?(app.pid) ? "no healthy response within #{timeout}s (#{last_error})" :
179
+ "process exited before becoming healthy — see log/yamine-#{name}.log"
180
+ event = Event.new(phase: "process", action: name, status: status,
181
+ duration_ms: ms, detail: detail)
182
+ out&.puts(JSON.generate(event.to_h)) if out
183
+ { name: name, status: status, phase: "process", detail: detail, duration_ms: ms }
184
+ end
185
+
186
+ def ok_result(name, started, ms, path, out)
187
+ detail = path ? "healthcheck #{path} returned 2xx-3xx" : "port accepted connection"
188
+ event = Event.new(phase: "process", action: name, status: "ok",
189
+ duration_ms: ms, detail: detail)
190
+ out&.puts(JSON.generate(event.to_h)) if out
191
+ { name: name, status: "ok", phase: "process", detail: detail, duration_ms: ms }
192
+ end
193
+
194
+ def fail_result(name, started, ms, detail, out)
195
+ event = Event.new(phase: "process", action: name, status: "fail",
196
+ duration_ms: ms, detail: detail)
197
+ out&.puts(JSON.generate(event.to_h)) if out
198
+ { name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
199
+ end
200
+
201
+ def process_alive?(pid)
202
+ Process.kill(0, pid)
203
+ true
204
+ rescue SystemCallError
205
+ false
206
+ end
207
+ end
208
+
209
+ # Machine-readable --wait payloads. Success carries URLs + per-process
210
+ # health + db provenance; failure names the first failed process, its
211
+ # phase, and the tail of its own log.
212
+ module WaitPayload
213
+ module_function
214
+
215
+ def success(resolved, wait_result, urls:, db_name:, db_url:, created:)
216
+ { ok: true, service: resolved.app,
217
+ urls: urls,
218
+ processes: wait_result.map { |r|
219
+ { name: r[:name], status: r[:status], duration_ms: r[:duration_ms] }
220
+ },
221
+ db: db_url ? { name: db_name, url: db_url, created: created } : nil }.compact
222
+ end
223
+
224
+ def failure(resolved, wait_result, failed:, log_tail:)
225
+ { ok: false, service: resolved.app,
226
+ failed_process: failed[:name], phase: failed[:phase],
227
+ detail: failed[:detail],
228
+ processes: wait_result.map { |r| { name: r[:name], status: r[:status] } },
229
+ log_path: log_tail[:path], log_tail: log_tail[:tail] }.compact
230
+ end
231
+ end
232
+ end
@@ -9,7 +9,7 @@ module Yamine
9
9
  module_function
10
10
 
11
11
  Result = Struct.new(:app, :tld, :host, :processes, :secrets,
12
- :sources, :variant, keyword_init: true)
12
+ :sources, :variant, :db, :env, keyword_init: true)
13
13
 
14
14
  def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
15
15
  config = Config.load(dir, variant: variant)
@@ -51,7 +51,9 @@ module Yamine
51
51
  processes: processes,
52
52
  secrets: config.secrets,
53
53
  sources: sources,
54
- variant: variant_name
54
+ variant: variant_name,
55
+ db: config.data["db"],
56
+ env: config.env_config
55
57
  )
56
58
  end
57
59
 
data/lib/yamine/runner.rb CHANGED
@@ -91,22 +91,59 @@ module Yamine
91
91
 
92
92
  # Run an arbitrary command with PORT + YAMINE_URL. Returns App.
93
93
  # register: false spawns without a route (background processes).
94
+ # Child output goes to log/yamine-<name>.log so `--wait` failure
95
+ # payloads can tail it (same file the managed path uses).
94
96
  def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
95
- rails_dev_host: nil, register: true, database_url: nil)
97
+ rails_dev_host: nil, register: true, database_url: nil, spec: nil)
96
98
  port ||= Ports.find_free
97
99
  env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
98
100
  database_url: database_url)
99
- pid = with_clean_env { spawn(env, *command, chdir: dir) }
101
+ path = log_path(dir, name)
102
+ pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
100
103
  Process.detach(pid)
101
104
  target = "127.0.0.1:#{port}"
102
105
  if register
103
- @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
106
+ spec ||= { "dir" => File.expand_path(dir), "proc" => name }
107
+ @store.add_route(hostname, target, Process.pid, kind: "tcp",
108
+ force: force, spec: spec)
104
109
  write_backend_pid(hostname, pid)
105
110
  end
106
111
  App.new(name: name, hostname: hostname, url: url, pid: pid,
107
112
  target: target, kind: "tcp", command: command)
108
113
  end
109
114
 
115
+ # Spawn without registering or waiting: the --wait path spins the
116
+ # whole tree up concurrently, then polls every route until healthy.
117
+ # Returns the placeholder App (target known before bind). Fate of
118
+ # the backend is decided by wait, not by spawn.
119
+ def spawn_http(name:, hostname:, url:, dir:, command:, port:, rails_dev_host: nil,
120
+ database_url: nil, force: false)
121
+ env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
122
+ database_url: database_url)
123
+ path = log_path(dir, name)
124
+ pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
125
+ Process.detach(pid)
126
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
127
+ target: "127.0.0.1:#{port}", kind: "tcp", command: command)
128
+ end
129
+
130
+ # Register an already-spawned backend: route + sidecar, together.
131
+ def adopt(hostname, app, force: false, spec: nil)
132
+ @store.add_route(hostname, app.target, Process.pid, kind: app.kind,
133
+ force: force, spec: spec)
134
+ write_backend_pid(hostname, app.pid)
135
+ nil
136
+ end
137
+
138
+ # Still running? A port that accepts is not proof of life, but a
139
+ # reaped/dead pid is proof of death — that is all wait needs.
140
+ def spawned?(pid)
141
+ Process.kill(0, pid)
142
+ true
143
+ rescue SystemCallError
144
+ false
145
+ end
146
+
110
147
  def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
111
148
  env = { "YAMINE_URL" => url }
112
149
  env["PORT"] = port.to_s if port
@@ -217,6 +254,8 @@ module Yamine
217
254
  end
218
255
  end
219
256
 
257
+ # Log path for a process — public so the --wait failure payload
258
+ # can tail the right file for the phase that failed.
220
259
  def log_path(dir, name)
221
260
  path = File.expand_path(File.join(dir, "log", "yamine-#{name}.log"))
222
261
  FileUtils.mkdir_p(File.dirname(path))
@@ -234,6 +273,8 @@ module Yamine
234
273
  nil
235
274
  end
236
275
 
276
+ # Last lines of a log file for failure payloads. Public so the
277
+ # --wait path can attribute the right tail to the failed phase.
237
278
  def log_tail(path, lines: 10)
238
279
  return "(no log file)" unless File.file?(path)
239
280
 
@@ -242,6 +283,8 @@ module Yamine
242
283
  "(unreadable log)"
243
284
  end
244
285
 
286
+ private
287
+
245
288
  def stop_pid(pid)
246
289
  Process.kill("TERM", pid)
247
290
  rescue SystemCallError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/yamine.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "yamine/proxy"
21
21
  require_relative "yamine/proxy_control"
22
22
  require_relative "yamine/supervisor"
23
23
  require_relative "yamine/runner"
24
+ require_relative "yamine/readiness"
24
25
  require_relative "yamine/resolver"
25
26
  require_relative "yamine/trust"
26
27
  require_relative "yamine/doctor"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -104,6 +104,7 @@ files:
104
104
  - lib/yamine/procfile.rb
105
105
  - lib/yamine/proxy.rb
106
106
  - lib/yamine/proxy_control.rb
107
+ - lib/yamine/readiness.rb
107
108
  - lib/yamine/resolver.rb
108
109
  - lib/yamine/route_store.rb
109
110
  - lib/yamine/runner.rb