yamine 0.6.1 → 0.7.1

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: 8066e974d3f0f4849a0b6173c3a8d6acf5f576149afffc9bec3981623ce7faee
4
- data.tar.gz: ded221f8f3f2c6430383c4666d69bc2d397ee924e9f1409d04b0096c750674d7
3
+ metadata.gz: 96bdebf9dc726c4caf747a07f2599bbde292a975e3395bd7cf43b1daf752bea3
4
+ data.tar.gz: 4abda73bc87a74f344070a3d69e87d470b788f05d04abfa4d6fb5783649fea20
5
5
  SHA512:
6
- metadata.gz: 28e06d646cb8f5d7e5a9a16a40f1e4a091ed544f39a5dcd6753d7bd740d11cfa331c213c23c417543670d3c2ab09485edd50717abd5eef3533034d0f1e58d883
7
- data.tar.gz: 6b1180bffb37227e10bff3d0072faae0d790d684b2e74f462e64b3db3ded819c3a96736b27e8b30098c0ad2abcf50ed95f292babc02409e242d7887eac8d6e03
6
+ metadata.gz: 627bc6d9fcb038635d90ec9b49eb7a321fae414cb58ed584b02f4bcc7fbd8fd3a49fe98b610d7fb033564c416ab8ccdbed1b87c5ef6983ef7c70023eca351110
7
+ data.tar.gz: 19a0c6f70d82fddf4385c616e820d04d00a12d60be5016a13ce12054da5bf82aeb5fc7a47940d030518645eda95dac28a1957502d7df139715da65695681863f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1] — 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - **`yamine start` now honors `YAMINE_PORT`.** `ensure_workstation!`
8
+ hardcoded port 443, so the documented escape hatch for CI and
9
+ sandboxes — "where 443 is impossible" — was ignored: with
10
+ `YAMINE_PORT=8443` on a non-interactive machine, `start` aborted with
11
+ "port 443 needs root to bind" and demanded sudo for a port the user
12
+ had deliberately chosen to avoid. The port and scheme now come from
13
+ the context, and an unprivileged port is started directly instead of
14
+ being treated as a setup failure. The root path is unchanged: 443 on a
15
+ non-interactive machine still points at `yamine setup`.
16
+
17
+ ## [0.7.0] — 2026-09-10
18
+
19
+ Keeping the no-port promise when the machine's recorded port goes stale,
20
+ and making boot legible while it happens.
21
+
22
+ ### Added
23
+
24
+ - `Log::Report::Human` / `Log::Report::Json`: boot progress sinks, one
25
+ line per phase (`[web] ok (2.2s) healthcheck /up returned 2xx-3xx`).
26
+ `Readiness.phase`/`wait_all` take a `sink:` and never format output
27
+ themselves; `--json` emits one JSON object per event on stdout while
28
+ the human banner moves to stderr, so stdout stays parseable. The phase
29
+ events were previously computed and discarded — `opts[:events]` was
30
+ never set — so a 2-minute deps or healthcheck phase looked like a hang.
31
+ - `ProxyControl.default_port?` / `port_notice`, and a `warn` state on
32
+ `Doctor::Check` (rendered `[warn]`, JSON `warn`/`warnings`, never
33
+ affecting exit status).
34
+
35
+ ### Fixed
36
+
37
+ - **A stale recorded proxy port no longer sticks the machine on a
38
+ port.** `Context#proxy_port` trusted `proxy.port` unconditionally, so a
39
+ one-off `proxy start -p 1355` (CI, a sandbox, a gem-dev foreground
40
+ proxy) outlived its process: the next boot raised a fresh proxy on
41
+ 1355 and put `:1355` in every URL — the single outcome yamine exists to
42
+ prevent. The recorded port is now honored only while something is
43
+ actually listening on it; otherwise the machine returns to the clean
44
+ default (443). An explicit `YAMINE_PORT` still wins.
45
+ - **Attaching to a non-default port is now said out loud.** Both
46
+ `yamine start` and `yamine doctor` report it (`[warn] listening on port
47
+ 1355 — every URL carries :1355 …`) instead of a bare "[ok] listening on
48
+ port 1355" that let a leftover dev proxy quietly downgrade every
49
+ project on the machine.
50
+ - **`.localhost` no longer reports as unresolvable.** `Hosts.resolves?`
51
+ used `Resolv.getaddress` — a pure-Ruby DNS client with no nsswitch and
52
+ no RFC 6761 knowledge — so it returned "no address for
53
+ anyworkers.localhost" on a machine where `.localhost` resolves
54
+ perfectly, and `doctor` printed a `[FAIL] dns` while `yamine start`
55
+ advised `sudo yamine hosts sync` (an elevated /etc/hosts write) for a
56
+ problem that did not exist. It now asks the system resolver
57
+ (`Addrinfo.getaddrinfo`), which is what browsers and curl use.
58
+ - `supervise_tree` names the process that exited and its log path
59
+ instead of "a process exited — cleaning up all routes".
60
+
3
61
  ## [0.6.1] — 2026-09-10
