yamine 0.4.0 → 0.6.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.
@@ -657,18 +657,28 @@ module Yamine
657
657
  def start(ctx, args)
658
658
  if args.include?("--help") || args.include?("-h")
659
659
  puts <<~HELP
660
- Usage: yamine start [name] [cmd...] [options]
660
+ Usage: yamine start [options]
661
661
 
662
662
  One-setup-and-go: if the workstation isn't ready (CA, proxy,
663
663
  hosts), runs the minimal needed setup first, then boots the
664
664
  app in the current directory.
665
665
 
666
- yamine start # infer name, boot -> https://<app>.localhost
667
- yamine start myapp # explicit name
666
+ yamine start # block until every route is healthy, then supervise
667
+ yamine start --no-wait # fire-and-forget (register routes immediately)
668
+ yamine start --json # machine-readable result payload (--wait only)
668
669
  yamine start -- --help # pass --help to the app, not here
669
670
 
670
671
  Options are passed through to the boot path:
671
- --name <name> --service <svc> --variant <v> --tld <tld> --branch
672
+ --variant <v> --tld <tld> --force --app-port <port>
673
+ --wait (alias, default) --no-wait --json
674
+
675
+ Default waits: every process is spawned concurrently, each
676
+ healthcheck (or TCP accept when none is declared) is polled,
677
+ routes are registered only when all are healthy, and `ready:`
678
+ is printed with exit 0. On failure everything spawned is
679
+ killed, the failed process + its log tail is printed, and
680
+ the CLI exits 1 — no half-booted routes. `--no-wait` keeps
681
+ the old fire-and-forget path.
672
682
 
673
683
  Setup failures become hard errors pointing at `yamine setup`;
674
684
  non-interactive CI without a running proxy exits immediately.
@@ -763,6 +773,134 @@ module Yamine
763
773
  puts " - #{app}-#{slug}.#{domain}"
764
774
  puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
765
775
  end
