yamine 0.3.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 +7 -0
- data/CHANGELOG.md +243 -0
- data/LICENSE +21 -0
- data/README.md +294 -0
- data/bin/yamine +6 -0
- data/lib/ask/skills/yamine/SKILL.md +113 -0
- data/lib/yamine/certs.rb +177 -0
- data/lib/yamine/cli/boot.rb +247 -0
- data/lib/yamine/cli/context.rb +104 -0
- data/lib/yamine/cli/routes.rb +255 -0
- data/lib/yamine/cli/system.rb +768 -0
- data/lib/yamine/cli.rb +108 -0
- data/lib/yamine/command.rb +28 -0
- data/lib/yamine/config.rb +394 -0
- data/lib/yamine/doctor.rb +133 -0
- data/lib/yamine/errors.rb +18 -0
- data/lib/yamine/framework.rb +75 -0
- data/lib/yamine/hostname.rb +44 -0
- data/lib/yamine/hosts.rb +86 -0
- data/lib/yamine/inference.rb +142 -0
- data/lib/yamine/log.rb +59 -0
- data/lib/yamine/ownership.rb +53 -0
- data/lib/yamine/ports.rb +45 -0
- data/lib/yamine/procfile.rb +137 -0
- data/lib/yamine/proxy.rb +489 -0
- data/lib/yamine/proxy_control.rb +222 -0
- data/lib/yamine/resolver.rb +92 -0
- data/lib/yamine/route_store.rb +148 -0
- data/lib/yamine/runner.rb +243 -0
- data/lib/yamine/sanitize.rb +41 -0
- data/lib/yamine/supervisor.rb +242 -0
- data/lib/yamine/trust.rb +131 -0
- data/lib/yamine/variant.rb +134 -0
- data/lib/yamine/version.rb +5 -0
- data/lib/yamine.rb +37 -0
- metadata +137 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: yamine
|
|
3
|
+
description: Run Ruby apps through yamine for stable named .localhost URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when booting dev servers (Rails, Rack, Roda, Sinatra, Jekyll), wiring frontend to API, configuring OAuth callbacks or webhooks, debugging port conflicts, or working in git worktrees.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Local Dev with yamine
|
|
7
|
+
|
|
8
|
+
Never invent ports. Never parse them from logs. Every app has a stable URL.
|
|
9
|
+
|
|
10
|
+
## One file, one command
|
|
11
|
+
|
|
12
|
+
Every app declares `config/local.yml` — the single source of truth:
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
service: myapp
|
|
16
|
+
proxy:
|
|
17
|
+
tld: localhost
|
|
18
|
+
processes:
|
|
19
|
+
web:
|
|
20
|
+
cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
|
|
21
|
+
proxy: true # gets https://myapp.localhost
|
|
22
|
+
worker:
|
|
23
|
+
cmd: bundle exec sidekiq
|
|
24
|
+
proxy: false # background, supervised, no URL
|
|
25
|
+
env:
|
|
26
|
+
clear:
|
|
27
|
+
RAILS_ENV: development
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If the file is missing, yamine prints `Run yamine init`. Generate it
|
|
31
|
+
with `yamine init` (migrates an existing Procfile). Rails apps need
|
|
32
|
+
no extra gem — the proxied hostname is allowed automatically via
|
|
33
|
+
`RAILS_DEVELOPMENT_HOSTS`.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
yamine start # setup if needed, then boot every process
|
|
37
|
+
yamine # same as start
|
|
38
|
+
yamine stop # stop this app's backend + routes
|
|
39
|
+
yamine status # show service, processes, and URLs
|
|
40
|
+
yamine log [-f] # tail the web process log
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`$PORT` and `YAMINE_URL` are injected per process; HTTP processes get
|
|
44
|
+
stable URLs, background ones are supervised without routes. A process
|
|
45
|
+
that exits cleans up the whole tree.
|
|
46
|
+
|
|
47
|
+
## Cross-service wiring
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
yamine get backend # -> https://backend.localhost
|
|
51
|
+
yamine get backend --variant demo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
|
|
55
|
+
targets. Do not guess ports.
|
|
56
|
+
|
|
57
|
+
## Variants are files
|
|
58
|
+
|
|
59
|
+
A variant is a file overlay, Kamal-style: `config/local.<variant>.yml`
|
|
60
|
+
deep-merges over `config/local.yml`. Select it with `YAMINE_VARIANT`
|
|
61
|
+
or `--variant`. Worktrees get a branch prefix automatically.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
YAMINE_VARIANT=fix-ui yamine # boots with config/local.fix-ui.yml merged
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## First time on a machine
|
|
68
|
+
|
|
69
|
+
Run `yamine start` — it does the one-shot CA trust, port 443, and
|
|
70
|
+
hosts sync if anything is missing, then boots. Prefer `yamine setup`
|
|
71
|
+
for workstation setup without booting. If any command fails with a
|
|
72
|
+
privileged-port error, do not work around it with `-p` — run
|
|
73
|
+
`yamine setup` instead. A `:port` suffix in a URL means someone
|
|
74
|
+
explicitly opted into it.
|
|
75
|
+
|
|
76
|
+
## OAuth and webhooks
|
|
77
|
+
|
|
78
|
+
Build callback URLs from `YAMINE_URL` (injected into every HTTP
|
|
79
|
+
process):
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
callback = "#{Yamine::Rails.url}/auth/google/callback"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Strict providers (Google, Apple) reject `.localhost`. Serve the app on a
|
|
86
|
+
domain you own instead — no code change, just config:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
proxy:
|
|
90
|
+
host: myapp.local.example.com # instead of tld: localhost
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Troubleshooting
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
yamine doctor # read-only: proxy, routes, DNS, CA trust
|
|
97
|
+
yamine list --json # routes as stable JSON
|
|
98
|
+
yamine prune # clear stale routes from crashed sessions
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If a hostname does not resolve: `yamine hosts sync`. If the browser
|
|
102
|
+
warns about TLS: `yamine trust`.
|
|
103
|
+
|
|
104
|
+
## When NOT to use yamine
|
|
105
|
+
|
|
106
|
+
- **CI pipelines**: no TTY, no sudo, no browsers. Run the app's own test
|
|
107
|
+
command directly; yamine fails fast here by design.
|
|
108
|
+
- **Production consoles and servers**: the proxy binds loopback only and
|
|
109
|
+
the CA is self-signed. Use Kamal + kamal-proxy for anything real.
|
|
110
|
+
- **Docker-internal networking**: containers reach each other by service
|
|
111
|
+
name on the compose network, not via the host's `.localhost`.
|
|
112
|
+
- **Debugging the proxy itself**: use `yamine proxy start --foreground`
|
|
113
|
+
and read the log; do not layer another yamine on top.
|
data/lib/yamine/certs.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Yamine
|
|
7
|
+
# Local CA + per-hostname certificates, all in-process via OpenSSL.
|
|
8
|
+
#
|
|
9
|
+
# *.localhost sits at a public-suffix boundary so a wildcard cert is
|
|
10
|
+
# not honored; every hostname gets an exact-SAN cert minted on demand
|
|
11
|
+
# through the SNI callback and held in an in-memory LRU
|
|
12
|
+
# (puma-dev ssl.go does the same; portless caches on disk instead).
|
|
13
|
+
module Certs
|
|
14
|
+
CA_COMMON_NAME = "Ask Local CA"
|
|
15
|
+
CA_VALIDITY_DAYS = 3650
|
|
16
|
+
HOST_VALIDITY_DAYS = 825
|
|
17
|
+
CACHE_SIZE = 1024
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def state_dir
|
|
22
|
+
ENV["YAMINE_STATE_DIR"] || File.join(home, ".yamine")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def home
|
|
26
|
+
ENV["HOME"] || Dir.home
|
|
27
|
+
rescue ArgumentError
|
|
28
|
+
Dir.pwd
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ca_paths(dir = state_dir)
|
|
32
|
+
{ cert: File.join(dir, "ca.pem"), key: File.join(dir, "ca-key.pem") }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ensure_ca(dir = state_dir)
|
|
36
|
+
FileUtils.mkdir_p(dir, mode: 0o755)
|
|
37
|
+
Ownership.fix(dir)
|
|
38
|
+
paths = ca_paths(dir)
|
|
39
|
+
return paths if valid_pair?(paths[:cert], paths[:key])
|
|
40
|
+
|
|
41
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
42
|
+
cert = OpenSSL::X509::Certificate.new
|
|
43
|
+
cert.version = 2
|
|
44
|
+
cert.serial = OpenSSL::BN.rand(128, 0)
|
|
45
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=#{CA_COMMON_NAME}")
|
|
46
|
+
cert.not_before = Time.now - 3600
|
|
47
|
+
cert.not_after = Time.now + (CA_VALIDITY_DAYS * 86_400)
|
|
48
|
+
cert.public_key = key
|
|
49
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
|
50
|
+
ef.subject_certificate = cert
|
|
51
|
+
ef.issuer_certificate = cert
|
|
52
|
+
cert.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true))
|
|
53
|
+
cert.add_extension(ef.create_extension("keyUsage", "keyCertSign,cRLSign", true))
|
|
54
|
+
cert.sign(key, "SHA256")
|
|
55
|
+
|
|
56
|
+
File.write(paths[:key], key.to_pem, mode: "w", perm: 0o600)
|
|
57
|
+
File.write(paths[:cert], cert.to_pem, mode: "w", perm: 0o644)
|
|
58
|
+
Ownership.fix(paths[:key], paths[:cert])
|
|
59
|
+
paths
|
|
60
|
+
rescue OpenSSL::OpenSSLError => e
|
|
61
|
+
raise CertError, "Could not generate local CA: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_ca(dir = state_dir)
|
|
65
|
+
paths = ensure_ca(dir)
|
|
66
|
+
[OpenSSL::X509::Certificate.new(File.read(paths[:cert])),
|
|
67
|
+
OpenSSL::PKey.read(File.read(paths[:key]))]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Mint a leaf cert for one hostname, signed by the CA.
|
|
71
|
+
def mint_host(hostname, ca_cert, ca_key)
|
|
72
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
73
|
+
cert = OpenSSL::X509::Certificate.new
|
|
74
|
+
cert.version = 2
|
|
75
|
+
cert.serial = OpenSSL::BN.rand(128, 0)
|
|
76
|
+
cert.subject = OpenSSL::X509::Name.parse("/CN=#{hostname[0, 64]}")
|
|
77
|
+
cert.issuer = ca_cert.subject
|
|
78
|
+
cert.not_before = Time.now - 3600
|
|
79
|
+
cert.not_after = Time.now + (HOST_VALIDITY_DAYS * 86_400)
|
|
80
|
+
cert.public_key = key
|
|
81
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
|
82
|
+
ef.subject_certificate = cert
|
|
83
|
+
ef.issuer_certificate = ca_cert
|
|
84
|
+
cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE", true))
|
|
85
|
+
cert.add_extension(ef.create_extension("keyUsage", "digitalSignature,keyEncipherment", true))
|
|
86
|
+
cert.add_extension(ef.create_extension("extendedKeyUsage", "serverAuth"))
|
|
87
|
+
cert.add_extension(ef.create_extension("subjectAltName", "DNS:#{hostname}"))
|
|
88
|
+
cert.sign(ca_key, "SHA256")
|
|
89
|
+
[cert, key]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Build an SSLContext whose SNI callback serves the right cert per host.
|
|
93
|
+
def server_context(dir = state_dir)
|
|
94
|
+
ca_cert, ca_key = load_ca(dir)
|
|
95
|
+
cache = CertCache.new(CACHE_SIZE)
|
|
96
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
97
|
+
ctx.cert = ca_cert
|
|
98
|
+
ctx.key = ca_key
|
|
99
|
+
# ruby-openssl versions differ in how the callback receives its
|
|
100
|
+
# arguments: [[socket, name]] (one array arg), (socket, name), or
|
|
101
|
+
# (name). Flatten defensively — a raise inside the callback
|
|
102
|
+
# surfaces as an unrecognized-name handshake alert.
|
|
103
|
+
ctx.servername_cb = lambda do |*args|
|
|
104
|
+
host = Array(args).flatten.last.to_s.downcase
|
|
105
|
+
entry = cache.fetch(host) do
|
|
106
|
+
cert, key = mint_host(host, ca_cert, ca_key)
|
|
107
|
+
[cert, key]
|
|
108
|
+
end
|
|
109
|
+
entry ? OpenSSL::SSL::SSLContext.new.tap { |c| c.cert, c.key = entry } : nil
|
|
110
|
+
end
|
|
111
|
+
ctx
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def trusted?(dir = state_dir)
|
|
115
|
+
paths = ca_paths(dir)
|
|
116
|
+
return false unless File.file?(paths[:cert])
|
|
117
|
+
|
|
118
|
+
marker = File.join(dir, "ca.trusted")
|
|
119
|
+
return false unless File.file?(marker)
|
|
120
|
+
|
|
121
|
+
Digest::SHA256.hexdigest(File.read(paths[:cert])).then do |fp|
|
|
122
|
+
File.read(marker).strip == fp
|
|
123
|
+
end
|
|
124
|
+
rescue SystemCallError
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def mark_trusted(dir = state_dir)
|
|
129
|
+
require "digest"
|
|
130
|
+
paths = ca_paths(dir)
|
|
131
|
+
fp = Digest::SHA256.hexdigest(File.read(paths[:cert]))
|
|
132
|
+
File.write(File.join(dir, "ca.trusted"), "#{fp}\n")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def valid_pair?(cert_path, key_path)
|
|
136
|
+
return false unless File.file?(cert_path) && File.file?(key_path)
|
|
137
|
+
|
|
138
|
+
cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
|
|
139
|
+
cert.not_after > Time.now + (7 * 86_400) &&
|
|
140
|
+
cert.subject.to_s.include?(CA_COMMON_NAME)
|
|
141
|
+
rescue OpenSSL::OpenSSLError, SystemCallError, ArgumentError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# In-memory LRU for minted host certs.
|
|
146
|
+
class CertCache
|
|
147
|
+
def initialize(max)
|
|
148
|
+
@max = max
|
|
149
|
+
@store = {}
|
|
150
|
+
@order = []
|
|
151
|
+
@mutex = Mutex.new
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def fetch(host)
|
|
155
|
+
@mutex.synchronize do
|
|
156
|
+
if @store.key?(host)
|
|
157
|
+
@order.delete(host)
|
|
158
|
+
@order << host
|
|
159
|
+
return @store[host]
|
|
160
|
+
end
|
|
161
|
+
value = yield
|
|
162
|
+
@store[host] = value
|
|
163
|
+
@order << host
|
|
164
|
+
if @order.length > @max
|
|
165
|
+
oldest = @order.shift
|
|
166
|
+
@store.delete(oldest)
|
|
167
|
+
end
|
|
168
|
+
value
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def size
|
|
173
|
+
@mutex.synchronize { @store.size }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
class CLI
|
|
5
|
+
# Boot commands: `yamine`, `yamine start`, `yamine run`.
|
|
6
|
+
# The config file is mandatory — missing file prints the fix and exits.
|
|
7
|
+
# `boot_all` fans out over every process declared in config/local.yml.
|
|
8
|
+
# No inference, no Procfile at boot, no single-process default.
|
|
9
|
+
module BootCommand
|
|
10
|
+
PORT_IGNORING = %w[jekyll middleman bridgetown].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Bare `yamine` / `yamine start` / `yamine run`.
|
|
15
|
+
# Reads config/local.yml via the resolver, ensures the proxy,
|
|
16
|
+
# boots every process, supervises the tree, cleans up on exit.
|
|
17
|
+
def run_inferred(ctx, args)
|
|
18
|
+
variant = ENV["YAMINE_VARIANT"]
|
|
19
|
+
opts = ctx.parse_flags(args, %i[variant tld force])
|
|
20
|
+
resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
|
|
21
|
+
ensure_proxy!(ctx)
|
|
22
|
+
boot_all(ctx, resolved, opts)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_explicit(ctx, args)
|
|
26
|
+
run_inferred(ctx, args)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run_named(ctx, name, _args)
|
|
30
|
+
$stderr.puts "Error: `yamine #{name}` is no longer supported."
|
|
31
|
+
$stderr.puts " All processes come from config/local.yml. Run `yamine init` to create one."
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Core boot loop: iterate processes from config/local.yml,
|
|
36
|
+
# classify HTTP vs background, spawn each, register routes for
|
|
37
|
+
# HTTP processes, then supervise the tree.
|
|
38
|
+
def boot_all(ctx, resolved, opts)
|
|
39
|
+
service = resolved.app
|
|
40
|
+
tld = resolved.tld
|
|
41
|
+
host = resolved.host
|
|
42
|
+
processes = resolved.processes
|
|
43
|
+
|
|
44
|
+
if processes.empty?
|
|
45
|
+
$stderr.puts "Error: no processes in config/local.yml. Add at least one."
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
primary = Resolver.primary_proc(resolved)
|
|
50
|
+
if primary.nil?
|
|
51
|
+
$stderr.puts "Error: no HTTP process (proxy: true) found in config/local.yml."
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
runner = Runner.new(store: ctx.store)
|
|
56
|
+
children = []
|
|
57
|
+
routes_registered = []
|
|
58
|
+
|
|
59
|
+
puts "yamine (#{service})"
|
|
60
|
+
puts "--"
|
|
61
|
+
|
|
62
|
+
processes.each do |proc_name, entry|
|
|
63
|
+
next if entry["proxy"] == false
|
|
64
|
+
|
|
65
|
+
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
66
|
+
next unless hostname
|
|
67
|
+
|
|
68
|
+
url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
69
|
+
hostnames = [hostname]
|
|
70
|
+
puts " [#{proc_name}] #{url}"
|
|
71
|
+
|
|
72
|
+
# Each process cmd runs through the shell so $PORT (and other
|
|
73
|
+
# env refs) expand — same trust boundary as a Procfile line
|
|
74
|
+
# (repo code, not user input). Compound lines are refused.
|
|
75
|
+
cmd = entry["cmd"].to_s
|
|
76
|
+
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
77
|
+
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
port = opts[:app_port] || Ports.find_free
|
|
82
|
+
shell_cmd = ["sh", "-c", cmd]
|
|
83
|
+
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
84
|
+
# this env var when not a Rails app — safe for every framework).
|
|
85
|
+
app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
|
|
86
|
+
dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
|
|
87
|
+
rails_dev_host: hostname)
|
|
88
|
+
register_all(ctx, hostnames, app, force: opts[:force],
|
|
89
|
+
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
|
|
90
|
+
routes_registered << { hostnames: hostnames, app: app }
|
|
91
|
+
|
|
92
|
+
puts " -> #{url}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
background = processes.select { |_, v| v["proxy"] == false }
|
|
96
|
+
unless background.empty?
|
|
97
|
+
puts " [background] #{background.keys.join(', ')}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
puts
|
|
101
|
+
ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
|
|
102
|
+
|
|
103
|
+
# Supervisor: exit when ANY child dies (loud cleanup).
|
|
104
|
+
all_pids = routes_registered.map { |r| r[:app].pid }
|
|
105
|
+
trap_cleanup(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
|
|
106
|
+
supervise_tree(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_env(ctx, resolved, entry)
|
|
110
|
+
env = {}
|
|
111
|
+
# Merge config env.clear
|
|
112
|
+
config_env = resolved.secrets || {}
|
|
113
|
+
entry_env = entry["env"] || {}
|
|
114
|
+
(entry_env["clear"] || {}).each { |k, v| env[k] = v }
|
|
115
|
+
# Merge secrets from config/local.secrets
|
|
116
|
+
secret_keys = entry_env["secret"] || []
|
|
117
|
+
secret_keys.each do |k|
|
|
118
|
+
env[k] = config_env[k] if config_env.key?(k)
|
|
119
|
+
end
|
|
120
|
+
# Host env (dotenv from .env) already in ENV
|
|
121
|
+
env
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def supervise_tree(ctx, hostnames, pids)
|
|
125
|
+
loop do
|
|
126
|
+
sleep 0.5
|
|
127
|
+
if pids.any? { |pid| !ProxyControl.pid_alive?(pid) }
|
|
128
|
+
puts "\nA process exited — cleaning up all routes."
|
|
129
|
+
cleanup_routes(ctx, hostnames)
|
|
130
|
+
# Kill remaining children
|
|
131
|
+
pids.each do |pid|
|
|
132
|
+
Process.kill("TERM", pid) rescue nil
|
|
133
|
+
end
|
|
134
|
+
exit 0
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def inject_port_flags(command, port)
|
|
140
|
+
return command if command.empty?
|
|
141
|
+
return command if command.any? { |a| a.match?(/\A(-p|--port)(=|\z)/) }
|
|
142
|
+
return command if command.any? { |a| a.include?("$PORT") }
|
|
143
|
+
|
|
144
|
+
bin = File.basename(command.first.to_s)
|
|
145
|
+
needs_flags = PORT_IGNORING.include?(bin) ||
|
|
146
|
+
(command.length > 2 && PORT_IGNORING.include?(File.basename(command[2].to_s)))
|
|
147
|
+
return command unless needs_flags
|
|
148
|
+
|
|
149
|
+
command + ["--port", port.to_s, "--host", "127.0.0.1"]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Legacy procfile parsing for backwards compat.
|
|
153
|
+
def procfile_command(process = nil)
|
|
154
|
+
path = ::Yamine::Procfile.find_file(Dir.pwd)
|
|
155
|
+
return nil unless path
|
|
156
|
+
lines = ::Yamine::Procfile.parse_file(path)
|
|
157
|
+
line = if process
|
|
158
|
+
lines.find { |l| l.name == process }
|
|
159
|
+
else
|
|
160
|
+
lines.first
|
|
161
|
+
end
|
|
162
|
+
return nil unless line
|
|
163
|
+
line.compound ? nil : ["sh", "-c", line.command]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def trap_cleanup(ctx, hostnames, pids = [])
|
|
167
|
+
%w[INT TERM].each do |sig|
|
|
168
|
+
trap(sig) do
|
|
169
|
+
cleanup_routes(ctx, hostnames)
|
|
170
|
+
pids.each { |pid| Process.kill("TERM", pid) rescue nil }
|
|
171
|
+
exit 0
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def register_all(ctx, hostnames, app, force:, spec: nil)
|
|
177
|
+
hostnames[1..].each do |h|
|
|
178
|
+
ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
|
|
179
|
+
force: force, spec: spec)
|
|
180
|
+
write_backend_sidecar(ctx, h, app.pid)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def write_backend_sidecar(ctx, hostname, pid)
|
|
185
|
+
ctx.store.ensure_dir
|
|
186
|
+
File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
|
|
187
|
+
rescue SystemCallError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cleanup_routes(ctx, hostnames)
|
|
192
|
+
hostnames.each do |h|
|
|
193
|
+
begin
|
|
194
|
+
entry = ctx.store.find(h)
|
|
195
|
+
pid = ctx.backend_pid_for(entry) if entry
|
|
196
|
+
if pid && ProxyControl.pid_alive?(pid)
|
|
197
|
+
Process.kill("TERM", pid) rescue nil
|
|
198
|
+
end
|
|
199
|
+
ctx.store.remove_route(h, owner_pid: Process.pid) rescue nil
|
|
200
|
+
FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{h}.pid"))
|
|
201
|
+
rescue StandardError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resolve!(ctx, variant: nil, tld: nil)
|
|
208
|
+
# The resolver calls Config.load, which raises ConfigError if
|
|
209
|
+
# config/local.yml is missing — exactly what we want.
|
|
210
|
+
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def ensure_proxy!(ctx)
|
|
214
|
+
port = ctx.proxy_port
|
|
215
|
+
tls = ctx.proxy_tls
|
|
216
|
+
if ProxyControl.listening?(port)
|
|
217
|
+
return if ProxyControl.ours?(port, tls: tls)
|
|
218
|
+
$stderr.puts "Error: port #{port} is in use by another process."
|
|
219
|
+
$stderr.puts " Stop it, or yamine proxy start -p <port>"
|
|
220
|
+
exit 1
|
|
221
|
+
end
|
|
222
|
+
privileged = port < 1024 && !ProxyControl.root?
|
|
223
|
+
if privileged && !ctx.interactive?
|
|
224
|
+
$stderr.puts "Error: proxy is not running and port #{port} needs root."
|
|
225
|
+
$stderr.puts " Human: run this once — yamine setup"
|
|
226
|
+
$stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
|
|
227
|
+
$stderr.puts " yamine sudoers > /tmp/yamine.sudoers"
|
|
228
|
+
$stderr.puts " sudo install -o root -g wheel -m 440 /tmp/yamine.sudoers /etc/sudoers.d/yamine"
|
|
229
|
+
$stderr.puts " Or start the proxy by hand: sudo yamine proxy start"
|
|
230
|
+
exit 1
|
|
231
|
+
end
|
|
232
|
+
puts "Starting proxy#{privileged ? " (sudo)" : ""}..."
|
|
233
|
+
begin
|
|
234
|
+
Yamine::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
|
|
235
|
+
rescue Yamine::ProxyNotRunningError => e
|
|
236
|
+
$stderr.puts "Error: #{e.message.lines.first&.strip}"
|
|
237
|
+
$stderr.puts " Fix once: yamine setup"
|
|
238
|
+
exit 1
|
|
239
|
+
end
|
|
240
|
+
rescue Errno::EACCES
|
|
241
|
+
$stderr.puts "Error: permission denied binding port #{port}."
|
|
242
|
+
$stderr.puts " Fix once: yamine setup"
|
|
243
|
+
exit 1
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
class CLI
|
|
5
|
+
# Shared state and helpers for command objects. Every command gets a
|
|
6
|
+
# Context instead of reaching into CLI privates, so cli.rb stays a
|
|
7
|
+
# thin dispatcher.
|
|
8
|
+
class Context
|
|
9
|
+
def store
|
|
10
|
+
@store ||= RouteStore.new(Certs.state_dir,
|
|
11
|
+
on_warning: ->(m) { warn m })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def interactive?
|
|
15
|
+
$stdin.tty? && ENV["CI"].nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def proxy_port
|
|
19
|
+
ProxyControl.proxy_port(store) || ProxyControl.default_port(proxy_tls)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def proxy_tls
|
|
23
|
+
ProxyControl.proxy_tls(store)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def report_unresolved(hostnames)
|
|
27
|
+
missing = Hosts.unresolved(hostnames)
|
|
28
|
+
return if missing.empty?
|
|
29
|
+
|
|
30
|
+
warn "Warning: #{missing.join(", ")} will not resolve. Run: yamine hosts sync"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wait_for_exit(pid, timeout:)
|
|
34
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
|
+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
36
|
+
return true unless ProxyControl.pid_alive?(pid)
|
|
37
|
+
|
|
38
|
+
sleep 0.25
|
|
39
|
+
end
|
|
40
|
+
!ProxyControl.pid_alive?(pid)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def backend_pid_for(entry)
|
|
44
|
+
return nil unless entry
|
|
45
|
+
|
|
46
|
+
sidecar = File.join(store.dir, "backend-#{entry["hostname"]}.pid")
|
|
47
|
+
return nil unless File.file?(sidecar)
|
|
48
|
+
|
|
49
|
+
pid = File.read(sidecar).strip.to_i
|
|
50
|
+
pid.positive? ? pid : nil
|
|
51
|
+
rescue SystemCallError, ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def backend_alive?(entry)
|
|
56
|
+
case entry["kind"]
|
|
57
|
+
when "socket"
|
|
58
|
+
File.socket?(entry["target"].to_s)
|
|
59
|
+
when "tcp"
|
|
60
|
+
host, port = entry["target"].to_s.split(":", 2)
|
|
61
|
+
begin
|
|
62
|
+
TCPSocket.new(host, port.to_i).close
|
|
63
|
+
true
|
|
64
|
+
rescue SystemCallError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_flags(args, known)
|
|
73
|
+
opts = { rest: [] }
|
|
74
|
+
i = 0
|
|
75
|
+
rest_start = nil
|
|
76
|
+
while i < args.length
|
|
77
|
+
arg = args[i]
|
|
78
|
+
if arg == "--"
|
|
79
|
+
rest_start = i + 1
|
|
80
|
+
break
|
|
81
|
+
elsif arg.start_with?("--")
|
|
82
|
+
key = arg.sub(/\A--/, "").tr("-", "_").to_sym
|
|
83
|
+
if known.include?(key)
|
|
84
|
+
if %i[branch force].include?(key)
|
|
85
|
+
opts[key] = true
|
|
86
|
+
i += 1
|
|
87
|
+
else
|
|
88
|
+
opts[key] = args.fetch(i + 1)
|
|
89
|
+
i += 2
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
raise Error, "Unknown flag #{arg}"
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
rest_start = i
|
|
96
|
+
break
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
opts[:rest] = rest_start ? args[rest_start..] : []
|
|
100
|
+
opts
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|