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
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,67 @@
|
|
|
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
|
+
|
|
46
|
+
## [0.5.0] — Unreleased
|
|
47
|
+
|
|
48
|
+
Per-worktree databases: every worktree directory gets its own database
|
|
49
|
+
so concurrent agents never share tables.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
|
|
54
|
+
(`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
|
|
55
|
+
name), 63-byte Postgres cap with hash-suffix truncation, collision
|
|
56
|
+
guard via state map (silent sharing would be the bug this kills).
|
|
57
|
+
- Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
|
|
58
|
+
never raises — warns and shares template on failure), runs the app's
|
|
59
|
+
schema-load (`db.schema_load` in config, Rails default, else nothing),
|
|
60
|
+
and injects `DATABASE_URL` into every process. SQLite left alone.
|
|
61
|
+
- `yamine db list|create|drop` and `yamine worktree list|clean`
|
|
62
|
+
(orphan reaping for deleted worktrees). Branch plays no part —
|
|
63
|
+
directories are stable, branches hop.
|
|
64
|
+
|
|
3
65
|
## [0.3.0] — Unreleased
|
|
4
66
|
|
|
5
67
|
Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
|
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 = []
|
|
@@ -62,59 +77,182 @@ module Yamine
|
|
|
62
77
|
puts "yamine (#{service})"
|
|
63
78
|
puts "--"
|
|
64
79
|
|
|
80
|
+
# Per-worktree database: one database per directory so concurrent
|
|
81
|
+
# agents never share tables. Resolved once, injected into every
|
|
82
|
+
# process as DATABASE_URL, created on demand with the app's own
|
|
83
|
+
# schema-load command. SQLite needs nothing (relative paths
|
|
84
|
+
# already isolate); the branch plays no part (dirs are stable,
|
|
85
|
+
# branches hop).
|
|
86
|
+
db_name = Database.name_for(Dir.pwd, env: rails_env,
|
|
87
|
+
state_dir: ctx.store.dir)
|
|
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
|
|
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
|
|
65
124
|
processes.each do |proc_name, entry|
|
|
66
125
|
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
67
126
|
if entry["proxy"] == false
|
|
68
|
-
boot_background(ctx, runner, resolved, proc_name, entry, opts,
|
|
127
|
+
boot_background(ctx, runner, resolved, proc_name, entry, opts,
|
|
128
|
+
children, db_url)
|
|
69
129
|
next
|
|
70
130
|
end
|
|
71
131
|
next unless hostname
|
|
72
132
|
|
|
73
133
|
url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
74
|
-
hostnames = [hostname]
|
|
75
|
-
puts " [#{proc_name}] #{url}"
|
|
76
|
-
|
|
77
|
-
# Each process cmd runs through the shell so $PORT (and other
|
|
78
|
-
# env refs) expand — same trust boundary as a Procfile line
|
|
79
|
-
# (repo code, not user input). Compound lines are refused.
|
|
80
134
|
cmd = entry["cmd"].to_s
|
|
81
135
|
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
82
136
|
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
83
137
|
next
|
|
84
138
|
end
|
|
85
|
-
|
|
86
139
|
port = opts[:app_port] || Ports.find_free
|
|
87
|
-
|
|
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]}"
|
|
88
152
|
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
89
153
|
# this env var when not a Rails app — safe for every framework).
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
154
|
+
# DATABASE_URL points at this worktree's own database; non-Ruby
|
|
155
|
+
# frameworks that honor it get isolation for free, others ignore it.
|
|
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]}"
|
|
98
165
|
end
|
|
166
|
+
children
|
|
167
|
+
end
|
|
99
168
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
103
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
|
|
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
|
|
104
200
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
216
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
113
251
|
end
|
|
114
252
|
|
|
115
253
|
# A background process (proxy: false) is spawned, logged, and
|
|
116
254
|
# supervised exactly like an HTTP one — it just gets no route.
|
|
117
|
-
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
|
|
255
|
+
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
|
|
118
256
|
cmd = entry["cmd"].to_s
|
|
119
257
|
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
120
258
|
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
@@ -124,7 +262,8 @@ module Yamine
|
|
|
124
262
|
url = "background://#{resolved.app}/#{proc_name}"
|
|
125
263
|
app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
|
|
126
264
|
url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
|
|
127
|
-
force: opts[:force], rails_dev_host: nil, register: false
|
|
265
|
+
force: opts[:force], rails_dev_host: nil, register: false,
|
|
266
|
+
database_url: db_url)
|
|
128
267
|
children << { name: proc_name, pid: app.pid }
|
|
129
268
|
puts " [#{proc_name}] background (pid #{app.pid})"
|
|
130
269
|
end
|
|
@@ -166,6 +305,138 @@ module Yamine
|
|
|
166
305
|
env
|
|
167
306
|
end
|
|
168
307
|
|
|
308
|
+
# Rails env for database naming: RAILS_ENV when set, else development.
|
|
309
|
+
# Non-Rails apps ignore it (their DATABASE_URL template decides).
|
|
310
|
+
def rails_env
|
|
311
|
+
ENV.fetch("RAILS_ENV", "development")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Resolve this worktree's database, create it if missing, and run
|
|
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)
|
|
328
|
+
|
|
329
|
+
template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
|
|
330
|
+
if template.nil? || template.strip.empty?
|
|
331
|
+
warn_missing_template(resolved)
|
|
332
|
+
return [false, nil]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
url = Database.url_for(db_name, template)
|
|
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
|
|
352
|
+
|
|
353
|
+
run_schema_load(resolved, url)
|
|
354
|
+
end
|
|
355
|
+
unless schema_event.status == "ok"
|
|
356
|
+
$stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
|
|
357
|
+
end
|
|
358
|
+
[created, url]
|
|
359
|
+
end
|
|
360
|
+
|
|
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
|
|
406
|
+
end
|
|
407
|
+
|
|
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
|
+
|
|
413
|
+
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
414
|
+
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
415
|
+
clear["DATABASE_URL"]
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Schema-load on first boot uses the app's own command when declared
|
|
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.
|
|
422
|
+
def run_schema_load(resolved, url)
|
|
423
|
+
cmd = db_schema_load(resolved) ||
|
|
424
|
+
("bin/rails db:schema:load" if rails_app?)
|
|
425
|
+
return "no schema-load command" unless cmd
|
|
426
|
+
|
|
427
|
+
ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
|
|
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
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def rails_app?
|
|
437
|
+
File.file?(File.join(Dir.pwd, "config", "application.rb"))
|
|
438
|
+
end
|
|
439
|
+
|
|
169
440
|
def supervise_tree(ctx, hostnames, pids)
|
|
170
441
|
loop do
|
|
171
442
|
sleep 0.5
|
|
@@ -218,21 +489,6 @@ module Yamine
|
|
|
218
489
|
end
|
|
219
490
|
end
|
|
220
491
|
|
|
221
|
-
def register_all(ctx, hostnames, app, force:, spec: nil)
|
|
222
|
-
hostnames[1..].each do |h|
|
|
223
|
-
ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
|
|
224
|
-
force: force, spec: spec)
|
|
225
|
-
write_backend_sidecar(ctx, h, app.pid)
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def write_backend_sidecar(ctx, hostname, pid)
|
|
230
|
-
ctx.store.ensure_dir
|
|
231
|
-
File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
|
|
232
|
-
rescue SystemCallError
|
|
233
|
-
nil
|
|
234
|
-
end
|
|
235
|
-
|
|
236
492
|
def cleanup_routes(ctx, hostnames)
|
|
237
493
|
hostnames.each do |h|
|
|
238
494
|
begin
|
data/lib/yamine/cli/context.rb
CHANGED