yamine 0.8.1 → 0.9.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 +53 -0
- data/lib/yamine/cli/boot.rb +9 -4
- data/lib/yamine/cli/context.rb +1 -4
- data/lib/yamine/cli/system.rb +17 -1
- data/lib/yamine/doctor.rb +42 -1
- data/lib/yamine/proxy_control.rb +72 -3
- 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: 65dc796c2e121f6376d6a76b1baa7bb1ada3598c7facf8634262c83915c9cf2c
|
|
4
|
+
data.tar.gz: 0a2cea7a624133d68512b79e5e1060a96337a7da4948504313ea24ba6ecce080
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c1f73c73193f3e9c4eae8615ad0b790111bd2c6f29615a19f35c380e99db2d2a1a239e38b10c9328795a6f1c7cf1b28b0ecce721cb5414710769ba63a99b622b
|
|
7
|
+
data.tar.gz: 2200ab3b518fac757902b25e7ec2e6933d08e2183830aba2bbf5d893622b2ded021cc7bf4f07064e6efe9ff7500dad408ea73f7bc30a4c83c25b645829bdfdf2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0] — 2026-09-10
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **The service proxy now records its own state.** The launchd/systemd
|
|
8
|
+
service runs `proxy start --foreground`, and that path recorded
|
|
9
|
+
nothing: `write_pid` was reachable only from `spawn_daemon`. So after
|
|
10
|
+
`yamine setup`, the pid/port/scheme files still held the PREVIOUS
|
|
11
|
+
daemon's values. On a machine whose root service was serving clean
|
|
12
|
+
`https://<app>.localhost` on 443, the recorded port still said 8443 —
|
|
13
|
+
`yamine start` printed and baked `:8443` into `YAMINE_URL`, doctor
|
|
14
|
+
warned about the long-dead 8443, and `proxy stop` aimed at a pid that
|
|
15
|
+
was already gone. `setup` even contradicted itself in one run:
|
|
16
|
+
"proxy is up on port 443" followed by "listening on port 8443". The
|
|
17
|
+
foreground path now writes pid/port/tls plus a new `proxy.version`
|
|
18
|
+
marker BEFORE it serves.
|
|
19
|
+
- The launchd plist and systemd unit pass `--port 443` explicitly. They
|
|
20
|
+
relied on the default, which consults `YAMINE_PORT` — one stray export
|
|
21
|
+
would silently move the boot service off 443.
|
|
22
|
+
- **`proxy stop` no longer claims success on a root-owned proxy.** The
|
|
23
|
+
kill raised `Errno::EPERM`, which was swallowed by a blanket rescue:
|
|
24
|
+
the state files were cleared and "Proxy stopped." printed while the
|
|
25
|
+
service kept serving — now with nothing on disk to find or stop it by.
|
|
26
|
+
It reports `needs_root` and points at `sudo yamine service uninstall`.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `doctor` gains a `proxy state` check and now finds the proxy that is
|
|
31
|
+
actually serving (`ProxyControl.serving_port`, ownership-proven)
|
|
32
|
+
instead of trusting the recorded port. It warns when the recorded port
|
|
33
|
+
disagrees with reality, and when the running proxy is an older gem
|
|
34
|
+
version than the CLI (the root service keeps serving old code after an
|
|
35
|
+
upgrade until `sudo yamine service install` re-registers it).
|
|
36
|
+
- The clean default port wins when our proxy serves there, so a leftover
|
|
37
|
+
daemon on another port can no longer make the machine report a
|
|
38
|
+
downgraded URL as its state. A non-default port is still reported when
|
|
39
|
+
it is the only proxy — the deliberate CI/sandbox case.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## [0.8.2] — 2026-09-10
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **The rest of the `--json` stdout leaks.** Two writers still reached
|
|
47
|
+
stdout during a machine-readable run, corrupting the stream a parser
|
|
48
|
+
reads: the `-> <url>` line from the sequential boot path, and
|
|
49
|
+
"Starting proxy..." — which is printed before the reporter is even
|
|
50
|
+
installed, so it would have been the FIRST line of a `--json` run.
|
|
51
|
+
Both now route through the same stderr-when-json rule as the rest of
|
|
52
|
+
the narration, and the contract is pinned by tests rather than assumed
|
|
53
|
+
(two separate leaks have now been caught here).
|
|
54
|
+
|
|
55
|
+
|
|
3
56
|
## [0.8.1] — 2026-09-10
|
|
4
57
|
|
|
5
58
|
### Fixed
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Yamine
|
|
|
21
21
|
# Ownership gate before any side effects: no proxy spawn, no
|
|
22
22
|
# port allocation when we'd refuse anyway.
|
|
23
23
|
check_worktree_ownership!(ctx, resolved, force: opts[:force])
|
|
24
|
-
ensure_proxy!(ctx)
|
|
24
|
+
ensure_proxy!(ctx, json: opts[:json])
|
|
25
25
|
boot_all(ctx, resolved, opts)
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -180,7 +180,7 @@ module Yamine
|
|
|
180
180
|
rails_dev_host: item[:hostname], database_url: db_url)
|
|
181
181
|
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
say opts, " -> #{item[:url]}"
|
|
184
184
|
end
|
|
185
185
|
children
|
|
186
186
|
end
|
|
@@ -548,7 +548,11 @@ module Yamine
|
|
|
548
548
|
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
|
|
549
549
|
end
|
|
550
550
|
|
|
551
|
-
|
|
551
|
+
# json: keeps stdout free for the machine-readable stream — this runs
|
|
552
|
+
# BEFORE boot_all installs the reporter, so its progress line is the
|
|
553
|
+
# one piece of narration that could still land mid-JSON and break a
|
|
554
|
+
# parser on the first line of a --json run.
|
|
555
|
+
def ensure_proxy!(ctx, json: false)
|
|
552
556
|
port = ctx.proxy_port
|
|
553
557
|
tls = ctx.proxy_tls
|
|
554
558
|
if ProxyControl.listening?(port)
|
|
@@ -570,7 +574,8 @@ module Yamine
|
|
|
570
574
|
$stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
|
|
571
575
|
exit 1
|
|
572
576
|
end
|
|
573
|
-
|
|
577
|
+
starting = "Starting proxy#{privileged ? " (sudo)" : ""}..."
|
|
578
|
+
json ? $stderr.puts(starting) : puts(starting)
|
|
574
579
|
begin
|
|
575
580
|
Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
|
|
576
581
|
rescue Yamine::ProxyNotRunningError => e
|
data/lib/yamine/cli/context.rb
CHANGED
|
@@ -28,10 +28,7 @@ module Yamine
|
|
|
28
28
|
# listening there. A dead proxy leaves the machine on the clean
|
|
29
29
|
# default (443), and an explicit YAMINE_PORT always wins.
|
|
30
30
|
def proxy_port
|
|
31
|
-
|
|
32
|
-
return recorded if recorded && ProxyControl.listening?(recorded)
|
|
33
|
-
|
|
34
|
-
ProxyControl.default_port(proxy_tls)
|
|
31
|
+
ProxyControl.active_port(store)
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
def proxy_tls
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -70,6 +70,14 @@ module Yamine
|
|
|
70
70
|
if foreground
|
|
71
71
|
write_tls_marker(ctx, tls)
|
|
72
72
|
write_tlds_file(ctx, tlds)
|
|
73
|
+
# Record BEFORE serving. This process (the launchd/systemd
|
|
74
|
+
# service, or a hand-run foreground proxy) IS the proxy, and
|
|
75
|
+
# every reader trusts these files: doctor, Context's URL
|
|
76
|
+
# building, the supervisor, `proxy stop`. Not recording left
|
|
77
|
+
# the previous proxy's port on disk, so a machine serving
|
|
78
|
+
# clean 443 still advertised :8443 everywhere.
|
|
79
|
+
ProxyControl.write_proxy_state(ctx.store,
|
|
80
|
+
pid: Process.pid, port: port, tls: tls)
|
|
73
81
|
sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
|
|
74
82
|
on_event: ->(m) { warn m })
|
|
75
83
|
sup.start
|
|
@@ -85,6 +93,13 @@ module Yamine
|
|
|
85
93
|
when :stale then puts "Removed stale proxy state."
|
|
86
94
|
when :not_running then puts "Proxy is not running."
|
|
87
95
|
when :unknown_process then puts "Port in use by an unknown process."
|
|
96
|
+
when :needs_root
|
|
97
|
+
# A root service keeps serving; say so rather than implying
|
|
98
|
+
# success. Uninstalling is the real answer — signalling it
|
|
99
|
+
# would just leave launchd's KeepAlive restarting it.
|
|
100
|
+
$stderr.puts "The proxy runs as root (the launchd/systemd service)."
|
|
101
|
+
$stderr.puts " Uninstall it: sudo yamine service uninstall"
|
|
102
|
+
exit 1
|
|
88
103
|
end
|
|
89
104
|
else
|
|
90
105
|
raise Error, "Usage: yamine proxy [start|stop]"
|
|
@@ -282,6 +297,7 @@ module Yamine
|
|
|
282
297
|
<string>#{RbConfig.ruby}</string>
|
|
283
298
|
<string>#{ProxyControl.bin_path}</string>
|
|
284
299
|
<string>proxy</string><string>start</string><string>--foreground</string>
|
|
300
|
+
<string>--port</string><string>#{ProxyControl::DEFAULT_TLS_PORT}</string>
|
|
285
301
|
</array>
|
|
286
302
|
<key>EnvironmentVariables</key>
|
|
287
303
|
<dict>
|
|
@@ -357,7 +373,7 @@ module Yamine
|
|
|
357
373
|
After=network.target
|
|
358
374
|
|
|
359
375
|
[Service]
|
|
360
|
-
ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
|
|
376
|
+
ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground --port #{ProxyControl::DEFAULT_TLS_PORT}
|
|
361
377
|
Environment=YAMINE_STATE_DIR=#{state_dir}
|
|
362
378
|
Environment=HOME=#{home}
|
|
363
379
|
|
data/lib/yamine/doctor.rb
CHANGED
|
@@ -18,12 +18,18 @@ module Yamine
|
|
|
18
18
|
|
|
19
19
|
def run(store:, port: nil, tls: nil)
|
|
20
20
|
checks = []
|
|
21
|
-
port ||= ProxyControl.proxy_port(store)
|
|
22
21
|
tls = ProxyControl.proxy_tls(store) if tls.nil?
|
|
22
|
+
# Prove ownership rather than trusting the port file: doctor is
|
|
23
|
+
# where a wrong answer is expensive, and a connection is cheap. This
|
|
24
|
+
# finds the proxy on the scheme default even when a stale port file
|
|
25
|
+
# says otherwise, which is how a root service serving clean 443 went
|
|
26
|
+
# unreported while doctor warned about a dead 8443.
|
|
27
|
+
port ||= ProxyControl.serving_port(store)
|
|
23
28
|
|
|
24
29
|
checks << check_state_dir(store)
|
|
25
30
|
checks << check_disk(store)
|
|
26
31
|
checks << check_proxy(port, tls: tls)
|
|
32
|
+
checks << check_proxy_state(store, port)
|
|
27
33
|
checks << check_routes(store)
|
|
28
34
|
checks << check_dns(store)
|
|
29
35
|
checks << check_ca
|
|
@@ -93,6 +99,41 @@ module Yamine
|
|
|
93
99
|
end
|
|
94
100
|
end
|
|
95
101
|
|
|
102
|
+
# The recorded port, and whether the proxy serving is the version we
|
|
103
|
+
# have installed.
|
|
104
|
+
#
|
|
105
|
+
# A file that disagrees with reality is the failure mode this whole
|
|
106
|
+
# area keeps producing: a stopped daemon's port outliving it made
|
|
107
|
+
# `yamine start` bake :8443 into URLs while the machine served clean
|
|
108
|
+
# 443, and a root service left from an old gem keeps serving old code
|
|
109
|
+
# after an upgrade (`sudo yamine service install` re-registers it).
|
|
110
|
+
# Neither is visible from the URL bar, so both are named here.
|
|
111
|
+
def check_proxy_state(store, serving)
|
|
112
|
+
recorded = ProxyControl.proxy_port(store)
|
|
113
|
+
version = ProxyControl.proxy_version(store)
|
|
114
|
+
problems = []
|
|
115
|
+
|
|
116
|
+
if recorded && serving && recorded != serving
|
|
117
|
+
problems << "state says port #{recorded} but the proxy is on #{serving}" \
|
|
118
|
+
" — run: yamine proxy stop"
|
|
119
|
+
elsif recorded && serving.nil?
|
|
120
|
+
problems << "state says port #{recorded}, but no yamine proxy is serving" \
|
|
121
|
+
" — run: yamine proxy stop"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if version && version != Yamine::VERSION
|
|
125
|
+
problems << "the running proxy is v#{version}, this CLI is v#{Yamine::VERSION}" \
|
|
126
|
+
" — re-register it: sudo yamine service install"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if problems.empty?
|
|
130
|
+
Check.new(name: "proxy state", ok: true, message: "consistent")
|
|
131
|
+
else
|
|
132
|
+
Check.new(name: "proxy state", ok: true, warn: true,
|
|
133
|
+
message: problems.join("; "))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
96
137
|
def check_routes(store)
|
|
97
138
|
routes = store.load_routes
|
|
98
139
|
if routes.empty?
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -156,17 +156,33 @@ module Yamine
|
|
|
156
156
|
nil
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
# Record this process as THE running proxy: pid, port, scheme, and the
|
|
160
|
+
# gem version serving it.
|
|
161
|
+
#
|
|
162
|
+
# Every reader trusts these files — doctor reports the port, Context
|
|
163
|
+
# and the supervisor build URLs from it, `proxy stop` signals the pid
|
|
164
|
+
# — so a proxy that serves without recording is worse than one that
|
|
165
|
+
# fails to start: it leaves the previous proxy's state on disk and the
|
|
166
|
+
# whole CLI acts on it. The launchd/systemd service used to take
|
|
167
|
+
# exactly that path (it runs `proxy start --foreground`, which
|
|
168
|
+
# recorded nothing), so a stopped 8443 daemon's port stayed on disk
|
|
169
|
+
# and `yamine start` baked :8443 into URLs while the machine served
|
|
170
|
+
# clean https://<app>.localhost on 443.
|
|
171
|
+
def write_proxy_state(store, pid:, port:, tls:)
|
|
160
172
|
store.ensure_dir
|
|
173
|
+
version_path = File.join(store.dir, "proxy.version")
|
|
161
174
|
File.write(store.pid_path, "#{pid}\n")
|
|
162
175
|
File.write(store.port_path, "#{port}\n")
|
|
163
176
|
File.write(File.join(store.dir, "proxy.tls"), tls ? "1" : "0")
|
|
164
|
-
|
|
177
|
+
File.write(version_path, "#{Yamine::VERSION}\n")
|
|
178
|
+
Ownership.fix(store.pid_path, store.port_path,
|
|
179
|
+
File.join(store.dir, "proxy.tls"), version_path)
|
|
165
180
|
end
|
|
166
181
|
|
|
167
182
|
def clear_pid(store)
|
|
168
183
|
FileUtils.rm_f(store.pid_path)
|
|
169
184
|
FileUtils.rm_f(store.port_path)
|
|
185
|
+
FileUtils.rm_f(File.join(store.dir, "proxy.version"))
|
|
170
186
|
end
|
|
171
187
|
|
|
172
188
|
def proxy_port(store)
|
|
@@ -178,6 +194,54 @@ module Yamine
|
|
|
178
194
|
nil
|
|
179
195
|
end
|
|
180
196
|
|
|
197
|
+
# The gem version of the proxy that recorded its state, or nil when
|
|
198
|
+
# nothing is recorded. Compared against Yamine::VERSION so a service
|
|
199
|
+
# left behind by an older install is visible instead of silently
|
|
200
|
+
# serving stale code.
|
|
201
|
+
def proxy_version(store)
|
|
202
|
+
path = File.join(store.dir, "proxy.version")
|
|
203
|
+
return nil unless File.file?(path)
|
|
204
|
+
|
|
205
|
+
version = File.read(path).strip
|
|
206
|
+
version.empty? ? nil : version
|
|
207
|
+
rescue SystemCallError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Cheap resolution for the hot paths (URL building on every boot):
|
|
212
|
+
# the recorded port while something is listening there, else the
|
|
213
|
+
# scheme default. Proves liveness, not ownership — a foreign process
|
|
214
|
+
# on the recorded port still wins here and is then caught by
|
|
215
|
+
# `ensure_proxy!` ("port in use by another process"). Use
|
|
216
|
+
# `serving_port` when the answer must be OUR proxy.
|
|
217
|
+
def active_port(store)
|
|
218
|
+
recorded = proxy_port(store)
|
|
219
|
+
return recorded if recorded && listening?(recorded)
|
|
220
|
+
|
|
221
|
+
default_port(proxy_tls(store))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Where OUR proxy is actually serving, or nil when it is not running.
|
|
225
|
+
# Proves ownership, so it costs a probe — doctor-only, where an
|
|
226
|
+
# occasional connection is free and a wrong answer is not.
|
|
227
|
+
#
|
|
228
|
+
# The default port wins when our proxy serves there, because that is
|
|
229
|
+
# the configuration yamine exists to produce: after `yamine setup`
|
|
230
|
+
# installs the 443 service, a leftover daemon on another port must not
|
|
231
|
+
# make doctor report the downgraded URL as the state of the machine.
|
|
232
|
+
# Only when the default has no proxy do we report the recorded port —
|
|
233
|
+
# which is the deliberate CI/sandbox case (`proxy start -p 1355`).
|
|
234
|
+
def serving_port(store)
|
|
235
|
+
tls = proxy_tls(store)
|
|
236
|
+
default = default_port(tls)
|
|
237
|
+
return default if ours?(default, tls: tls)
|
|
238
|
+
|
|
239
|
+
recorded = proxy_port(store)
|
|
240
|
+
return recorded if recorded && recorded != default && ours?(recorded, tls: tls)
|
|
241
|
+
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
181
245
|
# Start the proxy as a detached daemon. Sudo is used for privileged
|
|
182
246
|
# ports (portless auto-elevate pattern); the state dir is passed
|
|
183
247
|
# explicitly because sudo does not preserve the environment.
|
|
@@ -205,7 +269,7 @@ module Yamine
|
|
|
205
269
|
|
|
206
270
|
sleep 0.25
|
|
207
271
|
end
|
|
208
|
-
|
|
272
|
+
write_proxy_state(store, pid: pid, port: port, tls: tls)
|
|
209
273
|
pid
|
|
210
274
|
end
|
|
211
275
|
|
|
@@ -231,6 +295,11 @@ module Yamine
|
|
|
231
295
|
end
|
|
232
296
|
begin
|
|
233
297
|
Process.kill("TERM", pid)
|
|
298
|
+
rescue Errno::EPERM
|
|
299
|
+
# The proxy runs as root (launchd/systemd service) and we do not.
|
|
300
|
+
# Clearing the state here would be a lie: the proxy keeps serving,
|
|
301
|
+
# now with nothing on disk to find or stop it by.
|
|
302
|
+
return :needs_root
|
|
234
303
|
rescue SystemCallError
|
|
235
304
|
clear_pid(store)
|
|
236
305
|
return :stale
|
data/lib/yamine/version.rb
CHANGED