yamine 0.10.3 → 0.11.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: 9db7f2ecccb27a731e287afd46aee1e3bcea4654ccaed6f44e1fa622ec4ba4c4
4
- data.tar.gz: aa2ad6be04141dc6b4f3dabf89c67b8ff93dc8c4d296369735f2ddc8cda9201d
3
+ metadata.gz: 1efaa2b9488a10007f6a386cf874eb78b3e8489e9b772efb2e88713286e98e2d
4
+ data.tar.gz: 410ac56d153c94589d1d36fd1a6b5670ac215c6c5999ca956590acd3d7c46a98
5
5
  SHA512:
6
- metadata.gz: a0787fe2e1c69fa0842fae4a4048b2eb42187ee3ce43a4b850db0e9956d32aa3c7be7c733b81574e1d619bb1f33542de31489b987fc3fde06d0351875777eac9
7
- data.tar.gz: 5441bf6b2c0bf9fd8d375d22386d906c8ff71c1f35db7f8845c31dfe78fecc45c00134aec213c548e14a4bf3b6b803bb9b6e5f7b673b08c23db575f70ad832ec
6
+ metadata.gz: ed28898e26abce37591fb15902697e2f96d2dc4ed4bc20d951872c48f586e19482affdafb24606bbe8c95cd0016b63a637d45f23c15fd7778f6ebd9a2415e0b7
7
+ data.tar.gz: 075d1d80e12a0578377d956421bfbc7ac9dc61e414552262d0ffd61ea9613f1382732135c5b8e7c7af8fbfb9d54e02905d05bc53347cba7054ced38879fef0d4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0] — 2026-09-11
4
+
5
+ ### Added
6
+
7
+ - **Ruby, git, and curl now trust the local CA — so an app can call
8
+ another app at `https://<name>.localhost` with no configuration.** The
9
+ gap surfaced when anywaye (a Rails app) tried to reach anymark at
10
+ `https://anymark-directory.localhost`: `certificate verify failed`. The
11
+ CA was trusted everywhere a human looks (Safari, Chrome, curl) and
12
+ nowhere a child process looks — only Node was covered, via
13
+ `NODE_EXTRA_CA_CERTS`.
14
+
15
+ The naive fix is wrong in a way that shows up much later. `SSL_CERT_FILE`
16
+ does not *add* to OpenSSL's roots, it *replaces* them, so pointing it at
17
+ `~/.yamine/ca.pem` would break every public HTTPS call the app makes —
18
+ rubygems, payment APIs, an LLM provider — while fixing the local one. So
19
+ yamine now builds `~/.yamine/bundle.pem`: the system's roots plus its own
20
+ CA, regenerated only when either changes, and exports it as
21
+ `SSL_CERT_FILE` and `GIT_SSL_CAINFO` for every child. Node keeps getting
22
+ the CA alone, because its variable really is additive.
23
+
24
+ `yamine doctor` reports the bundle (`bundle: 129 certificates`), so a
25
+ missing or stale one is visible before an app fails on it, and a real
26
+ TLS handshake against a yamine-minted certificate is pinned by a test —
27
+ not just the presence of the environment variables.
28
+
29
+ ## [0.10.4] — 2026-09-11
30
+
31
+ ### Fixed
32
+
33
+ - **A stale `tmp/pids/server.pid` no longer kills the boot 2s in.** The
34
+ anywaye incident: an interrupted earlier run left puma alive, Rails
35
+ refused to start ("A server is already running (pid: N)"), `web` died
36
+ before its first healthcheck, and the failure line only said "process
37
+ exited" — the actual reason sat in the log file. Now:
38
+ - Pre-flight `Readiness.server_pid_conflict` checks the pidfile BEFORE
39
+ spawning. A live holder is a conflict, not a mystery crash; a dead
40
+ pid is fine (Rails overwrites it).
41
+ - When the holder is this app's own leftover puma (`puma ... [app]`),
42
+ yamine reaps it automatically. Anything else fails with the exact
43
+ fix (`kill N && rm tmp/pids/server.pid`), or `--force` to take over.
44
+ - `Readiness.fatal_line` recognizes the common killers (already
45
+ running, address in use, missing gems, missing database) in the
46
+ process's own log and puts the real reason in the failure line.
47
+ - **No more orphans on any failure path.** The incident left puma +
48
+ solid-queue + ask-app-server running for 24 minutes with no routes
49
+ after the CLI exited. Now `Runner#boot_run` reaps the backend when
50
+ registration is refused (quota/conflict), `boot_concurrent` tracks
51
+ every spawned HTTP backend in `children` before adopt, and `boot_all`
52
+ wraps spawn+boot so a raise mid-boot kills every child and removes
53
+ every route already registered. Tests pin all three (with `expects`
54
+ so they cannot pass vacuously).
55
+
3
56
  ## [0.10.3] — 2026-09-11