4
62
 
5
63
  ### Fixed
data/README.md CHANGED
@@ -41,6 +41,15 @@ The only port-suffixed URLs are the ones you explicitly ask for:
41
41
  `yamine proxy start -p 1355` (CI/sandboxes where 443 is impossible).
42
42
  There the suffix is honest, and `YAMINE_URL` carries it faithfully.
43
43
 
44
+ Because the port is recorded machine-wide, yamine refuses to let a
45
+ one-off `-p` outlive its process: a recorded port is reused only while
46
+ something is actually listening on it. Stop that proxy and the next
47
+ `yamine` run goes back to the clean default (443) instead of quietly
48
+ raising another proxy on 1355. `yamine doctor` and `yamine start` both
49
+ say so out loud when a running proxy is on a non-default port — the
50
+ `[warn]` line names the port, the `:PORT` it puts in every URL, and how
51
+ to get back to 443.
52
+
44
53
  ## Port 443: one-time setup, then never again
45
54
 
46
55
  Binding 443 is privileged, so yamine installs a **root-owned launchd
@@ -25,6 +25,22 @@ module Yamine
25
25
  boot_all(ctx, resolved, opts)
26
26
  end
27
27
 
28
+ # Progress sink for the boot's phases. Human runs narrate to stderr
29
+ # (stdout is the payload); --json runs emit one JSON line per event
30
+ # on stdout. Without this the phase events were computed and thrown
31
+ # away (opts[:events] was never set), so a 2-minute dependency or
32
+ # healthcheck phase looked like a hang.
33
+ def reporter(json:)
34
+ json ? Log::Report::Json.new : Log::Report::Human.new
35
+ end
36
+
37
+ # Boot banner / URL lines. In --json mode they move to stderr so
38
+ # stdout stays pure JSON (one event per line, payload last) for
39
+ # agents that parse it; humans still get them on stdout.
40
+ def say(opts, message = "")
41
+ opts[:json] ? $stderr.puts(message) : puts(message)
42
+ end
43
+
28
44
  def run_explicit(ctx, args)
29
45
  run_inferred(ctx, args)
30
46
  end
@@ -43,7 +59,7 @@ module Yamine
43
59
  tld = resolved.tld
44
60
  host = resolved.host
45
61
  processes = resolved.processes
46
- events = opts[:events]
62
+ events = opts[:events] || reporter(json: opts[:json])
47
63
 
48
64
  if processes.empty?
49
65
  $stderr.puts "Error: no processes in config/local.yml. Add at least one."
@@ -59,7 +75,7 @@ module Yamine
59
75
  # Pre-flight: verify runtime deps BEFORE spawning anything.
60
76
  # A missing bundle fails here in seconds with the fix, instead
61
77
  # of a 60s Puma crash-loop ending in "did not boot".
62
- deps = Readiness.phase(:deps, "check", out: events) do
78
+ deps = Readiness.phase(:deps, "deps", sink: events) do
63
79
  ok, fix = Readiness.check_deps(Dir.pwd)
64
80
  raise fix unless ok
65
81
 
@@ -74,8 +90,8 @@ module Yamine
74
90
  children = []
75
91
  routes_registered = []
76
92
 
77
- puts "yamine (#{service})"
78
- puts "--"
93
+ say opts, "yamine (#{service})"
94
+ say opts, "--"
79
95
 
80
96
  # Per-worktree database: one database per directory so concurrent
81
97
  # agents never share tables. Resolved once, injected into every
@@ -86,14 +102,14 @@ module Yamine
86
102
  db_name = Database.name_for(Dir.pwd, env: rails_env,
87
103
  state_dir: ctx.store.dir)
88
104
  db_created, db_url = setup_database(ctx, resolved, db_name, events: events)
89
- puts " [db] #{db_name}#{db_created ? " (created)" : ""}" if db_url
105
+ events&.note("[db] #{db_name}#{db_created ? " (created)" : ""}") if db_url
90
106
 
91
107
  with_wait = !opts[:no_wait]
92
108
  spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
93
109
 
94
110
  if with_wait
95
111
  boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
96
- routes_registered, db_url, db_name, db_created)
112
+ routes_registered, db_url, db_name, db_created, events: events)
97
113
  else
98
114
  boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
99
115
  routes_registered, db_url, events: events)
@@ -101,17 +117,20 @@ module Yamine
101
117
 
102
118
  background = processes.select { |_, v| v["proxy"] == false }
103
119
  unless background.empty?
104
- puts " [background] #{background.keys.join(', ')}"
120
+ say opts, " [background] #{background.keys.join(', ')}"
105
121
  end
106
122
 
107
- puts
123
+ say opts
108
124
  ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
109
125
 
110
- # Supervisor: exit when ANY child dies (loud cleanup).
111
- all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
126
+ # Supervisor: exit when ANY child dies (loud cleanup). pid => name
127
+ # so the message names the casualty instead of "a process".
128
+ named_pids = {}
129
+ routes_registered.each { |r| named_pids[r[:app].pid] = r[:app].name }
130
+ children.each { |c| named_pids[c[:pid]] = c[:name] }
112
131
  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)
132
+ trap_cleanup(ctx, all_hostnames, named_pids.keys)
133
+ supervise_tree(ctx, all_hostnames, named_pids, reporter: events)
115
134
  end
116
135
 
117
136
  # One pass over config: background processes spawn immediately
@@ -148,7 +167,7 @@ module Yamine
148
167
  def boot_sequential(ctx, runner, resolved, opts, plan, children,
149
168
  routes_registered, db_url, events: nil)
150
169
  plan.each do |item|
151
- puts " [#{item[:name]}] #{item[:url]}"
170
+ say opts, " [#{item[:name]}] #{item[:url]}"
152
171
  # Always allow the proxied hostname in Rails dev (Rails ignores
153
172
  # this env var when not a Rails app — safe for every framework).
154
173
  # DATABASE_URL points at this worktree's own database; non-Ruby
@@ -171,10 +190,10 @@ module Yamine
171
190
  # never leak into the proxy. A backend that dies before its route
172
191
  # registers is cleaned up like any other failed process.
173
192
  def boot_concurrent(ctx, runner, resolved, opts, plan, children,
174
- routes_registered, db_url, db_name, db_created)
193
+ routes_registered, db_url, db_name, db_created, events: nil)
175
194
  apps = {}
176
195
  plan.each do |item|
177
- puts " [#{item[:name]}] #{item[:url]}"
196
+ say opts, " [#{item[:name]}] #{item[:url]}"
178
197
  apps[item[:name]] = {
179
198
  item: item,
180
199
  app: runner.spawn_http(name: item[:name], hostname: item[:hostname],
@@ -183,7 +202,7 @@ module Yamine
183
202
  database_url: db_url, force: opts[:force])
184
203
  }
185
204
  end
186
- wait_result = Readiness.wait_all(apps, out: nil)
205
+ wait_result = Readiness.wait_all(apps, sink: events)
187
206
  failed = wait_result.select { |r| r[:status] != "ok" }
188
207
 
189
208
  if failed.empty?
@@ -191,38 +210,45 @@ module Yamine
191
210
  runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
192
211
  spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
193
212
  routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
194
- puts " -> #{slot[:item][:url]}"
213
+ say opts, " -> #{slot[:item][:url]}"
195
214
  end
196
215
  end
197
216
  finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
198
217
  children, db_name, db_url, db_created, json: opts[:json])
199
218
  end
200
219
 
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.
220
+ # --wait epilogue. Success: the summary line and, with --json, the
221
+ # success payload on stdout (the human summary moves to stderr so
222
+ # stdout stays parseable). Failure: every spawned child is killed
223
+ # and removed, the failed phase + that process's log tail is
224
+ # reported, and start exits 1 — no half-booted routes left behind.
206
225
  def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
207
226
  children, db_name, db_url, db_created, json: false)
208
227
  urls = apps.transform_values { |slot| slot[:item][:url] }
228
+ summary = "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
229
+
209
230
  if failed.empty?
210
- puts "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
211
231
  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
232
+ db_name: db_name, db_url: db_url, created: db_created)
233
+ if json
234
+ $stderr.puts summary
235
+ puts JSON.generate(payload)
236
+ else
237
+ puts summary
238
+ end
214
239
  return
215
240
  end
216
241
 
217
242
  apps.each_value { |slot| stop_spawned(slot[:app]) }
218
243
  children.each { |c| stop_spawned_pid(c[:pid]) }
219
244
  first = failed.first
245
+ error = "Error: #{first[:name]} failed (#{first[:phase]}): #{first[:detail]}"
220
246
  payload = Yamine::WaitPayload.failure(resolved, wait_result, failed: first,
221
- log_tail: tail_for(failed.first, apps))
247
+ log_tail: tail_for(first, apps))
248
+ $stderr.puts error
222
249
  if json
223
250
  puts JSON.generate(payload)
224
251
  else
225
- $stderr.puts "Error: #{first[:name]} failed (#{first[:phase]}): #{first[:detail]}"
226
252
  $stderr.puts payload[:log_tail].to_s.lines.last(10).join if payload[:log_tail]
227
253
  $stderr.puts "Full log: #{payload[:log_path]}" if payload[:log_path]
228
254
  end
@@ -265,7 +291,7 @@ module Yamine
265
291
  force: opts[:force], rails_dev_host: nil, register: false,
266
292
  database_url: db_url)
267
293
  children << { name: proc_name, pid: app.pid }
268
- puts " [#{proc_name}] background (pid #{app.pid})"
294
+ say opts, " [#{proc_name}] background (pid #{app.pid})"
269
295
  end
270
296
 
271
297
  # Refuse to boot over another agent's live routes unless forced.
@@ -335,7 +361,7 @@ module Yamine
335
361
  url = Database.url_for(db_name, template)
336
362
  return [false, nil] unless url
337
363
 
338
- db_event = Readiness.phase(:db, "ensure #{db_name}", out: events) do
364
+ db_event = Readiness.phase(:db, "ensure #{db_name}", sink: events) do
339
365
  existed = Database.exists?(db_name, template)
340
366
  raise "database server unreachable — is postgres/mysql running?" unless Database.ensure_exists(db_name, template)
341
367
 
@@ -347,7 +373,7 @@ module Yamine
347
373
  end
348
374
  created = db_event.detail.to_s.start_with?("created")
349
375
 
350
- schema_event = Readiness.phase(:schema, "load #{db_name}", out: events) do
376
+ schema_event = Readiness.phase(:schema, "load #{db_name}", sink: events) do
351
377
  next "skipped (already exists)" unless created
352
378
 
353
379
  run_schema_load(resolved, url)
@@ -437,15 +463,26 @@ module Yamine
437
463
  File.file?(File.join(Dir.pwd, "config", "application.rb"))
438
464
  end
439
465
 
440
- def supervise_tree(ctx, hostnames, pids)
466
+ # Supervise the booted tree: the first child to exit ends the run,
467
+ # because a half-stack is worse than no stack — a dead jobs worker
468
+ # with a live web process looks healthy until someone wonders why
469
+ # nothing is being processed. Name the casualty and its log: "a
470
+ # process exited" left the user to guess which one, and the log
471
+ # worth reading is per-process.
472
+ def supervise_tree(ctx, hostnames, named_pids, reporter: nil)
441
473
  loop do
442
474
  sleep 0.5
443
- if pids.any? { |pid| !ProxyControl.pid_alive?(pid) }
444
- puts "\nA process exited — cleaning up all routes."
475
+ dead = named_pids.find { |pid, _| !ProxyControl.pid_alive?(pid) }
476
+ if dead
477
+ pid, name = dead
478
+ $stderr.puts "\n[#{name}] exited (pid #{pid}) — stopping the whole tree."
479
+ log = File.join(Dir.pwd, "log", "yamine-#{name}.log")
480
+ $stderr.puts " log: #{log}" if File.file?(log)
481
+ reporter&.note("#{name} exited; cleaning up routes")
445
482
  cleanup_routes(ctx, hostnames)
446
483
  # Kill remaining children
447
- pids.each do |pid|
448
- Process.kill("TERM", pid) rescue nil
484
+ named_pids.each_key do |other|
485
+ Process.kill("TERM", other) rescue nil
449
486
  end
450
487
  exit 0
451
488
  end
@@ -515,7 +552,10 @@ module Yamine
515
552
  port = ctx.proxy_port
516
553
  tls = ctx.proxy_tls
517
554
  if ProxyControl.listening?(port)
518
- return if ProxyControl.ours?(port, tls: tls)
555
+ if ProxyControl.ours?(port, tls: tls)
556
+ warn_non_default_port(port, tls)
557
+ return
558
+ end
519
559
  $stderr.puts "Error: port #{port} is in use by another process."
