yamine 0.10.4 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/lib/yamine/certs.rb +68 -0
- data/lib/yamine/cli/boot.rb +40 -11
- data/lib/yamine/doctor.rb +17 -0
- data/lib/yamine/runner.rb +35 -8
- 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: e48f66da1b02dd9ea589c1a30225d6b1f9dc46cd1abd92ffad90c27422b6d4ca
|
|
4
|
+
data.tar.gz: '0209cb0ce1c976c5f6a934ba0a74283f3b8064e2fc9aed493208c74077dd5a62'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2626fc9696988148d9637ffd1ce14adc7d7d656b03d61854296ff3b6c818fd82ff840fce5904395ac8d2b54c027c9f7e63f47f0f077609f896542af06c282d28
|
|
7
|
+
data.tar.gz: 110d265a913ee76a3ff40328c40cfdc0475e195c3e1518a490294dd2bb7bca0536edbaeedba7bc37815625aa3975880efdb1b1a66252b3c2a9ed16d6a2b5abd1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.1] — 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`env:` in `config/local.yml` now actually reaches the processes.**
|
|
8
|
+
It never did. `build_env` was written, correct-looking, and had no
|
|
9
|
+
callers — so every top-level `env.clear` value and every `env.secret`
|
|
10
|
+
was parsed, validated, and dropped on the floor. It stayed invisible
|
|
11
|
+
because `DATABASE_URL` had its own path to the child; a config whose
|
|
12
|
+
*integration* depended on that block booted with the variable blank,
|
|
13
|
+
which looks exactly like a code bug.
|
|
14
|
+
|
|
15
|
+
Found while wiring a Rails app to a sibling service at
|
|
16
|
+
`https://other-app.localhost`: the URL was in `config/local.yml`, the
|
|
17
|
+
process had `nil`.
|
|
18
|
+
|
|
19
|
+
Both levels now merge, process over top-level, with `env.secret` names
|
|
20
|
+
resolved from `config/local.secrets` (or an existing export). A declared
|
|
21
|
+
secret nobody supplies warns at boot instead of starting blank — an app
|
|
22
|
+
with a missing API key fails somewhere far less obvious later.
|
|
23
|
+
|
|
24
|
+
yamine's own variables (`PORT`, `YAMINE_URL`, `DATABASE_URL`, the TLS
|
|
25
|
+
paths) are applied *after* the config, so a stray key in `local.yml`
|
|
26
|
+
cannot steer a process off its route. Pinned end to end by a test that
|
|
27
|
+
spawns a real process and reads the value out of its environment —
|
|
28
|
+
asserting on `build_env`'s return would have passed on the broken
|
|
29
|
+
version too.
|
|
30
|
+
|
|
31
|
+
## [0.11.0] — 2026-09-11
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Ruby, git, and curl now trust the local CA — so an app can call
|
|
36
|
+
another app at `https://<name>.localhost` with no configuration.** The
|
|
37
|
+
gap surfaced when anywaye (a Rails app) tried to reach anymark at
|
|
38
|
+
`https://anymark-directory.localhost`: `certificate verify failed`. The
|
|
39
|
+
CA was trusted everywhere a human looks (Safari, Chrome, curl) and
|
|
40
|
+
nowhere a child process looks — only Node was covered, via
|
|
41
|
+
`NODE_EXTRA_CA_CERTS`.
|
|
42
|
+
|
|
43
|
+
The naive fix is wrong in a way that shows up much later. `SSL_CERT_FILE`
|
|
44
|
+
does not *add* to OpenSSL's roots, it *replaces* them, so pointing it at
|
|
45
|
+
`~/.yamine/ca.pem` would break every public HTTPS call the app makes —
|
|
46
|
+
rubygems, payment APIs, an LLM provider — while fixing the local one. So
|
|
47
|
+
yamine now builds `~/.yamine/bundle.pem`: the system's roots plus its own
|
|
48
|
+
CA, regenerated only when either changes, and exports it as
|
|
49
|
+
`SSL_CERT_FILE` and `GIT_SSL_CAINFO` for every child. Node keeps getting
|
|
50
|
+
the CA alone, because its variable really is additive.
|
|
51
|
+
|
|
52
|
+
`yamine doctor` reports the bundle (`bundle: 129 certificates`), so a
|
|
53
|
+
missing or stale one is visible before an app fails on it, and a real
|
|
54
|
+
TLS handshake against a yamine-minted certificate is pinned by a test —
|
|
55
|
+
not just the presence of the environment variables.
|
|
56
|
+
|
|
3
57
|
## [0.10.4] — 2026-09-11
|
|
4
58
|
|
|
5
59
|
### Fixed
|
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")
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -241,7 +241,8 @@ module Yamine
|
|
|
241
241
|
app = runner.boot_run(name: item[:name], hostname: item[:hostname],
|
|
242
242
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
243
243
|
port: item[:port], force: opts[:force],
|
|
244
|
-
rails_dev_host: item[:hostname], database_url: db_url
|
|
244
|
+
rails_dev_host: item[:hostname], database_url: db_url,
|
|
245
|
+
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
245
246
|
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
246
247
|
|
|
247
248
|
say opts, " -> #{item[:url]}"
|
|
@@ -261,7 +262,8 @@ module Yamine
|
|
|
261
262
|
app = runner.spawn_http(name: item[:name], hostname: item[:hostname],
|
|
262
263
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
263
264
|
port: item[:port], rails_dev_host: item[:hostname],
|
|
264
|
-
database_url: db_url, force: opts[:force]
|
|
265
|
+
database_url: db_url, force: opts[:force],
|
|
266
|
+
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
265
267
|
# Tracked before registration so any later failure (a raise
|
|
266
268
|
# during adopt, a crash while another process waits) can
|
|
267
269
|
# always reap it. Duplicate names in named_pids are harmless.
|
|
@@ -359,7 +361,8 @@ module Yamine
|
|
|
359
361
|
app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
|
|
360
362
|
url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
|
|
361
363
|
force: opts[:force], rails_dev_host: nil, register: false,
|
|
362
|
-
database_url: db_url
|
|
364
|
+
database_url: db_url,
|
|
365
|
+
extra_env: build_env(resolved, entry, proc_name: proc_name))
|
|
363
366
|
children << { name: proc_name, pid: app.pid }
|
|
364
367
|
say opts, " [#{proc_name}] background (pid #{app.pid})"
|
|
365
368
|
end
|
|
@@ -398,18 +401,44 @@ module Yamine
|
|
|
398
401
|
end
|
|
399
402
|
end
|
|
400
403
|
|
|
401
|
-
|
|
404
|
+
# The environment a process gets, from the config.
|
|
405
|
+
#
|
|
406
|
+
# Two levels, and the merge order is the point: top-level `env:`
|
|
407
|
+
# describes the whole app (an API URL every process needs), a
|
|
408
|
+
# process's own `env:` describes that process, and the process wins
|
|
409
|
+
# where they collide — "this one worker talks to staging" should not
|
|
410
|
+
# require restructuring the file.
|
|
411
|
+
#
|
|
412
|
+
# `env.clear` is literal values; `env.secret` names keys whose values
|
|
413
|
+
# come from config/local.secrets (dotenv, gitignored), so a credential
|
|
414
|
+
# stays out of the config file while still reaching the process. A
|
|
415
|
+
# named secret that is missing is reported rather than silently
|
|
416
|
+
# dropped: an app booting with a blank API key fails later, somewhere
|
|
417
|
+
# far less obvious.
|
|
418
|
+
def build_env(resolved, entry, proc_name: nil)
|
|
402
419
|
env = {}
|
|
403
|
-
|
|
404
|
-
|
|
420
|
+
|
|
421
|
+
top = resolved.env.is_a?(Hash) ? resolved.env : {}
|
|
422
|
+
(top["clear"] || {}).each { |k, v| env[k] = v }
|
|
423
|
+
|
|
405
424
|
entry_env = entry["env"] || {}
|
|
406
425
|
(entry_env["clear"] || {}).each { |k, v| env[k] = v }
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
426
|
+
|
|
427
|
+
secrets = resolved.secrets || {}
|
|
428
|
+
(Array(top["secret"]) + Array(entry_env["secret"])).uniq.each do |key|
|
|
429
|
+
if secrets.key?(key)
|
|
430
|
+
env[key] = secrets[key]
|
|
431
|
+
elsif ENV.key?(key)
|
|
432
|
+
# Already in the environment (a shell export, an agent's
|
|
433
|
+
# session) — the declaration is satisfied.
|
|
434
|
+
env[key] = ENV[key]
|
|
435
|
+
else
|
|
436
|
+
label = proc_name ? "#{proc_name}: " : ""
|
|
437
|
+
$stderr.puts " #{label}warning: env.secret lists #{key}, but it is not in " \
|
|
438
|
+
"config/local.secrets (and not in the environment) — starting without it"
|
|
439
|
+
end
|
|
411
440
|
end
|
|
412
|
-
|
|
441
|
+
|
|
413
442
|
env
|
|
414
443
|
end
|
|
415
444
|
|
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).
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -93,11 +93,16 @@ module Yamine
|
|
|
93
93
|
# register: false spawns without a route (background processes).
|
|
94
94
|
# Child output goes to log/yamine-<name>.log so `--wait` failure
|
|
95
95
|
# payloads can tail it (same file the managed path uses).
|
|
96
|
+
#
|
|
97
|
+
# extra_env is the config's env.clear/env.secret for this process —
|
|
98
|
+
# yamine's own keys (PORT, YAMINE_URL, DATABASE_URL, the TLS vars)
|
|
99
|
+
# win over it, because those describe the boot rather than the app.
|
|
96
100
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
97
|
-
rails_dev_host: nil, register: true, database_url: nil, spec: nil
|
|
101
|
+
rails_dev_host: nil, register: true, database_url: nil, spec: nil,
|
|
102
|
+
extra_env: nil)
|
|
98
103
|
port ||= Ports.find_free
|
|
99
104
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
100
|
-
database_url: database_url)
|
|
105
|
+
database_url: database_url, extra_env: extra_env)
|
|
101
106
|
path = log_path(dir, name)
|
|
102
107
|
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
103
108
|
Process.detach(pid)
|
|
@@ -124,9 +129,9 @@ module Yamine
|
|
|
124
129
|
# Returns the placeholder App (target known before bind). Fate of
|
|
125
130
|
# the backend is decided by wait, not by spawn.
|
|
126
131
|
def spawn_http(name:, hostname:, url:, dir:, command:, port:, rails_dev_host: nil,
|
|
127
|
-
database_url: nil, force: false)
|
|
132
|
+
database_url: nil, force: false, extra_env: nil)
|
|
128
133
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
129
|
-
database_url: database_url)
|
|
134
|
+
database_url: database_url, extra_env: extra_env)
|
|
130
135
|
path = log_path(dir, name)
|
|
131
136
|
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
132
137
|
Process.detach(pid)
|
|
@@ -151,12 +156,34 @@ module Yamine
|
|
|
151
156
|
false
|
|
152
157
|
end
|
|
153
158
|
|
|
154
|
-
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
|
|
155
|
-
|
|
159
|
+
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil, extra_env: nil)
|
|
160
|
+
# The config's own variables first, so everything below overrides
|
|
161
|
+
# them: PORT, YAMINE_URL, DATABASE_URL, and the TLS paths describe
|
|
162
|
+
# *this boot* and must not be lost to a stray key in local.yml.
|
|
163
|
+
env = (extra_env || {}).transform_keys(&:to_s).transform_values(&:to_s)
|
|
164
|
+
env["YAMINE_URL"] = url
|
|
156
165
|
env["PORT"] = port.to_s if port
|
|
157
166
|
env["HOST"] = "127.0.0.1"
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
|
|
168
|
+
# TLS: yamine's names present certificates signed by its own CA, so
|
|
169
|
+
# every process that talks to one of them has to be told to trust it.
|
|
170
|
+
# Three clients, three conventions — and getting this wrong is not
|
|
171
|
+
# subtle. A Ruby app calling https://other-app.localhost fails with
|
|
172
|
+
# "certificate verify failed", which reads as a broken app rather
|
|
173
|
+
# than a missing trust setting:
|
|
174
|
+
#
|
|
175
|
+
# * Ruby/OpenSSL, curl, and git honor SSL_CERT_FILE — but it
|
|
176
|
+
# *replaces* the store, so it points at the bundle (the system's
|
|
177
|
+
# roots plus our CA), never at our CA alone; a CA-only file would
|
|
178
|
+
# break every public HTTPS call the app makes.
|
|
179
|
+
# * git takes GIT_SSL_CAINFO as its own override.
|
|
180
|
+
# * Node ignores both and reads NODE_EXTRA_CA_CERTS, which is
|
|
181
|
+
# additive — the CA by itself is right there.
|
|
182
|
+
bundle = Certs.ensure_bundle
|
|
183
|
+
env["NODE_EXTRA_CA_CERTS"] = Certs.ca_cert_path
|
|
184
|
+
env["SSL_CERT_FILE"] = bundle
|
|
185
|
+
env["GIT_SSL_CAINFO"] = bundle
|
|
186
|
+
|
|
160
187
|
# Rails blocks unknown Host headers in development. Allow the
|
|
161
188
|
# proxied hostname so Rails apps boot behind yamine with zero
|
|
162
189
|
# config — this replaces the yamine-rails hosts patch.
|
data/lib/yamine/version.rb
CHANGED