4
57
 
5
58
  ### Added
data/lib/yamine/certs.rb CHANGED
@@ -44,6 +44,12 @@ module Yamine
44
44
  { cert: File.join(dir, "ca.pem"), key: File.join(dir, "ca-key.pem") }
45
45
  end
46
46
 
47
+ # The CA certificate's path on disk. Generated on first ask.
48
+ def ca_cert_path(dir = state_dir)
49
+ ensure_ca(dir)
50
+ ca_paths(dir)[:cert]
51
+ end
52
+
47
53
  def ensure_ca(dir = state_dir)
48
54
  FileUtils.mkdir_p(dir, mode: 0o755)
49
55
  Ownership.fix(dir)
@@ -79,6 +85,68 @@ module Yamine
79
85
  OpenSSL::PKey.read(File.read(paths[:key]))]
80
86
  end
81
87
 
88
+ # A CA bundle a child process can verify against: the system roots
89
+ # *plus* this CA.
90
+ #
91
+ # Why a bundle and not just the CA: `SSL_CERT_FILE` (Ruby/OpenSSL,
92
+ # curl, git) *replaces* the default store rather than adding to it, so
93
+ # pointing it at a file containing only our CA makes every public
94
+ # HTTPS request in that process fail verification. Concatenating gives
95
+ # both — yamine's names verify and rubygems, APIs, and git-over-https
96
+ # keep working.
97
+ #
98
+ # Built fresh when the CA or the system store changes, and cached
99
+ # otherwise: the bundle is ~230 KB and regenerating it on every boot
100
+ # would be pure waste, while serving a stale one after `yamine trust`
101
+ # regenerates the CA would silently break every TLS connection in the
102
+ # app.
103
+ def bundle_path(dir = state_dir)
104
+ File.join(dir, "bundle.pem")
105
+ end
106
+
107
+ # The system's own roots, in the order OpenSSL would consider them.
108
+ # SSL_CERT_FILE wins when set (that is the variable we are about to
109
+ # write, and a caller may have pointed it somewhere deliberately),
110
+ # else OpenSSL's compiled-in file, else the usual macOS/Linux paths.
111
+ def system_bundle
112
+ candidates = [ENV["SSL_CERT_FILE"],
113
+ OpenSSL::X509::DEFAULT_CERT_FILE,
114
+ "/etc/ssl/cert.pem",
115
+ "/etc/pki/tls/certs/ca-bundle.crt",
116
+ "/etc/ssl/certs/ca-certificates.crt"].compact
117
+ candidates.find { |path| File.file?(path) && File.size(path).positive? }
118
+ end
119
+
120
+ def ensure_bundle(dir = state_dir)
121
+ paths = ensure_ca(dir)
122
+ target = bundle_path(dir)
123
+ ca = File.read(paths[:cert])
124
+ system_file = system_bundle
125
+ # Regenerate when the CA changed, when the system store changed, or
126
+ # when the bundle is missing. mtime comparison is enough: these files
127
+ # are written by installers, not edited in place.
128
+ return target if bundle_current?(target, paths[:cert], system_file)
129
+
130
+ FileUtils.mkdir_p(dir)
131
+ content = +""
132
+ content << File.read(system_file) if system_file
133
+ content << "\n" unless content.empty? || content.end_with?("\n")
134
+ content << ca
135
+ File.write(target, content, mode: "w", perm: 0o644)
136
+ Ownership.fix(target)
137
+ target
138
+ end
139
+
140
+ def bundle_current?(target, ca_path, system_file)
141
+ return false unless File.file?(target)
142
+
143
+ target_mtime = File.mtime(target)
144
+ return false if File.mtime(ca_path) > target_mtime
145
+ return false if system_file && File.mtime(system_file) > target_mtime
146
+
147
+ true
148
+ end
149
+
82
150
  # Mint a leaf cert for one hostname, signed by the CA.
