yamine 0.15.2 → 0.17.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 +130 -0
- data/README.md +96 -6
- data/lib/ask/skills/yamine/SKILL.md +40 -13
- data/lib/yamine/cli/boot.rb +311 -42
- data/lib/yamine/cli/system.rb +105 -26
- data/lib/yamine/cli/worktree.rb +145 -45
- data/lib/yamine/cli.rb +1 -1
- data/lib/yamine/database.rb +171 -4
- data/lib/yamine/probe.rb +136 -0
- data/lib/yamine/proxy.rb +6 -1
- data/lib/yamine/readiness.rb +27 -2
- data/lib/yamine/runner.rb +18 -10
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
data/lib/yamine/database.rb
CHANGED
|
@@ -32,6 +32,17 @@ module Yamine
|
|
|
32
32
|
|
|
33
33
|
MAX_IDENTIFIER_BYTES = 63
|
|
34
34
|
STATE_FILE = "databases.json"
|
|
35
|
+
# The per-worktree environment files yamine writes: `.env` carries
|
|
36
|
+
# the development set for ANY dotenv-style loader (framework-agnostic),
|
|
37
|
+
# `.env.test` carries PRIMARY_DATABASE_URL — the key Rails checks
|
|
38
|
+
# *before* DATABASE_URL for a flat test config, so the test URL wins
|
|
39
|
+
# no matter which order a loader reads the two files in. Hand-run
|
|
40
|
+
# commands (`rails console`, `rails test`, `db:migrate`) pick these
|
|
41
|
+
# up; env injection still covers processes yamine spawns itself.
|
|
42
|
+
ENV_FILES = %w[.env .env.development .env.test].freeze
|
|
43
|
+
# 0.16.0's marker file — removed in favor of .env; deleted on sight
|
|
44
|
+
# so upgraded worktrees don't accumulate dead files.
|
|
45
|
+
LEGACY_MARKER_FILE = ".yamine-db-suffix"
|
|
35
46
|
|
|
36
47
|
# Database name for a worktree dir + env (development/test).
|
|
37
48
|
# Main checkout (no worktree marker) keeps the bare name.
|
|
@@ -77,7 +88,12 @@ module Yamine
|
|
|
77
88
|
if map.key?(name) && map[name]["dir"] != dir
|
|
78
89
|
return nil
|
|
79
90
|
end
|
|
80
|
-
|
|
91
|
+
# Merge, don't replace: boot re-claims on every run, and a
|
|
92
|
+
# multi-database claim's suffix/names/bases are stored under
|
|
93
|
+
# this key — a fresh hash would erase them and orphan the whole
|
|
94
|
+
# set on the next drop.
|
|
95
|
+
map[name] = (map[name] || {}).merge("dir" => dir,
|
|
96
|
+
"claimed_at" => Time.now.utc.iso8601)
|
|
81
97
|
save_map(state_dir, map)
|
|
82
98
|
name
|
|
83
99
|
rescue SystemCallError
|
|
@@ -93,13 +109,134 @@ module Yamine
|
|
|
93
109
|
{}
|
|
94
110
|
end
|
|
95
111
|
|
|
112
|
+
# Claim URLs carry database passwords (the app's own resolved
|
|
113
|
+
# config, probed at worktree-add time), so the state file is
|
|
114
|
+
# private — it held no secrets before multi-database claims.
|
|
96
115
|
def save_map(state_dir, map)
|
|
97
116
|
FileUtils.mkdir_p(state_dir)
|
|
98
|
-
File.
|
|
117
|
+
path = File.join(state_dir, STATE_FILE)
|
|
118
|
+
File.write(path, JSON.pretty_generate(map))
|
|
119
|
+
File.chmod(0o600, path)
|
|
99
120
|
rescue SystemCallError
|
|
100
121
|
nil
|
|
101
122
|
end
|
|
102
123
|
|
|
124
|
+
# ── multi-database claims ─────────────────────────────────────
|
|
125
|
+
#
|
|
126
|
+
# A multi-database claim carries the whole set beside the usual
|
|
127
|
+
# dir/claimed_at:
|
|
128
|
+
# suffix the token every database name gains — also the marker
|
|
129
|
+
# file's contents, the one string yamine and the app's
|
|
130
|
+
# database.yml must agree on, byte for byte
|
|
131
|
+
# names config name -> actual database name
|
|
132
|
+
# bases config name -> the app's own URL (server coordinates
|
|
133
|
+
# for create/drop; where the suffix gets inserted)
|
|
134
|
+
# Single-database claims predate this and have none of the three;
|
|
135
|
+
# every reader falls back to the claim key as the one name.
|
|
136
|
+
|
|
137
|
+
# The suffix for a worktree: the claim key minus the env. Base
|
|
138
|
+
# names already carry the environment (myrr_markdown_development),
|
|
139
|
+
# so appending "_development" a second time only burns identifier
|
|
140
|
+
# budget. The claim key keeps the env (and the collision guard);
|
|
141
|
+
# the suffix is what has to fit inside a database name.
|
|
142
|
+
def suffix_for(claim_name, env)
|
|
143
|
+
stripped = claim_name.delete_suffix("_#{env}")
|
|
144
|
+
stripped.empty? ? claim_name : stripped
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Fit the suffix so `base + "_" + suffix` stays within the
|
|
148
|
+
# identifier cap for ANY base: budget against the longest one.
|
|
149
|
+
# Fitting happens once, here, because the marker file holds the
|
|
150
|
+
# suffix and the app only concatenates — a hand-run `rails
|
|
151
|
+
# console` must arrive at byte-identical names with no truncation
|
|
152
|
+
# logic of its own. Returns nil when even a hash-shortened suffix
|
|
153
|
+
# cannot fit (a base name so long no suffix follows it); callers
|
|
154
|
+
# name the problem instead of guessing.
|
|
155
|
+
def fit_suffix(suffix, dir, base_databases)
|
|
156
|
+
longest = base_databases.map { |d| d.to_s.bytesize }.max || 0
|
|
157
|
+
budget = MAX_IDENTIFIER_BYTES - longest - 1
|
|
158
|
+
return suffix if budget.positive? && suffix.bytesize <= budget
|
|
159
|
+
return nil if budget < 8
|
|
160
|
+
|
|
161
|
+
keep = budget - 7
|
|
162
|
+
"#{suffix.byteslice(0, keep).gsub(/_+\z/, "")}_#{short_hash(dir)}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The actual database name for one base under a suffix. Raises
|
|
166
|
+
# rather than truncating: silent truncation would diverge from
|
|
167
|
+
# what the marker file produces inside the app, and yamine names
|
|
168
|
+
# and app names disagreeing is exactly the bug this prevents.
|
|
169
|
+
# fit_suffix makes this unreachable for fitted suffixes; the
|
|
170
|
+
# raise guards programmatic misuse.
|
|
171
|
+
def multiname(base_database, suffix)
|
|
172
|
+
name = "#{base_database}_#{suffix}"
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"database name exceeds #{MAX_IDENTIFIER_BYTES} bytes: #{name}" if name.bytesize > MAX_IDENTIFIER_BYTES
|
|
175
|
+
|
|
176
|
+
name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Write the worktree's environment files: `.env` with the whole
|
|
180
|
+
# development set (dotenv-compatible — any framework or plain Ruby
|
|
181
|
+
# app that loads .env gets hand-run isolation for free) and
|
|
182
|
+
# `.env.test` with the test URL under PRIMARY_DATABASE_URL.
|
|
183
|
+
# Private (URLs carry whatever the app's config carries), and the
|
|
184
|
+
# 0.16.0 marker file is removed on the way past.
|
|
185
|
+
def write_env_files(dir, dev_env, test_url: nil)
|
|
186
|
+
FileUtils.rm_f(File.join(dir, LEGACY_MARKER_FILE))
|
|
187
|
+
return if dev_env.nil? && test_url.nil?
|
|
188
|
+
|
|
189
|
+
unless dev_env.nil? || dev_env.empty?
|
|
190
|
+
body = dotenv_body(dev_env.reject { |k, _v| k == "PRIMARY_DATABASE_URL" })
|
|
191
|
+
write_env_file(File.join(dir, ".env"), body)
|
|
192
|
+
# Same content under the env-specific name: a loader that
|
|
193
|
+
# prefers .env.development over .env finds identical values.
|
|
194
|
+
write_env_file(File.join(dir, ".env.development"), body)
|
|
195
|
+
end
|
|
196
|
+
return if test_url.nil? || test_url.to_s.empty?
|
|
197
|
+
|
|
198
|
+
write_env_file(File.join(dir, ".env.test"),
|
|
199
|
+
dotenv_body({ "PRIMARY_DATABASE_URL" => test_url }))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def write_env_file(path, body)
|
|
203
|
+
File.write(path, body)
|
|
204
|
+
File.chmod(0o600, path)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# dotenv format: single-quoted values (literal — no interpolation
|
|
208
|
+
# of a password's `$` or `#`), with the two escapes the dotenv
|
|
209
|
+
# grammar allows inside them.
|
|
210
|
+
def dotenv_body(env)
|
|
211
|
+
header = "# Per-worktree databases, written by yamine (worktree add).\n" \
|
|
212
|
+
"# Loaded by dotenv-rails / any dotenv loader; removed with this worktree.\n"
|
|
213
|
+
lines = env.map { |k, v| "#{k}='#{dotenv_escape(v.to_s)}'" }
|
|
214
|
+
"#{header}#{lines.join("\n")}\n"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def dotenv_escape(value)
|
|
218
|
+
value.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Keep files out of `git status` without touching the committed
|
|
222
|
+
# .gitignore: git's exclude file lives in the shared git dir, so
|
|
223
|
+
# entries cover every worktree of the repo. Idempotent per line.
|
|
224
|
+
def exclude_files(dir, names)
|
|
225
|
+
out, status = Open3.capture2("git", "-C", dir, "rev-parse", "--git-path", "info/exclude")
|
|
226
|
+
return unless status.success?
|
|
227
|
+
|
|
228
|
+
path = out.strip
|
|
229
|
+
path = File.expand_path(path, dir) unless path.start_with?("/")
|
|
230
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
231
|
+
existing = File.file?(path) ? File.read(path).lines.map(&:strip) : []
|
|
232
|
+
additions = Array(names).reject { |n| existing.include?(n) }
|
|
233
|
+
return if additions.empty?
|
|
234
|
+
|
|
235
|
+
File.open(path, "a") { |f| additions.each { |n| f.puts n } }
|
|
236
|
+
rescue SystemCallError, IOError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
103
240
|
# The DATABASE_URL template per-worktree names derive from: ENV
|
|
104
241
|
# first, then the config's top-level env.clear, then per-process
|
|
105
242
|
# env.clear (configs written before top-level env existed). Shared
|
|
@@ -127,6 +264,29 @@ module Yamine
|
|
|
127
264
|
load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
|
|
128
265
|
end
|
|
129
266
|
|
|
267
|
+
# The multi-database claim under `key`, or nil — single-database
|
|
268
|
+
# claims (and foreign keys) simply have no names, and every reader
|
|
269
|
+
# falls back to the key itself as the one database name.
|
|
270
|
+
def multi_claim(state_dir, key)
|
|
271
|
+
info = load_map(state_dir)[key]
|
|
272
|
+
info if info.is_a?(Hash) && info["names"].is_a?(Hash) && !info["names"].empty?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Every [database_name, server_url] pair a claim stands for: N
|
|
276
|
+
# pairs for a multi claim, nil for a legacy single claim (caller
|
|
277
|
+
# falls back to the key + its own template lookup). bases may lag
|
|
278
|
+
# names on older claims; a missing base falls back to the first —
|
|
279
|
+
# all of an app's databases live on one server in every layout
|
|
280
|
+
# yamine has seen.
|
|
281
|
+
def claim_pairs(info)
|
|
282
|
+
names = info.is_a?(Hash) ? info["names"] : nil
|
|
283
|
+
return nil if names.nil? || names.empty?
|
|
284
|
+
|
|
285
|
+
bases = info["bases"] || {}
|
|
286
|
+
default = bases.values.first
|
|
287
|
+
names.filter_map { |cfg, db| [db, (bases[cfg] || default)] }
|
|
288
|
+
end
|
|
289
|
+
|
|
130
290
|
# Adapter detection from DATABASE_URL scheme. Returns :postgres,
|
|
131
291
|
# :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
|
|
132
292
|
# DATABASE_URL treatment; sqlite is left alone (relative paths
|
|
@@ -189,7 +349,11 @@ module Yamine
|
|
|
189
349
|
return :failed unless reachable
|
|
190
350
|
|
|
191
351
|
uri = URI.parse(database_url)
|
|
192
|
-
|
|
352
|
+
# env as the FIRST positional (like ensure_postgres): Open3
|
|
353
|
+
# forwards kwargs to spawn, and spawn has no env: option — as a
|
|
354
|
+
# kwarg this raised ArgumentError on every real drop, crashing
|
|
355
|
+
# teardown after the routes were already stopped.
|
|
356
|
+
_out, status = Open3.capture2(pg_env(uri), "dropdb", name)
|
|
193
357
|
return :dropped if status.success?
|
|
194
358
|
|
|
195
359
|
reachable, exists = pg_state(name, database_url)
|
|
@@ -293,9 +457,12 @@ module Yamine
|
|
|
293
457
|
args
|
|
294
458
|
end
|
|
295
459
|
|
|
460
|
+
# PGHOST only when the URL names a host: an empty authority
|
|
461
|
+
# (postgres:///dbname) means the unix socket, and forcing
|
|
462
|
+
# 127.0.0.1 there would reach the wrong server — or none.
|
|
296
463
|
def pg_env(uri)
|
|
297
464
|
{
|
|
298
|
-
"PGHOST" => uri.host
|
|
465
|
+
"PGHOST" => (uri.host unless uri.host.to_s.empty?),
|
|
299
466
|
"PGPORT" => (uri.port || 5432).to_s,
|
|
300
467
|
"PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
|
|
301
468
|
"PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
|
data/lib/yamine/probe.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Yamine
|
|
8
|
+
# What databases an app actually has — asked, never guessed.
|
|
9
|
+
#
|
|
10
|
+
# A Rails multi-database app keeps its per-database URLs wherever it
|
|
11
|
+
# likes (credentials, ENV, hard-coded); yamine must not parse
|
|
12
|
+
# database.yml or touch decryption keys to learn them. The app
|
|
13
|
+
# itself answers: `bin/rails runner` prints the resolved
|
|
14
|
+
# configuration for the environment, so whatever the app would
|
|
15
|
+
# really connect to is exactly what yamine provisions, names, and
|
|
16
|
+
# drops. Credentials decrypt inside that process — the probe never
|
|
17
|
+
# sees a key.
|
|
18
|
+
#
|
|
19
|
+
# The runner emits one `YAMINE_DBS=<json>` line; Rails boot noise
|
|
20
|
+
# shares stdout, so the marker (never the last line) finds the
|
|
21
|
+
# payload. Any failure — no bin/rails, boot error, timeout — returns
|
|
22
|
+
# nil: the caller falls back to the single-database path. A probe is
|
|
23
|
+
# an enhancement, not a requirement, and it must never take a boot
|
|
24
|
+
# down with it.
|
|
25
|
+
module Probe
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
MARKER = "YAMINE_DBS="
|
|
29
|
+
TIMEOUT = 90
|
|
30
|
+
|
|
31
|
+
# Runs inside the app (bin/rails runner), so configuration_hash is
|
|
32
|
+
# fully resolved: Rails has already parsed any `url:` into
|
|
33
|
+
# components and decrypted credentials. The URL is rebuilt here
|
|
34
|
+
# from those components — a password travels from credentials to
|
|
35
|
+
# the claim file without yamine ever parsing config itself.
|
|
36
|
+
# SQLite-family adapters keep their scheme and skip the host part;
|
|
37
|
+
# relative paths already isolate per-worktree, so they never enter
|
|
38
|
+
# the multi-database claim.
|
|
39
|
+
SCRIPT = <<~'RUBY'
|
|
40
|
+
require "json"
|
|
41
|
+
require "uri"
|
|
42
|
+
rows = ActiveRecord::Base.configurations
|
|
43
|
+
.configs_for(env_name: ENV.fetch("RAILS_ENV", "development"))
|
|
44
|
+
.map do |c|
|
|
45
|
+
h = c.configuration_hash
|
|
46
|
+
db = h[:database].to_s
|
|
47
|
+
url = h[:url].to_s
|
|
48
|
+
if url.empty?
|
|
49
|
+
scheme = { "postgresql" => "postgres", "postgres" => "postgres",
|
|
50
|
+
"postgis" => "postgres", "mysql2" => "mysql",
|
|
51
|
+
"trilogy" => "mysql" }[h[:adapter].to_s]
|
|
52
|
+
if scheme
|
|
53
|
+
info = +""
|
|
54
|
+
if h[:username]
|
|
55
|
+
info << URI.encode_www_form_component(h[:username].to_s)
|
|
56
|
+
if h[:password]
|
|
57
|
+
info << ":" << URI.encode_www_form_component(h[:password].to_s)
|
|
58
|
+
end
|
|
59
|
+
info << "@"
|
|
60
|
+
end
|
|
61
|
+
host = h[:host].to_s
|
|
62
|
+
authority = host.empty? ? "#{info}" : "#{info}#{host}#{h[:port] ? ":#{h[:port]}" : ""}"
|
|
63
|
+
url = "#{scheme}://#{authority}/#{db}"
|
|
64
|
+
else
|
|
65
|
+
url = "#{h[:adapter]}:#{db}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
{ "name" => c.name, "database" => db, "url" => url,
|
|
69
|
+
# What DATABASE_URL held INSIDE the app — the discriminator
|
|
70
|
+
# between "no .env loader ran" (nil) and "loader ran but
|
|
71
|
+
# database.yml's url: keys took precedence over it".
|
|
72
|
+
"env_database_url" => ENV["DATABASE_URL"] }
|
|
73
|
+
end
|
|
74
|
+
puts "YAMINE_DBS=#{JSON.generate(rows)}"
|
|
75
|
+
RUBY
|
|
76
|
+
|
|
77
|
+
# [{ "name", "database", "url" }, ...] for the environment, or nil
|
|
78
|
+
# when the app cannot answer (not Rails, boot failure, timeout).
|
|
79
|
+
def rails_databases(dir, env: nil)
|
|
80
|
+
env ||= ENV.fetch("RAILS_ENV", "development")
|
|
81
|
+
bin = File.join(dir, "bin", "rails")
|
|
82
|
+
return nil unless File.file?(bin)
|
|
83
|
+
|
|
84
|
+
out = +""
|
|
85
|
+
status = nil
|
|
86
|
+
# A stray DATABASE_URL (or NAME_DATABASE_URL) in the caller's
|
|
87
|
+
# environment would win over database.yml inside the resolver —
|
|
88
|
+
# the probe must see the app's own configuration. spawn MERGES
|
|
89
|
+
# the env hash over the inherited one, so merely omitting the
|
|
90
|
+
# keys would leave them in place: pass them as nil to unset.
|
|
91
|
+
scrub = ENV.keys
|
|
92
|
+
.select { |k| k == "DATABASE_URL" || k.end_with?("_DATABASE_URL") }
|
|
93
|
+
.to_h { |k| [k, nil] }
|
|
94
|
+
child_env = scrub.merge("RAILS_ENV" => env)
|
|
95
|
+
Open3.popen2e(child_env, bin, "runner", SCRIPT, chdir: dir) do |stdin, stdout, wait|
|
|
96
|
+
stdin.close
|
|
97
|
+
begin
|
|
98
|
+
Timeout.timeout(TIMEOUT) { out = stdout.read }
|
|
99
|
+
rescue Timeout::Error
|
|
100
|
+
Process.kill("KILL", wait.pid) rescue nil
|
|
101
|
+
wait.value rescue nil
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
status = wait.value
|
|
105
|
+
end
|
|
106
|
+
return nil unless status&.success?
|
|
107
|
+
|
|
108
|
+
parse(out)
|
|
109
|
+
rescue SystemCallError, IOError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The marker line amidst whatever the boot printed. JSON parse
|
|
114
|
+
# failure returns nil rather than raising: garbage in, "cannot
|
|
115
|
+
# probe" out.
|
|
116
|
+
def parse(output)
|
|
117
|
+
line = output.to_s.lines.find { |l| l.start_with?(MARKER) }
|
|
118
|
+
return nil unless line
|
|
119
|
+
|
|
120
|
+
rows = JSON.parse(line.delete_prefix(MARKER).strip)
|
|
121
|
+
return nil unless rows.is_a?(Array)
|
|
122
|
+
|
|
123
|
+
rows.select { |r| r.is_a?(Hash) && r["name"] && r["url"] && r["database"] }
|
|
124
|
+
.then { |r| r.empty? ? nil : r }
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The subset that lives on a server (postgres/mysql). SQLite and
|
|
130
|
+
# friends need no provisioning — their relative paths are already
|
|
131
|
+
# per-worktree — so they never enter a claim.
|
|
132
|
+
def server_backed(rows)
|
|
133
|
+
rows.select { |r| %i[postgres mysql].include?(Yamine::Database.adapter_for(r["url"])) }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/yamine/proxy.rb
CHANGED
|
@@ -485,7 +485,12 @@ module Yamine
|
|
|
485
485
|
items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
|
|
486
486
|
body = "<h1>No app registered for #{escape(bare)}</h1>" \
|
|
487
487
|
"#{hint}<ul>#{items}</ul>"
|
|
488
|
-
|
|
488
|
+
# 503, not 404: 404 says the app answered and has nothing at that path,
|
|
489
|
+
# and a client that believes it goes looking for a bug in the app. This
|
|
490
|
+
# is the proxy saying the app is not there — the same answer a dead
|
|
491
|
+
# backend gets (502 Bad Gateway), so no caller has to read the body to
|
|
492
|
+
# tell "not running" from "the app said no".
|
|
493
|
+
respond(sock, 503, body)
|
|
489
494
|
end
|
|
490
495
|
|
|
491
496
|
def render_bad_gateway(sock)
|
data/lib/yamine/readiness.rb
CHANGED
|
@@ -68,7 +68,8 @@ module Yamine
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Pre-flight: verify runtime dependencies before spawning anything.
|
|
71
|
-
# Ruby (bundle check), Node (node_modules present
|
|
71
|
+
# Ruby (bundle check), Node (node_modules present — but only when the
|
|
72
|
+
# manifest actually declares dependencies). Returns [ok, fix].
|
|
72
73
|
# Fast-fails with the fix instead of letting Puma crash-loop for 60s.
|
|
73
74
|
def check_deps(dir = Dir.pwd)
|
|
74
75
|
gemfile = File.join(dir, "Gemfile")
|
|
@@ -80,7 +81,8 @@ module Yamine
|
|
|
80
81
|
end
|
|
81
82
|
end
|
|
82
83
|
package = File.join(dir, "package.json")
|
|
83
|
-
if File.file?(package) && !File.directory?(File.join(dir, "node_modules"))
|
|
84
|
+
if File.file?(package) && !File.directory?(File.join(dir, "node_modules")) &&
|
|
85
|
+
package_declares_dependencies?(package)
|
|
84
86
|
return [false, "node_modules missing — run `npm install` in #{dir}"]
|
|
85
87
|
end
|
|
86
88
|
[true, nil]
|
|
@@ -88,6 +90,29 @@ module Yamine
|
|
|
88
90
|
[false, e.message.lines.first&.strip]
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# Does this manifest need an install at all? A stub `{}`
|
|
94
|
+
# (dependency-less apps keep one so tooling finds a manifest) has
|
|
95
|
+
# nothing to install — `npm install` creates no node_modules for
|
|
96
|
+
# it — and demanding the directory would fail every fresh clone
|
|
97
|
+
# and worktree of such an app forever. An unreadable manifest
|
|
98
|
+
# keeps the old directory check: honest conservatism.
|
|
99
|
+
def package_declares_dependencies?(path)
|
|
100
|
+
data = JSON.parse(File.read(path))
|
|
101
|
+
return false unless data.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
%w[dependencies devDependencies optionalDependencies peerDependencies workspaces]
|
|
104
|
+
.any? do |key|
|
|
105
|
+
value = data[key]
|
|
106
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
107
|
+
!value.empty?
|
|
108
|
+
else
|
|
109
|
+
!value.nil?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
rescue JSON::ParserError, SystemCallError
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
91
116
|
# Poll an HTTP route until healthy or timeout. Healthy = healthcheck
|
|
92
117
|
# path returns 2xx-3xx, or (no healthcheck) TCP accept on the port.
|
|
93
118
|
# Returns [healthy, detail].
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -100,11 +100,11 @@ module Yamine
|
|
|
100
100
|
# yamine's own keys (PORT, YAMINE_URL, DATABASE_URL, the TLS vars)
|
|
101
101
|
# win over it, because those describe the boot rather than the app.
|
|
102
102
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
103
|
-
rails_dev_host: nil, register: true, database_url: nil,
|
|
104
|
-
extra_env: nil, subdomains: false)
|
|
103
|
+
rails_dev_host: nil, register: true, database_url: nil, database_env: nil,
|
|
104
|
+
spec: nil, extra_env: nil, subdomains: false)
|
|
105
105
|
port ||= Ports.find_free
|
|
106
106
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
107
|
-
database_url: database_url, extra_env: extra_env)
|
|
107
|
+
database_url: database_url, database_env: database_env, extra_env: extra_env)
|
|
108
108
|
path = log_path(dir, name)
|
|
109
109
|
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
110
110
|
Process.detach(pid)
|
|
@@ -131,9 +131,9 @@ module Yamine
|
|
|
131
131
|
# Returns the placeholder App (target known before bind). Fate of
|
|
132
132
|
# the backend is decided by wait, not by spawn.
|
|
133
133
|
def spawn_http(name:, hostname:, url:, dir:, command:, port:, rails_dev_host: nil,
|
|
134
|
-
database_url: nil, force: false, extra_env: nil)
|
|
134
|
+
database_url: nil, database_env: nil, force: false, extra_env: nil)
|
|
135
135
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
136
|
-
database_url: database_url, extra_env: extra_env)
|
|
136
|
+
database_url: database_url, database_env: database_env, extra_env: extra_env)
|
|
137
137
|
path = log_path(dir, name)
|
|
138
138
|
pid = with_clean_env { spawn(env, *command, chdir: dir, out: path, err: [:child, :out]) }
|
|
139
139
|
Process.detach(pid)
|
|
@@ -158,7 +158,8 @@ module Yamine
|
|
|
158
158
|
false
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil,
|
|
161
|
+
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil,
|
|
162
|
+
database_env: nil, extra_env: nil)
|
|
162
163
|
# The config's own variables first, so everything below overrides
|
|
163
164
|
# them: PORT, YAMINE_URL, DATABASE_URL, and the TLS paths describe
|
|
164
165
|
# *this boot* and must not be lost to a stray key in local.yml.
|
|
@@ -190,10 +191,17 @@ module Yamine
|
|
|
190
191
|
# proxied hostname so Rails apps boot behind yamine with zero
|
|
191
192
|
# config — this replaces the yamine-rails hosts patch.
|
|
192
193
|
env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
|
|
193
|
-
# Per-worktree
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
|
|
194
|
+
# Per-worktree databases: the whole isolated set this checkout
|
|
195
|
+
# claims — DATABASE_URL for primary plus NAME_DATABASE_URL per
|
|
196
|
+
# extra config (Rails' own convention), so a multi-database app
|
|
197
|
+
# gets every database redirected, not just the first. Frameworks
|
|
198
|
+
# that honor DATABASE_URL (Rails, Django, most Node) get
|
|
199
|
+
# isolation for free; others ignore it.
|
|
200
|
+
if database_env
|
|
201
|
+
database_env.each { |k, v| env[k.to_s] = v.to_s if v }
|
|
202
|
+
elsif database_url
|
|
203
|
+
env["DATABASE_URL"] = database_url
|
|
204
|
+
end
|
|
197
205
|
env
|
|
198
206
|
end
|
|
199
207
|
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "yamine/version"
|
|
|
4
4
|
require_relative "yamine/errors"
|
|
5
5
|
require_relative "yamine/agent"
|
|
6
6
|
require_relative "yamine/database"
|
|
7
|
+
require_relative "yamine/probe"
|
|
7
8
|
require_relative "yamine/sanitize"
|
|
8
9
|
require_relative "yamine/hostname"
|
|
9
10
|
require_relative "yamine/inference"
|
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.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/yamine/log.rb
|
|
104
104
|
- lib/yamine/ownership.rb
|
|
105
105
|
- lib/yamine/ports.rb
|
|
106
|
+
- lib/yamine/probe.rb
|
|
106
107
|
- lib/yamine/procfile.rb
|
|
107
108
|
- lib/yamine/proxy.rb
|
|
108
109
|
- lib/yamine/proxy_control.rb
|