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.
@@ -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.
@@ -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