yamine 0.3.0 → 0.5.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 +19 -0
- data/lib/yamine/agent.rb +43 -0
- data/lib/yamine/cli/boot.rb +123 -6
- data/lib/yamine/cli/routes.rb +72 -5
- data/lib/yamine/cli/system.rb +128 -0
- data/lib/yamine/cli.rb +5 -1
- data/lib/yamine/database.rb +193 -0
- data/lib/yamine/errors.rb +23 -4
- data/lib/yamine/proxy_control.rb +2 -2
- data/lib/yamine/route_store.rb +51 -20
- data/lib/yamine/runner.rb +13 -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: 0c28579a9f53f3078412006a6f697d366cd7f0b2f897e7cf52025b742b2d5ba2
|
|
4
|
+
data.tar.gz: e7c826ffece34cf08ac88fcb31d1613418a9f3f5713badb08f6725937d906409
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1a77d9dad1424f4cdbd0fa351224f626a858e986c6300063a70650dc7c02007f8ea8cbb83e2658a5f06e1a3ff8ba091986ce1480c28e510d9a096c287cc596d
|
|
7
|
+
data.tar.gz: 9194bc35c52acdb669a177bb758af0539c2983056c8e7e4dbd700d937ea850a2947708fef0315d8e2d6281b33850bb96a2de4f26abb0878a2b1b8d3db91cd777
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] — Unreleased
|
|
4
|
+
|
|
5
|
+
Per-worktree databases: every worktree directory gets its own database
|
|
6
|
+
so concurrent agents never share tables.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
|
|
11
|
+
(`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
|
|
12
|
+
name), 63-byte Postgres cap with hash-suffix truncation, collision
|
|
13
|
+
guard via state map (silent sharing would be the bug this kills).
|
|
14
|
+
- Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
|
|
15
|
+
never raises — warns and shares template on failure), runs the app's
|
|
16
|
+
schema-load (`db.schema_load` in config, Rails default, else nothing),
|
|
17
|
+
and injects `DATABASE_URL` into every process. SQLite left alone.
|
|
18
|
+
- `yamine db list|create|drop` and `yamine worktree list|clean`
|
|
19
|
+
(orphan reaping for deleted worktrees). Branch plays no part —
|
|
20
|
+
directories are stable, branches hop.
|
|
21
|
+
|
|
3
22
|
## [0.3.0] — Unreleased
|
|
4
23
|
|
|
5
24
|
Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
|
data/lib/yamine/agent.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
# Agent identity for multi-agent codebases.
|
|
5
|
+
#
|
|
6
|
+
# Every route records which agent booted it so concurrent agents can
|
|
7
|
+
# coexist: list shows owners, conflicts name the other agent, quotas
|
|
8
|
+
# are enforced per agent, and stop/prune can target one agent without
|
|
9
|
+
# touching another's live backends. Identity comes from YAMINE_AGENT
|
|
10
|
+
# (set by the harness per session); without it we fall back to
|
|
11
|
+
# USER@HOST so humans sharing a machine still get distinct owners.
|
|
12
|
+
module Agent
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
env = ENV["YAMINE_AGENT"]
|
|
17
|
+
return env.strip unless env.nil? || env.strip.empty?
|
|
18
|
+
|
|
19
|
+
user = ENV["USER"] || ENV["LOGNAME"] || "unknown"
|
|
20
|
+
host = begin
|
|
21
|
+
require "socket"
|
|
22
|
+
Socket.gethostname
|
|
23
|
+
rescue StandardError
|
|
24
|
+
"localhost"
|
|
25
|
+
end
|
|
26
|
+
"#{user}@#{host}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Default per-agent route cap. Generous: N agents x M services must
|
|
30
|
+
# fit in the ephemeral port range with headroom. Overridable via
|
|
31
|
+
# YAMINE_MAX_ROUTES (0 disables the cap).
|
|
32
|
+
DEFAULT_MAX_ROUTES = 32
|
|
33
|
+
|
|
34
|
+
def max_routes
|
|
35
|
+
raw = ENV["YAMINE_MAX_ROUTES"]
|
|
36
|
+
return DEFAULT_MAX_ROUTES if raw.nil? || raw.strip.empty?
|
|
37
|
+
|
|
38
|
+
Integer(raw)
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
DEFAULT_MAX_ROUTES
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -18,6 +18,9 @@ module Yamine
|
|
|
18
18
|
variant = ENV["YAMINE_VARIANT"]
|
|
19
19
|
opts = ctx.parse_flags(args, %i[variant tld force])
|
|
20
20
|
resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
|
|
21
|
+
# Ownership gate before any side effects: no proxy spawn, no
|
|
22
|
+
# port allocation when we'd refuse anyway.
|
|
23
|
+
check_worktree_ownership!(ctx, resolved, force: opts[:force])
|
|
21
24
|
ensure_proxy!(ctx)
|
|
22
25
|
boot_all(ctx, resolved, opts)
|
|
23
26
|
end
|
|
@@ -59,10 +62,23 @@ module Yamine
|
|
|
59
62
|
puts "yamine (#{service})"
|
|
60
63
|
puts "--"
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
# Per-worktree database: one database per directory so concurrent
|
|
66
|
+
# agents never share tables. Resolved once, injected into every
|
|
67
|
+
# process as DATABASE_URL, created on demand with the app's own
|
|
68
|
+
# schema-load command. SQLite needs nothing (relative paths
|
|
69
|
+
# already isolate); the branch plays no part (dirs are stable,
|
|
70
|
+
# branches hop).
|
|
71
|
+
db_name = Database.name_for(Dir.pwd, env: rails_env,
|
|
72
|
+
state_dir: ctx.store.dir)
|
|
73
|
+
db_url = setup_database(ctx, resolved, db_name)
|
|
74
|
+
puts " [db] #{db_name}" if db_url
|
|
64
75
|
|
|
76
|
+
processes.each do |proc_name, entry|
|
|
65
77
|
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
78
|
+
if entry["proxy"] == false
|
|
79
|
+
boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
|
|
80
|
+
next
|
|
81
|
+
end
|
|
66
82
|
next unless hostname
|
|
67
83
|
|
|
68
84
|
url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
|
|
@@ -82,9 +98,11 @@ module Yamine
|
|
|
82
98
|
shell_cmd = ["sh", "-c", cmd]
|
|
83
99
|
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
84
100
|
# this env var when not a Rails app — safe for every framework).
|
|
101
|
+
# DATABASE_URL points at this worktree's own database; non-Ruby
|
|
102
|
+
# frameworks that honor it get isolation for free, others ignore it.
|
|
85
103
|
app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
|
|
86
104
|
dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
|
|
87
|
-
rails_dev_host: hostname)
|
|
105
|
+
rails_dev_host: hostname, database_url: db_url)
|
|
88
106
|
register_all(ctx, hostnames, app, force: opts[:force],
|
|
89
107
|
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
|
|
90
108
|
routes_registered << { hostnames: hostnames, app: app }
|
|
@@ -101,9 +119,50 @@ module Yamine
|
|
|
101
119
|
ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
|
|
102
120
|
|
|
103
121
|
# Supervisor: exit when ANY child dies (loud cleanup).
|
|
104
|
-
all_pids = routes_registered.map { |r| r[:app].pid }
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
|
|
123
|
+
all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
|
|
124
|
+
trap_cleanup(ctx, all_hostnames, all_pids)
|
|
125
|
+
supervise_tree(ctx, all_hostnames, all_pids)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A background process (proxy: false) is spawned, logged, and
|
|
129
|
+
# supervised exactly like an HTTP one — it just gets no route.
|
|
130
|
+
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
|
|
131
|
+
cmd = entry["cmd"].to_s
|
|
132
|
+
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
133
|
+
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
port = opts[:app_port] || Ports.find_free
|
|
137
|
+
url = "background://#{resolved.app}/#{proc_name}"
|
|
138
|
+
app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
|
|
139
|
+
url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
|
|
140
|
+
force: opts[:force], rails_dev_host: nil, register: false,
|
|
141
|
+
database_url: db_url)
|
|
142
|
+
children << { name: proc_name, pid: app.pid }
|
|
143
|
+
puts " [#{proc_name}] background (pid #{app.pid})"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Refuse to boot over another agent's live routes unless forced.
|
|
147
|
+
# Compares our worktree dir (spec.dir) against the owner's: same
|
|
148
|
+
# dir + same agent is a restart, anything else names the owner.
|
|
149
|
+
def check_worktree_ownership!(ctx, resolved, force:)
|
|
150
|
+
mine = Agent.name
|
|
151
|
+
here = File.expand_path(Dir.pwd)
|
|
152
|
+
Resolver.hostnames(resolved).each do |hostname|
|
|
153
|
+
entry = ctx.store.find(hostname)
|
|
154
|
+
next unless entry
|
|
155
|
+
next unless entry["pid"] != 0 && ProxyControl.pid_alive?(entry["pid"])
|
|
156
|
+
next if entry["agent"] == mine && entry.dig("spec", "dir") == here
|
|
157
|
+
next if force
|
|
158
|
+
|
|
159
|
+
owner = entry["agent"] && !entry["agent"].empty? ? "agent #{entry["agent"].inspect}" : "PID #{entry["pid"]}"
|
|
160
|
+
dir = entry.dig("spec", "dir")
|
|
161
|
+
$stderr.puts "Error: #{hostname} is live and owned by #{owner}#{dir ? " (#{dir})" : ""}."
|
|
162
|
+
$stderr.puts " Work in your own worktree (each branch gets its own URL), or take over explicitly:"
|
|
163
|
+
$stderr.puts " yamine start --force"
|
|
164
|
+
exit 1
|
|
165
|
+
end
|
|
107
166
|
end
|
|
108
167
|
|
|
109
168
|
def build_env(ctx, resolved, entry)
|
|
@@ -121,6 +180,64 @@ module Yamine
|
|
|
121
180
|
env
|
|
122
181
|
end
|
|
123
182
|
|
|
183
|
+
# Rails env for database naming: RAILS_ENV when set, else development.
|
|
184
|
+
# Non-Rails apps ignore it (their DATABASE_URL template decides).
|
|
185
|
+
def rails_env
|
|
186
|
+
ENV.fetch("RAILS_ENV", "development")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Resolve this worktree's database, create it if missing, and run
|
|
190
|
+
# the app's schema-load command on first creation. Returns the
|
|
191
|
+
# DATABASE_URL for injection, or nil when there's nothing to do
|
|
192
|
+
# (no template URL, sqlite, or the app opted out via db: false).
|
|
193
|
+
def setup_database(ctx, resolved, db_name)
|
|
194
|
+
return nil if resolved.processes["db"] == false
|
|
195
|
+
|
|
196
|
+
template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
|
|
197
|
+
return nil if template.nil? || template.strip.empty?
|
|
198
|
+
|
|
199
|
+
url = Database.url_for(db_name, template)
|
|
200
|
+
return nil unless url
|
|
201
|
+
|
|
202
|
+
if Database.ensure_exists(db_name, template)
|
|
203
|
+
puts " [db] created #{db_name}" unless database_exists?(db_name, template)
|
|
204
|
+
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
|
+
end
|
|
209
|
+
url
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def database_exists?(name, template)
|
|
213
|
+
# ensure_exists is idempotent; this second call is cheap (psql -lqt)
|
|
214
|
+
# and tells us whether to print "created" vs nothing.
|
|
215
|
+
Database.ensure_exists(name, template)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def database_template_from_config(resolved)
|
|
219
|
+
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
220
|
+
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
221
|
+
clear["DATABASE_URL"]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Schema-load on first boot uses the app's own command when declared
|
|
225
|
+
# (db.schema_load in config/local.yml), else a Rails default when a
|
|
226
|
+
# Rails app is detected, else nothing (frameworks without a
|
|
227
|
+
# schema concept need no step).
|
|
228
|
+
def run_schema_load(resolved, url)
|
|
229
|
+
cmd = resolved.processes.dig("db", "schema_load") ||
|
|
230
|
+
("bin/rails db:schema:load" if rails_app?)
|
|
231
|
+
return unless cmd
|
|
232
|
+
|
|
233
|
+
system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
|
|
234
|
+
"sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def rails_app?
|
|
238
|
+
File.file?(File.join(Dir.pwd, "config", "application.rb"))
|
|
239
|
+
end
|
|
240
|
+
|
|
124
241
|
def supervise_tree(ctx, hostnames, pids)
|
|
125
242
|
loop do
|
|
126
243
|
sleep 0.5
|
data/lib/yamine/cli/routes.rb
CHANGED
|
@@ -8,12 +8,16 @@ module Yamine
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
# yamine get <name> [--service x] [--variant y] [--tld z]
|
|
11
|
+
# yamine get --all [--json]
|
|
11
12
|
#
|
|
12
13
|
# Variant and TLDs are inherited from the CURRENT directory's
|
|
13
14
|
# context (worktree branch, YAMINE_* env, config) so cross-service
|
|
14
15
|
# wiring works inside a variant: from a fix-ui worktree,
|
|
15
16
|
# `get backend` -> https://fix-ui.backend.localhost.
|
|
16
17
|
def get(ctx, args)
|
|
18
|
+
if args.delete("--all")
|
|
19
|
+
return registry(ctx, args)
|
|
20
|
+
end
|
|
17
21
|
name = args.first
|
|
18
22
|
raise Error, "Usage: yamine get <name> [--service s] [--variant v] [--tld t]" unless name
|
|
19
23
|
|
|
@@ -36,7 +40,8 @@ module Yamine
|
|
|
36
40
|
{ hostname: r["hostname"],
|
|
37
41
|
url: Hostname.url(r["hostname"], port: port, tls: tls),
|
|
38
42
|
target: r["target"], kind: r["kind"],
|
|
39
|
-
pid: r["pid"],
|
|
43
|
+
pid: r["pid"], agent: r["agent"],
|
|
44
|
+
supervised: !r["spec"].nil?,
|
|
40
45
|
alive: alive_state(ctx, r) }
|
|
41
46
|
end
|
|
42
47
|
if json
|
|
@@ -56,6 +61,34 @@ module Yamine
|
|
|
56
61
|
puts
|
|
57
62
|
end
|
|
58
63
|
|
|
64
|
+
# Shared discovery: `get --all` lists every live route (any owner)
|
|
65
|
+
# with its URL and agent, so one agent can find another's services
|
|
66
|
+
# without coupling. `--json` emits the same stable keys as list.
|
|
67
|
+
def registry(ctx, args)
|
|
68
|
+
json = args.delete("--json")
|
|
69
|
+
routes = ctx.store.load_routes
|
|
70
|
+
port = ctx.proxy_port
|
|
71
|
+
tls = ctx.proxy_tls
|
|
72
|
+
entries = routes.map do |r|
|
|
73
|
+
{ hostname: r["hostname"],
|
|
74
|
+
url: Hostname.url(r["hostname"], port: port, tls: tls),
|
|
75
|
+
agent: r["agent"], alive: alive_state(ctx, r) }
|
|
76
|
+
end
|
|
77
|
+
if json
|
|
78
|
+
require "json"
|
|
79
|
+
puts JSON.generate({ routes: entries })
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
if entries.empty?
|
|
83
|
+
puts "No active routes."
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
entries.each do |e|
|
|
87
|
+
owner = e[:agent] ? " (#{e[:agent]})" : ""
|
|
88
|
+
puts " #{e[:url]}#{owner}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
59
92
|
def alive_state(ctx, route)
|
|
60
93
|
if route["pid"] == 0
|
|
61
94
|
ctx.backend_alive?(route) ? "reachable" : "unreachable"
|
|
@@ -69,10 +102,11 @@ module Yamine
|
|
|
69
102
|
end
|
|
70
103
|
|
|
71
104
|
def label_for(entry)
|
|
105
|
+
owner = entry[:agent] ? " #{entry[:agent]}" : ""
|
|
72
106
|
if entry[:pid] == 0
|
|
73
|
-
"(alias, #{entry[:alive]})"
|
|
107
|
+
"(alias, #{entry[:alive]}#{owner})"
|
|
74
108
|
else
|
|
75
|
-
"(pid #{entry[:pid]}, #{entry[:alive]})"
|
|
109
|
+
"(pid #{entry[:pid]}, #{entry[:alive]}#{owner})"
|
|
76
110
|
end
|
|
77
111
|
end
|
|
78
112
|
|
|
@@ -84,7 +118,19 @@ module Yamine
|
|
|
84
118
|
label_for(entry)
|
|
85
119
|
end
|
|
86
120
|
|
|
87
|
-
def prune(ctx,
|
|
121
|
+
def prune(ctx, args)
|
|
122
|
+
if (i = args.index("--agent"))
|
|
123
|
+
agent = args.fetch(i + 1, nil)
|
|
124
|
+
raise Error, "Usage: yamine prune --agent NAME" if agent.nil? || agent.empty?
|
|
125
|
+
|
|
126
|
+
stale = ctx.store.prune_stale(agent: agent)
|
|
127
|
+
if stale.empty?
|
|
128
|
+
puts "No stale routes for agent #{agent.inspect}."
|
|
129
|
+
else
|
|
130
|
+
stale.each { |r| puts "Removed stale route #{r["hostname"]}." }
|
|
131
|
+
end
|
|
132
|
+
return
|
|
133
|
+
end
|
|
88
134
|
stale = ctx.store.prune_stale
|
|
89
135
|
if stale.empty?
|
|
90
136
|
puts "No stale routes."
|
|
@@ -124,15 +170,31 @@ module Yamine
|
|
|
124
170
|
# Stop the app in the current directory (route + backend).
|
|
125
171
|
# Exit codes are machine-readable for agents: 0 stopped something,
|
|
126
172
|
# 2 no route here, 3 route existed but the backend was already gone.
|
|
127
|
-
|
|
173
|
+
# Only our own agent's routes are touched: a foreign-owned live
|
|
174
|
+
# route is reported, never killed (shared backends survive one
|
|
175
|
+
# agent's cleanup). --force overrides and names the previous owner.
|
|
176
|
+
def stop(ctx, args, out: $stdout)
|
|
177
|
+
force = args.delete("--force")
|
|
128
178
|
resolved = Resolver.resolve(Dir.pwd)
|
|
129
179
|
hostnames = Resolver.hostnames(resolved)
|
|
180
|
+
mine = Agent.name
|
|
130
181
|
stopped = []
|
|
131
182
|
gone = []
|
|
183
|
+
foreign = []
|
|
132
184
|
hostnames.each do |hostname|
|
|
133
185
|
entry = ctx.store.find(hostname)
|
|
134
186
|
next unless entry
|
|
135
187
|
|
|
188
|
+
if entry["pid"] != 0 && entry["agent"] && entry["agent"] != mine &&
|
|
189
|
+
ProxyControl.pid_alive?(entry["pid"])
|
|
190
|
+
unless force
|
|
191
|
+
owner = entry["agent"].empty? ? "PID #{entry["pid"]}" : "agent #{entry["agent"].inspect}"
|
|
192
|
+
foreign << "#{hostname} (owned by #{owner})"
|
|
193
|
+
next
|
|
194
|
+
end
|
|
195
|
+
out.puts "Taking over #{hostname} from #{entry["agent"].inspect} (--force)."
|
|
196
|
+
end
|
|
197
|
+
|
|
136
198
|
backend_pid = ctx.backend_pid_for(entry)
|
|
137
199
|
if backend_pid && ProxyControl.pid_alive?(backend_pid)
|
|
138
200
|
begin
|
|
@@ -155,6 +217,11 @@ module Yamine
|
|
|
155
217
|
stopped.each { |s| out.puts "Stopped #{s}." }
|
|
156
218
|
return 0
|
|
157
219
|
end
|
|
220
|
+
if foreign.any?
|
|
221
|
+
out.puts "Skipped live routes owned by other agents: #{foreign.join(", ")}."
|
|
222
|
+
out.puts "Take over explicitly with `yamine stop --force`."
|
|
223
|
+
return 4
|
|
224
|
+
end
|
|
158
225
|
if gone.any?
|
|
159
226
|
out.puts "Route existed but the backend was already gone: #{gone.join(", ")}."
|
|
160
227
|
return 3
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -763,6 +763,134 @@ module Yamine
|
|
|
763
763
|
puts " - #{app}-#{slug}.#{domain}"
|
|
764
764
|
puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
|
|
765
765
|
end
|
|
766
|
+
|
|
767
|
+
# Per-worktree databases: list/create/drop, plus orphan cleanup.
|
|
768
|
+
# `db list` shows every claimed database with its worktree dir.
|
|
769
|
+
# `db create` resolves this worktree's name and creates it.
|
|
770
|
+
# `db drop <name>` drops one database (refuses without --force
|
|
771
|
+
# when its worktree still exists).
|
|
772
|
+
def db(ctx, args)
|
|
773
|
+
sub = args.first
|
|
774
|
+
case sub
|
|
775
|
+
when "list", nil then db_list(ctx)
|
|
776
|
+
when "create" then db_create(ctx)
|
|
777
|
+
when "drop" then db_drop(ctx, args[1..] || [])
|
|
778
|
+
else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def db_list(ctx)
|
|
783
|
+
map = Database.load_map(ctx.store.dir)
|
|
784
|
+
if map.empty?
|
|
785
|
+
puts "No worktree databases claimed yet."
|
|
786
|
+
puts "Boot an app with a DATABASE_URL template to create one."
|
|
787
|
+
return
|
|
788
|
+
end
|
|
789
|
+
map.each do |name, info|
|
|
790
|
+
alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
|
|
791
|
+
puts " #{name} -> #{info["dir"]}#{alive}"
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def db_create(ctx)
|
|
796
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
797
|
+
name = Database.name_for(Dir.pwd, env: boot_env_name,
|
|
798
|
+
state_dir: ctx.store.dir)
|
|
799
|
+
template = ENV["DATABASE_URL"]
|
|
800
|
+
unless template && !template.strip.empty?
|
|
801
|
+
$stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
|
|
802
|
+
exit 1
|
|
803
|
+
end
|
|
804
|
+
if Database.ensure_exists(name, template)
|
|
805
|
+
puts "Database #{name} ready."
|
|
806
|
+
else
|
|
807
|
+
$stderr.puts "Error: could not create #{name}. Check the database server is running."
|
|
808
|
+
exit 1
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def db_drop(ctx, args)
|
|
813
|
+
force = args.delete("--force")
|
|
814
|
+
name = args.first
|
|
815
|
+
raise Error, "Usage: yamine db drop <name> [--force]" unless name
|
|
816
|
+
|
|
817
|
+
map = Database.load_map(ctx.store.dir)
|
|
818
|
+
info = map[name]
|
|
819
|
+
if info && File.directory?(info["dir"]) && !force
|
|
820
|
+
$stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
|
|
821
|
+
exit 1
|
|
822
|
+
end
|
|
823
|
+
if drop_database(ctx, name)
|
|
824
|
+
map.delete(name)
|
|
825
|
+
Database.save_map(ctx.store.dir, map)
|
|
826
|
+
puts "Dropped #{name}."
|
|
827
|
+
else
|
|
828
|
+
$stderr.puts "Error: could not drop #{name}."
|
|
829
|
+
exit 1
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def drop_database(_ctx, name)
|
|
834
|
+
template = ENV["DATABASE_URL"]
|
|
835
|
+
return false unless template && !template.strip.empty?
|
|
836
|
+
|
|
837
|
+
case Database.adapter_for(template)
|
|
838
|
+
when :postgres
|
|
839
|
+
uri = URI.parse(template)
|
|
840
|
+
_out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
|
|
841
|
+
status.success?
|
|
842
|
+
when :mysql
|
|
843
|
+
uri = URI.parse(template)
|
|
844
|
+
args = ["-h", uri.host || "127.0.0.1", "-u",
|
|
845
|
+
URI.decode_www_form_component(uri.user || "root")]
|
|
846
|
+
_out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
|
|
847
|
+
status.success?
|
|
848
|
+
else
|
|
849
|
+
false
|
|
850
|
+
end
|
|
851
|
+
rescue SystemCallError
|
|
852
|
+
false
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def boot_env_name
|
|
856
|
+
ENV.fetch("RAILS_ENV", "development")
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Worktree helpers: list claimed databases with liveness, clean
|
|
860
|
+
# drops databases whose worktree dirs are gone.
|
|
861
|
+
def worktree(ctx, args)
|
|
862
|
+
sub = args.first
|
|
863
|
+
case sub
|
|
864
|
+
when "list", nil
|
|
865
|
+
map = Database.load_map(ctx.store.dir)
|
|
866
|
+
if map.empty?
|
|
867
|
+
puts "No worktree databases claimed yet."
|
|
868
|
+
return
|
|
869
|
+
end
|
|
870
|
+
map.each do |name, info|
|
|
871
|
+
alive = File.directory?(info["dir"]) ? "alive" : "gone"
|
|
872
|
+
puts " #{name} #{info["dir"]} (#{alive})"
|
|
873
|
+
end
|
|
874
|
+
when "clean"
|
|
875
|
+
orphaned = Database.orphaned(ctx.store.dir)
|
|
876
|
+
if orphaned.empty?
|
|
877
|
+
puts "No orphaned worktree databases."
|
|
878
|
+
return
|
|
879
|
+
end
|
|
880
|
+
orphaned.each do |name, _info|
|
|
881
|
+
if drop_database(ctx, name)
|
|
882
|
+
puts "Dropped orphaned #{name}."
|
|
883
|
+
else
|
|
884
|
+
$stderr.puts "Could not drop #{name} — remove manually."
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
map = Database.load_map(ctx.store.dir)
|
|
888
|
+
orphaned.each_key { |name| map.delete(name) }
|
|
889
|
+
Database.save_map(ctx.store.dir, map)
|
|
890
|
+
else
|
|
891
|
+
raise Error, "Usage: yamine worktree [list|clean]"
|
|
892
|
+
end
|
|
893
|
+
end
|
|
766
894
|
end
|
|
767
895
|
end
|
|
768
896
|
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
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
# Create the database if missing. Uses createdb/mysqladmin when
|
|
140
|
+
# available; returns true/false, never raises (boot reports, not dies).
|
|
141
|
+
def ensure_exists(name, database_url)
|
|
142
|
+
case adapter_for(database_url)
|
|
143
|
+
when :postgres then ensure_postgres(name, database_url)
|
|
144
|
+
when :mysql then ensure_mysql(name, database_url)
|
|
145
|
+
else false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ensure_postgres(name, database_url)
|
|
150
|
+
uri = URI.parse(database_url)
|
|
151
|
+
env = pg_env(uri)
|
|
152
|
+
_out, status = Open3.capture2(env, "psql", "-lqt")
|
|
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
|
|
159
|
+
|
|
160
|
+
_out, status = Open3.capture2(env, "createdb", name)
|
|
161
|
+
status.success?
|
|
162
|
+
rescue SystemCallError
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ensure_mysql(name, database_url)
|
|
167
|
+
uri = URI.parse(database_url)
|
|
168
|
+
args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
|
|
169
|
+
"-u", URI.decode_www_form_component(uri.user || "root")]
|
|
170
|
+
args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
|
|
171
|
+
_out, status = Open3.capture2("mysqladmin", *args, "create", name)
|
|
172
|
+
status.success? || already_exists_mysql(args, name)
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def already_exists_mysql(args, name)
|
|
178
|
+
out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
|
|
179
|
+
status.success? && out.split("\n").include?(name)
|
|
180
|
+
rescue SystemCallError
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def pg_env(uri)
|
|
185
|
+
{
|
|
186
|
+
"PGHOST" => uri.host || "127.0.0.1",
|
|
187
|
+
"PGPORT" => (uri.port || 5432).to_s,
|
|
188
|
+
"PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
|
|
189
|
+
"PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
|
|
190
|
+
}.compact
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/yamine/errors.rb
CHANGED
|
@@ -4,13 +4,32 @@ module Yamine
|
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
class ConfigError < Error; end
|
|
6
6
|
class RouteConflictError < Error
|
|
7
|
-
attr_reader :hostname, :existing_pid
|
|
7
|
+
attr_reader :hostname, :existing_pid, :existing_agent, :existing_dir
|
|
8
8
|
|
|
9
|
-
def initialize(hostname, existing_pid)
|
|
9
|
+
def initialize(hostname, existing_pid, existing_agent: nil, existing_dir: nil)
|
|
10
10
|
@hostname = hostname
|
|
11
11
|
@existing_pid = existing_pid
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
@existing_agent = existing_agent
|
|
13
|
+
@existing_dir = existing_dir
|
|
14
|
+
owner = if existing_agent && !existing_agent.empty?
|
|
15
|
+
"agent #{existing_agent.inspect}#{existing_dir ? " (#{existing_dir})" : ""}"
|
|
16
|
+
else
|
|
17
|
+
"PID #{existing_pid}"
|
|
18
|
+
end
|
|
19
|
+
super("\"#{hostname}\" is already registered by a running #{owner}. " \
|
|
20
|
+
"Use --force to override.")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
class QuotaExceededError < Error
|
|
24
|
+
attr_reader :agent, :limit, :count
|
|
25
|
+
|
|
26
|
+
def initialize(agent, limit, count)
|
|
27
|
+
@agent = agent
|
|
28
|
+
@limit = limit
|
|
29
|
+
@count = count
|
|
30
|
+
super("Agent #{agent.inspect} already owns #{count} routes (limit #{limit}). " \
|
|
31
|
+
"Stop something first: `yamine stop`, or prune one agent's orphans " \
|
|
32
|
+
"with `yamine prune --agent NAME`.")
|
|
14
33
|
end
|
|
15
34
|
end
|
|
16
35
|
class ProxyNotRunningError < Error; end
|
data/lib/yamine/proxy_control.rb
CHANGED
|
@@ -16,11 +16,11 @@ module Yamine
|
|
|
16
16
|
module_function
|
|
17
17
|
|
|
18
18
|
# Path to the yamine executable relative to this file
|
|
19
|
-
# (lib/
|
|
19
|
+
# (lib/yamine -> gem root/bin). Used for daemon spawn and
|
|
20
20
|
# service install; both must resolve identically in dev checkouts
|
|
21
21
|
# and installed gems.
|
|
22
22
|
def bin_path
|
|
23
|
-
File.expand_path("
|
|
23
|
+
File.expand_path("../../bin/yamine", __dir__)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def root?
|
data/lib/yamine/route_store.rb
CHANGED
|
@@ -4,12 +4,14 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
module Yamine
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
# Persistent hostname -> backend mapping in ~/.yamine/routes.json.
|
|
8
|
+
#
|
|
9
|
+
# Entries: {hostname, target, kind, pid, agent} where target is a unix
|
|
10
|
+
# socket path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), pid 0
|
|
11
|
+
# marks a static alias, and agent names the owner (YAMINE_AGENT or
|
|
12
|
+
# USER@HOST fallback) so concurrent agents can coexist. Guarded by
|
|
13
|
+
# flock; stale PIDs pruned on read.
|
|
14
|
+
# Modeled on portless RouteStore and puma-dev's AppPool registry.
|
|
13
15
|
class RouteStore
|
|
14
16
|
FILE_MODE = 0o644
|
|
15
17
|
DIR_MODE = 0o755
|
|
@@ -71,13 +73,22 @@ module Yamine
|
|
|
71
73
|
# Returns killed pid when force takes over a live route.
|
|
72
74
|
# `spec` marks daemon-supervised managed apps ({dir: ...}); the
|
|
73
75
|
# proxy supervisor may idle-kill and boot-on-request those backends.
|
|
74
|
-
|
|
76
|
+
# `agent` records the owner (defaults to Agent.name); foreign-owned
|
|
77
|
+
# live routes can only be taken with force: true, and the conflict
|
|
78
|
+
# error names the other agent plus its worktree dir.
|
|
79
|
+
def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil)
|
|
80
|
+
agent ||= Agent.name
|
|
75
81
|
killed = nil
|
|
76
82
|
with_lock do
|
|
77
83
|
routes = load_routes(prune: true)
|
|
84
|
+
enforce_quota!(routes, agent, hostname)
|
|
78
85
|
existing = routes.find { |r| r["hostname"] == hostname }
|
|
79
86
|
if existing && existing["pid"] != pid && alive?(existing["pid"])
|
|
80
|
-
|
|
87
|
+
unless force
|
|
88
|
+
raise RouteConflictError.new(hostname, existing["pid"],
|
|
89
|
+
existing_agent: existing["agent"],
|
|
90
|
+
existing_dir: existing.dig("spec", "dir"))
|
|
91
|
+
end
|
|
81
92
|
|
|
82
93
|
begin
|
|
83
94
|
Process.kill("TERM", existing["pid"])
|
|
@@ -87,7 +98,8 @@ module Yamine
|
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
routes.reject! { |r| r["hostname"] == hostname }
|
|
90
|
-
entry = { "hostname" => hostname, "target" => target, "kind" => kind,
|
|
101
|
+
entry = { "hostname" => hostname, "target" => target, "kind" => kind,
|
|
102
|
+
"pid" => pid, "agent" => agent }
|
|
91
103
|
entry["spec"] = spec if spec
|
|
92
104
|
routes << entry
|
|
93
105
|
save_routes(routes)
|
|
@@ -95,33 +107,52 @@ module Yamine
|
|
|
95
107
|
killed
|
|
96
108
|
end
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
routes.reject! do |r|
|
|
102
|
-
r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
|
|
103
|
-
end
|
|
104
|
-
save_routes(routes)
|
|
105
|
-
end
|
|
110
|
+
# Live routes owned by one agent (for quotas, targeted prune/stop).
|
|
111
|
+
def routes_for(agent)
|
|
112
|
+
load_routes.select { |r| r["agent"] == agent }
|
|
106
113
|
end
|
|
107
114
|
|
|
108
|
-
|
|
115
|
+
# Remove stale entries belonging to one agent only; other agents'
|
|
116
|
+
# routes are untouched even when their owners are dead.
|
|
117
|
+
def prune_stale(agent: nil)
|
|
109
118
|
stale = []
|
|
110
119
|
with_lock do
|
|
111
120
|
all = load_routes_raw
|
|
112
121
|
alive, dead = all.partition { |r| r["pid"] == 0 || alive?(r["pid"]) }
|
|
113
|
-
stale = dead
|
|
114
|
-
save_routes(alive) unless
|
|
122
|
+
stale = agent ? dead.select { |r| r["agent"] == agent } : dead
|
|
123
|
+
save_routes(alive + (dead - stale)) unless stale.empty?
|
|
115
124
|
end
|
|
116
125
|
stale
|
|
117
126
|
end
|
|
118
127
|
|
|
128
|
+
def remove_route(hostname, owner_pid: nil)
|
|
129
|
+
with_lock do
|
|
130
|
+
routes = load_routes(prune: true)
|
|
131
|
+
routes.reject! do |r|
|
|
132
|
+
r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
|
|
133
|
+
end
|
|
134
|
+
save_routes(routes)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
119
138
|
def find(hostname)
|
|
120
139
|
load_routes.find { |r| r["hostname"] == hostname }
|
|
121
140
|
end
|
|
122
141
|
|
|
123
142
|
private
|
|
124
143
|
|
|
144
|
+
# Per-agent route cap. Counts the agent's live routes (excluding the
|
|
145
|
+
# hostname being (re)registered, so restarts never trip the quota).
|
|
146
|
+
# Limit 0 disables the cap. Raises QuotaExceededError naming the
|
|
147
|
+
# agent, the count, and the fix.
|
|
148
|
+
def enforce_quota!(routes, agent, hostname)
|
|
149
|
+
limit = Agent.max_routes
|
|
150
|
+
return if limit.zero?
|
|
151
|
+
|
|
152
|
+
count = routes.count { |r| r["agent"] == agent && r["hostname"] != hostname }
|
|
153
|
+
raise QuotaExceededError.new(agent, limit, count) if count >= limit
|
|
154
|
+
end
|
|
155
|
+
|
|
125
156
|
def valid_route?(value)
|
|
126
157
|
value.is_a?(Hash) &&
|
|
127
158
|
value["hostname"].is_a?(String) &&
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -90,20 +90,24 @@ module Yamine
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
# Run an arbitrary command with PORT + YAMINE_URL. Returns App.
|
|
93
|
+
# register: false spawns without a route (background processes).
|
|
93
94
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
94
|
-
rails_dev_host: nil)
|
|
95
|
+
rails_dev_host: nil, register: true, database_url: nil)
|
|
95
96
|
port ||= Ports.find_free
|
|
96
|
-
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host
|
|
97
|
+
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
98
|
+
database_url: database_url)
|
|
97
99
|
pid = with_clean_env { spawn(env, *command, chdir: dir) }
|
|
98
100
|
Process.detach(pid)
|
|
99
101
|
target = "127.0.0.1:#{port}"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
if register
|
|
103
|
+
@store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
|
|
104
|
+
write_backend_pid(hostname, pid)
|
|
105
|
+
end
|
|
102
106
|
App.new(name: name, hostname: hostname, url: url, pid: pid,
|
|
103
107
|
target: target, kind: "tcp", command: command)
|
|
104
108
|
end
|
|
105
109
|
|
|
106
|
-
def child_env(dir, url:, port:, rails_dev_host: nil)
|
|
110
|
+
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
|
|
107
111
|
env = { "YAMINE_URL" => url }
|
|
108
112
|
env["PORT"] = port.to_s if port
|
|
109
113
|
env["HOST"] = "127.0.0.1"
|
|
@@ -113,6 +117,10 @@ module Yamine
|
|
|
113
117
|
# proxied hostname so Rails apps boot behind yamine with zero
|
|
114
118
|
# config — this replaces the yamine-rails hosts patch.
|
|
115
119
|
env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
|
|
120
|
+
# Per-worktree database: every process in this worktree shares one
|
|
121
|
+
# isolated database. Frameworks that honor DATABASE_URL (Rails,
|
|
122
|
+
# Django, most Node) get isolation for free; others ignore it.
|
|
123
|
+
env["DATABASE_URL"] = database_url if database_url
|
|
116
124
|
env
|
|
117
125
|
end
|
|
118
126
|
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -82,6 +82,7 @@ files:
|
|
|
82
82
|
- bin/yamine
|
|
83
83
|
- lib/ask/skills/yamine/SKILL.md
|
|
84
84
|
- lib/yamine.rb
|
|
85
|
+
- lib/yamine/agent.rb
|
|
85
86
|
- lib/yamine/certs.rb
|
|
86
87
|
- lib/yamine/cli.rb
|
|
87
88
|
- lib/yamine/cli/boot.rb
|
|
@@ -90,6 +91,7 @@ files:
|
|
|
90
91
|
- lib/yamine/cli/system.rb
|
|
91
92
|
- lib/yamine/command.rb
|
|
92
93
|
- lib/yamine/config.rb
|
|
94
|
+
- lib/yamine/database.rb
|
|
93
95
|
- lib/yamine/doctor.rb
|
|
94
96
|
- lib/yamine/errors.rb
|
|
95
97
|
- lib/yamine/framework.rb
|