yamine 0.5.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 +43 -0
- data/README.md +18 -4
- data/lib/ask/skills/yamine/SKILL.md +8 -5
- data/lib/yamine/cli/boot.rb +252 -68
- data/lib/yamine/cli/context.rb +1 -1
- data/lib/yamine/cli/system.rb +14 -4
- data/lib/yamine/cli.rb +1 -2
- data/lib/yamine/config.rb +12 -0
- data/lib/yamine/database.rb +37 -14
- data/lib/yamine/readiness.rb +232 -0
- data/lib/yamine/resolver.rb +4 -2
- data/lib/yamine/runner.rb +46 -3
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1fef68ea493be93af6dfb3271b446b793536a373d11b030f62e54c0d90438e88
|
|
4
|
+
data.tar.gz: 69c64d3bd1523b05ca6277f76329e20415879f16c95cd0640e30c9adf77f69cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2864def84f4afefe2dd79adda834a2381964e964da864f99c8740321c19559669ad48874cce4485222c0a09d5a285b343d99d2fdea5f1029ee54f8dc8cd86a76
|
|
7
|
+
data.tar.gz: fe58b77da7a315e3f6aa3fd59f7c153fe8a0d342ffdbbdf923e4199e8ccba02b039c2cde777a7c9d1855d127563ef3f021171b308c1696f5f7446b9ac068430e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] — Unreleased
|
|
4
|
+
|
|
5
|
+
`yamine start --wait` + boot readiness protocol. The remaining gap from
|
|
6
|
+
“one-storey building” onwards: boot had no definitive answer — agents
|
|
7
|
+
polled, guessed from logs, and raced half-booted routes. Now every phase
|
|
8
|
+
(deps, db, schema, per-process health) has a real event and `--wait`
|
|
9
|
+
delivers one call with a pass/fail payload. Credentials-only DB URLs
|
|
10
|
+
warn loudly instead of sharing silently.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Yamine::Readiness`: phased events (`phase`, `action`, `status`,
|
|
15
|
+
`duration_ms`, `detail`), `check_deps` pre-flight, `wait_healthy`
|
|
16
|
+
(healthcheck path or TCP accept), concurrent `wait_all` (one thread
|
|
17
|
+
per process, dead-pid short-circuit so a crash fails fast), per-phase
|
|
18
|
+
timeouts (deps 30s, db 15s, schema 90s, process 45s or per-healthcheck
|
|
19
|
+
timeout). `Yamine::WaitPayload` success/failure payloads for `--json`.
|
|
20
|
+
- `yamine start --wait [--json]`: spawns every process, polls until
|
|
21
|
+
healthy, registers routes only on success, kills everything (including
|
|
22
|
+
background children) and exits 1 with the failed phase + that process's
|
|
23
|
+
own log tail on failure — no half-booted routes. `--wait` is kept as a no-op alias;
|
|
24
|
+
`--no-wait` keeps the old sequential fire-and-forget path.
|
|
25
|
+
`--json` is only meaningful with wait (now default).
|
|
26
|
+
- Per-process `healthcheck: { path: /up, timeout: 30 }` in
|
|
27
|
+
`config/local.yml`, respected by `--wait`. Absent means TCP accept.
|
|
28
|
+
- Boot phases now visible: deps pre-flight, db (`exists?`/`ensure_exists`
|
|
29
|
+
split so provenance doesn't need a second probe), schema, per-process.
|
|
30
|
+
`Runner#spawn_http`/`adopt`/`spawned?` support the concurrent path;
|
|
31
|
+
`boot_run` child output is logged so failure payloads can tail it.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Top-level `db: false` opts out of per-worktree databases (exotic
|
|
36
|
+
setups: manual `establish_connection`, shared staging DB, …). Validated
|
|
37
|
+
as boolean-or-mapping; `x-` extensions still ignored. `db.schema_load`
|
|
38
|
+
moves to top-level `db` (per-process ghost `db` entry was a category
|
|
39
|
+
error). Credentials-stored `DATABASE_URL`s now work when the template is
|
|
40
|
+
declared in `env.clear` (password via `local.secrets`); when no template
|
|
41
|
+
exists but the app looks database-backed (`database.yml` adapter or
|
|
42
|
+
`pg`/`mysql2`/`trilogy` in `Gemfile`) boot warns loudly with the fix
|
|
43
|
+
instead of silently sharing one database. Framework-agnostic: injection
|
|
44
|
+
is still `DATABASE_URL`, no `ask-auth` dep, no Rails load at boot.
|
|
45
|
+
|
|
3
46
|
## [0.5.0] — Unreleased
|
|
4
47
|
|
|
5
48
|
Per-worktree databases: every worktree directory gets its own database
|
data/README.md
CHANGED
|
@@ -91,6 +91,12 @@ processes:
|
|
|
91
91
|
proxy: false
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
Optional top-level `db: false` opts out of per-worktree databases
|
|
95
|
+
(exotic setups — manual `establish_connection`, shared staging DB, …);
|
|
96
|
+
`db.schema_load` overrides the schema-load command. Per-process
|
|
97
|
+
`healthcheck: { path: /up, timeout: 30 }` declares what `--wait` polls
|
|
98
|
+
(TCP accept when absent).
|
|
99
|
+
|
|
94
100
|
`yamine init` creates the file (migrating an existing Procfile);
|
|
95
101
|
Rails apps need no extra gem — yamine injects `RAILS_DEVELOPMENT_HOSTS`
|
|
96
102
|
so the proxied hostname is allowed automatically. `yamine`
|
|
@@ -133,19 +139,21 @@ yamine --tld preview.example.com # your own domain (OAuth parity)
|
|
|
133
139
|
## Commands
|
|
134
140
|
|
|
135
141
|
```bash
|
|
136
|
-
yamine #
|
|
142
|
+
yamine # boot app (waits until healthy, then supervises)
|
|
143
|
+
yamine start --no-wait # fire-and-forget (register routes immediately)
|
|
144
|
+
yamine start --json # machine-readable wait result (--wait default)
|
|
137
145
|
yamine run -- <cmd> # run explicit command through proxy
|
|
138
|
-
yamine <name> <cmd> # explicit app name
|
|
139
146
|
yamine get <name> # print URL for cross-service wiring
|
|
140
147
|
yamine alias <name> <port> # static route (e.g. Docker)
|
|
141
148
|
yamine list [--json] # show active routes (+ backend liveness)
|
|
142
149
|
yamine status [--json] # show effective naming context here
|
|
143
150
|
yamine doctor [--json] # machine-readable health checks
|
|
144
151
|
yamine open [name] # open the app URL in a browser
|
|
145
|
-
yamine doctor # read-only health check (state, proxy, routes, DNS, CA)
|
|
146
152
|
yamine trust # add local CA to system trust store
|
|
147
153
|
yamine clean # remove state and hosts entries
|
|
148
154
|
yamine prune # remove stale routes
|
|
155
|
+
yamine db list|create|drop # per-worktree databases
|
|
156
|
+
yamine worktree list|clean # worktree databases + orphan cleanup
|
|
149
157
|
yamine stop # stop this app's backend + routes
|
|
150
158
|
yamine restart # touch tmp/restart.txt (managed apps reboot)
|
|
151
159
|
yamine log [-f] [n] # tail (or follow) this app's backend log
|
|
@@ -186,12 +194,18 @@ connection (verified end-to-end: RFC 6455 handshake + frame echo).
|
|
|
186
194
|
|
|
187
195
|
## Machine-readable output
|
|
188
196
|
|
|
189
|
-
`list`, `status`, and `
|
|
197
|
+
`list`, `status`, `doctor`, and `yamine start --wait` accept `--json` with stable keys for
|
|
190
198
|
agents and scripts (`DevUrl` in ask-ruby-harness consumes the same data
|
|
191
199
|
in-process). Hostnames that fall outside the configured TLDs get a bare
|
|
192
200
|
404 naming nothing — route names never leak to foreign hosts
|
|
193
201
|
(DNS-rebinding boundary).
|
|
194
202
|
|
|
203
|
+
`yamine start` (default `--wait`) exits 0 only once every HTTP route is healthy
|
|
204
|
+
(healthcheck path when declared, TCP accept otherwise). On failure it
|
|
205
|
+
exits 1 with the failed process, its phase, and the tail of its own log
|
|
206
|
+
— no guessing, no polling, no half-booted routes. `--no-wait` keeps the
|
|
207
|
+
old fire-and-forget path.
|
|
208
|
+
|
|
195
209
|
## Log rotation
|
|
196
210
|
|
|
197
211
|
`proxy.log` and per-app backend logs rotate at 5MB
|
|
@@ -33,11 +33,13 @@ no extra gem — the proxied hostname is allowed automatically via
|
|
|
33
33
|
`RAILS_DEVELOPMENT_HOSTS`.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
yamine start
|
|
37
|
-
yamine
|
|
38
|
-
yamine
|
|
39
|
-
yamine
|
|
40
|
-
yamine
|
|
36
|
+
yamine start # setup if needed, then boot every process (waits until healthy)
|
|
37
|
+
yamine start --no-wait # fire-and-forget (register routes immediately)
|
|
38
|
+
yamine start --json # machine-readable wait result (--wait default)
|
|
39
|
+
yamine # same as start
|
|
40
|
+
yamine stop # stop this app's backend + routes
|
|
41
|
+
yamine status # show service, processes, and URLs
|
|
42
|
+
yamine log [-f] # tail the web process log
|
|
41
43
|
```
|
|
42
44
|
|
|
43
45
|
`$PORT` and `YAMINE_URL` are injected per process; HTTP processes get
|
|
@@ -96,6 +98,7 @@ proxy:
|
|
|
96
98
|
yamine doctor # read-only: proxy, routes, DNS, CA trust
|
|
97
99
|
yamine list --json # routes as stable JSON
|
|
98
100
|
yamine prune # clear stale routes from crashed sessions
|
|
101
|
+
yamine start --json # boot readiness payload (pass/fail + log tail)
|
|
99
102
|
```
|
|
100
103
|
|
|
101
104
|
If a hostname does not resolve: `yamine hosts sync`. If the browser
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Yamine
|
|
|
16
16
|
# boots every process, supervises the tree, cleans up on exit.
|
|
17
17
|
def run_inferred(ctx, args)
|
|
18
18
|
variant = ENV["YAMINE_VARIANT"]
|
|
19
|
-
opts = ctx.parse_flags(args, %i[variant tld force])
|
|
19
|
+
opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
|
|
20
20
|
resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
|
|
21
21
|
# Ownership gate before any side effects: no proxy spawn, no
|
|
22
22
|
# port allocation when we'd refuse anyway.
|
|
@@ -43,6 +43,7 @@ module Yamine
|
|
|
43
43
|
tld = resolved.tld
|
|
44
44
|
host = resolved.host
|
|
45
45
|
processes = resolved.processes
|
|
46
|
+
events = opts[:events]
|
|
46
47
|
|
|
47
48
|
if processes.empty?
|
|
48
49
|
$stderr.puts "Error: no processes in config/local.yml. Add at least one."
|
|
@@ -55,6 +56,20 @@ module Yamine
|
|
|
55
56
|
exit 1
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Pre-flight: verify runtime deps BEFORE spawning anything.
|
|
60
|
+
# A missing bundle fails here in seconds with the fix, instead
|
|
61
|
+
# of a 60s Puma crash-loop ending in "did not boot".
|
|
62
|
+
deps = Readiness.phase(:deps, "check", out: events) do
|
|
63
|
+
ok, fix = Readiness.check_deps(Dir.pwd)
|
|
64
|
+
raise fix unless ok
|
|
65
|
+
|
|
66
|
+
"dependencies satisfied"
|
|
67
|
+
end
|
|
68
|
+
unless deps.status == "ok"
|
|
69
|
+
$stderr.puts "Error: #{deps.detail}"
|
|
70
|
+
exit 1
|
|
71
|
+
end
|
|
72
|
+
|
|
58
73
|
runner = Runner.new(store: ctx.store)
|
|
59
74
|
children = []
|
|
60
75
|
routes_registered = []
|
|
@@ -70,59 +85,169 @@ module Yamine
|
|
|
70
85
|
# branches hop).
|
|
71
86
|
db_name = Database.name_for(Dir.pwd, env: rails_env,
|
|
72
87
|
state_dir: ctx.store.dir)
|
|
73
|
-
db_url = setup_database(ctx, resolved, db_name)
|
|
74
|
-
puts " [db] #{db_name}" if db_url
|
|
88
|
+
db_created, db_url = setup_database(ctx, resolved, db_name, events: events)
|
|
89
|
+
puts " [db] #{db_name}#{db_created ? " (created)" : ""}" if db_url
|
|
90
|
+
|
|
91
|
+
with_wait = !opts[:no_wait]
|
|
92
|
+
spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
|
|
93
|
+
|
|
94
|
+
if with_wait
|
|
95
|
+
boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
|
|
96
|
+
routes_registered, db_url, db_name, db_created)
|
|
97
|
+
else
|
|
98
|
+
boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
|
|
99
|
+
routes_registered, db_url, events: events)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
background = processes.select { |_, v| v["proxy"] == false }
|
|
103
|
+
unless background.empty?
|
|
104
|
+
puts " [background] #{background.keys.join(', ')}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts
|
|
108
|
+
ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
|
|
109
|
+
|
|
110
|
+
# Supervisor: exit when ANY child dies (loud cleanup).
|
|
111
|
+
all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
|
|
112
|
+
all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
|
|
113
|
+
trap_cleanup(ctx, all_hostnames, all_pids)
|
|
114
|
+
supervise_tree(ctx, all_hostnames, all_pids)
|
|
115
|
+
end
|
|
75
116
|
|
|
117
|
+
# One pass over config: background processes spawn immediately
|
|
118
|
+
# (no route to wait on), HTTP processes become a spawn plan the
|
|
119
|
+
# boot mode executes. Compound-line refusal happens here so both
|
|
120
|
+
# paths share one rule.
|
|
121
|
+
def collect_spawns(ctx, runner, resolved, opts, db_url, children)
|
|
122
|
+
plan = []
|
|
123
|
+
processes = resolved.processes
|
|
76
124
|
processes.each do |proc_name, entry|
|
|
77
125
|
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
78
126
|
if entry["proxy"] == false
|
|
79
|
-
boot_background(ctx, runner, resolved, proc_name, entry, opts,
|
|
127
|
+
boot_background(ctx, runner, resolved, proc_name, entry, opts,
|
|
128
|
+
children, db_url)
|
|
80
129
|
next
|
|
81
130
|
end
|
|
82
131
|
next unless hostname
|
|
83
132
|
|
|
84
133
|
url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
85
|
-
hostnames = [hostname]
|
|
86
|
-
puts " [#{proc_name}] #{url}"
|
|
87
|
-
|
|
88
|
-
# Each process cmd runs through the shell so $PORT (and other
|
|
89
|
-
# env refs) expand — same trust boundary as a Procfile line
|
|
90
|
-
# (repo code, not user input). Compound lines are refused.
|
|
91
134
|
cmd = entry["cmd"].to_s
|
|
92
135
|
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
93
136
|
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
94
137
|
next
|
|
95
138
|
end
|
|
96
|
-
|
|
97
139
|
port = opts[:app_port] || Ports.find_free
|
|
98
|
-
|
|
140
|
+
plan << { name: proc_name, entry: entry, hostname: hostname,
|
|
141
|
+
hostnames: [hostname], url: url, port: port,
|
|
142
|
+
command: ["sh", "-c", cmd] }
|
|
143
|
+
end
|
|
144
|
+
plan
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Default: sequential boot, route registered per process as before.
|
|
148
|
+
def boot_sequential(ctx, runner, resolved, opts, plan, children,
|
|
149
|
+
routes_registered, db_url, events: nil)
|
|
150
|
+
plan.each do |item|
|
|
151
|
+
puts " [#{item[:name]}] #{item[:url]}"
|
|
99
152
|
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
100
153
|
# this env var when not a Rails app — safe for every framework).
|
|
101
154
|
# DATABASE_URL points at this worktree's own database; non-Ruby
|
|
102
155
|
# frameworks that honor it get isolation for free, others ignore it.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
156
|
+
# boot_run registers the route + backend sidecar itself
|
|
157
|
+
# (register: true default); no separate adopt step needed.
|
|
158
|
+
app = runner.boot_run(name: item[:name], hostname: item[:hostname],
|
|
159
|
+
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
160
|
+
port: item[:port], force: opts[:force],
|
|
161
|
+
rails_dev_host: item[:hostname], database_url: db_url)
|
|
162
|
+
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
163
|
+
|
|
164
|
+
puts " -> #{item[:url]}"
|
|
111
165
|
end
|
|
166
|
+
children
|
|
167
|
+
end
|
|
112
168
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
169
|
+
# --wait: spawn everything first, then poll every port/path until
|
|
170
|
+
# healthy. No route exists until its backend answers, so half-boots
|
|
171
|
+
# never leak into the proxy. A backend that dies before its route
|
|
172
|
+
# registers is cleaned up like any other failed process.
|
|
173
|
+
def boot_concurrent(ctx, runner, resolved, opts, plan, children,
|
|
174
|
+
routes_registered, db_url, db_name, db_created)
|
|
175
|
+
apps = {}
|
|
176
|
+
plan.each do |item|
|
|
177
|
+
puts " [#{item[:name]}] #{item[:url]}"
|
|
178
|
+
apps[item[:name]] = {
|
|
179
|
+
item: item,
|
|
180
|
+
app: runner.spawn_http(name: item[:name], hostname: item[:hostname],
|
|
181
|
+
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
182
|
+
port: item[:port], rails_dev_host: item[:hostname],
|
|
183
|
+
database_url: db_url, force: opts[:force])
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
wait_result = Readiness.wait_all(apps, tls: ctx.proxy_tls, out: nil)
|
|
187
|
+
failed = wait_result.select { |r| r[:status] != "ok" }
|
|
188
|
+
|
|
189
|
+
if failed.empty?
|
|
190
|
+
apps.each do |name, slot|
|
|
191
|
+
runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
|
|
192
|
+
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
|
|
193
|
+
routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
|
|
194
|
+
puts " -> #{slot[:item][:url]}"
|
|
195
|
+
end
|
|
116
196
|
end
|
|
197
|
+
finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
|
|
198
|
+
children, db_name, db_url, db_created, json: opts[:json])
|
|
199
|
+
end
|
|
117
200
|
|
|
118
|
-
|
|
119
|
-
|
|
201
|
+
# --wait epilogue: on success the summary payload (URLs + per-process
|
|
202
|
+
# health) goes to stdout and boot continues into supervision. On
|
|
203
|
+
# failure every spawned child is killed and removed, the failure
|
|
204
|
+
# payload (failed phase + log tail) prints, and start exits 1 —
|
|
205
|
+
# no half-booted routes left behind.
|
|
206
|
+
def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
|
|
207
|
+
children, db_name, db_url, db_created, json: false)
|
|
208
|
+
urls = apps.transform_values { |slot| slot[:item][:url] }
|
|
209
|
+
if failed.empty?
|
|
210
|
+
puts "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
|
|
211
|
+
payload = Yamine::WaitPayload.success(resolved, wait_result, urls: urls,
|
|
212
|
+
db_name: db_name, db_url: db_url, created: db_created) if json
|
|
213
|
+
puts JSON.generate(payload) if json
|
|
214
|
+
return
|
|
215
|
+
end
|
|
120
216
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
217
|
+
apps.each_value { |slot| stop_spawned(slot[:app]) }
|
|
218
|
+
children.each { |c| stop_spawned_pid(c[:pid]) }
|
|
219
|
+
first = failed.first
|
|
220
|
+
payload = Yamine::WaitPayload.failure(resolved, wait_result, failed: first,
|
|
221
|
+
log_tail: tail_for(failed.first, apps))
|
|
222
|
+
if json
|
|
223
|
+
puts JSON.generate(payload)
|
|
224
|
+
else
|
|
225
|
+
$stderr.puts "Error: #{first[:name]} failed (#{first[:phase]}): #{first[:detail]}"
|
|
226
|
+
$stderr.puts payload[:log_tail].to_s.lines.last(10).join if payload[:log_tail]
|
|
227
|
+
$stderr.puts "Full log: #{payload[:log_path]}" if payload[:log_path]
|
|
228
|
+
end
|
|
229
|
+
cleanup_routes(ctx, apps.values.map { |slot| slot[:item][:hostname] })
|
|
230
|
+
exit 1
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def tail_for(failure, apps)
|
|
234
|
+
name = failure[:name]
|
|
235
|
+
slot = apps[name]
|
|
236
|
+
path = slot ? File.expand_path(File.join(Dir.pwd, "log", "yamine-#{name}.log")) : nil
|
|
237
|
+
lines = path && File.file?(path) ? File.readlines(path).last(20).join : "(no log file)"
|
|
238
|
+
{ path: path, tail: lines }
|
|
239
|
+
rescue SystemCallError
|
|
240
|
+
{ path: path, tail: "(unreadable log)" }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def stop_spawned(app)
|
|
244
|
+
stop_spawned_pid(app.pid)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def stop_spawned_pid(pid)
|
|
248
|
+
Process.kill("TERM", pid)
|
|
249
|
+
rescue SystemCallError
|
|
250
|
+
nil
|
|
126
251
|
end
|
|
127
252
|
|
|
128
253
|
# A background process (proxy: false) is spawned, logged, and
|
|
@@ -187,51 +312,125 @@ module Yamine
|
|
|
187
312
|
end
|
|
188
313
|
|
|
189
314
|
# Resolve this worktree's database, create it if missing, and run
|
|
190
|
-
# the app's schema-load command on first creation. Returns
|
|
191
|
-
#
|
|
192
|
-
# (
|
|
193
|
-
|
|
194
|
-
|
|
315
|
+
# the app's schema-load command on first creation. Returns
|
|
316
|
+
# [created, url]: created is true only when this boot created the
|
|
317
|
+
# database (so --wait can report provenance without a second
|
|
318
|
+
# probe), url is nil when there is nothing to inject.
|
|
319
|
+
#
|
|
320
|
+
# Template sources: ENV DATABASE_URL, then config env.clear
|
|
321
|
+
# DATABASE_URL (top-level env, falling back to per-process env
|
|
322
|
+
# for configs written before top-level env existed). Injected
|
|
323
|
+
# DATABASE_URL overrides database.yml/credentials at connection
|
|
324
|
+
# time (ActiveRecord merges env over file config), so credentials
|
|
325
|
+
# apps work — they just need the template declared in config.
|
|
326
|
+
def setup_database(ctx, resolved, db_name, events: nil)
|
|
327
|
+
return [false, nil] if db_opted_out?(resolved)
|
|
195
328
|
|
|
196
329
|
template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
|
|
197
|
-
|
|
330
|
+
if template.nil? || template.strip.empty?
|
|
331
|
+
warn_missing_template(resolved)
|
|
332
|
+
return [false, nil]
|
|
333
|
+
end
|
|
198
334
|
|
|
199
335
|
url = Database.url_for(db_name, template)
|
|
200
|
-
return nil unless url
|
|
336
|
+
return [false, nil] unless url
|
|
337
|
+
|
|
338
|
+
db_event = Readiness.phase(:db, "ensure #{db_name}", out: events) do
|
|
339
|
+
existed = Database.exists?(db_name, template)
|
|
340
|
+
raise "database server unreachable — is postgres/mysql running?" unless Database.ensure_exists(db_name, template)
|
|
341
|
+
|
|
342
|
+
existed ? "already exists" : "created #{db_name}"
|
|
343
|
+
end
|
|
344
|
+
unless db_event.status == "ok"
|
|
345
|
+
$stderr.puts " [db] WARNING: #{db_event.detail} — processes share the template database."
|
|
346
|
+
return [false, nil]
|
|
347
|
+
end
|
|
348
|
+
created = db_event.detail.to_s.start_with?("created")
|
|
349
|
+
|
|
350
|
+
schema_event = Readiness.phase(:schema, "load #{db_name}", out: events) do
|
|
351
|
+
next "skipped (already exists)" unless created
|
|
201
352
|
|
|
202
|
-
if Database.ensure_exists(db_name, template)
|
|
203
|
-
puts " [db] created #{db_name}" unless database_exists?(db_name, template)
|
|
204
353
|
run_schema_load(resolved, url)
|
|
205
|
-
else
|
|
206
|
-
$stderr.puts " [db] WARNING: could not create #{db_name} — processes share the template database."
|
|
207
|
-
return nil
|
|
208
354
|
end
|
|
209
|
-
|
|
355
|
+
unless schema_event.status == "ok"
|
|
356
|
+
$stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
|
|
357
|
+
end
|
|
358
|
+
[created, url]
|
|
210
359
|
end
|
|
211
360
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
361
|
+
# Top-level `db: false` opts out of per-worktree databases.
|
|
362
|
+
# (A `db` process entry still boots as a process — only the
|
|
363
|
+
# explicit top-level key opts out.)
|
|
364
|
+
def db_opted_out?(resolved)
|
|
365
|
+
resolved.db == false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The dangerous case made loud: the app looks database-backed
|
|
369
|
+
# (server adapter in database.yml, or pg/mysql2 in the Gemfile)
|
|
370
|
+
# but no template was declared, so every worktree would silently
|
|
371
|
+
# share one database. Name the fix; do not guess.
|
|
372
|
+
def warn_missing_template(resolved)
|
|
373
|
+
return unless database_backed?
|
|
374
|
+
|
|
375
|
+
$stderr.puts " [db] WARNING: app looks database-backed but no DATABASE_URL template found."
|
|
376
|
+
$stderr.puts " Each worktree would share one database. Declare the template in config/local.yml:"
|
|
377
|
+
$stderr.puts " env:"
|
|
378
|
+
$stderr.puts " clear:"
|
|
379
|
+
$stderr.puts " DATABASE_URL: postgres://user@localhost:5432/myapp_development"
|
|
380
|
+
$stderr.puts " (password via config/local.secrets + env.secret), or opt out with top-level `db: false`."
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Heuristics, deliberately conservative: only warn when there is
|
|
384
|
+
# positive evidence of a server database. SQLite-only and
|
|
385
|
+
# database-less apps stay silent.
|
|
386
|
+
def database_backed?
|
|
387
|
+
database_yml_server? || gemfile_server_adapter?
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def database_yml_server?(path = File.join(Dir.pwd, "config", "database.yml"))
|
|
391
|
+
return false unless File.file?(path)
|
|
392
|
+
|
|
393
|
+
content = File.read(path)
|
|
394
|
+
content.match?(/adapter:\s*(postgresql|postgres|postgis|mysql2?|trilogy)/i)
|
|
395
|
+
rescue SystemCallError
|
|
396
|
+
false
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def gemfile_server_adapter?(path = File.join(Dir.pwd, "Gemfile"))
|
|
400
|
+
return false unless File.file?(path)
|
|
401
|
+
|
|
402
|
+
content = File.read(path)
|
|
403
|
+
content.match?(/gem\s+["'](pg|mysql2|trilogy)["']/)
|
|
404
|
+
rescue SystemCallError
|
|
405
|
+
false
|
|
216
406
|
end
|
|
217
407
|
|
|
218
408
|
def database_template_from_config(resolved)
|
|
409
|
+
top = resolved.env.is_a?(Hash) ? resolved.env["clear"] : nil
|
|
410
|
+
from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
|
|
411
|
+
return from_top if from_top && !from_top.to_s.strip.empty?
|
|
412
|
+
|
|
219
413
|
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
220
414
|
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
221
415
|
clear["DATABASE_URL"]
|
|
222
416
|
end
|
|
223
417
|
|
|
224
418
|
# Schema-load on first boot uses the app's own command when declared
|
|
225
|
-
# (db.schema_load in config/local.yml), else a Rails
|
|
226
|
-
# Rails app is detected, else nothing (frameworks
|
|
227
|
-
# schema concept need no step).
|
|
419
|
+
# (top-level db.schema_load in config/local.yml), else a Rails
|
|
420
|
+
# default when a Rails app is detected, else nothing (frameworks
|
|
421
|
+
# without a schema concept need no step). Returns a detail string.
|
|
228
422
|
def run_schema_load(resolved, url)
|
|
229
|
-
cmd = resolved
|
|
423
|
+
cmd = db_schema_load(resolved) ||
|
|
230
424
|
("bin/rails db:schema:load" if rails_app?)
|
|
231
|
-
return unless cmd
|
|
425
|
+
return "no schema-load command" unless cmd
|
|
232
426
|
|
|
233
|
-
system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
|
|
427
|
+
ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
|
|
234
428
|
"sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
|
|
429
|
+
ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def db_schema_load(resolved)
|
|
433
|
+
resolved.db.is_a?(Hash) ? resolved.db["schema_load"] : nil
|
|
235
434
|
end
|
|
236
435
|
|
|
237
436
|
def rails_app?
|
|
@@ -290,21 +489,6 @@ module Yamine
|
|
|
290
489
|
end
|
|
291
490
|
end
|
|
292
491
|
|
|
293
|
-
def register_all(ctx, hostnames, app, force:, spec: nil)
|
|
294
|
-
hostnames[1..].each do |h|
|
|
295
|
-
ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
|
|
296
|
-
force: force, spec: spec)
|
|
297
|
-
write_backend_sidecar(ctx, h, app.pid)
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def write_backend_sidecar(ctx, hostname, pid)
|
|
302
|
-
ctx.store.ensure_dir
|
|
303
|
-
File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
|
|
304
|
-
rescue SystemCallError
|
|
305
|
-
nil
|
|
306
|
-
end
|
|
307
|
-
|
|
308
492
|
def cleanup_routes(ctx, hostnames)
|
|
309
493
|
hostnames.each do |h|
|
|
310
494
|
begin
|
data/lib/yamine/cli/context.rb
CHANGED
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.
|
data/lib/yamine/cli.rb
CHANGED
|
@@ -94,8 +94,7 @@ module Yamine
|
|
|
94
94
|
yamine restart Touch tmp/restart.txt
|
|
95
95
|
yamine log [-f] [n] Tail (or follow) this app's backend log
|
|
96
96
|
|
|
97
|
-
Flags: --
|
|
98
|
-
--app-port, --proc (pick a Procfile process, e.g. --proc web)
|
|
97
|
+
Flags: --variant, --tld, --force, --app-port, --wait (default), --no-wait, --json
|
|
99
98
|
Env: YAMINE_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, YAMINE_BRANCH=1
|
|
100
99
|
HELP
|
|
101
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?
|
data/lib/yamine/database.rb
CHANGED
|
@@ -136,6 +136,16 @@ module Yamine
|
|
|
136
136
|
nil
|
|
137
137
|
end
|
|
138
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
|
+
|
|
139
149
|
# Create the database if missing. Uses createdb/mysqladmin when
|
|
140
150
|
# available; returns true/false, never raises (boot reports, not dies).
|
|
141
151
|
def ensure_exists(name, database_url)
|
|
@@ -149,13 +159,7 @@ module Yamine
|
|
|
149
159
|
def ensure_postgres(name, database_url)
|
|
150
160
|
uri = URI.parse(database_url)
|
|
151
161
|
env = pg_env(uri)
|
|
152
|
-
|
|
153
|
-
return false unless status.success?
|
|
154
|
-
|
|
155
|
-
exists = Open3.capture2(env, "psql", "-lqt")[0].split("\n").any? do |line|
|
|
156
|
-
line.split("|").first.to_s.strip == name
|
|
157
|
-
end
|
|
158
|
-
return true if exists
|
|
162
|
+
return true if pg_exists?(name, database_url)
|
|
159
163
|
|
|
160
164
|
_out, status = Open3.capture2(env, "createdb", name)
|
|
161
165
|
status.success?
|
|
@@ -163,24 +167,43 @@ module Yamine
|
|
|
163
167
|
false
|
|
164
168
|
end
|
|
165
169
|
|
|
166
|
-
def
|
|
170
|
+
def pg_exists?(name, database_url)
|
|
167
171
|
uri = URI.parse(database_url)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
173
178
|
rescue SystemCallError
|
|
174
179
|
false
|
|
175
180
|
end
|
|
176
181
|
|
|
177
|
-
def
|
|
182
|
+
def mysql_exists?(name, database_url)
|
|
183
|
+
uri = URI.parse(database_url)
|
|
184
|
+
args = mysql_args(uri)
|
|
178
185
|
out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
|
|
179
186
|
status.success? && out.split("\n").include?(name)
|
|
180
187
|
rescue SystemCallError
|
|
181
188
|
false
|
|
182
189
|
end
|
|
183
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
|
+
|
|
184
207
|
def pg_env(uri)
|
|
185
208
|
{
|
|
186
209
|
"PGHOST" => uri.host || "127.0.0.1",
|
|
@@ -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
|
data/lib/yamine/resolver.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Yamine
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
Result = Struct.new(:app, :tld, :host, :processes, :secrets,
|
|
12
|
-
:sources, :variant, keyword_init: true)
|
|
12
|
+
:sources, :variant, :db, :env, keyword_init: true)
|
|
13
13
|
|
|
14
14
|
def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
|
|
15
15
|
config = Config.load(dir, variant: variant)
|
|
@@ -51,7 +51,9 @@ module Yamine
|
|
|
51
51
|
processes: processes,
|
|
52
52
|
secrets: config.secrets,
|
|
53
53
|
sources: sources,
|
|
54
|
-
variant: variant_name
|
|
54
|
+
variant: variant_name,
|
|
55
|
+
db: config.data["db"],
|
|
56
|
+
env: config.env_config
|
|
55
57
|
)
|
|
56
58
|
end
|
|
57
59
|
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -91,22 +91,59 @@ module Yamine
|
|
|
91
91
|
|
|
92
92
|
# Run an arbitrary command with PORT + YAMINE_URL. Returns App.
|
|
93
93
|
# register: false spawns without a route (background processes).
|
|
94
|
+
# Child output goes to log/yamine-<name>.log so `--wait` failure
|
|
95
|
+
# payloads can tail it (same file the managed path uses).
|
|
94
96
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
95
|
-
rails_dev_host: nil, register: true, database_url: nil)
|
|
97
|
+
rails_dev_host: nil, register: true, database_url: nil, spec: nil)
|
|
96
98
|
port ||= Ports.find_free
|
|
97
99
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
98
100
|
database_url: database_url)
|
|
99
|
-
|
|
101
|
+
path = log_path(dir, name)
|
|
102
|
+
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
100
103
|
Process.detach(pid)
|
|
101
104
|
target = "127.0.0.1:#{port}"
|
|
102
105
|
if register
|
|
103
|
-
|
|
106
|
+
spec ||= { "dir" => File.expand_path(dir), "proc" => name }
|
|
107
|
+
@store.add_route(hostname, target, Process.pid, kind: "tcp",
|
|
108
|
+
force: force, spec: spec)
|
|
104
109
|
write_backend_pid(hostname, pid)
|
|
105
110
|
end
|
|
106
111
|
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
107
112
|
target: target, kind: "tcp", command: command)
|
|
108
113
|
end
|
|
109
114
|
|
|
115
|
+
# Spawn without registering or waiting: the --wait path spins the
|
|
116
|
+
# whole tree up concurrently, then polls every route until healthy.
|
|
117
|
+
# Returns the placeholder App (target known before bind). Fate of
|
|
118
|
+
# the backend is decided by wait, not by spawn.
|
|
119
|
+
def spawn_http(name:, hostname:, url:, dir:, command:, port:, rails_dev_host: nil,
|
|
120
|
+
database_url: nil, force: false)
|
|
121
|
+
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
122
|
+
database_url: database_url)
|
|
123
|
+
path = log_path(dir, name)
|
|
124
|
+
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
125
|
+
Process.detach(pid)
|
|
126
|
+
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
127
|
+
target: "127.0.0.1:#{port}", kind: "tcp", command: command)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Register an already-spawned backend: route + sidecar, together.
|
|
131
|
+
def adopt(hostname, app, force: false, spec: nil)
|
|
132
|
+
@store.add_route(hostname, app.target, Process.pid, kind: app.kind,
|
|
133
|
+
force: force, spec: spec)
|
|
134
|
+
write_backend_pid(hostname, app.pid)
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Still running? A port that accepts is not proof of life, but a
|
|
139
|
+
# reaped/dead pid is proof of death — that is all wait needs.
|
|
140
|
+
def spawned?(pid)
|
|
141
|
+
Process.kill(0, pid)
|
|
142
|
+
true
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
110
147
|
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
|
|
111
148
|
env = { "YAMINE_URL" => url }
|
|
112
149
|
env["PORT"] = port.to_s if port
|
|
@@ -217,6 +254,8 @@ module Yamine
|
|
|
217
254
|
end
|
|
218
255
|
end
|
|
219
256
|
|
|
257
|
+
# Log path for a process — public so the --wait failure payload
|
|
258
|
+
# can tail the right file for the phase that failed.
|
|
220
259
|
def log_path(dir, name)
|
|
221
260
|
path = File.expand_path(File.join(dir, "log", "yamine-#{name}.log"))
|
|
222
261
|
FileUtils.mkdir_p(File.dirname(path))
|
|
@@ -234,6 +273,8 @@ module Yamine
|
|
|
234
273
|
nil
|
|
235
274
|
end
|
|
236
275
|
|
|
276
|
+
# Last lines of a log file for failure payloads. Public so the
|
|
277
|
+
# --wait path can attribute the right tail to the failed phase.
|
|
237
278
|
def log_tail(path, lines: 10)
|
|
238
279
|
return "(no log file)" unless File.file?(path)
|
|
239
280
|
|
|
@@ -242,6 +283,8 @@ module Yamine
|
|
|
242
283
|
"(unreadable log)"
|
|
243
284
|
end
|
|
244
285
|
|
|
286
|
+
private
|
|
287
|
+
|
|
245
288
|
def stop_pid(pid)
|
|
246
289
|
Process.kill("TERM", pid)
|
|
247
290
|
rescue SystemCallError
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "yamine/proxy"
|
|
|
21
21
|
require_relative "yamine/proxy_control"
|
|
22
22
|
require_relative "yamine/supervisor"
|
|
23
23
|
require_relative "yamine/runner"
|
|
24
|
+
require_relative "yamine/readiness"
|
|
24
25
|
require_relative "yamine/resolver"
|
|
25
26
|
require_relative "yamine/trust"
|
|
26
27
|
require_relative "yamine/doctor"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yamine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -104,6 +104,7 @@ files:
|
|
|
104
104
|
- lib/yamine/procfile.rb
|
|
105
105
|
- lib/yamine/proxy.rb
|
|
106
106
|
- lib/yamine/proxy_control.rb
|
|
107
|
+
- lib/yamine/readiness.rb
|
|
107
108
|
- lib/yamine/resolver.rb
|
|
108
109
|
- lib/yamine/route_store.rb
|
|
109
110
|
- lib/yamine/runner.rb
|