520
560
  $stderr.puts " Stop it, or yamine proxy start -p <port>"
521
561
  exit 1
@@ -543,6 +583,24 @@ module Yamine
543
583
  $stderr.puts " Fix once: yamine setup"
544
584
  exit 1
545
585
  end
586
+
587
+ # yamine's whole promise is a bare https://<app>.localhost. An
588
+ # already-running proxy on another port silently defeats it: every
589
+ # URL grows a :1355, which then leaks into OAuth callbacks, mailer
590
+ # hosts, and webhooks. The port file is machine-wide state, so a
591
+ # single `-p 1355` run (CI, a sandbox, a gem-dev foreground proxy)
592
+ # downgrades every project on the machine until someone notices.
593
+ #
594
+ # Not an error: CI and sandboxes opt into a port deliberately, and
595
+ # refusing would break them. So: proceed, but say plainly what the
596
+ # URL will look like and how to get the clean one back.
597
+ def warn_non_default_port(port, tls)
598
+ return if ENV["YAMINE_PORT"] && ENV["YAMINE_PORT"].to_i == port
599
+ notice = ProxyControl.port_notice(port, tls)
600
+ return unless notice
601
+
602
+ $stderr.puts "Warning: #{notice}"
603
+ end
546
604
  end
547
605
  end
548
606
  end
@@ -15,8 +15,23 @@ module Yamine
15
15
  $stdin.tty? && ENV["CI"].nil?
16
16
  end
17
17
 
18
+ # The port the proxy is on, or the port it should be started on.
19
+ #
20
+ # The recorded port is machine-wide sticky state, and trusting it
21
+ # blindly is how a one-off `proxy start -p 1355` (CI, a sandbox, a
22
+ # gem-dev foreground proxy) downgrades the machine permanently: the
23
+ # file outlives the process, so the NEXT boot would raise a fresh
24
+ # proxy on 1355 and put a port in every URL again — the exact
25
+ # outcome yamine exists to prevent.
26
+ #
27
+ # So the recorded port is honored only while something is actually
28
+ # listening there. A dead proxy leaves the machine on the clean
29
+ # default (443), and an explicit YAMINE_PORT always wins.
18
30
  def proxy_port
19
- ProxyControl.proxy_port(store) || ProxyControl.default_port(proxy_tls)
31
+ recorded = ProxyControl.proxy_port(store)
32
+ return recorded if recorded && ProxyControl.listening?(recorded)
33
+
34
+ ProxyControl.default_port(proxy_tls)
20
35
  end
21
36
 
22
37
  def proxy_tls
@@ -700,9 +700,15 @@ module Yamine
700
700
 
701
701
 
702
702
  # Quiet workstation setup for `start`: trust the CA, ensure a proxy
703
- # on 443 (root service, sudo daemon fallback), and sync hosts.
704
- # Each step is idempotent; only missing pieces run. Non-interactive
705
- # CI without a proxy fails fast rather than prompting for sudo.
703
+ # on this run's port (443 by default — root service, sudo daemon
704
+ # fallback), and sync hosts. Each step is idempotent; only missing
705
+ # pieces run. Non-interactive CI without a proxy fails fast rather
706
+ # than prompting for sudo.
707
+ #
708
+ # The port comes from ctx, not a literal: YAMINE_PORT is the
709
+ # documented escape hatch for CI and sandboxes ("where 443 is
710
+ # impossible"), and hardcoding 443 here demanded root for a port
711
+ # those users had already chosen to avoid.
706
712
  def ensure_workstation!(ctx)
707
713
  # 1. CA
708
714
  unless Yamine::Certs.trusted?(ctx.store.dir)
@@ -714,19 +720,30 @@ module Yamine
714
720
  end
715
721
  end
716
722
 
717
- # 2. Proxy on 443
718
- port = 443
719
- tls = true
723
+ # 2. Proxy on the port this run wants
724
+ port = ctx.proxy_port
725
+ tls = ctx.proxy_tls
720
726
  unless Yamine::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
721
- if port < 1024 && !Yamine::ProxyControl.root? && !ctx.interactive?
722
- abort_setup("Proxy is not running and port 443 needs root to bind.", *privileged_port_hint)
727
+ privileged = port < 1024
728
+ if privileged && !Yamine::ProxyControl.root? && !ctx.interactive?
729
+ abort_setup("Proxy is not running and port #{port} needs root to bind.", *privileged_port_hint)
723
730
  end
724
731
  ok =
725
732
  if ProxyControl.root?
726
733
  Yamine::CLI::SystemCommand.ensure_root_service(ctx)
727
734
  elsif ctx.interactive?
728
735
  begin
729
- Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
736
+ Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
737
+ wait_for_ours(ctx, port, tls: tls)
738
+ rescue Yamine::ProxyNotRunningError, SystemCallError => e
739
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
740
+ false
741
+ end
742
+ elsif !privileged
743
+ # Non-interactive but unprivileged: nothing to elevate, so
744
+ # start the daemon directly instead of failing.
745
+ begin
746
+ Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: false)
730
747
  wait_for_ours(ctx, port, tls: tls)
731
748
  rescue Yamine::ProxyNotRunningError, SystemCallError => e
732
749
  warn " daemon start failed: #{e.message.lines.first&.strip}"
@@ -736,7 +753,8 @@ module Yamine
736
753
  false
737
754
  end
738
755
  unless ok
739
- abort_setup("Proxy is not running and could not be started on port 443.", *privileged_port_hint)
756
+ abort_setup("Proxy is not running and could not be started on port #{port}.",
757
+ *privileged_port_hint)
740
758
  end
741
759
  end
742
760
 
data/lib/yamine/doctor.rb CHANGED
@@ -4,7 +4,15 @@ module Yamine
4
4
  # Read-only health checks: proxy, routes, DNS, CA trust.
5
5
  # Never changes state; safe for agents to call any time.
6
6
  module Doctor
7
- Check = Struct.new(:name, :ok, :message, keyword_init: true)
7
+ # `warn` is a third state between ok and FAIL: the check passed, but
8
+ # something is configured in a way the user should know about and
9
+ # probably did not choose (a port-suffixed URL). Warnings never
10
+ # affect the exit status — only failures do.
11
+ Check = Struct.new(:name, :ok, :message, :warn, keyword_init: true) do
12
+ def warn?
13
+ warn ? true : false
14
+ end
15
+ end
8
16
 
9
17
  module_function
10
18
 
@@ -66,12 +74,23 @@ module Yamine
66
74
  return Check.new(name: "proxy", ok: false,
67
75
  message: "port #{port} not listening — run: yamine proxy start")
68
76
  end
69
- if ProxyControl.ours?(port, tls: tls)
70
- Check.new(name: "proxy", ok: true, message: "listening on port #{port}")
71
- else
72
- Check.new(name: "proxy", ok: false,
77
+ unless ProxyControl.ours?(port, tls: tls)
78
+ return Check.new(name: "proxy", ok: false,
73
79
  message: "port #{port} is in use by another process")
74
80
  end
81
+
82
+ # A proxy on a non-default port is legitimate — CI and sandboxes
83
+ # opt in with `-p` — but it makes EVERY url carry :PORT, which is
84
+ # the one thing yamine exists to avoid. Reporting that as a bare
85
+ # "[ok] listening on port 1355" let a stale dev proxy quietly
86
+ # downgrade every project on the machine.
87
+ notice = ProxyControl.port_notice(port, tls)
88
+ if notice
89
+ Check.new(name: "proxy", ok: true, warn: true,
90
+ message: "listening on port #{port} — #{notice}")
91
+ else
92
+ Check.new(name: "proxy", ok: true, message: "listening on port #{port}")
93
+ end
75
94
  end
76
95
 
77
96
  def check_routes(store)
@@ -116,14 +135,18 @@ module Yamine
116
135
  if json
117
136
  require "json"
118
137
  out.puts JSON.generate({
119
- checks: checks.map { |c| { name: c.name, ok: c.ok, message: c.message } },
120
- failed: checks.count { |c| !c.ok }
138
+ checks: checks.map { |c| { name: c.name, ok: c.ok, warn: c.warn?, message: c.message } },
139
+ failed: checks.count { |c| !c.ok },
140
+ warnings: checks.count(&:warn?)
121
141
  })
122
142
  return checks.count { |c| !c.ok }
123
143
  end
124
144
  failed = 0
125
145
  checks.each do |c|
126
- mark = c.ok ? "ok" : "FAIL"
146
+ mark = if !c.ok then "FAIL"
147
+ elsif c.warn? then "warn"
148
+ else "ok"
149
+ end
127
150
  failed += 1 unless c.ok
128
151
  out.puts " [#{mark}] #{c.name}: #{c.message}"
129
152
  end
data/lib/yamine/hosts.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "resolv"
3
+ require "socket"
4
4
  require "timeout"
5
5
 
6
6
  module Yamine
@@ -72,10 +72,21 @@ module Yamine
72
72
  false
73
73
  end
74
74
 
75
+ # Does the hostname resolve for the programs that will actually use
76
+ # it — browsers, curl, the app's own HTTP clients? That question can
77
+ # only be answered by the SYSTEM resolver (getaddrinfo), which reads
78
+ # nsswitch (so /etc/hosts counts) and implements the RFC 6761
79
+ # special-use TLDs.
80
+ #
81
+ # Ruby's Resolv is a pure-Ruby DNS client: it never sees /etc/hosts
82
+ # and knows nothing about RFC 6761, so it reports "no address for
83
+ # myapp.localhost" on a machine where .localhost resolves perfectly.
84
+ # That false negative sent users to `sudo yamine hosts sync` — an
85
+ # elevated write to /etc/hosts — to fix a problem they did not have.
75
86
  def resolves?(hostname)
76
- Timeout.timeout(2) { Resolv.getaddress(hostname) }
87
+ Timeout.timeout(2) { Addrinfo.getaddrinfo(hostname, nil) }
77
88
  true
78
- rescue Resolv::ResolvError, SystemCallError, Timeout::Error
89
+ rescue SocketError, SystemCallError, Timeout::Error
79
90
  false
80
91
  end
81
92
 
data/lib/yamine/log.rb CHANGED
@@ -55,5 +55,62 @@ module Yamine
55
55
  "#{bytes} B"
56
56
  end
57
57
  end
58
+
59
+ # Boot progress sinks. Readiness emits one Event per phase; the sink
60
+ # decides how to render it. Rendering lives here rather than in
61
+ # Readiness so the human stream and the --json stream carry exactly
62
+ # the same events — a phase added to the boot loop shows up in both
63
+ # without touching either renderer.
64
+ module Report
65
+ # " [web] ok (2.2s) healthcheck /up returned 2xx-3xx" — one line
66
+ # per completed phase, in the boot banner's bracket style.
67
+ class Human
68
+ def initialize(io = $stderr)
69
+ @io = io
70
+ end
71
+
72
+ def event(event)
73
+ @io.puts " [#{event.action}] #{event.status} " \
74
+ "(#{format_seconds(event.duration_ms)})#{detail(event)}"
75
+ end
76
+
77
+ def note(message)
78
+ @io.puts " #{message}"
79
+ end
80
+
81
+ private
82
+
83
+ def detail(event)
84
+ d = event.detail.to_s.strip
85
+ d.empty? ? "" : " #{d}"
86
+ end
87
+
88
+ # Durations are read by humans deciding whether to wait; a
89
+ # sub-second boot should not render as "0.0s".
90
+ def format_seconds(ms)
91
+ return "0ms" if ms.nil? || ms < 1000
92
+
93
+ format("%.1fs", ms / 1000.0)
94
+ end
95
+ end
96
+
97
+ # One JSON object per line — the agent contract. Same events, no
98
+ # prose.
99
+ class Json
100
+ def initialize(io = $stdout)
101
+ @io = io
102
+ end
103
+
104
+ def event(event)
105
+ require "json"
106
+ @io.puts JSON.generate(event.to_h)
107
+ end
108
+
109
+ def note(message)
110
+ require "json"
111
+ @io.puts JSON.generate({ note: message })
112
+ end
113
+ end
114
+ end
58
115
  end
59
116
  end
@@ -34,6 +34,26 @@ module Yamine
34
34
  tls ? DEFAULT_TLS_PORT : DEFAULT_PLAIN_PORT
35
35
  end
36
36
 
37
+ # Is this port the clean default for the current scheme — 443 for
38
+ # https, 80 for http? A default port yields bare
39
+ # `https://myapp.localhost` URLs; any other port appends :PORT to
40
+ # every one of them.
41
+ def default_port?(port, tls)
42
+ port == (tls ? DEFAULT_TLS_PORT : DEFAULT_PLAIN_PORT)
43
+ end
44
+
45
+ # Human explanation for a non-default port, or nil when the port is
46
+ # the clean default. Callers use this to surface the downgrade
47
+ # honestly instead of implying the proxy is misconfigured.
48
+ def port_notice(port, tls)
49
+ return nil if default_port?(port, tls)
50
+
51
+ "every URL carries :#{port} instead of a clean " \
52
+ "#{tls ? "https" : "http"}://<app>.localhost. Stop it and run " \
53
+ "`yamine setup` (or `yamine proxy start`) to move to port " \
54
+ "#{default_port(tls)}."
55
+ end
56
+
37
57
  def proxy_tls(store)
38
58
  marker = File.join(store.dir, "proxy.tls")
39
59
  return false if ENV["YAMINE_HTTPS"] == "0"
@@ -38,7 +38,10 @@ module Yamine
38
38
 
39
39
  # Run a phase with timing + timeout. Yields; returns the Event.
40
40
  # On timeout or raise, status is "fail" with the error as detail.
41
- def phase(name, action, timeout: nil, out: nil)
41
+ #
42
+ # `sink` renders the event (Log::Report::Human for people, Json for
43
+ # agents); nil means silent. Readiness itself never formats output.
44
+ def phase(name, action, timeout: nil, sink: nil)
42
45
  timeout ||= DEFAULT_TIMEOUTS.fetch(name, 30)
43
46
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
47
  event = nil
@@ -56,7 +59,7 @@ module Yamine
56
59
  event = Event.new(phase: name, action: action, status: "fail",
57
60
  duration_ms: ms, detail: e.message.lines.first&.strip)
58
61
  end
59
- out&.puts(JSON.generate(event.to_h)) if out
62
+ sink&.event(event)
60
63
  event
61
64
  end
62
65
 
@@ -143,17 +146,17 @@ module Yamine
143
146
  # or "timeout" (never answered within its healthcheck timeout).
144
147
  # Dead-pid short-circuit: a reaped backend fails immediately
145
148
  # instead of burning its full timeout on connection-refused.
146
- def wait_all(apps, out: nil)
149
+ def wait_all(apps, sink: nil)
147
150
  threads = apps.map do |name, slot|
148
151
  Thread.new do
149
- Thread.current[:result] = wait_one(name, slot, out: out)
152
+ Thread.current[:result] = wait_one(name, slot, sink: sink)
150
153
  end
151
154
  end
152
155
  threads.each(&:join)
153
156
  threads.map { |t| t[:result] }
154
157
  end
155
158
 
156
- def wait_one(name, slot, out:)
159
+ def wait_one(name, slot, sink:)
157
160
  item = slot[:item]
158
161
  app = slot[:app]
159
162
  hc = item[:entry]["healthcheck"] || {}
@@ -166,12 +169,12 @@ module Yamine
166
169
  unless process_alive?(app.pid)
167
170
  ms = elapsed_ms(started)
168
171
  return fail_result(name, started, ms,
169
- "process exited before becoming healthy — see log/yamine-#{name}.log", out)
172
+ "process exited before becoming healthy — see log/yamine-#{name}.log", sink)
170
173
  end
171
174
  ok, detail = probe(item[:hostname], item[:port], path: path)
172
175
  if ok
173
176
  ms = elapsed_ms(started)
174
- return ok_result(name, started, ms, path, out)
177
+ return ok_result(name, started, ms, path, sink)
175
178
  end
176
179
  last_error = detail
177
180
  sleep 0.5
@@ -182,22 +185,22 @@ module Yamine
182
185
  "process exited before becoming healthy — see log/yamine-#{name}.log"
183
186
  event = Event.new(phase: "process", action: name, status: status,
184
187
  duration_ms: ms, detail: detail)
185
- out&.puts(JSON.generate(event.to_h)) if out
188
+ sink&.event(event)
186
189
  { name: name, status: status, phase: "process", detail: detail, duration_ms: ms }
187
190
  end
188
191
 
189
- def ok_result(name, started, ms, path, out)
192
+ def ok_result(name, started, ms, path, sink)
190
193
  detail = path ? "healthcheck #{path} returned 2xx-3xx" : "port accepted connection"
191
194
  event = Event.new(phase: "process", action: name, status: "ok",
192
195
  duration_ms: ms, detail: detail)
193
- out&.puts(JSON.generate(event.to_h)) if out
196
+ sink&.event(event)
194
197
  { name: name, status: "ok", phase: "process", detail: detail, duration_ms: ms }
195
198
  end
196
199
 
197
- def fail_result(name, started, ms, detail, out)
200
+ def fail_result(name, started, ms, detail, sink)
198
201
  event = Event.new(phase: "process", action: name, status: "fail",
199
202
  duration_ms: ms, detail: detail)
200
- out&.puts(JSON.generate(event.to_h)) if out
203
+ sink&.event(event)
201
204
  { name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
202
205
  end
203
206
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.1"
5
5
  end
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.6.1
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto