ask-local 0.1.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 +119 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/bin/ask-local +6 -0
- data/lib/ask/local/certs.rb +179 -0
- data/lib/ask/local/cli/boot.rb +255 -0
- data/lib/ask/local/cli/context.rb +106 -0
- data/lib/ask/local/cli/routes.rb +253 -0
- data/lib/ask/local/cli/system.rb +294 -0
- data/lib/ask/local/cli.rb +104 -0
- data/lib/ask/local/config.rb +113 -0
- data/lib/ask/local/doctor.rb +135 -0
- data/lib/ask/local/errors.rb +20 -0
- data/lib/ask/local/framework.rb +77 -0
- data/lib/ask/local/hostname.rb +46 -0
- data/lib/ask/local/hosts.rb +80 -0
- data/lib/ask/local/inference.rb +144 -0
- data/lib/ask/local/log.rb +61 -0
- data/lib/ask/local/ownership.rb +62 -0
- data/lib/ask/local/ports.rb +47 -0
- data/lib/ask/local/proxy.rb +480 -0
- data/lib/ask/local/proxy_control.rb +199 -0
- data/lib/ask/local/resolver.rb +126 -0
- data/lib/ask/local/route_store.rb +150 -0
- data/lib/ask/local/runner.rb +240 -0
- data/lib/ask/local/sanitize.rb +43 -0
- data/lib/ask/local/supervisor.rb +244 -0
- data/lib/ask/local/trust.rb +123 -0
- data/lib/ask/local/variant.rb +136 -0
- data/lib/ask/local/version.rb +7 -0
- data/lib/ask/skills/ask-local/SKILL.md +89 -0
- data/lib/ask-local.rb +37 -0
- metadata +135 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
class CLI
|
|
6
|
+
# System commands: proxy, service, hosts, trust, clean, doctor, kamal.
|
|
7
|
+
module SystemCommand
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def doctor(ctx, args)
|
|
11
|
+
json = args.delete("--json")
|
|
12
|
+
failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout, json: !!json)
|
|
13
|
+
exit(failed.zero? ? 0 : 1)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def trust(_ctx, _args)
|
|
17
|
+
result = Trust.trust
|
|
18
|
+
if result[:trusted]
|
|
19
|
+
puts "CA trusted."
|
|
20
|
+
else
|
|
21
|
+
$stderr.puts "Error: #{result[:error]}"
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clean(ctx, _args)
|
|
27
|
+
ProxyControl.stop(ctx.store)
|
|
28
|
+
result = Trust.untrust
|
|
29
|
+
puts "CA removed from trust store." if result[:removed]
|
|
30
|
+
warn "CA untrust failed: #{result[:error]}" if result[:error]
|
|
31
|
+
Hosts.clean
|
|
32
|
+
require "fileutils"
|
|
33
|
+
FileUtils.rm_rf(ctx.store.dir)
|
|
34
|
+
puts "Cleaned ask-local state."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def hosts(ctx, args)
|
|
38
|
+
sub = args.first
|
|
39
|
+
case sub
|
|
40
|
+
when "sync"
|
|
41
|
+
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
42
|
+
if Hosts.sync(hostnames)
|
|
43
|
+
puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
|
|
44
|
+
else
|
|
45
|
+
$stderr.puts "Could not write /etc/hosts (try sudo)."
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
when "clean"
|
|
49
|
+
Hosts.clean
|
|
50
|
+
puts "Removed ask-local entries from /etc/hosts."
|
|
51
|
+
else
|
|
52
|
+
raise Error, "Usage: ask-local hosts [sync|clean]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def proxy(ctx, args)
|
|
57
|
+
sub = args.first
|
|
58
|
+
case sub
|
|
59
|
+
when "start"
|
|
60
|
+
port, tls, foreground = parse_proxy_start(args[1..])
|
|
61
|
+
tlds = active_tlds(args[1..])
|
|
62
|
+
if foreground
|
|
63
|
+
write_tls_marker(ctx, tls)
|
|
64
|
+
write_tlds_file(ctx, tlds)
|
|
65
|
+
sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
|
|
66
|
+
on_event: ->(m) { warn m })
|
|
67
|
+
sup.start
|
|
68
|
+
Proxy.new(store: ctx.store, port: port, tls: tls,
|
|
69
|
+
state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
|
|
70
|
+
else
|
|
71
|
+
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
|
|
72
|
+
puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
|
|
73
|
+
end
|
|
74
|
+
when "stop"
|
|
75
|
+
case ProxyControl.stop(ctx.store)
|
|
76
|
+
when :stopped then puts "Proxy stopped."
|
|
77
|
+
when :stale then puts "Removed stale proxy state."
|
|
78
|
+
when :not_running then puts "Proxy is not running."
|
|
79
|
+
when :unknown_process then puts "Port in use by an unknown process."
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
raise Error, "Usage: ask-local proxy [start|stop]"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_tls_marker(ctx, tls)
|
|
87
|
+
ctx.store.ensure_dir
|
|
88
|
+
path = File.join(ctx.store.dir, "proxy.tls")
|
|
89
|
+
tls ? File.write(path, "1") : File.write(path, "0")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# TLDs the proxy serves: --tld flags, else ASK_LOCAL_TLD, else
|
|
93
|
+
# localhost. Persisted so auto-restarted daemons agree, and so
|
|
94
|
+
# the 404 page knows which hosts are "ours" (rebinding boundary).
|
|
95
|
+
def active_tlds(args)
|
|
96
|
+
flags = []
|
|
97
|
+
i = 0
|
|
98
|
+
while i < args.length
|
|
99
|
+
if args[i] == "--tld"
|
|
100
|
+
flags << args.fetch(i + 1).to_s
|
|
101
|
+
i += 2
|
|
102
|
+
else
|
|
103
|
+
i += 1
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
list = flags.any? ? flags : (ENV["ASK_LOCAL_TLD"]&.split(",")&.map(&:strip) || [])
|
|
107
|
+
list = list.reject(&:empty?).map(&:downcase).uniq
|
|
108
|
+
list.empty? ? [Hostname::DEFAULT_TLD] : list
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_tlds_file(ctx, tlds)
|
|
112
|
+
ctx.store.ensure_dir
|
|
113
|
+
File.write(File.join(ctx.store.dir, "proxy.tlds"), "#{tlds.join("\n")}\n")
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_proxy_start(args)
|
|
119
|
+
port = nil
|
|
120
|
+
tls = true
|
|
121
|
+
foreground = false
|
|
122
|
+
i = 0
|
|
123
|
+
while i < args.length
|
|
124
|
+
case args[i]
|
|
125
|
+
when "-p", "--port" then port = args.fetch(i + 1).to_i; i += 2
|
|
126
|
+
when "--no-tls" then tls = false; i += 1
|
|
127
|
+
when "--https" then tls = true; i += 1
|
|
128
|
+
when "--foreground" then foreground = true; i += 1
|
|
129
|
+
when "--tld" then i += 2 # consumed by active_tlds
|
|
130
|
+
else i += 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
[port || ProxyControl.default_port(tls), tls, foreground]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def service(ctx, args)
|
|
137
|
+
sub = args.first
|
|
138
|
+
case sub
|
|
139
|
+
when "install" then service_install(ctx, args)
|
|
140
|
+
when "uninstall" then service_uninstall
|
|
141
|
+
when "status" then service_status(ctx)
|
|
142
|
+
else raise Error, "Usage: ask-local service [install|uninstall|status]"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
|
|
147
|
+
# Re-execs under sudo; the proxy runs with the invoking user's state
|
|
148
|
+
# dir so routes registered by unprivileged CLIs are shared
|
|
149
|
+
# (portless pattern). A root proxy can also write /etc/hosts.
|
|
150
|
+
def service_install(ctx, args)
|
|
151
|
+
if !ProxyControl.root? && !args.include?("--internal")
|
|
152
|
+
puts "Installing system service (sudo required)..."
|
|
153
|
+
state = Certs.state_dir
|
|
154
|
+
ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
|
|
155
|
+
RbConfig.ruby, ProxyControl.bin_path,
|
|
156
|
+
"service", "install", "--internal")
|
|
157
|
+
exit(ok ? 0 : 1)
|
|
158
|
+
end
|
|
159
|
+
case RUBY_PLATFORM
|
|
160
|
+
when /darwin/ then install_launchd(ctx)
|
|
161
|
+
when /linux/ then print_linux_unit
|
|
162
|
+
else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def user_home_for_service
|
|
167
|
+
sudo_user = ENV["SUDO_USER"]
|
|
168
|
+
if sudo_user && !sudo_user.empty?
|
|
169
|
+
require "etc"
|
|
170
|
+
Etc.getpwnam(sudo_user).dir
|
|
171
|
+
else
|
|
172
|
+
Certs.home
|
|
173
|
+
end
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
Certs.home
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def install_launchd(ctx)
|
|
179
|
+
require "etc"
|
|
180
|
+
home = user_home_for_service
|
|
181
|
+
state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
|
|
182
|
+
dir = "/Library/LaunchDaemons"
|
|
183
|
+
FileUtils.mkdir_p(dir)
|
|
184
|
+
plist = <<~PLIST
|
|
185
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
186
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
187
|
+
<plist version="1.0">
|
|
188
|
+
<dict>
|
|
189
|
+
<key>Label</key><string>dev.ask.local</string>
|
|
190
|
+
<key>ProgramArguments</key>
|
|
191
|
+
<array>
|
|
192
|
+
<string>#{RbConfig.ruby}</string>
|
|
193
|
+
<string>#{ProxyControl.bin_path}</string>
|
|
194
|
+
<string>proxy</string><string>start</string><string>--foreground</string>
|
|
195
|
+
</array>
|
|
196
|
+
<key>EnvironmentVariables</key>
|
|
197
|
+
<dict>
|
|
198
|
+
<key>ASK_LOCAL_STATE_DIR</key><string>#{state_dir}</string>
|
|
199
|
+
<key>HOME</key><string>#{home}</string>
|
|
200
|
+
</dict>
|
|
201
|
+
<key>KeepAlive</key><true/>
|
|
202
|
+
<key>RunAtLoad</key><true/>
|
|
203
|
+
</dict>
|
|
204
|
+
</plist>
|
|
205
|
+
PLIST
|
|
206
|
+
path = File.join(dir, "dev.ask.local.plist")
|
|
207
|
+
File.write(path, plist)
|
|
208
|
+
File.chmod(0o644, path)
|
|
209
|
+
Ownership.chown_service_files(path)
|
|
210
|
+
system("launchctl", "unload", path) rescue nil
|
|
211
|
+
system("launchctl", "load", path) or raise Error, "launchctl load failed"
|
|
212
|
+
puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
|
|
213
|
+
puts "Restart your machine or run: sudo launchctl load #{path}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def print_linux_unit
|
|
217
|
+
home = user_home_for_service
|
|
218
|
+
state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
|
|
219
|
+
puts <<~UNIT
|
|
220
|
+
# /etc/systemd/system/ask-local.service (binds 80/443 at boot)
|
|
221
|
+
[Unit]
|
|
222
|
+
After=network.target
|
|
223
|
+
|
|
224
|
+
[Service]
|
|
225
|
+
ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
|
|
226
|
+
Environment=ASK_LOCAL_STATE_DIR=#{state_dir}
|
|
227
|
+
Environment=HOME=#{home}
|
|
228
|
+
|
|
229
|
+
[Install]
|
|
230
|
+
WantedBy=multi-user.target
|
|
231
|
+
|
|
232
|
+
Install with: sudo cp ask-local.service /etc/systemd/system/ && sudo systemctl enable --now ask-local
|
|
233
|
+
(Run that install command with sudo so the service is root-owned.)
|
|
234
|
+
UNIT
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def service_uninstall(_ctx)
|
|
238
|
+
if !ProxyControl.root?
|
|
239
|
+
state = Certs.state_dir
|
|
240
|
+
ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
|
|
241
|
+
RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal")
|
|
242
|
+
exit(ok ? 0 : 1)
|
|
243
|
+
end
|
|
244
|
+
case RUBY_PLATFORM
|
|
245
|
+
when /darwin/
|
|
246
|
+
path = "/Library/LaunchDaemons/dev.ask.local.plist"
|
|
247
|
+
system("launchctl", "unload", path) rescue nil
|
|
248
|
+
FileUtils.rm_f(path)
|
|
249
|
+
puts "Removed root LaunchDaemon."
|
|
250
|
+
else
|
|
251
|
+
puts "Remove /etc/systemd/system/ask-local.service, then: sudo systemctl disable --now ask-local"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def service_status(ctx)
|
|
256
|
+
port = ProxyControl.proxy_port(ctx.store)
|
|
257
|
+
if port.nil? || !ProxyControl.listening?(port)
|
|
258
|
+
puts "Proxy not running."
|
|
259
|
+
elsif ProxyControl.ours?(port, tls: ProxyControl.proxy_tls(ctx.store))
|
|
260
|
+
puts "Proxy running on port #{port}."
|
|
261
|
+
else
|
|
262
|
+
puts "Port #{port} in use by another process."
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ask-local kamal <variant> [--app myapp] [--domain preview.example.com]
|
|
267
|
+
def kamal(_ctx, args)
|
|
268
|
+
opts = {}
|
|
269
|
+
rest = []
|
|
270
|
+
i = 0
|
|
271
|
+
a = args.dup
|
|
272
|
+
while i < a.length
|
|
273
|
+
case a[i]
|
|
274
|
+
when "--app" then opts[:app] = a.fetch(i + 1); i += 2
|
|
275
|
+
when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
|
|
276
|
+
else rest << a[i]; i += 1
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
variant = rest.first
|
|
280
|
+
raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com]" unless variant
|
|
281
|
+
|
|
282
|
+
app = opts[:app] || Resolver.resolve(Dir.pwd, use_branch: false).app
|
|
283
|
+
domain = opts[:domain] || ENV["ASK_LOCAL_KAMAL_DOMAIN"] || "preview.example.com"
|
|
284
|
+
slug = Sanitize.hostname_label(variant)
|
|
285
|
+
puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
|
|
286
|
+
puts "proxy:"
|
|
287
|
+
puts " ssl: true"
|
|
288
|
+
puts " hosts:"
|
|
289
|
+
puts " - #{app}-#{slug}.#{domain}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Command-line interface. Thin dispatcher: every command lives in
|
|
8
|
+
# lib/ask/local/cli/{boot,routes,system}.rb behind a shared Context.
|
|
9
|
+
# OptionParser only, no Thor.
|
|
10
|
+
#
|
|
11
|
+
# In non-interactive environments (no TTY or CI=1) we fail early with
|
|
12
|
+
# a clear message instead of prompting (portless lesson).
|
|
13
|
+
class CLI
|
|
14
|
+
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open].freeze
|
|
15
|
+
|
|
16
|
+
def self.run(argv)
|
|
17
|
+
new.run(argv)
|
|
18
|
+
0
|
|
19
|
+
rescue Error => e
|
|
20
|
+
$stderr.puts "Error: #{e.message}"
|
|
21
|
+
1
|
|
22
|
+
rescue OptionParser::InvalidOption => e
|
|
23
|
+
$stderr.puts "Error: #{e.message}"
|
|
24
|
+
1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(argv)
|
|
28
|
+
args = argv.dup
|
|
29
|
+
if args.empty? || (!SUBCOMMANDS.include?(args.first) && !args.first.start_with?("-"))
|
|
30
|
+
return BootCommand.run_inferred(Context.new, args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
cmd = args.shift
|
|
34
|
+
ctx = Context.new
|
|
35
|
+
case cmd
|
|
36
|
+
when "run" then BootCommand.run_explicit(ctx, args)
|
|
37
|
+
when "get" then RoutesCommand.get(ctx, args)
|
|
38
|
+
when "alias" then RoutesCommand.alias_add(ctx, args)
|
|
39
|
+
when "hosts" then SystemCommand.hosts(ctx, args)
|
|
40
|
+
when "list" then RoutesCommand.list(ctx, args)
|
|
41
|
+
when "doctor" then SystemCommand.doctor(ctx, args)
|
|
42
|
+
when "trust" then SystemCommand.trust(ctx, args)
|
|
43
|
+
when "clean" then SystemCommand.clean(ctx, args)
|
|
44
|
+
when "prune" then RoutesCommand.prune(ctx, args)
|
|
45
|
+
when "proxy" then SystemCommand.proxy(ctx, args)
|
|
46
|
+
when "service" then SystemCommand.service(ctx, args)
|
|
47
|
+
when "kamal" then SystemCommand.kamal(ctx, args)
|
|
48
|
+
when "stop"
|
|
49
|
+
exit RoutesCommand.stop(ctx, args)
|
|
50
|
+
when "restart" then RoutesCommand.restart(ctx, args)
|
|
51
|
+
when "log" then RoutesCommand.log(ctx, args)
|
|
52
|
+
when "status" then RoutesCommand.status(ctx, args)
|
|
53
|
+
when "open" then RoutesCommand.open(ctx, args)
|
|
54
|
+
when "--help", "-h" then help
|
|
55
|
+
when "--version", "-v" then puts "ask-local #{VERSION}"
|
|
56
|
+
else BootCommand.run_named(ctx, cmd, args)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def help
|
|
63
|
+
puts <<~HELP
|
|
64
|
+
ask-local - Stable named .localhost URLs for Ruby development.
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
ask-local Infer name, boot app -> https://<app>.localhost
|
|
68
|
+
ask-local run [cmd] Same, with explicit command
|
|
69
|
+
ask-local <name> <cmd> Run with explicit name
|
|
70
|
+
ask-local get <name> Print URL for a service
|
|
71
|
+
ask-local alias <name> <port> Static route (e.g. Docker)
|
|
72
|
+
ask-local list Show active routes (+ backend liveness)
|
|
73
|
+
ask-local status Show effective naming context here
|
|
74
|
+
ask-local open [name] Open the app URL in a browser
|
|
75
|
+
ask-local doctor Check proxy, routes, DNS, CA trust
|
|
76
|
+
ask-local trust Add local CA to trust store
|
|
77
|
+
ask-local clean Remove state and hosts entries
|
|
78
|
+
ask-local prune Remove stale routes
|
|
79
|
+
ask-local proxy start|stop Control the proxy
|
|
80
|
+
ask-local service install|status|uninstall OS startup service
|
|
81
|
+
ask-local hosts sync|clean Manage /etc/hosts entries
|
|
82
|
+
ask-local kamal <variant> Preview-deploy snippet for Kamal
|
|
83
|
+
ask-local stop Stop this app's backend + routes
|
|
84
|
+
ask-local restart Touch tmp/restart.txt
|
|
85
|
+
ask-local log [-f] [n] Tail (or follow) this app's backend log
|
|
86
|
+
|
|
87
|
+
Flags: --name, --service, --variant, --tld, --branch, --force,
|
|
88
|
+
--app-port, --proc (pick a Procfile process, e.g. --proc web)
|
|
89
|
+
Env: ASK_LOCAL_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, ASK_LOCAL_BRANCH=1
|
|
90
|
+
HELP
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Backwards-compatible access for tests written against the old
|
|
94
|
+
# monolith: CLI.new.send(:inject_port_flags / :procfile_command).
|
|
95
|
+
def inject_port_flags(command, port)
|
|
96
|
+
BootCommand.inject_port_flags(command, port)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def procfile_command(process = nil)
|
|
100
|
+
BootCommand.procfile_command(process)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Optional ask-local.json in the app directory.
|
|
8
|
+
#
|
|
9
|
+
# { "name": "myapp" }
|
|
10
|
+
# { "name": "myapp", "service": "api", "variant": "demo",
|
|
11
|
+
# "tlds": ["localhost"], "appPort": 3000 }
|
|
12
|
+
#
|
|
13
|
+
# Monorepo roots may add an "apps" map keyed by path relative to the
|
|
14
|
+
# config dir; top-level fields apply only in single-app mode.
|
|
15
|
+
class Config
|
|
16
|
+
FILENAME = "ask-local.json"
|
|
17
|
+
TOP_KEYS = %w[name service variant tlds appPort proxy apps].freeze
|
|
18
|
+
APP_KEYS = %w[name service variant tlds appPort proxy].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :data, :dir
|
|
21
|
+
|
|
22
|
+
def self.load(dir = Dir.pwd)
|
|
23
|
+
path = File.join(dir, FILENAME)
|
|
24
|
+
return nil unless File.file?(path)
|
|
25
|
+
|
|
26
|
+
parsed = JSON.parse(File.read(path))
|
|
27
|
+
raise ConfigError, "#{path} must be a JSON object" unless parsed.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
new(parsed, dir, path)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise ConfigError, "Invalid JSON in #{path}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(data, dir, path = FILENAME)
|
|
35
|
+
@data = data
|
|
36
|
+
@dir = dir
|
|
37
|
+
@path = path
|
|
38
|
+
validate!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def app_config(package_dir = dir)
|
|
42
|
+
return slice(APP_KEYS) unless data["apps"].is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
rel = relative(package_dir)
|
|
45
|
+
return {} if rel.nil? || rel.start_with?("..")
|
|
46
|
+
|
|
47
|
+
candidate = rel
|
|
48
|
+
loop do
|
|
49
|
+
hit = data["apps"][candidate]
|
|
50
|
+
return hit.select { |k, _| APP_KEYS.include?(k) } if hit.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
parent = File.dirname(candidate)
|
|
53
|
+
break if parent == "." || parent == candidate
|
|
54
|
+
|
|
55
|
+
candidate = parent
|
|
56
|
+
end
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def [](key)
|
|
61
|
+
data[key]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def slice(keys)
|
|
67
|
+
data.select { |k, _| keys.include?(k) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def relative(package_dir)
|
|
71
|
+
require "pathname"
|
|
72
|
+
Pathname.new(File.expand_path(package_dir))
|
|
73
|
+
.relative_path_from(Pathname.new(File.expand_path(dir))).to_s
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate!
|
|
79
|
+
data.each_key do |key|
|
|
80
|
+
warn "Warning: Unknown key #{key.inspect} in #{@path}. Known keys: #{TOP_KEYS.join(", ")}" unless TOP_KEYS.include?(key)
|
|
81
|
+
end
|
|
82
|
+
validate_app_fields(data, "top level")
|
|
83
|
+
if data["apps"]
|
|
84
|
+
raise ConfigError, %("apps" in #{@path} must be an object) unless data["apps"].is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
data["apps"].each do |name, entry|
|
|
87
|
+
raise ConfigError, %("apps.#{name}" in #{@path} must be an object) unless entry.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
validate_app_fields(entry, "apps.#{name}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_app_fields(fields, prefix)
|
|
95
|
+
if fields["appPort"] && !(fields["appPort"].is_a?(Integer) && fields["appPort"].between?(1, 65_535))
|
|
96
|
+
raise ConfigError, %("#{prefix}.appPort" in #{@path} must be an integer 1-65535)
|
|
97
|
+
end
|
|
98
|
+
if fields.key?("proxy") && ![true, false].include?(fields["proxy"])
|
|
99
|
+
raise ConfigError, %("#{prefix}.proxy" in #{@path} must be a boolean)
|
|
100
|
+
end
|
|
101
|
+
%w[name service variant].each do |key|
|
|
102
|
+
next unless fields.key?(key)
|
|
103
|
+
next if fields[key].is_a?(String) && !fields[key].strip.empty?
|
|
104
|
+
|
|
105
|
+
raise ConfigError, %("#{prefix}.#{key}" in #{@path} must be a non-empty string)
|
|
106
|
+
end
|
|
107
|
+
if fields["tlds"] && !(fields["tlds"].is_a?(Array) && fields["tlds"].all? { |t| t.is_a?(String) })
|
|
108
|
+
raise ConfigError, %("#{prefix}.tlds" in #{@path} must be an array of strings)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
# Read-only health checks: proxy, routes, DNS, CA trust.
|
|
6
|
+
# Never changes state; safe for agents to call any time.
|
|
7
|
+
module Doctor
|
|
8
|
+
Check = Struct.new(:name, :ok, :message, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def run(store:, port: nil, tls: nil)
|
|
13
|
+
checks = []
|
|
14
|
+
port ||= ProxyControl.proxy_port(store)
|
|
15
|
+
tls = ProxyControl.proxy_tls(store) if tls.nil?
|
|
16
|
+
|
|
17
|
+
checks << check_state_dir(store)
|
|
18
|
+
checks << check_disk(store)
|
|
19
|
+
checks << check_proxy(port, tls: tls)
|
|
20
|
+
checks << check_routes(store)
|
|
21
|
+
checks << check_dns(store)
|
|
22
|
+
checks << check_ca
|
|
23
|
+
checks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ENOSPC on the state dir looks like our bug (socket bind fails,
|
|
27
|
+
# route writes vanish). Warn well before that: 100MB is already
|
|
28
|
+
# unreasonable for route files plus a rotated proxy log.
|
|
29
|
+
DISK_WARN_BYTES = 100 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
def check_disk(store)
|
|
32
|
+
used = Log.disk_usage(store.dir)
|
|
33
|
+
if used > DISK_WARN_BYTES
|
|
34
|
+
Check.new(name: "disk", ok: false,
|
|
35
|
+
message: "state dir uses #{Log.human_bytes(used)} — " \
|
|
36
|
+
"check #{File.join(store.dir, "proxy.log*")} for runaway logging")
|
|
37
|
+
else
|
|
38
|
+
Check.new(name: "disk", ok: true,
|
|
39
|
+
message: "state dir uses #{Log.human_bytes(used)}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Root-owned state files lock the unprivileged CLI out of its own
|
|
44
|
+
# state — the failure then looks like corruption, not permissions.
|
|
45
|
+
# Say so plainly.
|
|
46
|
+
def check_state_dir(store)
|
|
47
|
+
dir = store.dir
|
|
48
|
+
begin
|
|
49
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
50
|
+
probe = File.join(dir, ".writability-probe")
|
|
51
|
+
File.write(probe, "1")
|
|
52
|
+
File.unlink(probe)
|
|
53
|
+
Check.new(name: "state", ok: true, message: "#{dir} writable")
|
|
54
|
+
rescue SystemCallError => e
|
|
55
|
+
Check.new(name: "state", ok: false,
|
|
56
|
+
message: "#{dir} not writable (#{e.message}) — " \
|
|
57
|
+
"if a root proxy wrote here, run: sudo chown -R $USER #{dir}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_proxy(port, tls:)
|
|
62
|
+
if port.nil?
|
|
63
|
+
return Check.new(name: "proxy", ok: false,
|
|
64
|
+
message: "not running — run: ask-local proxy start")
|
|
65
|
+
end
|
|
66
|
+
unless ProxyControl.listening?(port)
|
|
67
|
+
return Check.new(name: "proxy", ok: false,
|
|
68
|
+
message: "port #{port} not listening — run: ask-local proxy start")
|
|
69
|
+
end
|
|
70
|
+
if ProxyControl.ours?(port, tls: tls)
|
|
71
|
+
Check.new(name: "proxy", ok: true, message: "listening on port #{port}")
|
|
72
|
+
else
|
|
73
|
+
Check.new(name: "proxy", ok: false,
|
|
74
|
+
message: "port #{port} is in use by another process")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_routes(store)
|
|
79
|
+
routes = store.load_routes
|
|
80
|
+
if routes.empty?
|
|
81
|
+
Check.new(name: "routes", ok: true, message: "no active routes")
|
|
82
|
+
else
|
|
83
|
+
stale = store.load_routes_raw.length - routes.length
|
|
84
|
+
msg = "#{routes.length} active route(s)"
|
|
85
|
+
msg += " (#{stale} stale pruned)" if stale.positive?
|
|
86
|
+
Check.new(name: "routes", ok: true, message: msg)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def check_dns(store)
|
|
91
|
+
hostnames = store.load_routes.map { |r| r["hostname"] }
|
|
92
|
+
return Check.new(name: "dns", ok: true, message: "no routes to resolve") if hostnames.empty?
|
|
93
|
+
|
|
94
|
+
missing = Hosts.unresolved(hostnames)
|
|
95
|
+
if missing.empty?
|
|
96
|
+
Check.new(name: "dns", ok: true, message: "all #{hostnames.length} hostname(s) resolve")
|
|
97
|
+
else
|
|
98
|
+
Check.new(name: "dns", ok: false,
|
|
99
|
+
message: "#{missing.join(", ")} do not resolve — run: ask-local hosts sync")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_ca
|
|
104
|
+
dir = Certs.state_dir
|
|
105
|
+
paths = Certs.ca_paths(dir)
|
|
106
|
+
unless File.file?(paths[:cert])
|
|
107
|
+
return Check.new(name: "ca", ok: false, message: "no CA yet — run: ask-local trust")
|
|
108
|
+
end
|
|
109
|
+
if Certs.trusted?(dir)
|
|
110
|
+
Check.new(name: "ca", ok: true, message: "CA trusted")
|
|
111
|
+
else
|
|
112
|
+
Check.new(name: "ca", ok: false, message: "CA not trusted — run: ask-local trust")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def print(checks, out: $stdout, json: false)
|
|
117
|
+
if json
|
|
118
|
+
require "json"
|
|
119
|
+
out.puts JSON.generate({
|
|
120
|
+
checks: checks.map { |c| { name: c.name, ok: c.ok, message: c.message } },
|
|
121
|
+
failed: checks.count { |c| !c.ok }
|
|
122
|
+
})
|
|
123
|
+
return checks.count { |c| !c.ok }
|
|
124
|
+
end
|
|
125
|
+
failed = 0
|
|
126
|
+
checks.each do |c|
|
|
127
|
+
mark = c.ok ? "ok" : "FAIL"
|
|
128
|
+
failed += 1 unless c.ok
|
|
129
|
+
out.puts " [#{mark}] #{c.name}: #{c.message}"
|
|
130
|
+
end
|
|
131
|
+
failed
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConfigError < Error; end
|
|
7
|
+
class RouteConflictError < Error
|
|
8
|
+
attr_reader :hostname, :existing_pid
|
|
9
|
+
|
|
10
|
+
def initialize(hostname, existing_pid)
|
|
11
|
+
@hostname = hostname
|
|
12
|
+
@existing_pid = existing_pid
|
|
13
|
+
super("\"#{hostname}\" is already registered by a running process " \
|
|
14
|
+
"(PID #{existing_pid}). Use --force to override.")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
class ProxyNotRunningError < Error; end
|
|
18
|
+
class CertError < Error; end
|
|
19
|
+
end
|
|
20
|
+
end
|