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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/README.md +18 -4
- data/lib/ask/skills/yamine/SKILL.md +8 -5
- data/lib/yamine/cli/boot.rb +301 -45
- data/lib/yamine/cli/context.rb +1 -1
- data/lib/yamine/cli/system.rb +142 -4
- data/lib/yamine/cli.rb +6 -3
- data/lib/yamine/config.rb +12 -0
- data/lib/yamine/database.rb +216 -0
- data/lib/yamine/readiness.rb +232 -0
- data/lib/yamine/resolver.rb +4 -2
- data/lib/yamine/runner.rb +53 -5
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +2 -0
- metadata +3 -1
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -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 [
|
|
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 #
|
|
667
|
-
yamine start
|
|
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
|
-
--
|
|
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: --
|
|
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
|