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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ # Resolve hostnames from config/local.yml — the ONLY source of truth.
5
+ # No inference, no Procfile fallback, no ENV for naming. If the file
6
+ # is missing, resolve raises ConfigError telling the user to run
7
+ # `yamine init`.
8
+ module Resolver
9
+ module_function
10
+
11
+ Result = Struct.new(:app, :tld, :host, :processes, :secrets,
12
+ :sources, :variant, keyword_init: true)
13
+
14
+ def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
15
+ config = Config.load(dir, variant: variant)
16
+ unless config
17
+ raise ConfigError, Config.missing_message(dir)
18
+ end
19
+
20
+ service = config.service
21
+ proxy = config.proxy_config
22
+ processes = config.processes
23
+
24
+ variant_name = (variant || ENV["YAMINE_VARIANT"])&.strip
25
+ variant_name = nil if variant_name&.empty?
26
+
27
+ # host or tld: config proxy.host wins over proxy.tld; flags override.
28
+ if host || proxy["host"]
29
+ effective_host = (host || proxy["host"]).to_s.strip.downcase
30
+ effective_tld = nil
31
+ else
32
+ effective_tld = (tld || proxy["tld"] || Hostname::DEFAULT_TLD).to_s.strip.downcase
33
+ effective_tld = effective_tld.gsub(/\A\./, "")
34
+ unless Sanitize.valid_tld?(effective_tld)
35
+ raise ConfigError, "Invalid tld #{effective_tld.inspect} in #{config.path} proxy.tld"
36
+ end
37
+ effective_host = nil
38
+ end
39
+
40
+ sources = {
41
+ app: config.path.to_s,
42
+ tld: proxy["tld"] ? "#{config.path} proxy.tld" : "default (localhost)",
43
+ host: proxy["host"] ? "#{config.path} proxy.host" : nil,
44
+ variant: variant_name ? "config/local.#{variant_name}.yml" : nil
45
+ }
46
+
47
+ Result.new(
48
+ app: Sanitize.hostname_label(service),
49
+ tld: effective_tld,
50
+ host: effective_host,
51
+ processes: processes,
52
+ secrets: config.secrets,
53
+ sources: sources,
54
+ variant: variant_name
55
+ )
56
+ end
57
+
58
+ # Hostname for one process: primary (first proxy:true) is bare;
59
+ # others are proc.app.tld; proxy:false have none.
60
+ def hostname_for(result, proc_name)
61
+ entry = result.processes[proc_name]
62
+ return nil unless entry
63
+ return nil if entry["proxy"] == false
64
+
65
+ base = result.host ? result.host : "#{result.app}.#{result.tld}"
66
+ proc_name.to_s == primary_proc(result) ? base : "#{proc_name}.#{base}"
67
+ end
68
+
69
+ def primary_proc(result)
70
+ result.processes.find { |_, v| v["proxy"] != false }&.first
71
+ end
72
+
73
+ # All HTTP hostnames (for route registration / doctor).
74
+ def hostnames(result)
75
+ result.processes.filter_map { |name, entry|
76
+ next if entry["proxy"] == false
77
+
78
+ hostname_for(result, name)
79
+ }
80
+ end
81
+
82
+ def url_for(result, proc_name, port:, tls:)
83
+ host = hostname_for(result, proc_name)
84
+ host && Hostname.url(host, port: port, tls: tls)
85
+ end
86
+
87
+ # Effective URL list for display (status): primary bare, others prefixed.
88
+ def urls(result, port:, tls:)
89
+ hostnames(result).map { |h| Hostname.url(h, port: port, tls: tls) }
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Yamine
7
+ # Persistent hostname -> backend mapping in ~/.yamine/routes.json.
8
+ #
9
+ # Entries: {hostname, target, kind, pid} where target is a unix socket
10
+ # path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), and pid 0
11
+ # marks a static alias. Guarded by flock; stale PIDs pruned on read.
12
+ # Modeled on portless RouteStore and puma-dev's AppPool registry.
13
+ class RouteStore
14
+ FILE_MODE = 0o644
15
+ DIR_MODE = 0o755
16
+
17
+ attr_reader :dir, :routes_path, :pid_path, :port_path
18
+
19
+ def initialize(dir, on_warning: nil)
20
+ @dir = dir
21
+ @routes_path = File.join(dir, "routes.json")
22
+ @pid_path = File.join(dir, "proxy.pid")
23
+ @port_path = File.join(dir, "proxy.port")
24
+ @on_warning = on_warning
25
+ end
26
+
27
+ def ensure_dir
28
+ FileUtils.mkdir_p(dir, mode: DIR_MODE)
29
+ File.chmod(DIR_MODE, dir)
30
+ Ownership.fix(dir)
31
+ rescue SystemCallError
32
+ nil
33
+ end
34
+
35
+ def with_lock
36
+ ensure_dir
37
+ File.open("#{routes_path}.lock", File::CREAT | File::RDWR, FILE_MODE) do |f|
38
+ f.flock(File::LOCK_EX)
39
+ yield
40
+ end
41
+ end
42
+
43
+ def load_routes(prune: false)
44
+ return [] unless File.file?(routes_path)
45
+
46
+ parsed = JSON.parse(File.read(routes_path))
47
+ unless parsed.is_a?(Array)
48
+ @on_warning&.call("Corrupted routes file (expected array): #{routes_path}")
49
+ return []
50
+ end
51
+ routes = parsed.select { |r| valid_route?(r) }
52
+ alive = routes.select { |r| r["pid"] == 0 || alive?(r["pid"]) }
53
+ save_routes(alive) if prune && alive.length != routes.length
54
+ alive
55
+ rescue JSON::ParserError
56
+ @on_warning&.call("Corrupted routes file (invalid JSON): #{routes_path}")
57
+ []
58
+ rescue SystemCallError
59
+ []
60
+ end
61
+
62
+ def load_routes_raw
63
+ return [] unless File.file?(routes_path)
64
+
65
+ parsed = JSON.parse(File.read(routes_path))
66
+ parsed.is_a?(Array) ? parsed.select { |r| valid_route?(r) } : []
67
+ rescue JSON::ParserError, SystemCallError
68
+ []
69
+ end
70
+
71
+ # Returns killed pid when force takes over a live route.
72
+ # `spec` marks daemon-supervised managed apps ({dir: ...}); the
73
+ # proxy supervisor may idle-kill and boot-on-request those backends.
74
+ def add_route(hostname, target, pid, kind:, force: false, spec: nil)
75
+ killed = nil
76
+ with_lock do
77
+ routes = load_routes(prune: true)
78
+ existing = routes.find { |r| r["hostname"] == hostname }
79
+ if existing && existing["pid"] != pid && alive?(existing["pid"])
80
+ raise RouteConflictError.new(hostname, existing["pid"]) unless force
81
+
82
+ begin
83
+ Process.kill("TERM", existing["pid"])
84
+ killed = existing["pid"]
85
+ rescue SystemCallError
86
+ nil
87
+ end
88
+ end
89
+ routes.reject! { |r| r["hostname"] == hostname }
90
+ entry = { "hostname" => hostname, "target" => target, "kind" => kind, "pid" => pid }
91
+ entry["spec"] = spec if spec
92
+ routes << entry
93
+ save_routes(routes)
94
+ end
95
+ killed
96
+ end
97
+
98
+ def remove_route(hostname, owner_pid: nil)
99
+ with_lock do
100
+ routes = load_routes(prune: true)
101
+ routes.reject! do |r|
102
+ r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
103
+ end
104
+ save_routes(routes)
105
+ end
106
+ end
107
+
108
+ def prune_stale
109
+ stale = []
110
+ with_lock do
111
+ all = load_routes_raw
112
+ alive, dead = all.partition { |r| r["pid"] == 0 || alive?(r["pid"]) }
113
+ stale = dead
114
+ save_routes(alive) unless dead.empty?
115
+ end
116
+ stale
117
+ end
118
+
119
+ def find(hostname)
120
+ load_routes.find { |r| r["hostname"] == hostname }
121
+ end
122
+
123
+ private
124
+
125
+ def valid_route?(value)
126
+ value.is_a?(Hash) &&
127
+ value["hostname"].is_a?(String) &&
128
+ value["target"].is_a?(String) &&
129
+ value["pid"].is_a?(Integer) &&
130
+ (value["spec"].nil? || (value["spec"].is_a?(Hash) && value["spec"]["dir"].is_a?(String)))
131
+ end
132
+
133
+ def save_routes(routes)
134
+ File.write(routes_path, JSON.pretty_generate(routes))
135
+ File.chmod(FILE_MODE, routes_path)
136
+ Ownership.fix(routes_path)
137
+ rescue SystemCallError
138
+ nil
139
+ end
140
+
141
+ def alive?(pid)
142
+ Process.kill(0, pid)
143
+ true
144
+ rescue SystemCallError
145
+ false
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "socket"
6
+
7
+ module Yamine
8
+ # Boots and supervises one app backend.
9
+ #
10
+ # Managed Ruby apps (Rails/Rack with puma available): puma bound to a
11
+ # unix socket — zero TCP ports, readiness by dialing the socket
12
+ # (puma-dev model). Without puma, managed degrades to rackup on TCP
13
+ # (run-mode shape). Run mode (everything else): subprocess with
14
+ # injected PORT/YAMINE_URL, readiness by TCP connect.
15
+ class Runner
16
+ SOCKET_DIR = File.join("tmp", "sockets")
17
+ SOCKET_NAME = "yamine.sock"
18
+
19
+ App = Struct.new(:name, :hostname, :url, :pid, :target, :kind,
20
+ :command, keyword_init: true)
21
+
22
+ def initialize(store:, on_log: nil)
23
+ @store = store
24
+ @on_log = on_log || ->(msg) { puts msg }
25
+ end
26
+
27
+ # Boot a managed Rack app. Socket when puma exists, else TCP rackup.
28
+ def boot_managed(name:, hostname:, url:, dir:, force: false)
29
+ if puma_available?(dir)
30
+ boot_socket(name: name, hostname: hostname, url: url, dir: dir, force: force)
31
+ else
32
+ boot_tcp_fallback(name: name, hostname: hostname, url: url, dir: dir, force: force)
33
+ end
34
+ end
35
+
36
+ # Boot a backend for an existing route (daemon supervisor). Does not
37
+ # re-register the route — target path is deterministic
38
+ # (dir/tmp/sockets/yamine.sock) and the route entry stays.
39
+ def boot_supervised(name:, hostname:, url:, dir:)
40
+ socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
41
+ FileUtils.mkdir_p(File.dirname(socket_path))
42
+ FileUtils.rm_f(socket_path)
43
+ pid = spawn_socket(dir, socket_path, name, url)
44
+ unless wait_for_socket(socket_path, timeout: 60)
45
+ stop_pid(pid)
46
+ raise Error, "App '#{name}' did not boot within 60s. " \
47
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
48
+ end
49
+ write_backend_pid(hostname, pid)
50
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
51
+ target: socket_path, kind: "socket", command: nil)
52
+ end
53
+
54
+ def boot_socket(name:, hostname:, url:, dir:, force: false)
55
+ socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
56
+ FileUtils.mkdir_p(File.dirname(socket_path))
57
+ FileUtils.rm_f(socket_path)
58
+ pid = spawn_socket(dir, socket_path, name, url)
59
+
60
+ unless wait_for_socket(socket_path, timeout: 60)
61
+ stop_pid(pid)
62
+ raise Error, "App '#{name}' did not boot within 60s. " \
63
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
64
+ end
65
+
66
+ @store.add_route(hostname, socket_path, Process.pid, kind: "socket",
67
+ force: force, spec: { "dir" => dir })
68
+ write_backend_pid(hostname, pid)
69
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
70
+ target: socket_path, kind: "socket", command: socket_command(dir, socket_path))
71
+ end
72
+
73
+ def boot_tcp_fallback(name:, hostname:, url:, dir:, force: false)
74
+ port = Ports.find_free
75
+ env = child_env(dir, url: url, port: port)
76
+ config_ru = File.join(dir, "config.ru")
77
+ cmd = ["rackup", "-o", "127.0.0.1", "-p", port.to_s, config_ru]
78
+ pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
79
+ Process.detach(pid)
80
+ unless wait_for_tcp(port, timeout: 60)
81
+ stop_pid(pid)
82
+ raise Error, "App '#{name}' did not boot within 60s. " \
83
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
84
+ end
85
+ target = "127.0.0.1:#{port}"
86
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
87
+ write_backend_pid(hostname, pid)
88
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
89
+ target: target, kind: "tcp", command: cmd)
90
+ end
91
+
92
+ # Run an arbitrary command with PORT + YAMINE_URL. Returns App.
93
+ def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
94
+ rails_dev_host: nil)
95
+ port ||= Ports.find_free
96
+ env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host)
97
+ pid = with_clean_env { spawn(env, *command, chdir: dir) }
98
+ Process.detach(pid)
99
+ target = "127.0.0.1:#{port}"
100
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
101
+ write_backend_pid(hostname, pid)
102
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
103
+ target: target, kind: "tcp", command: command)
104
+ end
105
+
106
+ def child_env(dir, url:, port:, rails_dev_host: nil)
107
+ env = { "YAMINE_URL" => url }
108
+ env["PORT"] = port.to_s if port
109
+ env["HOST"] = "127.0.0.1"
110
+ ca = File.join(Certs.state_dir, "ca.pem")
111
+ env["NODE_EXTRA_CA_CERTS"] = ca if File.file?(ca)
112
+ # Rails blocks unknown Host headers in development. Allow the
113
+ # proxied hostname so Rails apps boot behind yamine with zero
114
+ # config — this replaces the yamine-rails hosts patch.
115
+ env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
116
+ env
117
+ end
118
+
119
+ def puma_available?(dir)
120
+ with_clean_env { bundle_puma?(dir) || system_puma? }
121
+ end
122
+
123
+ # Spawned backends must not inherit our own bundle: under
124
+ # `bundle exec` rubygems restricts executables to the Gemfile,
125
+ # hiding system puma/rackup from the child. Strip Bundler env so
126
+ # the app boots with its own gems.
127
+ def with_clean_env(&block)
128
+ if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
129
+ Bundler.with_unbundled_env(&block)
130
+ else
131
+ saved = {}
132
+ %w[BUNDLE_GEMFILE RUBYOPT RUBYLIB GEM_HOME GEM_PATH].each do |k|
133
+ saved[k] = ENV.delete(k)
134
+ end
135
+ begin
136
+ yield
137
+ ensure
138
+ saved.each { |k, v| ENV[k] = v unless v.nil? }
139
+ end
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def socket_command(dir, socket_path)
146
+ socket = "unix://#{socket_path}"
147
+ config_ru = File.join(dir, "config.ru")
148
+ # Puma 8 takes config.ru positionally (no --rackup flag).
149
+ if bundle_puma?(dir)
150
+ ["bundle", "exec", "puma", "-b", socket, config_ru]
151
+ else
152
+ ["puma", "-b", socket, config_ru]
153
+ end
154
+ end
155
+
156
+ # Shared spawn for CLI boots and daemon supervision.
157
+ def spawn_socket(dir, socket_path, name, url)
158
+ env = child_env(dir, url: url, port: nil)
159
+ cmd = socket_command(dir, socket_path)
160
+ pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
161
+ Process.detach(pid)
162
+ pid
163
+ end
164
+
165
+ def bundle_puma?(dir)
166
+ gemfile = File.join(dir, "Gemfile")
167
+ return false unless File.file?(gemfile)
168
+
169
+ out, status = Open3.capture2("bundle", "exec", "puma", "-V",
170
+ chdir: dir, err: File::NULL)
171
+ status.success? && !out.empty?
172
+ rescue SystemCallError
173
+ false
174
+ end
175
+
176
+ def system_puma?
177
+ _out, status = Open3.capture2("puma", "-V", err: File::NULL)
178
+ status.success?
179
+ rescue SystemCallError
180
+ false
181
+ end
182
+
183
+ def wait_for_socket(path, timeout:)
184
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
185
+ until File.socket?(path)
186
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
187
+
188
+ sleep 0.25
189
+ end
190
+ begin
191
+ UNIXSocket.new(path).close
192
+ true
193
+ rescue SystemCallError
194
+ false
195
+ end
196
+ end
197
+
198
+ def wait_for_tcp(port, timeout:)
199
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
200
+ loop do
201
+ begin
202
+ TCPSocket.new("127.0.0.1", port).close
203
+ return true
204
+ rescue SystemCallError
205
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
206
+
207
+ sleep 0.25
208
+ end
209
+ end
210
+ end
211
+
212
+ def log_path(dir, name)
213
+ path = File.expand_path(File.join(dir, "log", "yamine-#{name}.log"))
214
+ FileUtils.mkdir_p(File.dirname(path))
215
+ Log.rotate(path)
216
+ path
217
+ end
218
+
219
+ # Sidecar so `yamine stop` can find the backend after the CLI
220
+ # that booted it has exited (the route owner pid is the CLI, not
221
+ # the backend). Removed alongside the route on clean stop.
222
+ def write_backend_pid(hostname, pid)
223
+ @store.ensure_dir
224
+ File.write(File.join(@store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
225
+ rescue SystemCallError
226
+ nil
227
+ end
228
+
229
+ def log_tail(path, lines: 10)
230
+ return "(no log file)" unless File.file?(path)
231
+
232
+ File.readlines(path).last(lines).join
233
+ rescue SystemCallError
234
+ "(unreadable log)"
235
+ end
236
+
237
+ def stop_pid(pid)
238
+ Process.kill("TERM", pid)
239
+ rescue SystemCallError
240
+ nil
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Yamine
6
+ # DNS label sanitization (RFC 1035): lowercase, hyphen-separated,
7
+ # max 63 chars with hash suffix on truncation.
8
+ #
9
+ # Borrowed semantics from portless sanitizeForHostname/truncateLabel,
10
+ # reimplemented in idiomatic Ruby.
11
+ module Sanitize
12
+ MAX_DNS_LABEL_LENGTH = 63
13
+
14
+ module_function
15
+
16
+ def truncate_label(label)
17
+ return label if label.length <= MAX_DNS_LABEL_LENGTH
18
+
19
+ hash = Digest::SHA256.hexdigest(label)[0, 6]
20
+ prefix = label[0, MAX_DNS_LABEL_LENGTH - 7].gsub(/-+\z/, "")
21
+ "#{prefix}-#{hash}"
22
+ end
23
+
24
+ def hostname_label(name)
25
+ sanitized = name.to_s.downcase
26
+ .gsub(/[^a-z0-9-]/, "-")
27
+ .gsub(/-{2,}/, "-")
28
+ .gsub(/\A-+|-+\z/, "")
29
+ truncate_label(sanitized)
30
+ end
31
+
32
+ def valid_tld?(tld)
33
+ return false if tld.nil? || tld.empty? || tld.length > 253
34
+
35
+ tld.split(".").all? do |label|
36
+ !label.empty? && label.length <= 63 &&
37
+ label.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
38
+ end
39
+ end
40
+ end
41
+ end