776
+
777
+ # Per-worktree databases: list/create/drop, plus orphan cleanup.
778
+ # `db list` shows every claimed database with its worktree dir.
779
+ # `db create` resolves this worktree's name and creates it.
780
+ # `db drop <name>` drops one database (refuses without --force
781
+ # when its worktree still exists).
782
+ def db(ctx, args)
783
+ sub = args.first
784
+ case sub
785
+ when "list", nil then db_list(ctx)
786
+ when "create" then db_create(ctx)
787
+ when "drop" then db_drop(ctx, args[1..] || [])
788
+ else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
789
+ end
790
+ end
791
+
792
+ def db_list(ctx)
793
+ map = Database.load_map(ctx.store.dir)
794
+ if map.empty?
795
+ puts "No worktree databases claimed yet."
796
+ puts "Boot an app with a DATABASE_URL template to create one."
797
+ return
798
+ end
799
+ map.each do |name, info|
800
+ alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
801
+ puts " #{name} -> #{info["dir"]}#{alive}"
802
+ end
803
+ end
804
+
805
+ def db_create(ctx)
806
+ resolved = Resolver.resolve(Dir.pwd)
807
+ name = Database.name_for(Dir.pwd, env: boot_env_name,
808
+ state_dir: ctx.store.dir)
809
+ template = ENV["DATABASE_URL"]
810
+ unless template && !template.strip.empty?
811
+ $stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
812
+ exit 1
813
+ end
814
+ if Database.ensure_exists(name, template)
815
+ puts "Database #{name} ready."
816
+ else
817
+ $stderr.puts "Error: could not create #{name}. Check the database server is running."
818
+ exit 1
819
+ end
820
+ end
821
+
822
+ def db_drop(ctx, args)
823
+ force = args.delete("--force")
824
+ name = args.first
825
+ raise Error, "Usage: yamine db drop <name> [--force]" unless name
826
+
827
+ map = Database.load_map(ctx.store.dir)
828
+ info = map[name]
829
+ if info && File.directory?(info["dir"]) && !force
830
+ $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
831
+ exit 1
832
+ end
833
+ if drop_database(ctx, name)
834
+ map.delete(name)
835
+ Database.save_map(ctx.store.dir, map)
836
+ puts "Dropped #{name}."
837
+ else
838
+ $stderr.puts "Error: could not drop #{name}."
839
+ exit 1
840
+ end
841
+ end
842
+
843
+ def drop_database(_ctx, name)
844
+ template = ENV["DATABASE_URL"]
845
+ return false unless template && !template.strip.empty?
846
+
847
+ case Database.adapter_for(template)
848
+ when :postgres
849
+ uri = URI.parse(template)
850
+ _out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
851
+ status.success?
852
+ when :mysql
853
+ uri = URI.parse(template)
854
+ args = ["-h", uri.host || "127.0.0.1", "-u",
855
+ URI.decode_www_form_component(uri.user || "root")]
856
+ _out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
857
+ status.success?
858
+ else
859
+ false
860
+ end
861
+ rescue SystemCallError
862
+ false
863
+ end
864
+
865
+ def boot_env_name
866
+ ENV.fetch("RAILS_ENV", "development")
867
+ end
868
+
869
+ # Worktree helpers: list claimed databases with liveness, clean
870
+ # drops databases whose worktree dirs are gone.
871
+ def worktree(ctx, args)
872
+ sub = args.first
873
+ case sub
874
+ when "list", nil
875
+ map = Database.load_map(ctx.store.dir)
876
+ if map.empty?
877
+ puts "No worktree databases claimed yet."
878
+ return
879
+ end
880
+ map.each do |name, info|
881
+ alive = File.directory?(info["dir"]) ? "alive" : "gone"
882
+ puts " #{name} #{info["dir"]} (#{alive})"
883
+ end
884
+ when "clean"
885
+ orphaned = Database.orphaned(ctx.store.dir)
886
+ if orphaned.empty?
887
+ puts "No orphaned worktree databases."
888
+ return
889
+ end
890
+ orphaned.each do |name, _info|
891
+ if drop_database(ctx, name)
892
+ puts "Dropped orphaned #{name}."
893
+ else
894
+ $stderr.puts "Could not drop #{name} — remove manually."
895
+ end
896
+ end
897
+ map = Database.load_map(ctx.store.dir)
898
+ orphaned.each_key { |name| map.delete(name) }
899
+ Database.save_map(ctx.store.dir, map)
900
+ else
901
+ raise Error, "Usage: yamine worktree [list|clean]"
902
+ end
903
+ end
766
904
  end
767
905
  end
768
906
  end
data/lib/yamine/cli.rb CHANGED
@@ -10,7 +10,7 @@ module Yamine
10
10
  # In non-interactive environments (no TTY or CI=1) we fail early with
11
11
  # a clear message instead of prompting (portless lesson).
12
12
  class CLI
13
- SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
13
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init db worktree].freeze
14
14
 
15
15
  def self.run(argv)
16
16
  new.run(argv)
@@ -47,6 +47,8 @@ module Yamine
47
47
  when "setup" then SystemCommand.setup(ctx, args)
48
48
  when "init" then SystemCommand.init(ctx, args)
49
49
  when "start" then SystemCommand.start(ctx, args)
50
+ when "db" then SystemCommand.db(ctx, args)
51
+ when "worktree" then SystemCommand.worktree(ctx, args)
50
52
  when "kamal" then SystemCommand.kamal(ctx, args)
51
53
  when "stop"
52
54
  exit RoutesCommand.stop(ctx, args)
@@ -81,6 +83,8 @@ module Yamine
81
83
  yamine trust Add local CA to trust store
82
84
  yamine clean Remove state and hosts entries
83
85
  yamine prune Remove stale routes
86
+ yamine db list|create|drop Per-worktree databases
87
+ yamine worktree list|clean Worktree databases + orphan cleanup
84
88
  yamine proxy start|stop Control the proxy
85
89
  yamine service install|status|uninstall OS startup service
86
90
  yamine sudoers Print scoped passwordless-sudo rules for port 443
@@ -90,8 +94,7 @@ module Yamine
90
94
  yamine restart Touch tmp/restart.txt
91
95
  yamine log [-f] [n] Tail (or follow) this app's backend log
92
96
 
93
- Flags: --name, --service, --variant, --tld, --branch, --force,
94
- --app-port, --proc (pick a Procfile process, e.g. --proc web)
97
+ Flags: --variant, --tld, --force, --app-port, --wait (default), --no-wait, --json
95
98
  Env: YAMINE_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, YAMINE_BRANCH=1
96
99
  HELP
97
100
  end
data/lib/yamine/config.rb CHANGED
@@ -53,6 +53,10 @@ module Yamine
53
53
  "tld" => "localhost",
54
54
  "host" => "myapp.local.example.com"
55
55
  },
56
+ # db: false opts out of per-worktree databases entirely (exotic
57
+ # setups: manual establish_connection, shared staging DB, ...).
58
+ # Absent means enabled. A mapping holds future db options.
59
+ "db" => false,
56
60
  "processes" => {
57
61
  "web" => {
58
62
  "cmd" => "bin/rails server -p $PORT",
@@ -238,6 +242,9 @@ module Yamine
238
242
  if data["proxy"]
239
243
  validate_proxy(data["proxy"], "#{@path} proxy")
240
244
  end
245
+ if data.key?("db")
246
+ validate_db(data["db"], "#{@path} db")
247
+ end
241
248
  if data["processes"]
242
249
  validate_processes(data["processes"], "#{@path} processes")
243
250
  end
@@ -269,6 +276,11 @@ module Yamine
269
276
  end
270
277
  end
271
278
 
279
+ def validate_db(value, context)
280
+ return if value == false
281
+ raise ConfigError, "#{context} must be false or a mapping" unless value.is_a?(Hash)
282
+ end
283
+
272
284
  def validate_processes(value, context)
273
285
  raise ConfigError, "#{context} must be a mapping" unless value.is_a?(Hash)
274
286
  raise ConfigError, "#{context} must list at least one process" if value.empty?
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+
8
+ module Yamine
9
+ # Per-worktree databases. Every worktree directory gets its own database
10
+ # so concurrent agents never share tables: no migration races, no seed
11
+ # pollution, no schema drift between branches.
12
+ #
13
+ # Naming: <sanitized-dir-basename>_<env> — e.g. /Users/kaka/code/myapp-fix
14
+ # becomes myapp_fix_development. The branch a worktree has checked out is
15
+ # irrelevant and mutable, so it plays no part; the directory is stable.
16
+ # The main checkout keeps the bare <basename>_<env> name. Postgres
17
+ # identifiers cap at 63 bytes; overlong names truncate with a hash
18
+ # suffix (same rule as hostname labels).
19
+ #
20
+ # Collision guard: two directories can sanitize identically
21
+ # (myapp-fix vs myapp_fix). A state file records which directory
22
+ # claimed which name first; on collision the newcomer gets a short
23
+ # hash suffix. Silent sharing would be exactly the bug this exists to
24
+ # kill, so the check is mandatory, not best-effort.
25
+ #
26
+ # Framework-agnostic: yamine injects DATABASE_URL and never touches
27
+ # framework files. SQLite needs nothing (relative paths already isolate
28
+ # per worktree). Creation runs the app's own schema-load command from
29
+ # config/local.yml — never assumed.
30
+ module Database
31
+ module_function
32
+
33
+ MAX_IDENTIFIER_BYTES = 63
34
+ STATE_FILE = "databases.json"
35
+
36
+ # Database name for a worktree dir + env (development/test).
37
+ # Main checkout (no worktree marker) keeps the bare name.
38
+ def name_for(dir, env: "development", state_dir: nil)
39
+ base = sanitize(File.basename(File.expand_path(dir)))
40
+ name = "#{base}_#{env}"
41
+ name = truncate(name)
42
+ claimed = claim(name, dir, state_dir: state_dir)
43
+ claimed || "#{truncate("#{base}_#{short_hash(dir)}")}_#{env}"
44
+ end
45
+
46
+ # The main checkout is the one whose .git is a directory (linked
47
+ # worktrees have a .git FILE pointing into /worktrees/). Main keeps
48
+ # the bare name; only linked worktrees get suffixed names... in
49
+ # practice every dir gets the mapping, but main's mapping is itself.
50
+ def main_checkout?(dir)
51
+ File.directory?(File.join(File.expand_path(dir), ".git"))
52
+ end
53
+
54
+ def sanitize(name)
55
+ name.downcase.gsub(/[^a-z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_+|_+\z/, "")
56
+ end
57
+
58
+ def truncate(name)
59
+ return name if name.bytesize <= MAX_IDENTIFIER_BYTES
60
+
61
+ hash = Digest::SHA256.hexdigest(name)[0, 6]
62
+ prefix = name.byteslice(0, MAX_IDENTIFIER_BYTES - 7).gsub(/_+\z/, "")
63
+ "#{prefix}_#{hash}"
64
+ end
65
+
66
+ def short_hash(dir)
67
+ Digest::SHA256.hexdigest(File.expand_path(dir))[0, 6]
68
+ end
69
+
70
+ # Record dir -> name claim; returns the name if ours (or unclaimed),
71
+ # nil if another dir claimed it first (caller must suffix).
72
+ def claim(name, dir, state_dir: nil)
73
+ return name unless state_dir
74
+
75
+ dir = File.expand_path(dir)
76
+ map = load_map(state_dir)
77
+ if map.key?(name) && map[name]["dir"] != dir
78
+ return nil
79
+ end
80
+ map[name] = { "dir" => dir, "claimed_at" => Time.now.utc.iso8601 }
81
+ save_map(state_dir, map)
82
+ name
83
+ rescue SystemCallError
84
+ name
85
+ end
86
+
87
+ def load_map(state_dir)
88
+ path = File.join(state_dir, STATE_FILE)
89
+ return {} unless File.file?(path)
90
+
91
+ JSON.parse(File.read(path))
92
+ rescue JSON::ParserError, SystemCallError
93
+ {}
94
+ end
95
+
96
+ def save_map(state_dir, map)
97
+ FileUtils.mkdir_p(state_dir)
98
+ File.write(File.join(state_dir, STATE_FILE), JSON.pretty_generate(map))
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ # Databases whose worktree dirs no longer exist (for `worktree clean`).
104
+ def orphaned(state_dir)
105
+ load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
106
+ end
107
+
108
+ # Adapter detection from DATABASE_URL scheme. Returns :postgres,
109
+ # :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
110
+ # DATABASE_URL treatment; sqlite is left alone (relative paths
111
+ # already isolate per worktree).
112
+ def adapter_for(database_url)
113
+ str = database_url.to_s.strip
114
+ return :sqlite if str.empty?
115
+
116
+ scheme = str[/\A[a-z0-9+]+/i].to_s.downcase
117
+ case scheme
118
+ when "postgres", "postgresql" then :postgres
119
+ when "mysql", "mysql2" then :mysql
120
+ when "sqlite", "sqlite3" then :sqlite
121
+ else :unknown
122
+ end
123
+ end
124
+
125
+ # Build a DATABASE_URL for name from a template URL (host, port, user
126
+ # preserved; database segment replaced). Returns nil when the template
127
+ # is absent or sqlite.
128
+ def url_for(name, template_url)
129
+ return nil if template_url.nil? || template_url.strip.empty?
130
+ return nil if adapter_for(template_url) == :sqlite
131
+
132
+ uri = URI.parse(template_url)
133
+ uri.path = "/#{name}"
134
+ uri.to_s
135
+ rescue URI::InvalidURIError
136
+ nil
137
+ end
138
+
139
+ # True when the named database already exists. False covers both
140
+ # missing and unreachable server — ensure_exists decides creation.
141
+ def exists?(name, database_url)
142
+ case adapter_for(database_url)
143
+ when :postgres then pg_exists?(name, database_url)
144
+ when :mysql then mysql_exists?(name, database_url)
145
+ else false
146
+ end
147
+ end
148
+
149
+ # Create the database if missing. Uses createdb/mysqladmin when
150
+ # available; returns true/false, never raises (boot reports, not dies).
151
+ def ensure_exists(name, database_url)
152
+ case adapter_for(database_url)
153
+ when :postgres then ensure_postgres(name, database_url)
154
+ when :mysql then ensure_mysql(name, database_url)
155
+ else false
156
+ end
157
+ end
158
+
159
+ def ensure_postgres(name, database_url)
160
+ uri = URI.parse(database_url)
161
+ env = pg_env(uri)
162
+ return true if pg_exists?(name, database_url)
163
+
164
+ _out, status = Open3.capture2(env, "createdb", name)
165
+ status.success?
166
+ rescue SystemCallError
167
+ false
168
+ end
169
+
170
+ def pg_exists?(name, database_url)
171
+ uri = URI.parse(database_url)
172
+ out, status = Open3.capture2(pg_env(uri), "psql", "-lqt")
173
+ return false unless status.success?
174
+
175
+ out.split("\n").any? do |line|
176
+ line.split("|").first.to_s.strip == name
177
+ end
178
+ rescue SystemCallError
179
+ false
180
+ end
181
+
182
+ def mysql_exists?(name, database_url)
183
+ uri = URI.parse(database_url)
184
+ args = mysql_args(uri)
185
+ out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
186
+ status.success? && out.split("\n").include?(name)
187
+ rescue SystemCallError
188
+ false
189
+ end
190
+
191
+ def ensure_mysql(name, database_url)
192
+ uri = URI.parse(database_url)
193
+ args = mysql_args(uri)
194
+ _out, status = Open3.capture2("mysqladmin", *args, "create", name)
195
+ status.success? || mysql_exists?(name, database_url)
196
+ rescue SystemCallError
197
+ false
198
+ end
199
+
200
+ def mysql_args(uri)
201
+ args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
202
+ "-u", URI.decode_www_form_component(uri.user || "root")]
203
+ args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
204
+ args
205
+ end
206
+
207
+ def pg_env(uri)
208
+ {
209
+ "PGHOST" => uri.host || "127.0.0.1",
210
+ "PGPORT" => (uri.port || 5432).to_s,
211
+ "PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
212
+ "PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
213
+ }.compact
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "openssl"
7
+ require "socket"
8
+ require "timeout"
9
+ require "uri"
10
+
11
+ module Yamine
12
+ # Phased boot readiness: every boot step emits a structured event
13
+ # (phase, action, status, duration_ms, detail) so agents get one call
14
+ # with a definitive answer instead of polling logs and guessing.
15
+ #
16
+ # Phases: deps (bundle check etc) -> db (create) -> schema (load) ->
17
+ # process (spawn + healthcheck per process). Each phase has its own
18
+ # timeout; a timeout reports that phase's logs, not the whole tree's.
19
+ #
20
+ # `start --wait` blocks until every HTTP route is healthy and exits 0
21
+ # with a summary payload, or non-zero with the failed phase + log tail.
22
+ module Readiness
23
+ module_function
24
+
25
+ DEFAULT_TIMEOUTS = {
26
+ deps: 30,
27
+ db: 15,
28
+ schema: 90,
29
+ process: 45
30
+ }.freeze
31
+
32
+ Event = Struct.new(:phase, :action, :status, :duration_ms, :detail,
33
+ keyword_init: true) do
34
+ def to_h
35
+ { phase: phase, action: action, status: status,
36
+ duration_ms: duration_ms, detail: detail }.compact
37
+ end
38
+ end
39
+
40
+ # Run a phase with timing + timeout. Yields; returns the Event.
41
+ # On timeout or raise, status is "fail" with the error as detail.
42
+ def phase(name, action, timeout: nil, out: nil)
43
+ timeout ||= DEFAULT_TIMEOUTS.fetch(name, 30)
44
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ event = nil
46
+ begin
47
+ result = Timeout.timeout(timeout) { yield }
48
+ ms = elapsed_ms(started)
49
+ event = Event.new(phase: name, action: action, status: "ok",
50
+ duration_ms: ms, detail: result.is_a?(String) ? result : nil)
51
+ rescue Timeout::Error
52
+ ms = elapsed_ms(started)
53
+ event = Event.new(phase: name, action: action, status: "timeout",
54
+ duration_ms: ms, detail: "exceeded #{timeout}s")
55
+ rescue StandardError => e
56
+ ms = elapsed_ms(started)
57
+ event = Event.new(phase: name, action: action, status: "fail",
58
+ duration_ms: ms, detail: e.message.lines.first&.strip)
59
+ end
60
+ out&.puts(JSON.generate(event.to_h)) if out
61
+ event
62
+ end
63
+
64
+ def elapsed_ms(started)
65
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
66
+ end
67
+
68
+ # Pre-flight: verify runtime dependencies before spawning anything.
69
+ # Ruby (bundle check), Node (node_modules present). Returns [ok, fix].
70
+ # Fast-fails with the fix instead of letting Puma crash-loop for 60s.
71
+ def check_deps(dir = Dir.pwd)
72
+ gemfile = File.join(dir, "Gemfile")
73
+ if File.file?(gemfile)
74
+ _out, status = Open3.capture2("bundle", "check", chdir: dir,
75
+ err: File::NULL)
76
+ unless status.success?
77
+ return [false, "gems missing — run `bundle install` in #{dir}"]
78
+ end
79
+ end
80
+ package = File.join(dir, "package.json")
81
+ if File.file?(package) && !File.directory?(File.join(dir, "node_modules"))
82
+ return [false, "node_modules missing — run `npm install` in #{dir}"]
83
+ end
84
+ [true, nil]
85
+ rescue SystemCallError => e
86
+ [false, e.message.lines.first&.strip]
87
+ end
88
+
89
+ # Poll an HTTP route until healthy or timeout. Healthy = healthcheck
90
+ # path returns 2xx-3xx, or (no healthcheck) TCP accept on the port.
91
+ # Returns [healthy, detail].
92
+ def wait_healthy(hostname, port:, tls:, path: nil, timeout: 30)
93
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
94
+ last_error = "not yet attempted"
95
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
96
+ ok, detail = probe(hostname, port, tls: tls, path: path)
97
+ return [true, nil] if ok
98
+
99
+ last_error = detail
100
+ sleep 0.5
101
+ end
102
+ [false, last_error]
103
+ end
104
+
105
+ def probe(hostname, port, tls:, path: nil)
106
+ if path
107
+ probe_http(hostname, port, tls: tls, path: path)
108
+ else
109
+ probe_tcp(port)
110
+ end
111
+ end
112
+
113
+ def probe_tcp(port)
114
+ TCPSocket.new("127.0.0.1", port).close
115
+ [true, nil]
116
+ rescue SystemCallError => e
117
+ [false, e.message.lines.first&.strip]
118
+ end
119
+
120
+ def probe_http(hostname, port, tls:, path:)
121
+ http = Net::HTTP.new("127.0.0.1", port)
122
+ http.use_ssl = tls
123
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if tls
124
+ http.open_timeout = 3
125
+ http.read_timeout = 5
126
+ res = http.get(path, { "Host" => hostname })
127
+ if res.code.to_i.between?(200, 399)
128
+ [true, nil]
129
+ else
130
+ [false, "HTTP #{res.code} on #{path}"]
131
+ end
132
+ rescue StandardError => e
133
+ [false, e.message.lines.first&.strip]
134
+ end
135
+
136
+ # Wait for every spawned app concurrently. Each entry: name =>
137
+ # { item: {entry, hostname, port...}, app: <Runner::App> }.
138
+ # Returns an array of result hashes: {name, status, phase, detail,
139
+ # duration_ms}. status is "ok", "fail" (died with log tail hint),
140
+ # or "timeout" (never answered within its healthcheck timeout).
141
+ # Dead-pid short-circuit: a reaped backend fails immediately
142
+ # instead of burning its full timeout on connection-refused.
143
+ def wait_all(apps, tls: true, out: nil)
144
+ threads = apps.map do |name, slot|
145
+ Thread.new do
146
+ Thread.current[:result] = wait_one(name, slot, tls: tls, out: out)
147
+ end
148
+ end
149
+ threads.each(&:join)
150
+ threads.map { |t| t[:result] }
151
+ end
152
+
153
+ def wait_one(name, slot, tls:, out:)
154
+ item = slot[:item]
155
+ app = slot[:app]
156
+ hc = item[:entry]["healthcheck"] || {}
157
+ path = hc["path"]
158
+ timeout = hc["timeout"] || DEFAULT_TIMEOUTS[:process]
159
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ deadline = started + timeout
161
+ last_error = "not yet attempted"
162
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
163
+ unless process_alive?(app.pid)
164
+ ms = elapsed_ms(started)
165
+ return fail_result(name, started, ms,
166
+ "process exited before becoming healthy — see log/yamine-#{name}.log", out)
167
+ end
168
+ ok, detail = probe(item[:hostname], item[:port], tls: tls, path: path)
169
+ if ok
170
+ ms = elapsed_ms(started)
171
+ return ok_result(name, started, ms, path, out)
172
+ end
173
+ last_error = detail
174
+ sleep 0.5
175
+ end
176
+ ms = elapsed_ms(started)
177
+ status = process_alive?(app.pid) ? "timeout" : "fail"
178
+ detail = process_alive?(app.pid) ? "no healthy response within #{timeout}s (#{last_error})" :
179
+ "process exited before becoming healthy — see log/yamine-#{name}.log"
180
+ event = Event.new(phase: "process", action: name, status: status,
181
+ duration_ms: ms, detail: detail)
182
+ out&.puts(JSON.generate(event.to_h)) if out
183
+ { name: name, status: status, phase: "process", detail: detail, duration_ms: ms }
184
+ end
185
+
186
+ def ok_result(name, started, ms, path, out)
187
+ detail = path ? "healthcheck #{path} returned 2xx-3xx" : "port accepted connection"
188
+ event = Event.new(phase: "process", action: name, status: "ok",
189
+ duration_ms: ms, detail: detail)
190
+ out&.puts(JSON.generate(event.to_h)) if out
191
+ { name: name, status: "ok", phase: "process", detail: detail, duration_ms: ms }
192
+ end
193
+
194
+ def fail_result(name, started, ms, detail, out)
195
+ event = Event.new(phase: "process", action: name, status: "fail",
196
+ duration_ms: ms, detail: detail)
197
+ out&.puts(JSON.generate(event.to_h)) if out
198
+ { name: name, status: "fail", phase: "process", detail: detail, duration_ms: ms }
199
+ end
200
+
201
+ def process_alive?(pid)
202
+ Process.kill(0, pid)
203
+ true
204
+ rescue SystemCallError
205
+ false
206
+ end
207
+ end
208
+
209
+ # Machine-readable --wait payloads. Success carries URLs + per-process
210
+ # health + db provenance; failure names the first failed process, its
211
+ # phase, and the tail of its own log.
212
+ module WaitPayload
213
+ module_function
214
+
215
+ def success(resolved, wait_result, urls:, db_name:, db_url:, created:)
216
+ { ok: true, service: resolved.app,
217
+ urls: urls,
218
+ processes: wait_result.map { |r|
219
+ { name: r[:name], status: r[:status], duration_ms: r[:duration_ms] }
220
+ },
221
+ db: db_url ? { name: db_name, url: db_url, created: created } : nil }.compact
222
+ end
223
+
224
+ def failure(resolved, wait_result, failed:, log_tail:)
225
+ { ok: false, service: resolved.app,
226
+ failed_process: failed[:name], phase: failed[:phase],
227
+ detail: failed[:detail],
228
+ processes: wait_result.map { |r| { name: r[:name], status: r[:status] } },
229
+ log_path: log_tail[:path], log_tail: log_tail[:tail] }.compact
230
+ end
231
+ end
232
+ end