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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
class CLI
|
|
5
|
+
# Route and app-lifecycle commands: get/alias/list/prune,
|
|
6
|
+
# stop/restart/log/status/open.
|
|
7
|
+
module RoutesCommand
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# yamine get <name> [--service x] [--variant y] [--tld z]
|
|
11
|
+
#
|
|
12
|
+
# Variant and TLDs are inherited from the CURRENT directory's
|
|
13
|
+
# context (worktree branch, YAMINE_* env, config) so cross-service
|
|
14
|
+
# wiring works inside a variant: from a fix-ui worktree,
|
|
15
|
+
# `get backend` -> https://fix-ui.backend.localhost.
|
|
16
|
+
def get(ctx, args)
|
|
17
|
+
name = args.first
|
|
18
|
+
raise Error, "Usage: yamine get <name> [--service s] [--variant v] [--tld t]" unless name
|
|
19
|
+
|
|
20
|
+
opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
|
|
21
|
+
context = Resolver.resolve(Dir.pwd, variant: opts[:variant])
|
|
22
|
+
hostnames = Hostname.build(
|
|
23
|
+
app: Sanitize.hostname_label(name),
|
|
24
|
+
tlds: Array(context.tld || Yamine::Hostname::DEFAULT_TLD),
|
|
25
|
+
variant: context.variant
|
|
26
|
+
)
|
|
27
|
+
puts Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list(ctx, args)
|
|
31
|
+
json = args.delete("--json")
|
|
32
|
+
routes = ctx.store.load_routes
|
|
33
|
+
port = ctx.proxy_port
|
|
34
|
+
tls = ctx.proxy_tls
|
|
35
|
+
entries = routes.map do |r|
|
|
36
|
+
{ hostname: r["hostname"],
|
|
37
|
+
url: Hostname.url(r["hostname"], port: port, tls: tls),
|
|
38
|
+
target: r["target"], kind: r["kind"],
|
|
39
|
+
pid: r["pid"], supervised: !r["spec"].nil?,
|
|
40
|
+
alive: alive_state(ctx, r) }
|
|
41
|
+
end
|
|
42
|
+
if json
|
|
43
|
+
require "json"
|
|
44
|
+
puts JSON.generate({ routes: entries, proxy_port: port, tls: tls })
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
if entries.empty?
|
|
48
|
+
puts "No active routes."
|
|
49
|
+
puts "Start an app with: yamine"
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
puts "\nActive routes:\n"
|
|
53
|
+
entries.each do |e|
|
|
54
|
+
puts " #{e[:url]} -> #{e[:target]} #{label_for(e)}"
|
|
55
|
+
end
|
|
56
|
+
puts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def alive_state(ctx, route)
|
|
60
|
+
if route["pid"] == 0
|
|
61
|
+
ctx.backend_alive?(route) ? "reachable" : "unreachable"
|
|
62
|
+
elsif ProxyControl.pid_alive?(route["pid"])
|
|
63
|
+
"running"
|
|
64
|
+
else
|
|
65
|
+
"owner-gone"
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError
|
|
68
|
+
"unknown"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def label_for(entry)
|
|
72
|
+
if entry[:pid] == 0
|
|
73
|
+
"(alias, #{entry[:alive]})"
|
|
74
|
+
else
|
|
75
|
+
"(pid #{entry[:pid]}, #{entry[:alive]})"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Backend liveness per route: agents can see at a glance whether
|
|
80
|
+
# the route points at something alive. Static aliases (pid 0)
|
|
81
|
+
# report the probe, not a process.
|
|
82
|
+
def route_label(ctx, route)
|
|
83
|
+
entry = { pid: route["pid"], alive: alive_state(ctx, route) }
|
|
84
|
+
label_for(entry)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prune(ctx, _args)
|
|
88
|
+
stale = ctx.store.prune_stale
|
|
89
|
+
if stale.empty?
|
|
90
|
+
puts "No stale routes."
|
|
91
|
+
else
|
|
92
|
+
stale.each { |r| puts "Removed stale route #{r["hostname"]}" }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def alias_add(ctx, args)
|
|
97
|
+
if args.first == "--remove"
|
|
98
|
+
name = args[1] or raise Error, "Usage: yamine alias --remove <name>"
|
|
99
|
+
hostname = alias_hostname(name)
|
|
100
|
+
ctx.store.remove_route(hostname)
|
|
101
|
+
puts "Removed alias #{hostname}."
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
name, port_or_url = args
|
|
105
|
+
raise Error, "Usage: yamine alias <name> <port|url>" unless name && port_or_url
|
|
106
|
+
|
|
107
|
+
hostname = alias_hostname(name)
|
|
108
|
+
target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
|
|
109
|
+
force = args.include?("--force")
|
|
110
|
+
ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force)
|
|
111
|
+
puts "#{hostname} -> #{target}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A name containing dots is treated as a full hostname (any TLD);
|
|
115
|
+
# otherwise it is a label under the current TLD context
|
|
116
|
+
# (YAMINE_TLD first entry, else localhost).
|
|
117
|
+
def alias_hostname(name)
|
|
118
|
+
return Hostname.strip_port(name.downcase) if name.include?(".")
|
|
119
|
+
|
|
120
|
+
tld = ENV["YAMINE_TLD"]&.split(",")&.map(&:strip)&.reject(&:empty?)&.first
|
|
121
|
+
"#{Sanitize.hostname_label(name)}.#{tld || Hostname::DEFAULT_TLD}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Stop the app in the current directory (route + backend).
|
|
125
|
+
# Exit codes are machine-readable for agents: 0 stopped something,
|
|
126
|
+
# 2 no route here, 3 route existed but the backend was already gone.
|
|
127
|
+
def stop(ctx, _args, out: $stdout)
|
|
128
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
129
|
+
hostnames = Resolver.hostnames(resolved)
|
|
130
|
+
stopped = []
|
|
131
|
+
gone = []
|
|
132
|
+
hostnames.each do |hostname|
|
|
133
|
+
entry = ctx.store.find(hostname)
|
|
134
|
+
next unless entry
|
|
135
|
+
|
|
136
|
+
backend_pid = ctx.backend_pid_for(entry)
|
|
137
|
+
if backend_pid && ProxyControl.pid_alive?(backend_pid)
|
|
138
|
+
begin
|
|
139
|
+
Process.kill("TERM", backend_pid)
|
|
140
|
+
if ctx.wait_for_exit(backend_pid, timeout: 10)
|
|
141
|
+
stopped << "#{hostname} (backend #{backend_pid})"
|
|
142
|
+
else
|
|
143
|
+
stopped << "#{hostname} (backend #{backend_pid} still draining)"
|
|
144
|
+
end
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
gone << hostname
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
gone << hostname
|
|
150
|
+
end
|
|
151
|
+
ctx.store.remove_route(hostname)
|
|
152
|
+
FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{hostname}.pid"))
|
|
153
|
+
end
|
|
154
|
+
if stopped.any?
|
|
155
|
+
stopped.each { |s| out.puts "Stopped #{s}." }
|
|
156
|
+
return 0
|
|
157
|
+
end
|
|
158
|
+
if gone.any?
|
|
159
|
+
out.puts "Route existed but the backend was already gone: #{gone.join(", ")}."
|
|
160
|
+
return 3
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
out.puts "No yamine app running here."
|
|
164
|
+
2
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Touch tmp/restart.txt so a supervised managed app reboots.
|
|
168
|
+
def restart(_ctx, _args)
|
|
169
|
+
path = File.join(Dir.pwd, "tmp", "restart.txt")
|
|
170
|
+
require "fileutils"
|
|
171
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
172
|
+
FileUtils.touch(path)
|
|
173
|
+
puts "Touched #{path} — managed app restarts on next request."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Tail the app log (default 50 lines); --follow streams.
|
|
177
|
+
def log(ctx, args)
|
|
178
|
+
follow = args.delete("--follow") || args.delete("-f")
|
|
179
|
+
lines = (args.first || 50).to_i
|
|
180
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
181
|
+
path = File.expand_path(File.join(Dir.pwd, "log", "yamine-#{resolved.app}.log"))
|
|
182
|
+
unless File.file?(path)
|
|
183
|
+
puts "No log at #{path} yet."
|
|
184
|
+
return
|
|
185
|
+
end
|
|
186
|
+
if follow
|
|
187
|
+
exec("tail", "-F", "-n", lines.to_s, path)
|
|
188
|
+
else
|
|
189
|
+
puts File.readlines(path).last(lines).join
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Print the effective naming context for this directory: what
|
|
194
|
+
# `yamine` would boot here and why. Answers "why did I get
|
|
195
|
+
# this URL" without booting anything. --json emits stable keys
|
|
196
|
+
# for agents instead of prose.
|
|
197
|
+
def status(_ctx, args)
|
|
198
|
+
json = args.delete("--json")
|
|
199
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
200
|
+
hostnames = Resolver.hostnames(resolved)
|
|
201
|
+
urls = hostnames.map do |h|
|
|
202
|
+
Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
|
|
203
|
+
end
|
|
204
|
+
payload = {
|
|
205
|
+
app: resolved.app, app_source: resolved.sources[:app],
|
|
206
|
+
tld: resolved.tld, tld_source: resolved.sources[:tld],
|
|
207
|
+
host: resolved.host, host_source: resolved.sources[:host],
|
|
208
|
+
variant: resolved.variant, variant_source: resolved.sources[:variant],
|
|
209
|
+
urls: urls,
|
|
210
|
+
processes: resolved.processes.keys,
|
|
211
|
+
framework: Framework.detect(Dir.pwd).to_s
|
|
212
|
+
}
|
|
213
|
+
if json
|
|
214
|
+
require "json"
|
|
215
|
+
puts JSON.generate(payload)
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
puts "app: #{payload[:app]} (from #{payload[:app_source]})"
|
|
219
|
+
if payload[:host]
|
|
220
|
+
puts "host: #{payload[:host]} (from #{payload[:host_source]})"
|
|
221
|
+
else
|
|
222
|
+
puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
|
|
223
|
+
end
|
|
224
|
+
puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
|
|
225
|
+
puts "processes: #{payload[:processes].join(", ")}"
|
|
226
|
+
puts "urls:"
|
|
227
|
+
urls.each { |u| puts " #{u}" }
|
|
228
|
+
puts "framework: #{payload[:framework]}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Open the app URL in the default browser (macOS `open`).
|
|
232
|
+
def open(ctx, args)
|
|
233
|
+
name = args.first
|
|
234
|
+
url =
|
|
235
|
+
if name
|
|
236
|
+
opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
|
|
237
|
+
context = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld])
|
|
238
|
+
hostnames = Hostname.build(app: Sanitize.hostname_label(name),
|
|
239
|
+
tlds: Array(context.tld || Yamine::Hostname::DEFAULT_TLD),
|
|
240
|
+
variant: context.variant)
|
|
241
|
+
Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
242
|
+
else
|
|
243
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
244
|
+
Hostname.url(Resolver.hostnames(resolved).first,
|
|
245
|
+
port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
246
|
+
end
|
|
247
|
+
case RUBY_PLATFORM
|
|
248
|
+
when /darwin/ then exec("open", url)
|
|
249
|
+
when /linux/ then exec("xdg-open", url)
|
|
250
|
+
else puts url
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|