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.
@@ -1,60 +1,174 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "erb"
4
+ require "yaml"
4
5
 
5
6
  module Ask
6
7
  module Local
7
- # Optional ask-local.json in the app directory.
8
+ # Mandatory config/local.yml the ONLY source of truth for this app.
8
9
  #
9
- # { "name": "myapp" }
10
- # { "name": "myapp", "service": "api", "variant": "demo",
11
- # "tlds": ["localhost"], "appPort": 3000 }
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
- # 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.
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 = "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
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
- attr_reader :data, :dir
100
+ new(data, root, config_path)
101
+ end
21
102
 
22
- def self.load(dir = Dir.pwd)
23
- path = File.join(dir, FILENAME)
24
- return nil unless File.file?(path)
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 = JSON.parse(File.read(path))
27
- raise ConfigError, "#{path} must be a JSON object" unless parsed.is_a?(Hash)
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
- new(parsed, dir, path)
30
- rescue JSON::ParserError => e
31
- raise ConfigError, "Invalid JSON in #{path}: #{e.message}"
111
+ parsed
112
+ rescue Psych::SyntaxError => e
113
+ raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
32
114
  end
33
115
 
34
- def initialize(data, dir, path = FILENAME)
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 app_config(package_dir = dir)
42
- return slice(APP_KEYS) unless data["apps"].is_a?(Hash)
151
+ def service
152
+ data["service"].to_s
153
+ end
43
154
 
44
- rel = relative(package_dir)
45
- return {} if rel.nil? || rel.start_with?("..")
155
+ def proxy_config
156
+ data["proxy"] || {}
157
+ end
46
158
 
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)
159
+ def processes
160
+ data["processes"] || {}
161
+ end
51
162
 
52
- parent = File.dirname(candidate)
53
- break if parent == "." || parent == candidate
163
+ def env_config
164
+ data["env"] || {}
165
+ end
54
166
 
55
- candidate = parent
56
- end
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 slice(keys)
67
- data.select { |k, _| keys.include?(k) }
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 relative(package_dir)
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
- Pathname.new(File.expand_path(package_dir))
73
- .relative_path_from(Pathname.new(File.expand_path(dir))).to_s
74
- rescue ArgumentError
75
- nil
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
- data.each_key do |key|
80
- warn "Warning: Unknown key #{key.inspect} in #{@path}. Known keys: #{TOP_KEYS.join(", ")}" unless TOP_KEYS.include?(key)
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
- 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)
254
+ end
85
255
 
86
- data["apps"].each do |name, entry|
87
- raise ConfigError, %("apps.#{name}" in #{@path} must be an object) unless entry.is_a?(Hash)
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
- validate_app_fields(entry, "apps.#{name}")
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 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)
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 fields.key?("proxy") && ![true, false].include?(fields["proxy"])
99
- raise ConfigError, %("#{prefix}.proxy" in #{@path} must be a boolean)
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
- %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?
312
+ end
104
313
 
105
- raise ConfigError, %("#{prefix}.#{key}" in #{@path} must be a non-empty string)
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
- 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)
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
- probe_ours(port, tls: tls, host: "127.0.0.1") ||
69
- probe_ours(port, tls: tls, host: "::1")
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
- def probe_ours(port, tls:, host:)
73
- sock = TCPSocket.new(host, port)
74
- if tls
75
- ctx = OpenSSL::SSL::SSLContext.new
76
- ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
77
- sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
78
- sock.connect
79
- end
80
- Timeout.timeout(3) do
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.downcase.include?("x-ask-local: 1")
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
- false
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