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