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
data/lib/yamine/cli.rb
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# Command-line interface. Thin dispatcher: every command lives in
|
|
7
|
+
# lib/yamine/cli/{boot,routes,system}.rb behind a shared Context.
|
|
8
|
+
# OptionParser only, no Thor.
|
|
9
|
+
#
|
|
10
|
+
# In non-interactive environments (no TTY or CI=1) we fail early with
|
|
11
|
+
# a clear message instead of prompting (portless lesson).
|
|
12
|
+
class CLI
|
|
13
|
+
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
|
|
14
|
+
|
|
15
|
+
def self.run(argv)
|
|
16
|
+
new.run(argv)
|
|
17
|
+
0
|
|
18
|
+
rescue Error => e
|
|
19
|
+
$stderr.puts "Error: #{e.message}"
|
|
20
|
+
1
|
|
21
|
+
rescue OptionParser::InvalidOption => e
|
|
22
|
+
$stderr.puts "Error: #{e.message}"
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(argv)
|
|
27
|
+
args = argv.dup
|
|
28
|
+
if args.empty? || (!SUBCOMMANDS.include?(args.first) && !args.first.start_with?("-"))
|
|
29
|
+
return BootCommand.run_inferred(Context.new, args)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
cmd = args.shift
|
|
33
|
+
ctx = Context.new
|
|
34
|
+
case cmd
|
|
35
|
+
when "run" then BootCommand.run_explicit(ctx, args)
|
|
36
|
+
when "get" then RoutesCommand.get(ctx, args)
|
|
37
|
+
when "alias" then RoutesCommand.alias_add(ctx, args)
|
|
38
|
+
when "hosts" then SystemCommand.hosts(ctx, args)
|
|
39
|
+
when "list" then RoutesCommand.list(ctx, args)
|
|
40
|
+
when "doctor" then SystemCommand.doctor(ctx, args)
|
|
41
|
+
when "trust" then SystemCommand.trust(ctx, args)
|
|
42
|
+
when "clean" then SystemCommand.clean(ctx, args)
|
|
43
|
+
when "prune" then RoutesCommand.prune(ctx, args)
|
|
44
|
+
when "proxy" then SystemCommand.proxy(ctx, args)
|
|
45
|
+
when "service" then SystemCommand.service(ctx, args)
|
|
46
|
+
when "sudoers" then SystemCommand.sudoers(ctx, args)
|
|
47
|
+
when "setup" then SystemCommand.setup(ctx, args)
|
|
48
|
+
when "init" then SystemCommand.init(ctx, args)
|
|
49
|
+
when "start" then SystemCommand.start(ctx, args)
|
|
50
|
+
when "kamal" then SystemCommand.kamal(ctx, args)
|
|
51
|
+
when "stop"
|
|
52
|
+
exit RoutesCommand.stop(ctx, args)
|
|
53
|
+
when "restart" then RoutesCommand.restart(ctx, args)
|
|
54
|
+
when "log" then RoutesCommand.log(ctx, args)
|
|
55
|
+
when "status" then RoutesCommand.status(ctx, args)
|
|
56
|
+
when "open" then RoutesCommand.open(ctx, args)
|
|
57
|
+
when "--help", "-h" then help
|
|
58
|
+
when "--version", "-v" then puts "yamine #{VERSION}"
|
|
59
|
+
else BootCommand.run_named(ctx, cmd, args)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def help
|
|
66
|
+
puts <<~HELP
|
|
67
|
+
yamine - Stable named .localhost URLs for Ruby development.
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
yamine start [name] [cmd...] One-setup-and-go: setup if needed, then boot -> https://<app>.localhost
|
|
71
|
+
yamine setup One-shot workstation setup without booting (run once)
|
|
72
|
+
yamine Bare form of `start` -> https://<app>.localhost
|
|
73
|
+
yamine run [cmd] Same, with explicit command
|
|
74
|
+
yamine <name> <cmd> Run with explicit name
|
|
75
|
+
yamine get <name> Print URL for a service
|
|
76
|
+
yamine alias <name> <port> Static route (e.g. Docker)
|
|
77
|
+
yamine list Show active routes (+ backend liveness)
|
|
78
|
+
yamine status Show effective naming context here
|
|
79
|
+
yamine open [name] Open the app URL in a browser
|
|
80
|
+
yamine doctor Check proxy, routes, DNS, CA trust
|
|
81
|
+
yamine trust Add local CA to trust store
|
|
82
|
+
yamine clean Remove state and hosts entries
|
|
83
|
+
yamine prune Remove stale routes
|
|
84
|
+
yamine proxy start|stop Control the proxy
|
|
85
|
+
yamine service install|status|uninstall OS startup service
|
|
86
|
+
yamine sudoers Print scoped passwordless-sudo rules for port 443
|
|
87
|
+
yamine hosts sync|clean Manage /etc/hosts entries
|
|
88
|
+
yamine kamal <variant> Preview-deploy snippet for Kamal
|
|
89
|
+
yamine stop Stop this app's backend + routes
|
|
90
|
+
yamine restart Touch tmp/restart.txt
|
|
91
|
+
yamine log [-f] [n] Tail (or follow) this app's backend log
|
|
92
|
+
|
|
93
|
+
Flags: --name, --service, --variant, --tld, --branch, --force,
|
|
94
|
+
--app-port, --proc (pick a Procfile process, e.g. --proc web)
|
|
95
|
+
Env: YAMINE_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, YAMINE_BRANCH=1
|
|
96
|
+
HELP
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Backwards-compatible access for tests written against the old
|
|
100
|
+
# monolith: CLI.new.send(:inject_port_flags / :procfile_command).
|
|
101
|
+
def inject_port_flags(command, port)
|
|
102
|
+
BootCommand.inject_port_flags(command, port)
|
|
103
|
+
end
|
|
104
|
+
def procfile_command(process = nil)
|
|
105
|
+
BootCommand.procfile_command(process)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# Injectable command runner for privileged/child-process work.
|
|
7
|
+
#
|
|
8
|
+
# Every system()/Open3 call that tests must control goes through here
|
|
9
|
+
# (elevation re-exec, launchctl, systemctl, security). Unit tests stub
|
|
10
|
+
# Command.run / Command.capture2 so they never shell out — no real
|
|
11
|
+
# sudo prompt, no keychain mutation, hermetic and CI-safe. The real
|
|
12
|
+
# implementations are the thin wrappers below.
|
|
13
|
+
#
|
|
14
|
+
# A plain class (not module_function): Mocha stubs class methods
|
|
15
|
+
# reliably, whereas module_function singletons dodge the stub and the
|
|
16
|
+
# real command would run.
|
|
17
|
+
class Command
|
|
18
|
+
# True when the command exited 0. Mirrors Kernel#system semantics.
|
|
19
|
+
def self.run(*args)
|
|
20
|
+
system(*args)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# [stdout, Process::Status] — mirrors Open3.capture2.
|
|
24
|
+
def self.capture2(*args)
|
|
25
|
+
Open3.capture2(*args)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Yamine
|
|
7
|
+
# Mandatory config/local.yml — the ONLY source of truth for this app.
|
|
8
|
+
#
|
|
9
|
+
# Replaces the old optional JSON / inference / Procfile path with a
|
|
10
|
+
# single file. Kamal patterns borrowed: YAML rendered through ERB,
|
|
11
|
+
# validated against an example schema with context-pathed errors,
|
|
12
|
+
# x- extensions ignored, deep overlay for variants (Kamal
|
|
13
|
+
# destinations), env clear/secret split reading config/local.secrets.
|
|
14
|
+
#
|
|
15
|
+
# Shape:
|
|
16
|
+
#
|
|
17
|
+
# service: myrr-chat
|
|
18
|
+
#
|
|
19
|
+
# proxy:
|
|
20
|
+
# tld: localhost
|
|
21
|
+
# # host: myrr-chat.local.example.com
|
|
22
|
+
#
|
|
23
|
+
# processes:
|
|
24
|
+
# web:
|
|
25
|
+
# cmd: bin/rails server -p $PORT
|
|
26
|
+
# proxy: true
|
|
27
|
+
# healthcheck: { path: /up, timeout: 30 }
|
|
28
|
+
# worker:
|
|
29
|
+
# cmd: bin/jobs
|
|
30
|
+
# proxy: false
|
|
31
|
+
#
|
|
32
|
+
# env:
|
|
33
|
+
# clear:
|
|
34
|
+
# RAILS_ENV: development
|
|
35
|
+
# secret:
|
|
36
|
+
# - RAILS_MASTER_KEY
|
|
37
|
+
#
|
|
38
|
+
# Variant overlays: config/local.<variant>.yml deep-merged on top
|
|
39
|
+
# (like Kamal's deploy.<destination>.yml). The `variant:` key in the
|
|
40
|
+
# base file is not used — variants are files.
|
|
41
|
+
class Config
|
|
42
|
+
FILENAME = "local.yml"
|
|
43
|
+
RELATIVE_DIR = "config"
|
|
44
|
+
RELATIVE_PATH = File.join(RELATIVE_DIR, FILENAME)
|
|
45
|
+
SECRETS_PATH = File.join(RELATIVE_DIR, "local.secrets")
|
|
46
|
+
|
|
47
|
+
# Example schema: shapes the validator (types, required keys, array
|
|
48
|
+
# element types). Unknown keys raise with the context path; keys
|
|
49
|
+
# starting with "x-" are extensions and ignored (Kamal convention).
|
|
50
|
+
EXAMPLE = {
|
|
51
|
+
"service" => "myapp",
|
|
52
|
+
"proxy" => {
|
|
53
|
+
"tld" => "localhost",
|
|
54
|
+
"host" => "myapp.local.example.com"
|
|
55
|
+
},
|
|
56
|
+
"processes" => {
|
|
57
|
+
"web" => {
|
|
58
|
+
"cmd" => "bin/rails server -p $PORT",
|
|
59
|
+
"proxy" => true,
|
|
60
|
+
"healthcheck" => { "path" => "/up", "timeout" => 30 }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"env" => {
|
|
64
|
+
"clear" => { "RAILS_ENV" => "development" },
|
|
65
|
+
"secret" => ["RAILS_MASTER_KEY"]
|
|
66
|
+
}
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
REQUIRED_TOP = %w[service].freeze
|
|
70
|
+
|
|
71
|
+
attr_reader :data, :dir, :path
|
|
72
|
+
|
|
73
|
+
# Load config/local.yml for the app rooted at dir (walks up for the
|
|
74
|
+
# nearest config/local.yml, so running from a subdirectory works).
|
|
75
|
+
# Raises ConfigError when missing (mandatory) or invalid. The variant
|
|
76
|
+
# overlay (config/local.<variant>.yml) is deep-merged on top when
|
|
77
|
+
# YAMINE_VARIANT or the explicit variant arg is set.
|
|
78
|
+
def self.load(dir = Dir.pwd, variant: nil, overlay: nil)
|
|
79
|
+
variant ||= ENV["YAMINE_VARIANT"]
|
|
80
|
+
overlay ||= ENV["YAMINE_OVERLAY"]
|
|
81
|
+
root, config_path = find_root(dir)
|
|
82
|
+
unless root
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
data = load_yaml(config_path)
|
|
87
|
+
if variant && !variant.to_s.strip.empty?
|
|
88
|
+
overlay_path = File.join(root, RELATIVE_DIR, "local.#{variant.strip}.yml")
|
|
89
|
+
if File.file?(overlay_path)
|
|
90
|
+
overlay_data = load_yaml(overlay_path)
|
|
91
|
+
data = deep_merge(data, overlay_data)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
if overlay && File.file?(overlay)
|
|
95
|
+
overlay_data = load_yaml(overlay)
|
|
96
|
+
data = deep_merge(data, overlay_data)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
new(data, root, config_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.load_yaml(path)
|
|
103
|
+
template = File.read(path)
|
|
104
|
+
rendered = ERB.new(template, trim_mode: "-").result
|
|
105
|
+
return {} if rendered.strip.empty?
|
|
106
|
+
|
|
107
|
+
parsed = YAML.safe_load(rendered, aliases: true)
|
|
108
|
+
raise ConfigError, "#{path} must be a YAML mapping" unless parsed.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
parsed
|
|
111
|
+
rescue Psych::SyntaxError => e
|
|
112
|
+
raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.find_root(dir)
|
|
116
|
+
current = File.expand_path(dir)
|
|
117
|
+
loop do
|
|
118
|
+
candidate = File.join(current, RELATIVE_PATH)
|
|
119
|
+
return [current, candidate] if File.file?(candidate)
|
|
120
|
+
|
|
121
|
+
parent = File.dirname(current)
|
|
122
|
+
break if parent == current
|
|
123
|
+
|
|
124
|
+
current = parent
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.missing_message(dir)
|
|
130
|
+
"No #{RELATIVE_PATH} found from #{dir}. Run `yamine init`."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.deep_merge(base, overlay)
|
|
134
|
+
base.merge(overlay) do |_, base_val, overlay_val|
|
|
135
|
+
if base_val.is_a?(Hash) && overlay_val.is_a?(Hash)
|
|
136
|
+
deep_merge(base_val, overlay_val)
|
|
137
|
+
else
|
|
138
|
+
overlay_val
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def initialize(data, dir, path)
|
|
144
|
+
@data = data
|
|
145
|
+
@dir = dir
|
|
146
|
+
@path = path
|
|
147
|
+
validate!
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def service
|
|
151
|
+
data["service"].to_s
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def proxy_config
|
|
155
|
+
data["proxy"] || {}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def processes
|
|
159
|
+
data["processes"] || {}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def env_config
|
|
163
|
+
data["env"] || {}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Secrets read from config/local.secrets (dotenv), gitignored.
|
|
167
|
+
# Only needed when env.secret lists keys; missing file is not an
|
|
168
|
+
# error until a listed secret is referenced.
|
|
169
|
+
def secrets
|
|
170
|
+
@secrets ||= load_secrets
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def [](key)
|
|
174
|
+
data[key]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def load_secrets
|
|
180
|
+
secrets_file = File.join(dir, SECRETS_PATH)
|
|
181
|
+
return {} unless File.file?(secrets_file)
|
|
182
|
+
|
|
183
|
+
parse_dotenv(File.read(secrets_file))
|
|
184
|
+
rescue SystemCallError
|
|
185
|
+
{}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_dotenv(content)
|
|
189
|
+
result = {}
|
|
190
|
+
content.each_line do |line|
|
|
191
|
+
line = line.strip
|
|
192
|
+
next if line.empty? || line.start_with?("#")
|
|
193
|
+
|
|
194
|
+
if line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/)
|
|
195
|
+
result[Regexp.last_match(1)] = Regexp.last_match(2).strip.gsub(/\A["']|["']\z/, "")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def app_config(package_dir = dir)
|
|
202
|
+
# Monorepo: walk up looking for config/local.yml with apps map.
|
|
203
|
+
# Falls back to top-level fields for non-monorepo usage.
|
|
204
|
+
return data unless data["apps"].is_a?(Hash)
|
|
205
|
+
require "pathname"
|
|
206
|
+
rel = begin
|
|
207
|
+
Pathname.new(File.expand_path(package_dir))
|
|
208
|
+
.relative_path_from(Pathname.new(File.expand_path(dir))).to_s
|
|
209
|
+
rescue ArgumentError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
return data unless rel
|
|
213
|
+
candidate = rel
|
|
214
|
+
loop do
|
|
215
|
+
entry = data["apps"][candidate]
|
|
216
|
+
return entry if entry.is_a?(Hash)
|
|
217
|
+
parent = File.dirname(candidate)
|
|
218
|
+
break if parent == "." || parent == candidate
|
|
219
|
+
candidate = parent
|
|
220
|
+
end
|
|
221
|
+
data
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate!
|
|
225
|
+
# Unknown top-level keys (outside example + x- extensions).
|
|
226
|
+
unknown = data.keys.map(&:to_s) - EXAMPLE.keys.map(&:to_s)
|
|
227
|
+
unknown.reject! { |k| k.start_with?("x-") }
|
|
228
|
+
unless unknown.empty?
|
|
229
|
+
raise ConfigError, "Unknown key(s) #{unknown.map(&:inspect).join(", ")} in #{@path}"
|
|
230
|
+
end
|
|
231
|
+
REQUIRED_TOP.each do |key|
|
|
232
|
+
raise ConfigError, %("#{key}" is required in #{@path}) if data[key].nil? || data[key].to_s.strip.empty?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
Validator.new(data, EXAMPLE, context: @path).validate!
|
|
236
|
+
|
|
237
|
+
validate_service(data["service"], @path)
|
|
238
|
+
if data["proxy"]
|
|
239
|
+
validate_proxy(data["proxy"], "#{@path} proxy")
|
|
240
|
+
end
|
|
241
|
+
if data["processes"]
|
|
242
|
+
validate_processes(data["processes"], "#{@path} processes")
|
|
243
|
+
end
|
|
244
|
+
if data["env"]
|
|
245
|
+
validate_env(data["env"], "#{@path} env")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_service(value, context)
|
|
250
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
251
|
+
raise ConfigError, "#{context}: service must be a non-empty string"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_proxy(value, context)
|
|
256
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
257
|
+
|
|
258
|
+
if value["host"] && !value["host"].is_a?(String)
|
|
259
|
+
raise ConfigError, "#{context}: host must be a string"
|
|
260
|
+
end
|
|
261
|
+
if value["tld"] && !value["tld"].is_a?(String)
|
|
262
|
+
raise ConfigError, "#{context}: tld must be a string"
|
|
263
|
+
end
|
|
264
|
+
if value.key?("host") && value.key?("tld")
|
|
265
|
+
raise ConfigError, "#{context}: specify one of host or tld, not both"
|
|
266
|
+
end
|
|
267
|
+
if value["tld"] && !Sanitize.valid_tld?(value["tld"].downcase)
|
|
268
|
+
raise ConfigError, "#{context}: invalid tld #{value["tld"].inspect}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def validate_processes(value, context)
|
|
273
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
274
|
+
raise ConfigError, "#{context} must list at least one process" if value.empty?
|
|
275
|
+
|
|
276
|
+
value.each do |name, entry|
|
|
277
|
+
raise ConfigError, %("#{context}/#{name}" must be a mapping) unless entry.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
if entry["cmd"].nil? || entry["cmd"].to_s.strip.empty?
|
|
280
|
+
raise ConfigError, %("#{context}/#{name}" requires a non-empty cmd)
|
|
281
|
+
end
|
|
282
|
+
if entry.key?("proxy") && ![true, false].include?(entry["proxy"])
|
|
283
|
+
raise ConfigError, %("#{context}/#{name} proxy must be a boolean")
|
|
284
|
+
end
|
|
285
|
+
if entry["healthcheck"]
|
|
286
|
+
hc = entry["healthcheck"]
|
|
287
|
+
raise ConfigError, %("#{context}/#{name} healthcheck must be a mapping) unless hc.is_a?(Hash)
|
|
288
|
+
|
|
289
|
+
if hc.key?("path") && !hc["path"].is_a?(String)
|
|
290
|
+
raise ConfigError, %("#{context}/#{name} healthcheck path must be a string)
|
|
291
|
+
end
|
|
292
|
+
if hc.key?("timeout") && !hc["timeout"].is_a?(Integer)
|
|
293
|
+
raise ConfigError, %("#{context}/#{name} healthcheck timeout must be an integer)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def validate_env(value, context)
|
|
300
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
if value["clear"] && !value["clear"].is_a?(Hash)
|
|
303
|
+
raise ConfigError, "#{context} clear must be a mapping"
|
|
304
|
+
end
|
|
305
|
+
if value["secret"] && !value["secret"].is_a?(Array)
|
|
306
|
+
raise ConfigError, "#{context} secret must be an array of strings"
|
|
307
|
+
end
|
|
308
|
+
if value["secret"] && !value["secret"].all? { |k| k.is_a?(String) }
|
|
309
|
+
raise ConfigError, "#{context} secret keys must be strings"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Generic schema validator with context-pathed errors (Kamal
|
|
314
|
+
# Validator pattern): walks the example shape, type-checks each
|
|
315
|
+
# present key, and reports the path where the mismatch was found.
|
|
316
|
+
class Validator
|
|
317
|
+
def initialize(config, example, context:)
|
|
318
|
+
@config = config
|
|
319
|
+
@example = example
|
|
320
|
+
@context = context
|
|
321
|
+
@stack = []
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def validate!
|
|
325
|
+
validate_against_example!(@config, @example)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def validate_against_example!(config, example)
|
|
331
|
+
return unless example.is_a?(Hash) && config.is_a?(Hash)
|
|
332
|
+
|
|
333
|
+
# Only validate keys the config actually has; absent example
|
|
334
|
+
# keys are optional (Kamal ignores missing optional keys).
|
|
335
|
+
config.each do |key, value|
|
|
336
|
+
next if key.to_s.start_with?("x-")
|
|
337
|
+
|
|
338
|
+
with_context(key) do
|
|
339
|
+
example_value = example[key] || example[key.to_s]
|
|
340
|
+
next if example_value.nil? && !example.key?(key.to_s) && !example.key?(key)
|
|
341
|
+
|
|
342
|
+
validate_value!(value, example_value)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def validate_value!(value, example_value)
|
|
348
|
+
return if example_value == "..."
|
|
349
|
+
|
|
350
|
+
if example_value.is_a?(Hash) && value.is_a?(Hash)
|
|
351
|
+
validate_against_example!(value, example_value)
|
|
352
|
+
elsif example_value.is_a?(Array) && value.is_a?(Array)
|
|
353
|
+
validate_array_of!(value, example_value.first.class) unless example_value.empty?
|
|
354
|
+
elsif !example_value.nil?
|
|
355
|
+
expected = type_description(example_value.class)
|
|
356
|
+
unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
|
|
357
|
+
raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def validate_array_of!(array, type)
|
|
363
|
+
array.each_with_index do |value, index|
|
|
364
|
+
with_context(index) do
|
|
365
|
+
unless value.is_a?(type)
|
|
366
|
+
raise ConfigError, "#{current_context}: expected #{type.name.downcase}, got #{value.class.name.downcase}"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def type_description(type)
|
|
373
|
+
if type == Integer || type == Array
|
|
374
|
+
"an #{type.name.downcase}"
|
|
375
|
+
elsif type == TrueClass || type == FalseClass
|
|
376
|
+
"a boolean"
|
|
377
|
+
else
|
|
378
|
+
"a #{type.name.downcase}"
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def with_context(part)
|
|
383
|
+
@stack.push(part)
|
|
384
|
+
yield
|
|
385
|
+
ensure
|
|
386
|
+
@stack.pop
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def current_context
|
|
390
|
+
([@context] + @stack.map(&:to_s)).join("/")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
# Read-only health checks: proxy, routes, DNS, CA trust.
|
|
5
|
+
# Never changes state; safe for agents to call any time.
|
|
6
|
+
module Doctor
|
|
7
|
+
Check = Struct.new(:name, :ok, :message, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def run(store:, port: nil, tls: nil)
|
|
12
|
+
checks = []
|
|
13
|
+
port ||= ProxyControl.proxy_port(store)
|
|
14
|
+
tls = ProxyControl.proxy_tls(store) if tls.nil?
|
|
15
|
+
|
|
16
|
+
checks << check_state_dir(store)
|
|
17
|
+
checks << check_disk(store)
|
|
18
|
+
checks << check_proxy(port, tls: tls)
|
|
19
|
+
checks << check_routes(store)
|
|
20
|
+
checks << check_dns(store)
|
|
21
|
+
checks << check_ca
|
|
22
|
+
checks
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ENOSPC on the state dir looks like our bug (socket bind fails,
|
|
26
|
+
# route writes vanish). Warn well before that: 100MB is already
|
|
27
|
+
# unreasonable for route files plus a rotated proxy log.
|
|
28
|
+
DISK_WARN_BYTES = 100 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
def check_disk(store)
|
|
31
|
+
used = Log.disk_usage(store.dir)
|
|
32
|
+
if used > DISK_WARN_BYTES
|
|
33
|
+
Check.new(name: "disk", ok: false,
|
|
34
|
+
message: "state dir uses #{Log.human_bytes(used)} — " \
|
|
35
|
+
"check #{File.join(store.dir, "proxy.log*")} for runaway logging")
|
|
36
|
+
else
|
|
37
|
+
Check.new(name: "disk", ok: true,
|
|
38
|
+
message: "state dir uses #{Log.human_bytes(used)}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Root-owned state files lock the unprivileged CLI out of its own
|
|
43
|
+
# state — the failure then looks like corruption, not permissions.
|
|
44
|
+
# Say so plainly.
|
|
45
|
+
def check_state_dir(store)
|
|
46
|
+
dir = store.dir
|
|
47
|
+
begin
|
|
48
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
49
|
+
probe = File.join(dir, ".writability-probe")
|
|
50
|
+
File.write(probe, "1")
|
|
51
|
+
File.unlink(probe)
|
|
52
|
+
Check.new(name: "state", ok: true, message: "#{dir} writable")
|
|
53
|
+
rescue SystemCallError => e
|
|
54
|
+
Check.new(name: "state", ok: false,
|
|
55
|
+
message: "#{dir} not writable (#{e.message}) — " \
|
|
56
|
+
"if a root proxy wrote here, run: sudo chown -R $USER #{dir}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_proxy(port, tls:)
|
|
61
|
+
if port.nil?
|
|
62
|
+
return Check.new(name: "proxy", ok: false,
|
|
63
|
+
message: "not running — run: yamine proxy start")
|
|
64
|
+
end
|
|
65
|
+
unless ProxyControl.listening?(port)
|
|
66
|
+
return Check.new(name: "proxy", ok: false,
|
|
67
|
+
message: "port #{port} not listening — run: yamine proxy start")
|
|
68
|
+
end
|
|
69
|
+
if ProxyControl.ours?(port, tls: tls)
|
|
70
|
+
Check.new(name: "proxy", ok: true, message: "listening on port #{port}")
|
|
71
|
+
else
|
|
72
|
+
Check.new(name: "proxy", ok: false,
|
|
73
|
+
message: "port #{port} is in use by another process")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_routes(store)
|
|
78
|
+
routes = store.load_routes
|
|
79
|
+
if routes.empty?
|
|
80
|
+
Check.new(name: "routes", ok: true, message: "no active routes")
|
|
81
|
+
else
|
|
82
|
+
stale = store.load_routes_raw.length - routes.length
|
|
83
|
+
msg = "#{routes.length} active route(s)"
|
|
84
|
+
msg += " (#{stale} stale pruned)" if stale.positive?
|
|
85
|
+
Check.new(name: "routes", ok: true, message: msg)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def check_dns(store)
|
|
90
|
+
hostnames = store.load_routes.map { |r| r["hostname"] }
|
|
91
|
+
return Check.new(name: "dns", ok: true, message: "no routes to resolve") if hostnames.empty?
|
|
92
|
+
|
|
93
|
+
missing = Hosts.unresolved(hostnames)
|
|
94
|
+
if missing.empty?
|
|
95
|
+
Check.new(name: "dns", ok: true, message: "all #{hostnames.length} hostname(s) resolve")
|
|
96
|
+
else
|
|
97
|
+
Check.new(name: "dns", ok: false,
|
|
98
|
+
message: "#{missing.join(", ")} do not resolve — run: yamine hosts sync")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_ca
|
|
103
|
+
dir = Certs.state_dir
|
|
104
|
+
paths = Certs.ca_paths(dir)
|
|
105
|
+
unless File.file?(paths[:cert])
|
|
106
|
+
return Check.new(name: "ca", ok: false, message: "no CA yet — run: yamine trust")
|
|
107
|
+
end
|
|
108
|
+
if Certs.trusted?(dir)
|
|
109
|
+
Check.new(name: "ca", ok: true, message: "CA trusted")
|
|
110
|
+
else
|
|
111
|
+
Check.new(name: "ca", ok: false, message: "CA not trusted — run: yamine trust")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def print(checks, out: $stdout, json: false)
|
|
116
|
+
if json
|
|
117
|
+
require "json"
|
|
118
|
+
out.puts JSON.generate({
|
|
119
|
+
checks: checks.map { |c| { name: c.name, ok: c.ok, message: c.message } },
|
|
120
|
+
failed: checks.count { |c| !c.ok }
|
|
121
|
+
})
|
|
122
|
+
return checks.count { |c| !c.ok }
|
|
123
|
+
end
|
|
124
|
+
failed = 0
|
|
125
|
+
checks.each do |c|
|
|
126
|
+
mark = c.ok ? "ok" : "FAIL"
|
|
127
|
+
failed += 1 unless c.ok
|
|
128
|
+
out.puts " [#{mark}] #{c.name}: #{c.message}"
|
|
129
|
+
end
|
|
130
|
+
failed
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ConfigError < Error; end
|
|
6
|
+
class RouteConflictError < Error
|
|
7
|
+
attr_reader :hostname, :existing_pid
|
|
8
|
+
|
|
9
|
+
def initialize(hostname, existing_pid)
|
|
10
|
+
@hostname = hostname
|
|
11
|
+
@existing_pid = existing_pid
|
|
12
|
+
super("\"#{hostname}\" is already registered by a running process " \
|
|
13
|
+
"(PID #{existing_pid}). Use --force to override.")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
class ProxyNotRunningError < Error; end
|
|
17
|
+
class CertError < Error; end
|
|
18
|
+
end
|