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,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
|