83
151
  def mint_host(hostname, ca_cert, ca_key)
84
152
  key = OpenSSL::PKey::EC.generate("prime256v1")
@@ -90,6 +90,28 @@ module Yamine
90
90
  exit 1
91
91
  end
92
92
 
93
+ # Pre-flight: a live tmp/pids/server.pid makes Rails refuse to
94
+ # boot ("A server is already running") the moment web starts.
95
+ # When the holder is this app's own puma (`puma ... [app]`), take
96
+ # over automatically — that is one of ours. Anything else is not
97
+ # ours to kill: fail with the exact fix before spawning.
98
+ conflict_ok, conflict_detail, conflict_pid = Readiness.server_pid_conflict(Dir.pwd)
99
+ unless conflict_ok
100
+ if own_orphan_puma?(conflict_pid, resolved.app, Dir.pwd)
101
+ say opts, " reaping own orphaned server (pid #{conflict_pid})"
102
+ reap_stale_server(conflict_pid)
103
+ elsif opts[:force]
104
+ say opts, " --force: taking over pid #{conflict_pid}"
105
+ reap_stale_server(conflict_pid)
106
+ else
107
+ $stderr.puts "Error: #{conflict_detail}."
108
+ $stderr.puts " Rails will refuse to boot while it runs. Free it first:"
109
+ $stderr.puts " kill #{conflict_pid} && rm tmp/pids/server.pid"
110
+ $stderr.puts " Or re-run with --force to let yamine take over that pid."
111
+ exit 1
112
+ end
113
+ end
114
+
93
115
  runner = Runner.new(store: ctx.store)
94
116
  children = []
95
117
  routes_registered = []
@@ -111,12 +133,21 @@ module Yamine
111
133
  with_wait = !opts[:no_wait]
112
134
  spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
113
135
 
114
- if with_wait
115
- boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
116
- routes_registered, db_url, db_name, db_created, events: events)
117
- else
118
- boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
119
- routes_registered, db_url, events: events)
136
+ begin
137
+ if with_wait
138
+ boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
139
+ routes_registered, db_url, db_name, db_created, events: events)
140
+ else
141
+ boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
142
+ routes_registered, db_url, events: events)
143
+ end
144
+ rescue StandardError
145
+ # A raise mid-boot (route conflict, quota, unexpected error)
146
+ # must never leave half a tree running: reap every child and
147
+ # every route already registered, then surface the error.
148
+ children.each { |c| stop_spawned_pid(c[:pid]) }
149
+ cleanup_routes(ctx, routes_registered.flat_map { |r| r[:hostnames] })
150
+ raise
120
151
  end
121
152
 
122
153
  background = processes.select { |_, v| v["proxy"] == false }
@@ -137,6 +168,35 @@ module Yamine
137
168
  supervise_tree(ctx, all_hostnames, named_pids, reporter: events)
138
169
  end
139
170
 
171
+ # TERM the old server and make sure the pidfile no longer names
172
+ # a live process before we spawn (wait a moment; then remove the
173
+ # file if the process is still refusing to die, so Rails can boot
174
+ # over it — the socket it held is already ours to take).
175
+ def reap_stale_server(pid)
176
+ Process.kill("TERM", pid)
177
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
178
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
179
+ break unless ProxyControl.pid_alive?(pid)
180
+ sleep 0.2
181
+ end
182
+ FileUtils.rm_f(File.join(Dir.pwd, "tmp", "pids", "server.pid"))
183
+ rescue SystemCallError
184
+ FileUtils.rm_f(File.join(Dir.pwd, "tmp", "pids", "server.pid"))
185
+ end
186
+
187
+ # Is this pid a leftover backend of THIS app? `puma (tcp://...)
188
+ # [app]` names the app; we also accept a command containing the
189
+ # app dir. Only then may we reap it unasked.
190
+ def own_orphan_puma?(pid, app, dir)
191
+ _out, status = Open3.capture2("ps", "-p", pid.to_s, "-o", "command=")
192
+ return false unless status.success?
193
+
194
+ cmd = _out.to_s
195
+ cmd.include?("[#{app}]") || cmd.include?(File.expand_path(dir))
196
+ rescue SystemCallError
197
+ false
198
+ end
199
+
140
200
  # One pass over config: background processes spawn immediately
141
201
  # (no route to wait on), HTTP processes become a spawn plan the
142
202
  # boot mode executes. Compound-line refusal happens here so both
@@ -198,12 +258,18 @@ module Yamine
198
258
  apps = {}
199
259
  plan.each do |item|
200
260
  say opts, " [#{item[:name]}] #{item[:url]}"
261
+ app = runner.spawn_http(name: item[:name], hostname: item[:hostname],
262
+ url: item[:url], dir: Dir.pwd, command: item[:command],
263
+ port: item[:port], rails_dev_host: item[:hostname],
264
+ database_url: db_url, force: opts[:force])
265
+ # Tracked before registration so any later failure (a raise
266
+ # during adopt, a crash while another process waits) can
267
+ # always reap it. Duplicate names in named_pids are harmless.
268
+ children << { name: item[:name], pid: app.pid }
201
269
  apps[item[:name]] = {
202
270
  item: item,
203
- app: runner.spawn_http(name: item[:name], hostname: item[:hostname],
204
- url: item[:url], dir: Dir.pwd, command: item[:command],
205
- port: item[:port], rails_dev_host: item[:hostname],
206
- database_url: db_url, force: opts[:force])
271
+ log_path: File.join(Dir.pwd, "log", "yamine-#{item[:name]}.log"),
272
+ app: app
207
273
  }
208
274
  end
209
275
  wait_result = Readiness.wait_all(apps, sink: events)
data/lib/yamine/doctor.rb CHANGED
@@ -33,6 +33,7 @@ module Yamine
33
33
  checks << check_routes(store)
34
34
  checks << check_dns(store)
35
35
  checks << check_ca
36
+ checks << check_ca_bundle
36
37
  checks
37
38
  end
38
39
 
@@ -195,6 +196,22 @@ module Yamine
195
196
  end
196
197
  end
197
198
 
199
+ # The child-process trust bundle. Its absence is not theoretical: an
200
+ # app whose process was started before the bundle existed (or with a
201
+ # regenerated CA) fails every call to another *.localhost with
202
+ # "certificate verify failed". Regenerating it here is safe and is
203
+ # exactly what the next boot would do anyway.
204
+ def check_ca_bundle
205
+ dir = Certs.state_dir
206
+ return Check.new(name: "bundle", ok: false, message: "no CA yet — run: yamine trust") unless File.file?(Certs.ca_paths(dir)[:cert])
207
+
208
+ target = Certs.ensure_bundle(dir)
209
+ count = File.read(target).scan("BEGIN CERTIFICATE").size
210
+ Check.new(name: "bundle", ok: true, message: "#{count} certificates (system roots + yamine CA)")
211
+ rescue StandardError => e
212
+ Check.new(name: "bundle", ok: false, message: "could not build #{Certs.bundle_path(dir)}: #{e.message}")
213
+ end
214
+
198
215
  # How many trusted certificates carry one of our CA names without
199
216
  # being the CA currently on disk. Best-effort: 0 when the keychain
200
217
  # cannot be read (doctor must never fail on a read-only probe).
@@ -168,8 +168,7 @@ module Yamine
168
168
  until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
169
169
  unless process_alive?(app.pid)
170
170
  ms = elapsed_ms(started)
171
- return fail_result(name, started, ms,
172
- "process exited before becoming healthy — see log/yamine-#{name}.log", sink)
171
+ return fail_result(name, started, ms, died_detail(name, slot), sink)
173
172
  end
174
173
  ok, detail = probe(item[:hostname], item[:port], path: path)
175
174
  if ok
@@ -182,13 +181,24 @@ module Yamine
182
181
  ms = elapsed_ms(started)
183
182
  status = process_alive?(app.pid) ? "timeout" : "fail"
184
183
  detail = process_alive?(app.pid) ? "no healthy response within #{timeout}s (#{last_error})" :
185
- "process exited before becoming healthy — see log/yamine-#{name}.log"
184
+ died_detail(name, slot)
186
185
  event = Event.new(phase: "process", action: name, status: status,
187
186
  duration_ms: ms, detail: detail)
188
187
  sink&.event(event)
189
188
  { name: name, status: status, phase: "process", detail: detail, duration_ms: ms }
190
189
  end
191
190
 
191
+ # Why a process that died before answering died: the recognized
192
+ # fatal line when there is one, else point at its log.
193
+ def died_detail(name, slot)
194
+ reason = fatal_line(slot[:log_path])
195
+ if reason
196
+ "process exited: #{reason} (log/yamine-#{name}.log)"
197
+ else
198
+ "process exited before becoming healthy — see log/yamine-#{name}.log"
199
+ end
200
+ end
201
+
192
202
  def ok_result(name, started, ms, path, sink)
193
203
  detail = path ? "healthcheck #{path} returned 2xx-3xx" : "port accepted connection"
194
204
  event = Event.new(phase: "process", action: name, status: "ok",
@@ -204,6 +214,53 @@ module Yamine
204
214
  { name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
205
215
  end
206
216
 
217
+ # Rails refuses to boot when tmp/pids/server.pid names a live
218
+ # process ("A server is already running (pid: N)") — the classic
219
+ # interrupted-run leftover. Report the exact reason and the fix
220
+ # BEFORE spawning anything, instead of letting `web` die 2s later.
221
+ # Returns [ok, detail, pid]; ok is true when there is nothing in the
222
+ # way (no file, unreadable, or a dead pid — Rails overwrites those).
223
+ def server_pid_conflict(dir = Dir.pwd)
224
+ path = File.join(dir, "tmp", "pids", "server.pid")
225
+ return [true, nil, nil] unless File.file?(path)
226
+
227
+ pid = File.read(path).strip.to_i
228
+ return [true, nil, nil] unless pid.positive? && process_alive?(pid)
229
+
230
+ [false, "a Rails server is already running (pid #{pid}, from #{path})", pid]
231
+ rescue SystemCallError, ArgumentError
232
+ [true, nil, nil]
233
+ end
234
+
235
+ # Known fatal patterns in a dying backend's log. The log line that
236
+ # killed the process is more useful than "process exited" — agents
237
+ # should not have to open the file to learn why.
238
+ FATAL_PATTERNS = [
239
+ [/A server is already running \(pid:\s*(\d+)/i,
240
+ ->(m) { "another server is already running (pid #{m[1]}); stale tmp/pids/server.pid" }],
241
+ [/Address already in use/i,
242
+ ->(_m) { "address already in use; another process holds the port" }],
243
+ [/Bundler::GemNotFound|Could not find .* locally installed gems|run `?bundle install/i,
244
+ ->(_m) { "gems missing; run `bundle install`" }],
245
+ [/ActiveRecord::NoDatabaseError|PG::ConnectionBad|database .* does not exist/i,
246
+ ->(_m) { "database unreachable or missing; check DATABASE_URL / the database server" }]
247
+ ].freeze
248
+
249
+ def fatal_line(path)
250
+ return nil unless path && File.file?(path)
251
+
252
+ lines = File.readlines(path).last(80)
253
+ FATAL_PATTERNS.each do |pattern, describe|
254
+ match = lines.reverse.find { |l| l.match?(pattern) }
255
+ next unless match
256
+
257
+ return describe.call(match.match(pattern))
258
+ end
259
+ nil
260
+ rescue SystemCallError
261
+ nil
262
+ end
263
+
207
264
  def process_alive?(pid)
208
265
  Process.kill(0, pid)
209
266
  true
data/lib/yamine/runner.rb CHANGED
@@ -103,10 +103,17 @@ module Yamine
103
103
  Process.detach(pid)
104
104
  target = "127.0.0.1:#{port}"
105
105
  if register
106
- spec ||= { "dir" => File.expand_path(dir), "proc" => name }
107
- @store.add_route(hostname, target, Process.pid, kind: "tcp",
108
- force: force, spec: spec)
109
- write_backend_pid(hostname, pid)
106
+ begin
107
+ spec ||= { "dir" => File.expand_path(dir), "proc" => name }
108
+ @store.add_route(hostname, target, Process.pid, kind: "tcp",
109
+ force: force, spec: spec)
110
+ write_backend_pid(hostname, pid)
111
+ rescue StandardError
112
+ # Registration refused (quota, conflict): the backend is
113
+ # already spawned — never leave it running without a route.
114
+ stop_pid(pid)
115
+ raise
116
+ end
110
117
  end
111
118
  App.new(name: name, hostname: hostname, url: url, pid: pid,
112
119
  target: target, kind: "tcp", command: command)
@@ -148,8 +155,26 @@ module Yamine
148
155
  env = { "YAMINE_URL" => url }
149
156
  env["PORT"] = port.to_s if port
150
157
  env["HOST"] = "127.0.0.1"
151
- ca = File.join(Certs.state_dir, "ca.pem")
152
- env["NODE_EXTRA_CA_CERTS"] = ca if File.file?(ca)
158
+
159
+ # TLS: yamine's names present certificates signed by its own CA, so
160
+ # every process that talks to one of them has to be told to trust it.
161
+ # Three clients, three conventions — and getting this wrong is not
162
+ # subtle. A Ruby app calling https://other-app.localhost fails with
163
+ # "certificate verify failed", which reads as a broken app rather
164
+ # than a missing trust setting:
165
+ #
166
+ # * Ruby/OpenSSL, curl, and git honor SSL_CERT_FILE — but it
167
+ # *replaces* the store, so it points at the bundle (the system's
168
+ # roots plus our CA), never at our CA alone; a CA-only file would
169
+ # break every public HTTPS call the app makes.
170
+ # * git takes GIT_SSL_CAINFO as its own override.
171
+ # * Node ignores both and reads NODE_EXTRA_CA_CERTS, which is
172
+ # additive — the CA by itself is right there.
173
+ bundle = Certs.ensure_bundle
174
+ env["NODE_EXTRA_CA_CERTS"] = Certs.ca_cert_path
175
+ env["SSL_CERT_FILE"] = bundle
176
+ env["GIT_SSL_CAINFO"] = bundle
177
+
153
178
  # Rails blocks unknown Host headers in development. Allow the
154
179
  # proxied hostname so Rails apps boot behind yamine with zero
155
180
  # config — this replaces the yamine-rails hosts patch.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.10.3"
4
+ VERSION = "0.11.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.10.3
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto