ask-local 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +73 -32
- data/README.md +58 -9
- data/bin/askl +6 -0
- data/lib/ask/local/cli/boot.rb +150 -159
- data/lib/ask/local/cli/routes.rb +17 -13
- data/lib/ask/local/cli/system.rb +272 -2
- data/lib/ask/local/cli.rb +7 -3
- data/lib/ask/local/config.rb +342 -59
- data/lib/ask/local/procfile.rb +139 -0
- data/lib/ask/local/proxy_control.rb +38 -13
- data/lib/ask/local/resolver.rb +62 -94
- data/lib/ask/local/runner.rb +8 -3
- data/lib/ask/local/version.rb +1 -1
- data/lib/ask/skills/ask-local/SKILL.md +57 -33
- data/lib/ask-local.rb +1 -0
- metadata +4 -1
data/lib/ask/local/config.rb
CHANGED
|
@@ -1,60 +1,174 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "erb"
|
|
4
|
+
require "yaml"
|
|
4
5
|
|
|
5
6
|
module Ask
|
|
6
7
|
module Local
|
|
7
|
-
#
|
|
8
|
+
# Mandatory config/local.yml — the ONLY source of truth for this app.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# Replaces the old optional JSON / inference / Procfile path with a
|
|
11
|
+
# single file. Kamal patterns borrowed: YAML rendered through ERB,
|
|
12
|
+
# validated against an example schema with context-pathed errors,
|
|
13
|
+
# x- extensions ignored, deep overlay for variants (Kamal
|
|
14
|
+
# destinations), env clear/secret split reading config/local.secrets.
|
|
12
15
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
16
|
+
# Shape:
|
|
17
|
+
#
|
|
18
|
+
# service: myrr-chat
|
|
19
|
+
#
|
|
20
|
+
# proxy:
|
|
21
|
+
# tld: localhost
|
|
22
|
+
# # host: myrr-chat.local.example.com
|
|
23
|
+
#
|
|
24
|
+
# processes:
|
|
25
|
+
# web:
|
|
26
|
+
# cmd: bin/rails server -p $PORT
|
|
27
|
+
# proxy: true
|
|
28
|
+
# healthcheck: { path: /up, timeout: 30 }
|
|
29
|
+
# worker:
|
|
30
|
+
# cmd: bin/jobs
|
|
31
|
+
# proxy: false
|
|
32
|
+
#
|
|
33
|
+
# env:
|
|
34
|
+
# clear:
|
|
35
|
+
# RAILS_ENV: development
|
|
36
|
+
# secret:
|
|
37
|
+
# - RAILS_MASTER_KEY
|
|
38
|
+
#
|
|
39
|
+
# Variant overlays: config/local.<variant>.yml deep-merged on top
|
|
40
|
+
# (like Kamal's deploy.<destination>.yml). The `variant:` key in the
|
|
41
|
+
# base file is not used — variants are files.
|
|
15
42
|
class Config
|
|
16
|
-
FILENAME = "
|
|
17
|
-
|
|
18
|
-
|
|
43
|
+
FILENAME = "local.yml"
|
|
44
|
+
RELATIVE_DIR = "config"
|
|
45
|
+
RELATIVE_PATH = File.join(RELATIVE_DIR, FILENAME)
|
|
46
|
+
SECRETS_PATH = File.join(RELATIVE_DIR, "local.secrets")
|
|
47
|
+
|
|
48
|
+
# Example schema: shapes the validator (types, required keys, array
|
|
49
|
+
# element types). Unknown keys raise with the context path; keys
|
|
50
|
+
# starting with "x-" are extensions and ignored (Kamal convention).
|
|
51
|
+
EXAMPLE = {
|
|
52
|
+
"service" => "myapp",
|
|
53
|
+
"proxy" => {
|
|
54
|
+
"tld" => "localhost",
|
|
55
|
+
"host" => "myapp.local.example.com"
|
|
56
|
+
},
|
|
57
|
+
"processes" => {
|
|
58
|
+
"web" => {
|
|
59
|
+
"cmd" => "bin/rails server -p $PORT",
|
|
60
|
+
"proxy" => true,
|
|
61
|
+
"healthcheck" => { "path" => "/up", "timeout" => 30 }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"env" => {
|
|
65
|
+
"clear" => { "RAILS_ENV" => "development" },
|
|
66
|
+
"secret" => ["RAILS_MASTER_KEY"]
|
|
67
|
+
}
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
REQUIRED_TOP = %w[service].freeze
|
|
71
|
+
|
|
72
|
+
attr_reader :data, :dir, :path
|
|
73
|
+
|
|
74
|
+
# Load config/local.yml for the app rooted at dir (walks up for the
|
|
75
|
+
# nearest config/local.yml, so running from a subdirectory works).
|
|
76
|
+
# Raises ConfigError when missing (mandatory) or invalid. The variant
|
|
77
|
+
# overlay (config/local.<variant>.yml) is deep-merged on top when
|
|
78
|
+
# ASK_LOCAL_VARIANT or the explicit variant arg is set.
|
|
79
|
+
def self.load(dir = Dir.pwd, variant: nil, overlay: nil)
|
|
80
|
+
variant ||= ENV["ASK_LOCAL_VARIANT"]
|
|
81
|
+
overlay ||= ENV["ASK_LOCAL_OVERLAY"]
|
|
82
|
+
root, config_path = find_root(dir)
|
|
83
|
+
unless root
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
data = load_yaml(config_path)
|
|
88
|
+
if variant && !variant.to_s.strip.empty?
|
|
89
|
+
overlay_path = File.join(root, RELATIVE_DIR, "local.#{variant.strip}.yml")
|
|
90
|
+
if File.file?(overlay_path)
|
|
91
|
+
overlay_data = load_yaml(overlay_path)
|
|
92
|
+
data = deep_merge(data, overlay_data)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
if overlay && File.file?(overlay)
|
|
96
|
+
overlay_data = load_yaml(overlay)
|
|
97
|
+
data = deep_merge(data, overlay_data)
|
|
98
|
+
end
|
|
19
99
|
|
|
20
|
-
|
|
100
|
+
new(data, root, config_path)
|
|
101
|
+
end
|
|
21
102
|
|
|
22
|
-
def self.
|
|
23
|
-
|
|
24
|
-
|
|
103
|
+
def self.load_yaml(path)
|
|
104
|
+
template = File.read(path)
|
|
105
|
+
rendered = ERB.new(template, trim_mode: "-").result
|
|
106
|
+
return {} if rendered.strip.empty?
|
|
25
107
|
|
|
26
|
-
parsed =
|
|
27
|
-
raise ConfigError, "#{path} must be a
|
|
108
|
+
parsed = YAML.safe_load(rendered, aliases: true)
|
|
109
|
+
raise ConfigError, "#{path} must be a YAML mapping" unless parsed.is_a?(Hash)
|
|
28
110
|
|
|
29
|
-
|
|
30
|
-
rescue
|
|
31
|
-
raise ConfigError, "Invalid
|
|
111
|
+
parsed
|
|
112
|
+
rescue Psych::SyntaxError => e
|
|
113
|
+
raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
|
|
32
114
|
end
|
|
33
115
|
|
|
34
|
-
def
|
|
116
|
+
def self.find_root(dir)
|
|
117
|
+
current = File.expand_path(dir)
|
|
118
|
+
loop do
|
|
119
|
+
candidate = File.join(current, RELATIVE_PATH)
|
|
120
|
+
return [current, candidate] if File.file?(candidate)
|
|
121
|
+
|
|
122
|
+
parent = File.dirname(current)
|
|
123
|
+
break if parent == current
|
|
124
|
+
|
|
125
|
+
current = parent
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.missing_message(dir)
|
|
131
|
+
"No #{RELATIVE_PATH} found from #{dir}. Run `ask-local init`."
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.deep_merge(base, overlay)
|
|
135
|
+
base.merge(overlay) do |_, base_val, overlay_val|
|
|
136
|
+
if base_val.is_a?(Hash) && overlay_val.is_a?(Hash)
|
|
137
|
+
deep_merge(base_val, overlay_val)
|
|
138
|
+
else
|
|
139
|
+
overlay_val
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def initialize(data, dir, path)
|
|
35
145
|
@data = data
|
|
36
146
|
@dir = dir
|
|
37
147
|
@path = path
|
|
38
148
|
validate!
|
|
39
149
|
end
|
|
40
150
|
|
|
41
|
-
def
|
|
42
|
-
|
|
151
|
+
def service
|
|
152
|
+
data["service"].to_s
|
|
153
|
+
end
|
|
43
154
|
|
|
44
|
-
|
|
45
|
-
|
|
155
|
+
def proxy_config
|
|
156
|
+
data["proxy"] || {}
|
|
157
|
+
end
|
|
46
158
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return hit.select { |k, _| APP_KEYS.include?(k) } if hit.is_a?(Hash)
|
|
159
|
+
def processes
|
|
160
|
+
data["processes"] || {}
|
|
161
|
+
end
|
|
51
162
|
|
|
52
|
-
|
|
53
|
-
|
|
163
|
+
def env_config
|
|
164
|
+
data["env"] || {}
|
|
165
|
+
end
|
|
54
166
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
167
|
+
# Secrets read from config/local.secrets (dotenv), gitignored.
|
|
168
|
+
# Only needed when env.secret lists keys; missing file is not an
|
|
169
|
+
# error until a listed secret is referenced.
|
|
170
|
+
def secrets
|
|
171
|
+
@secrets ||= load_secrets
|
|
58
172
|
end
|
|
59
173
|
|
|
60
174
|
def [](key)
|
|
@@ -63,49 +177,218 @@ module Ask
|
|
|
63
177
|
|
|
64
178
|
private
|
|
65
179
|
|
|
66
|
-
def
|
|
67
|
-
|
|
180
|
+
def load_secrets
|
|
181
|
+
secrets_file = File.join(dir, SECRETS_PATH)
|
|
182
|
+
return {} unless File.file?(secrets_file)
|
|
183
|
+
|
|
184
|
+
parse_dotenv(File.read(secrets_file))
|
|
185
|
+
rescue SystemCallError
|
|
186
|
+
{}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_dotenv(content)
|
|
190
|
+
result = {}
|
|
191
|
+
content.each_line do |line|
|
|
192
|
+
line = line.strip
|
|
193
|
+
next if line.empty? || line.start_with?("#")
|
|
194
|
+
|
|
195
|
+
if line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/)
|
|
196
|
+
result[Regexp.last_match(1)] = Regexp.last_match(2).strip.gsub(/\A["']|["']\z/, "")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
result
|
|
68
200
|
end
|
|
69
201
|
|
|
70
|
-
def
|
|
202
|
+
def app_config(package_dir = dir)
|
|
203
|
+
# Monorepo: walk up looking for config/local.yml with apps map.
|
|
204
|
+
# Falls back to top-level fields for non-monorepo usage.
|
|
205
|
+
return data unless data["apps"].is_a?(Hash)
|
|
71
206
|
require "pathname"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
207
|
+
rel = begin
|
|
208
|
+
Pathname.new(File.expand_path(package_dir))
|
|
209
|
+
.relative_path_from(Pathname.new(File.expand_path(dir))).to_s
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
return data unless rel
|
|
214
|
+
candidate = rel
|
|
215
|
+
loop do
|
|
216
|
+
entry = data["apps"][candidate]
|
|
217
|
+
return entry if entry.is_a?(Hash)
|
|
218
|
+
parent = File.dirname(candidate)
|
|
219
|
+
break if parent == "." || parent == candidate
|
|
220
|
+
candidate = parent
|
|
221
|
+
end
|
|
222
|
+
data
|
|
76
223
|
end
|
|
77
224
|
|
|
78
225
|
def validate!
|
|
79
|
-
|
|
80
|
-
|
|
226
|
+
# Unknown top-level keys (outside example + x- extensions).
|
|
227
|
+
unknown = data.keys.map(&:to_s) - EXAMPLE.keys.map(&:to_s)
|
|
228
|
+
unknown.reject! { |k| k.start_with?("x-") }
|
|
229
|
+
unless unknown.empty?
|
|
230
|
+
raise ConfigError, "Unknown key(s) #{unknown.map(&:inspect).join(", ")} in #{@path}"
|
|
231
|
+
end
|
|
232
|
+
REQUIRED_TOP.each do |key|
|
|
233
|
+
raise ConfigError, %("#{key}" is required in #{@path}) if data[key].nil? || data[key].to_s.strip.empty?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
Validator.new(data, EXAMPLE, context: @path).validate!
|
|
237
|
+
|
|
238
|
+
validate_service(data["service"], @path)
|
|
239
|
+
if data["proxy"]
|
|
240
|
+
validate_proxy(data["proxy"], "#{@path} proxy")
|
|
241
|
+
end
|
|
242
|
+
if data["processes"]
|
|
243
|
+
validate_processes(data["processes"], "#{@path} processes")
|
|
244
|
+
end
|
|
245
|
+
if data["env"]
|
|
246
|
+
validate_env(data["env"], "#{@path} env")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_service(value, context)
|
|
251
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
252
|
+
raise ConfigError, "#{context}: service must be a non-empty string"
|
|
81
253
|
end
|
|
82
|
-
|
|
83
|
-
if data["apps"]
|
|
84
|
-
raise ConfigError, %("apps" in #{@path} must be an object) unless data["apps"].is_a?(Hash)
|
|
254
|
+
end
|
|
85
255
|
|
|
86
|
-
|
|
87
|
-
|
|
256
|
+
def validate_proxy(value, context)
|
|
257
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
258
|
+
|
|
259
|
+
if value["host"] && !value["host"].is_a?(String)
|
|
260
|
+
raise ConfigError, "#{context}: host must be a string"
|
|
261
|
+
end
|
|
262
|
+
if value["tld"] && !value["tld"].is_a?(String)
|
|
263
|
+
raise ConfigError, "#{context}: tld must be a string"
|
|
264
|
+
end
|
|
265
|
+
if value.key?("host") && value.key?("tld")
|
|
266
|
+
raise ConfigError, "#{context}: specify one of host or tld, not both"
|
|
267
|
+
end
|
|
268
|
+
if value["tld"] && !Sanitize.valid_tld?(value["tld"].downcase)
|
|
269
|
+
raise ConfigError, "#{context}: invalid tld #{value["tld"].inspect}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_processes(value, context)
|
|
274
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
275
|
+
raise ConfigError, "#{context} must list at least one process" if value.empty?
|
|
276
|
+
|
|
277
|
+
value.each do |name, entry|
|
|
278
|
+
raise ConfigError, %("#{context}/#{name}" must be a mapping) unless entry.is_a?(Hash)
|
|
279
|
+
|
|
280
|
+
if entry["cmd"].nil? || entry["cmd"].to_s.strip.empty?
|
|
281
|
+
raise ConfigError, %("#{context}/#{name}" requires a non-empty cmd)
|
|
282
|
+
end
|
|
283
|
+
if entry.key?("proxy") && ![true, false].include?(entry["proxy"])
|
|
284
|
+
raise ConfigError, %("#{context}/#{name} proxy must be a boolean")
|
|
285
|
+
end
|
|
286
|
+
if entry["healthcheck"]
|
|
287
|
+
hc = entry["healthcheck"]
|
|
288
|
+
raise ConfigError, %("#{context}/#{name} healthcheck must be a mapping) unless hc.is_a?(Hash)
|
|
88
289
|
|
|
89
|
-
|
|
290
|
+
if hc.key?("path") && !hc["path"].is_a?(String)
|
|
291
|
+
raise ConfigError, %("#{context}/#{name} healthcheck path must be a string)
|
|
292
|
+
end
|
|
293
|
+
if hc.key?("timeout") && !hc["timeout"].is_a?(Integer)
|
|
294
|
+
raise ConfigError, %("#{context}/#{name} healthcheck timeout must be an integer)
|
|
295
|
+
end
|
|
90
296
|
end
|
|
91
297
|
end
|
|
92
298
|
end
|
|
93
299
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
300
|
+
def validate_env(value, context)
|
|
301
|
+
raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
|
|
302
|
+
|
|
303
|
+
if value["clear"] && !value["clear"].is_a?(Hash)
|
|
304
|
+
raise ConfigError, "#{context} clear must be a mapping"
|
|
305
|
+
end
|
|
306
|
+
if value["secret"] && !value["secret"].is_a?(Array)
|
|
307
|
+
raise ConfigError, "#{context} secret must be an array of strings"
|
|
97
308
|
end
|
|
98
|
-
if
|
|
99
|
-
raise ConfigError,
|
|
309
|
+
if value["secret"] && !value["secret"].all? { |k| k.is_a?(String) }
|
|
310
|
+
raise ConfigError, "#{context} secret keys must be strings"
|
|
100
311
|
end
|
|
101
|
-
|
|
102
|
-
next unless fields.key?(key)
|
|
103
|
-
next if fields[key].is_a?(String) && !fields[key].strip.empty?
|
|
312
|
+
end
|
|
104
313
|
|
|
105
|
-
|
|
314
|
+
# Generic schema validator with context-pathed errors (Kamal
|
|
315
|
+
# Validator pattern): walks the example shape, type-checks each
|
|
316
|
+
# present key, and reports the path where the mismatch was found.
|
|
317
|
+
class Validator
|
|
318
|
+
def initialize(config, example, context:)
|
|
319
|
+
@config = config
|
|
320
|
+
@example = example
|
|
321
|
+
@context = context
|
|
322
|
+
@stack = []
|
|
106
323
|
end
|
|
107
|
-
|
|
108
|
-
|
|
324
|
+
|
|
325
|
+
def validate!
|
|
326
|
+
validate_against_example!(@config, @example)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def validate_against_example!(config, example)
|
|
332
|
+
return unless example.is_a?(Hash) && config.is_a?(Hash)
|
|
333
|
+
|
|
334
|
+
# Only validate keys the config actually has; absent example
|
|
335
|
+
# keys are optional (Kamal ignores missing optional keys).
|
|
336
|
+
config.each do |key, value|
|
|
337
|
+
next if key.to_s.start_with?("x-")
|
|
338
|
+
|
|
339
|
+
with_context(key) do
|
|
340
|
+
example_value = example[key] || example[key.to_s]
|
|
341
|
+
next if example_value.nil? && !example.key?(key.to_s) && !example.key?(key)
|
|
342
|
+
|
|
343
|
+
validate_value!(value, example_value)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_value!(value, example_value)
|
|
349
|
+
return if example_value == "..."
|
|
350
|
+
|
|
351
|
+
if example_value.is_a?(Hash) && value.is_a?(Hash)
|
|
352
|
+
validate_against_example!(value, example_value)
|
|
353
|
+
elsif example_value.is_a?(Array) && value.is_a?(Array)
|
|
354
|
+
validate_array_of!(value, example_value.first.class) unless example_value.empty?
|
|
355
|
+
elsif !example_value.nil?
|
|
356
|
+
expected = type_description(example_value.class)
|
|
357
|
+
unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
|
|
358
|
+
raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def validate_array_of!(array, type)
|
|
364
|
+
array.each_with_index do |value, index|
|
|
365
|
+
with_context(index) do
|
|
366
|
+
unless value.is_a?(type)
|
|
367
|
+
raise ConfigError, "#{current_context}: expected #{type.name.downcase}, got #{value.class.name.downcase}"
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def type_description(type)
|
|
374
|
+
if type == Integer || type == Array
|
|
375
|
+
"an #{type.name.downcase}"
|
|
376
|
+
elsif type == TrueClass || type == FalseClass
|
|
377
|
+
"a boolean"
|
|
378
|
+
else
|
|
379
|
+
"a #{type.name.downcase}"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def with_context(part)
|
|
384
|
+
@stack.push(part)
|
|
385
|
+
yield
|
|
386
|
+
ensure
|
|
387
|
+
@stack.pop
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def current_context
|
|
391
|
+
([@context] + @stack.map(&:to_s)).join("/")
|
|
109
392
|
end
|
|
110
393
|
end
|
|
111
394
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
# Procfile.dev multi-process support: parse every line, classify each
|
|
6
|
+
# process as HTTP (gets a .localhost URL) or background (supervised,
|
|
7
|
+
# no URL), and boot them all with one command.
|
|
8
|
+
#
|
|
9
|
+
# Classification is deliberately permissive (portless lesson: proxy by
|
|
10
|
+
# default): a process is background only when its NAME says so
|
|
11
|
+
# (worker/job/sidekiq/watch/tunnel/build/css) or an ask-local.json
|
|
12
|
+
# override says so. Everything else is HTTP. A misclassified worker
|
|
13
|
+
# harmlessly gets an unvisited route; a misclassified server with NO
|
|
14
|
+
# route is a broken dev day — so the bias is toward HTTP, and the boot
|
|
15
|
+
# banner always prints the classification so the fix is obvious.
|
|
16
|
+
#
|
|
17
|
+
# The Procfile stays canonical: Heroku, Docker, and plain
|
|
18
|
+
# `foreman start` keep working. ask-local.json only overrides
|
|
19
|
+
# classification, ports, and env — it never replaces the Procfile.
|
|
20
|
+
module Procfile
|
|
21
|
+
HTTP = :http
|
|
22
|
+
BACKGROUND = :background
|
|
23
|
+
|
|
24
|
+
# Name fragments that mark a background process. Matched against
|
|
25
|
+
# the process NAME (left of the colon), not the command.
|
|
26
|
+
BACKGROUND_HINTS = %w[
|
|
27
|
+
worker job sidekiq solid_queue mission_control
|
|
28
|
+
watch tailwind css esbuild vite assets
|
|
29
|
+
tunnel cloudflared ngrok expose
|
|
30
|
+
build compile
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
COMPOUND = /&&|\|\||[|;]/.freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Parsed line: {name, command, compound?}. Compound lines (shell
|
|
38
|
+
# operators) cannot be safely injected with PORT — refused loudly
|
|
39
|
+
# by the caller, never silently rewritten.
|
|
40
|
+
Line = Struct.new(:name, :command, :compound, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
def parse_file(path)
|
|
43
|
+
lines = File.readlines(path, chomp: true)
|
|
44
|
+
entries = []
|
|
45
|
+
lines.each do |line|
|
|
46
|
+
stripped = line.strip
|
|
47
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
48
|
+
next unless stripped.include?(":")
|
|
49
|
+
|
|
50
|
+
name, cmd = stripped.split(":", 2).map(&:strip)
|
|
51
|
+
next if name.nil? || name.empty? || cmd.nil? || cmd.empty?
|
|
52
|
+
|
|
53
|
+
entries << Line.new(name: name, command: cmd, compound: cmd.match?(COMPOUND))
|
|
54
|
+
end
|
|
55
|
+
entries
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def find_file(dir = Dir.pwd)
|
|
61
|
+
%w[Procfile.dev Procfile].each do |name|
|
|
62
|
+
path = File.join(dir, name)
|
|
63
|
+
return path if File.file?(path)
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Classify one process. Overrides win: {"processes": {"worker":
|
|
69
|
+
# {"type": "background"}}} in ask-local.json. Otherwise background
|
|
70
|
+
# on name hints, HTTP for everything else.
|
|
71
|
+
def classify(name, overrides: {})
|
|
72
|
+
override = overrides[name] || overrides[name.to_s]
|
|
73
|
+
if override.is_a?(Hash) && override["type"]
|
|
74
|
+
return override["type"].to_s == "background" ? BACKGROUND : HTTP
|
|
75
|
+
end
|
|
76
|
+
lowered = name.to_s.downcase
|
|
77
|
+
return BACKGROUND if BACKGROUND_HINTS.any? { |hint| lowered.include?(hint) }
|
|
78
|
+
|
|
79
|
+
HTTP
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Per-process overrides from ask-local.json "processes" map:
|
|
83
|
+
# {"web": {"type": "http", "port": 3000, "env": {...}}, ...}.
|
|
84
|
+
# Unknown keys warn; the Procfile stays the source of the command.
|
|
85
|
+
def load_overrides(dir = Dir.pwd)
|
|
86
|
+
config = Config.load(dir)
|
|
87
|
+
return {} unless config
|
|
88
|
+
|
|
89
|
+
procs = config.data["processes"]
|
|
90
|
+
return {} unless procs.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
procs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Split command string into argv for spawn (no shell). Returns nil
|
|
96
|
+
# for compound lines the caller must refuse.
|
|
97
|
+
def to_argv(command)
|
|
98
|
+
return nil if command.match?(COMPOUND)
|
|
99
|
+
|
|
100
|
+
split_command(command)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Minimal shell-word split (quotes + backslash escapes), matching
|
|
104
|
+
# Config.split_command semantics for Procfile lines.
|
|
105
|
+
def split_command(command)
|
|
106
|
+
args = []
|
|
107
|
+
current = +""
|
|
108
|
+
in_single = false
|
|
109
|
+
in_double = false
|
|
110
|
+
escaped = false
|
|
111
|
+
command.each_char do |ch|
|
|
112
|
+
if escaped
|
|
113
|
+
current << ch
|
|
114
|
+
escaped = false
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
if ch == "\\" && !in_single
|
|
118
|
+
escaped = true
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
if ch == "'" && !in_double
|
|
122
|
+
in_single = !in_single
|
|
123
|
+
elsif ch == '"' && !in_single
|
|
124
|
+
in_double = !in_double
|
|
125
|
+
elsif ch.match?(/\s/) && !in_single && !in_double
|
|
126
|
+
unless current.empty?
|
|
127
|
+
args << current
|
|
128
|
+
current = +""
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
current << ch
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
args << current unless current.empty?
|
|
135
|
+
args
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -64,30 +64,51 @@ module Ask
|
|
|
64
64
|
# where only one family answers the check must still succeed.
|
|
65
65
|
# An explicit regression test pins this (health_test pinning
|
|
66
66
|
# ensure_proxy's "is that ours" logic against future proxy changes).
|
|
67
|
+
#
|
|
68
|
+
# Probe order matters: plain HTTP first (our proxy byte-peeks and
|
|
69
|
+
# answers plain HTTP even on the TLS port), TLS second. A TLS-first
|
|
70
|
+
# handshake against a foreign plain-HTTP server blocks in connect
|
|
71
|
+
# waiting for a ServerHello that never comes — and connect used to
|
|
72
|
+
# sit outside the timeout, hanging ensure_proxy for over a minute.
|
|
73
|
+
#
|
|
74
|
+
# Speed: if the plain probe gets ANY HTTP response without our
|
|
75
|
+
# header, the server is definitively foreign — no TLS retry. The
|
|
76
|
+
# slow TLS retry only happens when plain yielded zero bytes
|
|
77
|
+
# (connection error, EOF, or timeout against a silent server).
|
|
67
78
|
def ours?(port, tls:)
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
["127.0.0.1", "::1"].any? do |host|
|
|
80
|
+
case probe_once(port, tls: false, host: host)
|
|
81
|
+
when :ours then true
|
|
82
|
+
when :foreign then false
|
|
83
|
+
else tls ? probe_once(port, tls: true, host: host) == :ours : false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
70
86
|
end
|
|
71
87
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sock =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
# Three outcomes: :ours (our header present), :foreign (an HTTP
|
|
89
|
+
# response without it), :unknown (no response at all).
|
|
90
|
+
def probe_once(port, tls:, host:)
|
|
91
|
+
sock = nil
|
|
92
|
+
Timeout.timeout(5) do
|
|
93
|
+
sock = TCPSocket.new(host, port)
|
|
94
|
+
if tls
|
|
95
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
96
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
97
|
+
sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
|
|
98
|
+
sock.connect
|
|
99
|
+
end
|
|
81
100
|
sock.write("GET / HTTP/1.1\r\nHost: ask-local-health.invalid\r\nConnection: close\r\n\r\n")
|
|
82
101
|
head = +""
|
|
83
102
|
while (chunk = sock.readpartial(4096))
|
|
84
103
|
head << chunk
|
|
85
104
|
break if head.include?("\r\n\r\n")
|
|
86
105
|
end
|
|
87
|
-
head.
|
|
106
|
+
return :unknown if head.empty?
|
|
107
|
+
|
|
108
|
+
return head.downcase.include?("x-ask-local: 1") ? :ours : :foreign
|
|
88
109
|
end
|
|
89
110
|
rescue SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, IOError, EOFError
|
|
90
|
-
|
|
111
|
+
:unknown
|
|
91
112
|
ensure
|
|
92
113
|
begin
|
|
93
114
|
sock&.close
|
|
@@ -96,6 +117,10 @@ module Ask
|
|
|
96
117
|
end
|
|
97
118
|
end
|
|
98
119
|
|
|
120
|
+
def probe_ours(port, tls:, host:)
|
|
121
|
+
probe_once(port, tls: tls, host: host) == :ours
|
|
122
|
+
end
|
|
123
|
+
|
|
99
124
|
def pid_alive?(pid)
|
|
100
125
|
Process.kill(0, pid)
|
|
101
126
|
true
|