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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 073eb6f5386c8500d62348d2093ebc11fa13dc291f6907d11410bcb1b81e09eb
4
- data.tar.gz: 9750f59d2dd9cce11832d232d3d1cb11248b31e52545133ab2b998d21bdf9549
3
+ metadata.gz: e48f66da1b02dd9ea589c1a30225d6b1f9dc46cd1abd92ffad90c27422b6d4ca
4
+ data.tar.gz: '0209cb0ce1c976c5f6a934ba0a74283f3b8064e2fc9aed493208c74077dd5a62'
5
5
  SHA512:
6
- metadata.gz: a0cb84ba9b5fccda18721eb0de3bbb05667cffe97caf8882a3d3217d8dbee339319af2c72090f3b5bee41df198b01cc345e2dcb0e74b8ca9af202f2cd688e7ee
7
- data.tar.gz: 89f533e131495a41e6e1613e09017b5e3b4d3a834bbaa5507bf2d13bdcb16054fb9320f36585d9b858d4f34c96734b3a5285fe04a0a8e01fac8227689573dfb4
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")
@@ -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
- def build_env(ctx, resolved, entry)
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
- # Merge config env.clear
404
- config_env = resolved.secrets || {}
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
- # Merge secrets from config/local.secrets
408
- secret_keys = entry_env["secret"] || []
409
- secret_keys.each do |k|
410
- env[k] = config_env[k] if config_env.key?(k)
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
- # Host env (dotenv from .env) already in ENV
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
- env = { "YAMINE_URL" => url }
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
- ca = File.join(Certs.state_dir, "ca.pem")
159
- env["NODE_EXTRA_CA_CERTS"] = ca if File.file?(ca)
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.10.4"
4
+ VERSION = "0.11.1"
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.4
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto