yamine 0.6.0 → 0.7.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: 1fef68ea493be93af6dfb3271b446b793536a373d11b030f62e54c0d90438e88
4
- data.tar.gz: 69c64d3bd1523b05ca6277f76329e20415879f16c95cd0640e30c9adf77f69cf
3
+ metadata.gz: 1003241d7826eb9d1b2c1f511a7e9b8e99f4be0d9438d2651dd86864d3cee1cc
4
+ data.tar.gz: 58ee2023a0f951f508c5032af8079d9417f97427bf76d48af08ec734661fd7a7
5
5
  SHA512:
6
- metadata.gz: 2864def84f4afefe2dd79adda834a2381964e964da864f99c8740321c19559669ad48874cce4485222c0a09d5a285b343d99d2fdea5f1029ee54f8dc8cd86a76
7
- data.tar.gz: fe58b77da7a315e3f6aa3fd59f7c153fe8a0d342ffdbbdf923e4199e8ccba02b039c2cde777a7c9d1855d127563ef3f021171b308c1696f5f7446b9ac068430e
6
+ metadata.gz: ea87625511591d754116bda3a16849466d9d982c7ba8f69a773508e36142e224e9c9a9ed96335c2f1d28c28f285116a488caa5c34d4ab7704cb06b4bb1d14a3f
7
+ data.tar.gz: 701589715a07e31778aefd39c60d05f7bdd9c865d77d72517f0a3881054046067ae60c2dc3ae97ecebe6f8b1dfd238ab95de5dd12d3a163bbd0f63aefaba9f3a
data/CHANGELOG.md CHANGED
@@ -1,6 +1,67 @@
1
1
  # Changelog
2
2
 
3
- ## [0.6.0] — Unreleased
3
+ ## [0.7.0] — 2026-09-10
4
+
5
+ Keeping the no-port promise when the machine's recorded port goes stale,
6
+ and making boot legible while it happens.
7
+
8
+ ### Added
9
+
10
+ - `Log::Report::Human` / `Log::Report::Json`: boot progress sinks, one
11
+ line per phase (`[web] ok (2.2s) healthcheck /up returned 2xx-3xx`).
12
+ `Readiness.phase`/`wait_all` take a `sink:` and never format output
13
+ themselves; `--json` emits one JSON object per event on stdout while
14
+ the human banner moves to stderr, so stdout stays parseable. The phase
15
+ events were previously computed and discarded — `opts[:events]` was
16
+ never set — so a 2-minute deps or healthcheck phase looked like a hang.
17
+ - `ProxyControl.default_port?` / `port_notice`, and a `warn` state on
18
+ `Doctor::Check` (rendered `[warn]`, JSON `warn`/`warnings`, never
19
+ affecting exit status).
20
+
21
+ ### Fixed
22
+
23
+ - **A stale recorded proxy port no longer sticks the machine on a
24
+ port.** `Context#proxy_port` trusted `proxy.port` unconditionally, so a
25
+ one-off `proxy start -p 1355` (CI, a sandbox, a gem-dev foreground
26
+ proxy) outlived its process: the next boot raised a fresh proxy on
27
+ 1355 and put `:1355` in every URL — the single outcome yamine exists to
28
+ prevent. The recorded port is now honored only while something is
29
+ actually listening on it; otherwise the machine returns to the clean
30
+ default (443). An explicit `YAMINE_PORT` still wins.
31
+ - **Attaching to a non-default port is now said out loud.** Both
32
+ `yamine start` and `yamine doctor` report it (`[warn] listening on port
33
+ 1355 — every URL carries :1355 …`) instead of a bare "[ok] listening on
34
+ port 1355" that let a leftover dev proxy quietly downgrade every
35
+ project on the machine.
36
+ - **`.localhost` no longer reports as unresolvable.** `Hosts.resolves?`
37
+ used `Resolv.getaddress` — a pure-Ruby DNS client with no nsswitch and
38
+ no RFC 6761 knowledge — so it returned "no address for
39
+ anyworkers.localhost" on a machine where `.localhost` resolves
40
+ perfectly, and `doctor` printed a `[FAIL] dns` while `yamine start`
41
+ advised `sudo yamine hosts sync` (an elevated /etc/hosts write) for a
42
+ problem that did not exist. It now asks the system resolver
43
+ (`Addrinfo.getaddrinfo`), which is what browsers and curl use.
44
+ - `supervise_tree` names the process that exited and its log path
45
+ instead of "a process exited — cleaning up all routes".
46
+
47
+ ## [0.6.1] — 2026-09-10
48
+
49
+ ### Fixed
50
+
51
+ - **Healthchecks no longer speak TLS to the backend.** `--wait` probed
52
+ `127.0.0.1:$PORT` — the app's own listener — with the *proxy's* `tls`
53
+ flag, so any app declaring `healthcheck: { path: }` could never boot
54
+ under the default TLS-on proxy: the probe began an SSL handshake
55
+ against a plaintext Puma, which logged `Invalid HTTP format, parsing
56
+ fails. Are you trying to open an SSL connection to a non-SSL Puma?`
57
+ once per attempt until the process phase timed out and the whole boot
58
+ was torn down. TLS is the proxy's job; the proxy dials backends with a
59
+ bare `TCPSocket`, so `Readiness.probe`/`probe_http`/`wait_healthy`/
60
+ `wait_all` drop the `tls:` keyword entirely (an `https://` banner URL
61
+ never implied the backend speaks TLS). Reported from anyworkers, whose
62
+ web process healthchecks `/up`.
63
+
64
+ ## [0.6.0] — 2026-09-09
4
65
 
5
66
  `yamine start --wait` + boot readiness protocol. The remaining gap from
6
67
  “one-storey building” onwards: boot had no definitive answer — agents
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
@@ -95,7 +104,9 @@ Optional top-level `db: false` opts out of per-worktree databases
95
104
  (exotic setups — manual `establish_connection`, shared staging DB, …);
96
105
  `db.schema_load` overrides the schema-load command. Per-process
97
106
  `healthcheck: { path: /up, timeout: 30 }` declares what `--wait` polls
98
- (TCP accept when absent).
107
+ (TCP accept when absent). The poll is plain HTTP against the app's own
108
+ `127.0.0.1:$PORT` listener — TLS is terminated by the proxy, so the path
109
+ is reached over http regardless of the `https://` URL in the banner.)
99
110
 
100
111
  `yamine init` creates the file (migrating an existing Procfile);
101
112
  Rails apps need no extra gem — yamine injects `RAILS_DEVELOPMENT_HOSTS`
@@ -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, tls: ctx.proxy_tls, 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
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"
@@ -3,7 +3,6 @@
3
3
  require "json"
4
4
  require "net/http"
5
5
  require "open3"
6
- require "openssl"
7
6
  require "socket"
8
7
  require "timeout"
9
8
  require "uri"
@@ -39,7 +38,10 @@ module Yamine
39
38
 
40
39
  # Run a phase with timing + timeout. Yields; returns the Event.
41
40
  # On timeout or raise, status is "fail" with the error as detail.
42
- 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)
43
45
  timeout ||= DEFAULT_TIMEOUTS.fetch(name, 30)
44
46
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
47
  event = nil
@@ -57,7 +59,7 @@ module Yamine
57
59
  event = Event.new(phase: name, action: action, status: "fail",
58
60
  duration_ms: ms, detail: e.message.lines.first&.strip)
59
61
  end
60
- out&.puts(JSON.generate(event.to_h)) if out
62
+ sink&.event(event)
61
63
  event
62
64
  end
63
65
 
@@ -89,11 +91,17 @@ module Yamine
89
91
  # Poll an HTTP route until healthy or timeout. Healthy = healthcheck
90
92
  # path returns 2xx-3xx, or (no healthcheck) TCP accept on the port.
91
93
  # Returns [healthy, detail].
92
- def wait_healthy(hostname, port:, tls:, path: nil, timeout: 30)
94
+ #
95
+ # Probes are plaintext by construction: the target is the app's OWN
96
+ # listener (127.0.0.1:$PORT). TLS is terminated by the proxy, which
97
+ # dials backends with a bare TCPSocket (Proxy#connect_backend), so a
98
+ # `tls:` flag taken from the proxy must never reach here — an
99
+ # https:// URL in the banner does not mean the backend speaks TLS.
100
+ def wait_healthy(hostname, port:, path: nil, timeout: 30)
93
101
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
94
102
  last_error = "not yet attempted"
95
103
  until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
96
- ok, detail = probe(hostname, port, tls: tls, path: path)
104
+ ok, detail = probe(hostname, port, path: path)
97
105
  return [true, nil] if ok
98
106
 
99
107
  last_error = detail
@@ -102,9 +110,9 @@ module Yamine
102
110
  [false, last_error]
103
111
  end
104
112
 
105
- def probe(hostname, port, tls:, path: nil)
113
+ def probe(hostname, port, path: nil)
106
114
  if path
107
- probe_http(hostname, port, tls: tls, path: path)
115
+ probe_http(hostname, port, path: path)
108
116
  else
109
117
  probe_tcp(port)
110
118
  end
@@ -117,10 +125,8 @@ module Yamine
117
125
  [false, e.message.lines.first&.strip]
118
126
  end
119
127
 
120
- def probe_http(hostname, port, tls:, path:)
128
+ def probe_http(hostname, port, path:)
121
129
  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
130
  http.open_timeout = 3
125
131
  http.read_timeout = 5
126
132
  res = http.get(path, { "Host" => hostname })
@@ -140,17 +146,17 @@ module Yamine
140
146
  # or "timeout" (never answered within its healthcheck timeout).
141
147
  # Dead-pid short-circuit: a reaped backend fails immediately
142
148
  # instead of burning its full timeout on connection-refused.
143
- def wait_all(apps, tls: true, out: nil)
149
+ def wait_all(apps, sink: nil)
144
150
  threads = apps.map do |name, slot|
145
151
  Thread.new do
146
- Thread.current[:result] = wait_one(name, slot, tls: tls, out: out)
152
+ Thread.current[:result] = wait_one(name, slot, sink: sink)
147
153
  end
148
154
  end
149
155
  threads.each(&:join)
150
156
  threads.map { |t| t[:result] }
151
157
  end
152
158
 
153
- def wait_one(name, slot, tls:, out:)
159
+ def wait_one(name, slot, sink:)
154
160
  item = slot[:item]
155
161
  app = slot[:app]
156
162
  hc = item[:entry]["healthcheck"] || {}
@@ -163,12 +169,12 @@ module Yamine
163
169
  unless process_alive?(app.pid)
164
170
  ms = elapsed_ms(started)
165
171
  return fail_result(name, started, ms,
166
- "process exited before becoming healthy — see log/yamine-#{name}.log", out)
172
+ "process exited before becoming healthy — see log/yamine-#{name}.log", sink)
167
173
  end
168
- ok, detail = probe(item[:hostname], item[:port], tls: tls, path: path)
174
+ ok, detail = probe(item[:hostname], item[:port], path: path)
169
175
  if ok
170
176
  ms = elapsed_ms(started)
171
- return ok_result(name, started, ms, path, out)
177
+ return ok_result(name, started, ms, path, sink)
172
178
  end
173
179
  last_error = detail
174
180
  sleep 0.5
@@ -179,22 +185,22 @@ module Yamine
179
185
  "process exited before becoming healthy — see log/yamine-#{name}.log"
180
186
  event = Event.new(phase: "process", action: name, status: status,
181
187
  duration_ms: ms, detail: detail)
182
- out&.puts(JSON.generate(event.to_h)) if out
188
+ sink&.event(event)
183
189
  { name: name, status: status, phase: "process", detail: detail, duration_ms: ms }
184
190
  end
185
191
 
186
- def ok_result(name, started, ms, path, out)
192
+ def ok_result(name, started, ms, path, sink)
187
193
  detail = path ? "healthcheck #{path} returned 2xx-3xx" : "port accepted connection"
188
194
  event = Event.new(phase: "process", action: name, status: "ok",
189
195
  duration_ms: ms, detail: detail)
190
- out&.puts(JSON.generate(event.to_h)) if out
196
+ sink&.event(event)
191
197
  { name: name, status: "ok", phase: "process", detail: detail, duration_ms: ms }
192
198
  end
193
199
 
194
- def fail_result(name, started, ms, detail, out)
200
+ def fail_result(name, started, ms, detail, sink)
195
201
  event = Event.new(phase: "process", action: name, status: "fail",
196
202
  duration_ms: ms, detail: detail)
197
- out&.puts(JSON.generate(event.to_h)) if out
203
+ sink&.event(event)
198
204
  { name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
199
205
  end
200
206
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
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.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto