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 +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +12 -1
- data/lib/yamine/cli/boot.rb +94 -36
- data/lib/yamine/cli/context.rb +16 -1
- data/lib/yamine/doctor.rb +31 -8
- data/lib/yamine/hosts.rb +14 -3
- data/lib/yamine/log.rb +57 -0
- data/lib/yamine/proxy_control.rb +20 -0
- data/lib/yamine/readiness.rb +27 -21
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1003241d7826eb9d1b2c1f511a7e9b8e99f4be0d9438d2651dd86864d3cee1cc
|
|
4
|
+
data.tar.gz: 58ee2023a0f951f508c5032af8079d9417f97427bf76d48af08ec734661fd7a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ea87625511591d754116bda3a16849466d9d982c7ba8f69a773508e36142e224e9c9a9ed96335c2f1d28c28f285116a488caa5c34d4ab7704cb06b4bb1d14a3f
|
|
7
|
+
data.tar.gz: 701589715a07e31778aefd39c60d05f7bdd9c865d77d72517f0a3881054046067ae60c2dc3ae97ecebe6f8b1dfd238ab95de5dd12d3a163bbd0f63aefaba9f3a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
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`
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -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, "
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
say opts, " [background] #{background.keys.join(', ')}"
|
|
105
121
|
end
|
|
106
122
|
|
|
107
|
-
|
|
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
|
-
|
|
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,
|
|
114
|
-
supervise_tree(ctx, all_hostnames,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
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)
|
|
213
|
-
|
|
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(
|
|
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
|
-
|
|
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}",
|
|
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}",
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
Process.kill("TERM",
|
|
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
|
-
|
|
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
|
data/lib/yamine/cli/context.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
Check.new(name: "proxy", ok:
|
|
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
|
|
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 "
|
|
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) {
|
|
87
|
+
Timeout.timeout(2) { Addrinfo.getaddrinfo(hostname, nil) }
|
|
77
88
|
true
|
|
78
|
-
rescue
|
|
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
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -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"
|
data/lib/yamine/readiness.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
113
|
+
def probe(hostname, port, path: nil)
|
|
106
114
|
if path
|
|
107
|
-
probe_http(hostname, port,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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",
|
|
172
|
+
"process exited before becoming healthy — see log/yamine-#{name}.log", sink)
|
|
167
173
|
end
|
|
168
|
-
ok, detail = probe(item[:hostname], item[:port],
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
203
|
+
sink&.event(event)
|
|
198
204
|
{ name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
|
|
199
205
|
end
|
|
200
206
|
|
data/lib/yamine/version.rb
CHANGED