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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
# Resolve the effective {app, service, variant, tlds} for a directory.
|
|
6
|
+
#
|
|
7
|
+
# Precedence per axis: CLI flag > ENV > ask-local.json > inference.
|
|
8
|
+
# Variant adds linked-worktree branch and opt-in current branch.
|
|
9
|
+
module Resolver
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
Result = Struct.new(:app, :service, :variant, :tlds, :sources, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def resolve(dir = Dir.pwd, name: nil, service: nil, variant: nil,
|
|
15
|
+
tlds: nil, use_branch: false)
|
|
16
|
+
config, config_dir = find_config(dir)
|
|
17
|
+
app_cfg = config ? config.app_config(dir) : {}
|
|
18
|
+
config_source = config_dir ? "ask-local.json (#{relative_label(config_dir, dir)})" : "ask-local.json"
|
|
19
|
+
|
|
20
|
+
app, app_source = first_present(
|
|
21
|
+
[name, "flag"],
|
|
22
|
+
[ENV["ASK_LOCAL_NAME"], "ASK_LOCAL_NAME"],
|
|
23
|
+
[app_cfg["name"], config_source],
|
|
24
|
+
[Inference.infer(dir), :infer]
|
|
25
|
+
)
|
|
26
|
+
app, app_source = app_source == :infer ? app : [app, app_source]
|
|
27
|
+
|
|
28
|
+
service, service_source = first_present(
|
|
29
|
+
[service, "flag"],
|
|
30
|
+
[ENV["ASK_LOCAL_SERVICE"], "ASK_LOCAL_SERVICE"],
|
|
31
|
+
[app_cfg["service"], config_source]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
variant_value, variant_source = if !variant.nil? || ENV["ASK_LOCAL_VARIANT"] || app_cfg["variant"]
|
|
35
|
+
first_present(
|
|
36
|
+
[variant, "flag"],
|
|
37
|
+
[ENV["ASK_LOCAL_VARIANT"], "ASK_LOCAL_VARIANT"],
|
|
38
|
+
[app_cfg["variant"], config_source]
|
|
39
|
+
)
|
|
40
|
+
else
|
|
41
|
+
Variant.resolve(dir, use_branch: use_branch) || [nil, nil]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
tld_list = parse_tlds(tlds) || parse_tlds(ENV["ASK_LOCAL_TLD"]) ||
|
|
45
|
+
Array(app_cfg["tlds"]) || [Hostname::DEFAULT_TLD]
|
|
46
|
+
tld_list = [Hostname::DEFAULT_TLD] if tld_list.empty?
|
|
47
|
+
tld_list.each do |tld|
|
|
48
|
+
raise ConfigError, "Invalid TLD #{tld.inspect}" unless Sanitize.valid_tld?(tld.downcase)
|
|
49
|
+
end
|
|
50
|
+
tld_list = tld_list.map(&:downcase).uniq
|
|
51
|
+
|
|
52
|
+
Result.new(
|
|
53
|
+
app: Sanitize.hostname_label(app),
|
|
54
|
+
service: service && Sanitize.hostname_label(service),
|
|
55
|
+
variant: variant_value && Sanitize.hostname_label(variant_value),
|
|
56
|
+
tlds: tld_list,
|
|
57
|
+
sources: { app: app_source, service: service_source, variant: variant_source }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hostnames(result)
|
|
62
|
+
Hostname.build(app: result.app, service: result.service,
|
|
63
|
+
variant: result.variant, tlds: result.tlds)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Walk up for the nearest ask-local.json; a root with an "apps" map
|
|
67
|
+
# covers subdirectories (monorepo). A nearer config without a match
|
|
68
|
+
# does not block a farther one with an apps entry.
|
|
69
|
+
def find_config(dir)
|
|
70
|
+
current = File.expand_path(dir)
|
|
71
|
+
fallback = nil
|
|
72
|
+
loop do
|
|
73
|
+
begin
|
|
74
|
+
loaded = Config.load(current)
|
|
75
|
+
if loaded
|
|
76
|
+
if loaded.data["apps"].is_a?(Hash)
|
|
77
|
+
return [loaded, current]
|
|
78
|
+
else
|
|
79
|
+
fallback ||= [loaded, current]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
rescue ConfigError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
parent = File.dirname(current)
|
|
86
|
+
break if parent == current
|
|
87
|
+
|
|
88
|
+
current = parent
|
|
89
|
+
end
|
|
90
|
+
fallback || [nil, nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def relative_label(config_dir, dir)
|
|
94
|
+
return "." if File.expand_path(config_dir) == File.expand_path(dir)
|
|
95
|
+
|
|
96
|
+
require "pathname"
|
|
97
|
+
Pathname.new(File.expand_path(dir))
|
|
98
|
+
.relative_path_from(Pathname.new(File.expand_path(config_dir))).to_s
|
|
99
|
+
rescue ArgumentError
|
|
100
|
+
"."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# NOTE: no `private` keyword here — it would cancel
|
|
104
|
+
# `module_function` mode (see Variant for details).
|
|
105
|
+
def first_present(*pairs)
|
|
106
|
+
pairs.each do |value, source|
|
|
107
|
+
if source == :infer
|
|
108
|
+
inferred, from = value
|
|
109
|
+
return [inferred, from] unless inferred.nil? || inferred.to_s.empty?
|
|
110
|
+
elsif !value.nil? && !value.to_s.strip.empty?
|
|
111
|
+
return [value.to_s.strip, source]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
[nil, nil]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_tlds(value)
|
|
118
|
+
return nil if value.nil?
|
|
119
|
+
return value.map(&:to_s) if value.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
parts = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
122
|
+
parts.empty? ? nil : parts
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Local
|
|
8
|
+
# Persistent hostname -> backend mapping in ~/.ask-local/routes.json.
|
|
9
|
+
#
|
|
10
|
+
# Entries: {hostname, target, kind, pid} where target is a unix socket
|
|
11
|
+
# path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), and pid 0
|
|
12
|
+
# marks a static alias. Guarded by flock; stale PIDs pruned on read.
|
|
13
|
+
# Modeled on portless RouteStore and puma-dev's AppPool registry.
|
|
14
|
+
class RouteStore
|
|
15
|
+
FILE_MODE = 0o644
|
|
16
|
+
DIR_MODE = 0o755
|
|
17
|
+
|
|
18
|
+
attr_reader :dir, :routes_path, :pid_path, :port_path
|
|
19
|
+
|
|
20
|
+
def initialize(dir, on_warning: nil)
|
|
21
|
+
@dir = dir
|
|
22
|
+
@routes_path = File.join(dir, "routes.json")
|
|
23
|
+
@pid_path = File.join(dir, "proxy.pid")
|
|
24
|
+
@port_path = File.join(dir, "proxy.port")
|
|
25
|
+
@on_warning = on_warning
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_dir
|
|
29
|
+
FileUtils.mkdir_p(dir, mode: DIR_MODE)
|
|
30
|
+
File.chmod(DIR_MODE, dir)
|
|
31
|
+
Ownership.fix(dir)
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def with_lock
|
|
37
|
+
ensure_dir
|
|
38
|
+
File.open("#{routes_path}.lock", File::CREAT | File::RDWR, FILE_MODE) do |f|
|
|
39
|
+
f.flock(File::LOCK_EX)
|
|
40
|
+
yield
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def load_routes(prune: false)
|
|
45
|
+
return [] unless File.file?(routes_path)
|
|
46
|
+
|
|
47
|
+
parsed = JSON.parse(File.read(routes_path))
|
|
48
|
+
unless parsed.is_a?(Array)
|
|
49
|
+
@on_warning&.call("Corrupted routes file (expected array): #{routes_path}")
|
|
50
|
+
return []
|
|
51
|
+
end
|
|
52
|
+
routes = parsed.select { |r| valid_route?(r) }
|
|
53
|
+
alive = routes.select { |r| r["pid"] == 0 || alive?(r["pid"]) }
|
|
54
|
+
save_routes(alive) if prune && alive.length != routes.length
|
|
55
|
+
alive
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
@on_warning&.call("Corrupted routes file (invalid JSON): #{routes_path}")
|
|
58
|
+
[]
|
|
59
|
+
rescue SystemCallError
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load_routes_raw
|
|
64
|
+
return [] unless File.file?(routes_path)
|
|
65
|
+
|
|
66
|
+
parsed = JSON.parse(File.read(routes_path))
|
|
67
|
+
parsed.is_a?(Array) ? parsed.select { |r| valid_route?(r) } : []
|
|
68
|
+
rescue JSON::ParserError, SystemCallError
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns killed pid when force takes over a live route.
|
|
73
|
+
# `spec` marks daemon-supervised managed apps ({dir: ...}); the
|
|
74
|
+
# proxy supervisor may idle-kill and boot-on-request those backends.
|
|
75
|
+
def add_route(hostname, target, pid, kind:, force: false, spec: nil)
|
|
76
|
+
killed = nil
|
|
77
|
+
with_lock do
|
|
78
|
+
routes = load_routes(prune: true)
|
|
79
|
+
existing = routes.find { |r| r["hostname"] == hostname }
|
|
80
|
+
if existing && existing["pid"] != pid && alive?(existing["pid"])
|
|
81
|
+
raise RouteConflictError.new(hostname, existing["pid"]) unless force
|
|
82
|
+
|
|
83
|
+
begin
|
|
84
|
+
Process.kill("TERM", existing["pid"])
|
|
85
|
+
killed = existing["pid"]
|
|
86
|
+
rescue SystemCallError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
routes.reject! { |r| r["hostname"] == hostname }
|
|
91
|
+
entry = { "hostname" => hostname, "target" => target, "kind" => kind, "pid" => pid }
|
|
92
|
+
entry["spec"] = spec if spec
|
|
93
|
+
routes << entry
|
|
94
|
+
save_routes(routes)
|
|
95
|
+
end
|
|
96
|
+
killed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def remove_route(hostname, owner_pid: nil)
|
|
100
|
+
with_lock do
|
|
101
|
+
routes = load_routes(prune: true)
|
|
102
|
+
routes.reject! do |r|
|
|
103
|
+
r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
|
|
104
|
+
end
|
|
105
|
+
save_routes(routes)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def prune_stale
|
|
110
|
+
stale = []
|
|
111
|
+
with_lock do
|
|
112
|
+
all = load_routes_raw
|
|
113
|
+
alive, dead = all.partition { |r| r["pid"] == 0 || alive?(r["pid"]) }
|
|
114
|
+
stale = dead
|
|
115
|
+
save_routes(alive) unless dead.empty?
|
|
116
|
+
end
|
|
117
|
+
stale
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def find(hostname)
|
|
121
|
+
load_routes.find { |r| r["hostname"] == hostname }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def valid_route?(value)
|
|
127
|
+
value.is_a?(Hash) &&
|
|
128
|
+
value["hostname"].is_a?(String) &&
|
|
129
|
+
value["target"].is_a?(String) &&
|
|
130
|
+
value["pid"].is_a?(Integer) &&
|
|
131
|
+
(value["spec"].nil? || (value["spec"].is_a?(Hash) && value["spec"]["dir"].is_a?(String)))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def save_routes(routes)
|
|
135
|
+
File.write(routes_path, JSON.pretty_generate(routes))
|
|
136
|
+
File.chmod(FILE_MODE, routes_path)
|
|
137
|
+
Ownership.fix(routes_path)
|
|
138
|
+
rescue SystemCallError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def alive?(pid)
|
|
143
|
+
Process.kill(0, pid)
|
|
144
|
+
true
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module Local
|
|
9
|
+
# Boots and supervises one app backend.
|
|
10
|
+
#
|
|
11
|
+
# Managed Ruby apps (Rails/Rack with puma available): puma bound to a
|
|
12
|
+
# unix socket — zero TCP ports, readiness by dialing the socket
|
|
13
|
+
# (puma-dev model). Without puma, managed degrades to rackup on TCP
|
|
14
|
+
# (run-mode shape). Run mode (everything else): subprocess with
|
|
15
|
+
# injected PORT/ASK_LOCAL_URL, readiness by TCP connect.
|
|
16
|
+
class Runner
|
|
17
|
+
SOCKET_DIR = File.join("tmp", "sockets")
|
|
18
|
+
SOCKET_NAME = "ask-local.sock"
|
|
19
|
+
|
|
20
|
+
App = Struct.new(:name, :hostname, :url, :pid, :target, :kind,
|
|
21
|
+
:command, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(store:, on_log: nil)
|
|
24
|
+
@store = store
|
|
25
|
+
@on_log = on_log || ->(msg) { puts msg }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Boot a managed Rack app. Socket when puma exists, else TCP rackup.
|
|
29
|
+
def boot_managed(name:, hostname:, url:, dir:, force: false)
|
|
30
|
+
if puma_available?(dir)
|
|
31
|
+
boot_socket(name: name, hostname: hostname, url: url, dir: dir, force: force)
|
|
32
|
+
else
|
|
33
|
+
boot_tcp_fallback(name: name, hostname: hostname, url: url, dir: dir, force: force)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Boot a backend for an existing route (daemon supervisor). Does not
|
|
38
|
+
# re-register the route — target path is deterministic
|
|
39
|
+
# (dir/tmp/sockets/ask-local.sock) and the route entry stays.
|
|
40
|
+
def boot_supervised(name:, hostname:, url:, dir:)
|
|
41
|
+
socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(socket_path))
|
|
43
|
+
FileUtils.rm_f(socket_path)
|
|
44
|
+
pid = spawn_socket(dir, socket_path, name, url)
|
|
45
|
+
unless wait_for_socket(socket_path, timeout: 60)
|
|
46
|
+
stop_pid(pid)
|
|
47
|
+
raise Error, "App '#{name}' did not boot within 60s. " \
|
|
48
|
+
"Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
|
|
49
|
+
end
|
|
50
|
+
write_backend_pid(hostname, pid)
|
|
51
|
+
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
52
|
+
target: socket_path, kind: "socket", command: nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def boot_socket(name:, hostname:, url:, dir:, force: false)
|
|
56
|
+
socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(socket_path))
|
|
58
|
+
FileUtils.rm_f(socket_path)
|
|
59
|
+
pid = spawn_socket(dir, socket_path, name, url)
|
|
60
|
+
|
|
61
|
+
unless wait_for_socket(socket_path, timeout: 60)
|
|
62
|
+
stop_pid(pid)
|
|
63
|
+
raise Error, "App '#{name}' did not boot within 60s. " \
|
|
64
|
+
"Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@store.add_route(hostname, socket_path, Process.pid, kind: "socket",
|
|
68
|
+
force: force, spec: { "dir" => dir })
|
|
69
|
+
write_backend_pid(hostname, pid)
|
|
70
|
+
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
71
|
+
target: socket_path, kind: "socket", command: socket_command(dir, socket_path))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def boot_tcp_fallback(name:, hostname:, url:, dir:, force: false)
|
|
75
|
+
port = Ports.find_free
|
|
76
|
+
env = child_env(dir, url: url, port: port)
|
|
77
|
+
config_ru = File.join(dir, "config.ru")
|
|
78
|
+
cmd = ["rackup", "-o", "127.0.0.1", "-p", port.to_s, config_ru]
|
|
79
|
+
pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
|
|
80
|
+
Process.detach(pid)
|
|
81
|
+
unless wait_for_tcp(port, timeout: 60)
|
|
82
|
+
stop_pid(pid)
|
|
83
|
+
raise Error, "App '#{name}' did not boot within 60s. " \
|
|
84
|
+
"Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
|
|
85
|
+
end
|
|
86
|
+
target = "127.0.0.1:#{port}"
|
|
87
|
+
@store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
|
|
88
|
+
write_backend_pid(hostname, pid)
|
|
89
|
+
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
90
|
+
target: target, kind: "tcp", command: cmd)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Run an arbitrary command with PORT + ASK_LOCAL_URL. Returns App.
|
|
94
|
+
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false)
|
|
95
|
+
port ||= Ports.find_free
|
|
96
|
+
env = child_env(dir, url: url, port: port)
|
|
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:)
|
|
107
|
+
env = { "ASK_LOCAL_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
|
+
env
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def puma_available?(dir)
|
|
116
|
+
with_clean_env { bundle_puma?(dir) || system_puma? }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Spawned backends must not inherit our own bundle: under
|
|
120
|
+
# `bundle exec` rubygems restricts executables to the Gemfile,
|
|
121
|
+
# hiding system puma/rackup from the child. Strip Bundler env so
|
|
122
|
+
# the app boots with its own gems.
|
|
123
|
+
def with_clean_env(&block)
|
|
124
|
+
if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
|
|
125
|
+
Bundler.with_unbundled_env(&block)
|
|
126
|
+
else
|
|
127
|
+
saved = {}
|
|
128
|
+
%w[BUNDLE_GEMFILE RUBYOPT RUBYLIB GEM_HOME GEM_PATH].each do |k|
|
|
129
|
+
saved[k] = ENV.delete(k)
|
|
130
|
+
end
|
|
131
|
+
begin
|
|
132
|
+
yield
|
|
133
|
+
ensure
|
|
134
|
+
saved.each { |k, v| ENV[k] = v unless v.nil? }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def socket_command(dir, socket_path)
|
|
142
|
+
socket = "unix://#{socket_path}"
|
|
143
|
+
config_ru = File.join(dir, "config.ru")
|
|
144
|
+
# Puma 8 takes config.ru positionally (no --rackup flag).
|
|
145
|
+
if bundle_puma?(dir)
|
|
146
|
+
["bundle", "exec", "puma", "-b", socket, config_ru]
|
|
147
|
+
else
|
|
148
|
+
["puma", "-b", socket, config_ru]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Shared spawn for CLI boots and daemon supervision.
|
|
153
|
+
def spawn_socket(dir, socket_path, name, url)
|
|
154
|
+
env = child_env(dir, url: url, port: nil)
|
|
155
|
+
cmd = socket_command(dir, socket_path)
|
|
156
|
+
pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
|
|
157
|
+
Process.detach(pid)
|
|
158
|
+
pid
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def bundle_puma?(dir)
|
|
162
|
+
gemfile = File.join(dir, "Gemfile")
|
|
163
|
+
return false unless File.file?(gemfile)
|
|
164
|
+
|
|
165
|
+
out, status = Open3.capture2("bundle", "exec", "puma", "-V",
|
|
166
|
+
chdir: dir, err: File::NULL)
|
|
167
|
+
status.success? && !out.empty?
|
|
168
|
+
rescue SystemCallError
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def system_puma?
|
|
173
|
+
_out, status = Open3.capture2("puma", "-V", err: File::NULL)
|
|
174
|
+
status.success?
|
|
175
|
+
rescue SystemCallError
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def wait_for_socket(path, timeout:)
|
|
180
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
181
|
+
until File.socket?(path)
|
|
182
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
183
|
+
|
|
184
|
+
sleep 0.25
|
|
185
|
+
end
|
|
186
|
+
begin
|
|
187
|
+
UNIXSocket.new(path).close
|
|
188
|
+
true
|
|
189
|
+
rescue SystemCallError
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def wait_for_tcp(port, timeout:)
|
|
195
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
196
|
+
loop do
|
|
197
|
+
begin
|
|
198
|
+
TCPSocket.new("127.0.0.1", port).close
|
|
199
|
+
return true
|
|
200
|
+
rescue SystemCallError
|
|
201
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
202
|
+
|
|
203
|
+
sleep 0.25
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def log_path(dir, name)
|
|
209
|
+
path = File.expand_path(File.join(dir, "log", "ask-local-#{name}.log"))
|
|
210
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
211
|
+
Log.rotate(path)
|
|
212
|
+
path
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Sidecar so `ask-local stop` can find the backend after the CLI
|
|
216
|
+
# that booted it has exited (the route owner pid is the CLI, not
|
|
217
|
+
# the backend). Removed alongside the route on clean stop.
|
|
218
|
+
def write_backend_pid(hostname, pid)
|
|
219
|
+
@store.ensure_dir
|
|
220
|
+
File.write(File.join(@store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
|
|
221
|
+
rescue SystemCallError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def log_tail(path, lines: 10)
|
|
226
|
+
return "(no log file)" unless File.file?(path)
|
|
227
|
+
|
|
228
|
+
File.readlines(path).last(lines).join
|
|
229
|
+
rescue SystemCallError
|
|
230
|
+
"(unreadable log)"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def stop_pid(pid)
|
|
234
|
+
Process.kill("TERM", pid)
|
|
235
|
+
rescue SystemCallError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# DNS label sanitization (RFC 1035): lowercase, hyphen-separated,
|
|
8
|
+
# max 63 chars with hash suffix on truncation.
|
|
9
|
+
#
|
|
10
|
+
# Borrowed semantics from portless sanitizeForHostname/truncateLabel,
|
|
11
|
+
# reimplemented in idiomatic Ruby.
|
|
12
|
+
module Sanitize
|
|
13
|
+
MAX_DNS_LABEL_LENGTH = 63
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def truncate_label(label)
|
|
18
|
+
return label if label.length <= MAX_DNS_LABEL_LENGTH
|
|
19
|
+
|
|
20
|
+
hash = Digest::SHA256.hexdigest(label)[0, 6]
|
|
21
|
+
prefix = label[0, MAX_DNS_LABEL_LENGTH - 7].gsub(/-+\z/, "")
|
|
22
|
+
"#{prefix}-#{hash}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def hostname_label(name)
|
|
26
|
+
sanitized = name.to_s.downcase
|
|
27
|
+
.gsub(/[^a-z0-9-]/, "-")
|
|
28
|
+
.gsub(/-{2,}/, "-")
|
|
29
|
+
.gsub(/\A-+|-+\z/, "")
|
|
30
|
+
truncate_label(sanitized)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid_tld?(tld)
|
|
34
|
+
return false if tld.nil? || tld.empty? || tld.length > 253
|
|
35
|
+
|
|
36
|
+
tld.split(".").all? do |label|
|
|
37
|
+
!label.empty? && label.length <= 63 &&
|
|
38
|
+
label.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|