yamine 0.12.0 → 0.13.1
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 +65 -4
- data/README.md +20 -4
- data/lib/ask/skills/yamine/SKILL.md +11 -0
- data/lib/yamine/cli/boot.rb +4 -8
- data/lib/yamine/cli/routes.rb +14 -4
- data/lib/yamine/cli/system.rb +47 -29
- data/lib/yamine/config.rb +35 -4
- data/lib/yamine/database.rb +89 -0
- data/lib/yamine/proxy.rb +27 -3
- data/lib/yamine/resolver.rb +2 -1
- data/lib/yamine/route_store.rb +7 -1
- data/lib/yamine/runner.rb +4 -4
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43ea000326072db8f0173360b3ef58a6cce132e20f372b65f0e777bafbb97e9d
|
|
4
|
+
data.tar.gz: 3e878d2cae8afffd1dca255a006a5863e12dbd4471a4b6214656dda5bc97d37f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 272dae53dfac6b0521c09c00ae096cdc10620dcabf8064a286a0fe25813a61738b3fefc4bb34cf8fdfdd8dd1467a0064ec4e8ceb6ec7b262a8ed638b66101bda
|
|
7
|
+
data.tar.gz: e16540d1830850c50a54451ec5f0bfb0ad87fd770b111a0b4276aad9a6916dadeeed4c106b61392e5fc5794e6276f003f718a5e53135885f36cc142944d1a12f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.1] — 2026-09-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`worktree clean` no longer reports "could not drop — remove manually"
|
|
8
|
+
for a database that never existed.** `dropdb` fails on a missing
|
|
9
|
+
database, and the old code collapsed that into a generic failure. Now
|
|
10
|
+
`Database.drop` returns an honest three-state outcome — `:dropped`,
|
|
11
|
+
`:missing` (nothing to drop, not an error), `:failed` (server
|
|
12
|
+
unreachable or drop refused) — by splitting "server reachable" from
|
|
13
|
+
"database present". Clean prints each outcome (`dropped N`,
|
|
14
|
+
`N did not exist — nothing to drop`) plus a summary line, exits 1 only
|
|
15
|
+
when something really failed, and **keeps the claim of a failed drop**
|
|
16
|
+
so the next clean can retry it (the old code deleted all claims
|
|
17
|
+
regardless, losing track of databases it failed to drop).
|
|
18
|
+
- `db drop` and `db create` resolve the DATABASE_URL template the way
|
|
19
|
+
boot does (ENV, then config env.clear) instead of ENV-only, so they
|
|
20
|
+
work from a shell without the variable exported.
|
|
21
|
+
|
|
22
|
+
## [0.13.0] — 2026-09-11
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **The subdomain fallback is now opt-in: `proxy.subdomains`.** A route
|
|
27
|
+
used to answer its own subdomains unconditionally, so *any* label under
|
|
28
|
+
a live app silently resolved to that app. The expensive case is a
|
|
29
|
+
worktree: once `<branch>.myapp.localhost` became the worktree URL, the
|
|
30
|
+
same hostname with that worktree **stopped** answered as the main
|
|
31
|
+
checkout — HTTP 200, a real app, the wrong code, and nothing to
|
|
32
|
+
distinguish it from the right answer. You had to notice the branch
|
|
33
|
+
was running to know you were looking at someone else's build.
|
|
34
|
+
|
|
35
|
+
Unregistered hostnames now 404 instead, and the page names the parent
|
|
36
|
+
app and its directory: "`myapp.localhost` is running in `/code/myapp`.
|
|
37
|
+
If `my-branch.myapp.localhost` is a worktree or branch, start it there
|
|
38
|
+
(`yamine start`)." The failure states its own fix.
|
|
39
|
+
|
|
40
|
+
Opting in is one line per app that genuinely wants it:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
proxy:
|
|
44
|
+
subdomains: true # this app answers *.myapp.localhost
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`yamine alias <name> <port> --wildcard` is the same opt-in for a
|
|
48
|
+
single ad-hoc route. `yamine status` now reports which mode an app is
|
|
49
|
+
in, since it changes what an unregistered label resolves to.
|
|
50
|
+
|
|
51
|
+
Nothing depended on the ambient behavior: no sibling app config used
|
|
52
|
+
subdomains, and the only artifact was one test, now the opt-in test.
|
|
53
|
+
Route files written before this need no migration — the flag is absent
|
|
54
|
+
unless set, and absent means exact hostname only.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`proxy: false` on a process named `web` no longer fails validation.**
|
|
59
|
+
The EXAMPLE can only name one class per key (`true` → TrueClass), so a
|
|
60
|
+
literal `false` was compared as falseclass and rejected with the
|
|
61
|
+
self-contradictory "expected a boolean, got falseclass". Booleans are
|
|
62
|
+
now checked as booleans.
|
|
63
|
+
|
|
3
64
|
## [0.12.0] — 2026-09-11
|
|
4
65
|
|
|
5
66
|
### Fixed
|
|
@@ -84,10 +145,10 @@
|
|
|
84
145
|
|
|
85
146
|
- **Ruby, git, and curl now trust the local CA — so an app can call
|
|
86
147
|
another app at `https://<name>.localhost` with no configuration.** The
|
|
87
|
-
gap surfaced when anywaye (a Rails app) tried to reach
|
|
88
|
-
|
|
89
|
-
CA was trusted everywhere a human looks (Safari, Chrome,
|
|
90
|
-
nowhere a child process looks — only Node was covered, via
|
|
148
|
+
gap surfaced when anywaye (a Rails app) tried to reach a second
|
|
149
|
+
checkout of anymark at its `.localhost` URL: `certificate verify
|
|
150
|
+
failed`. The CA was trusted everywhere a human looks (Safari, Chrome,
|
|
151
|
+
curl) and nowhere a child process looks — only Node was covered, via
|
|
91
152
|
`NODE_EXTRA_CA_CERTS`.
|
|
92
153
|
|
|
93
154
|
The naive fix is wrong in a way that shows up much later. `SSL_CERT_FILE`
|
data/README.md
CHANGED
|
@@ -91,6 +91,7 @@ of truth for service name, proxy TLD/host, processes, and env:
|
|
|
91
91
|
service: myapp
|
|
92
92
|
proxy:
|
|
93
93
|
tld: localhost
|
|
94
|
+
subdomains: false # opt in to answering *.myapp.localhost
|
|
94
95
|
processes:
|
|
95
96
|
web:
|
|
96
97
|
cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
|
|
@@ -162,10 +163,25 @@ yamine --variant demo # -> https://demo.myapp.localhos
|
|
|
162
163
|
yamine --tld preview.example.com # your own domain (OAuth parity)
|
|
163
164
|
```
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
## Subdomains are opt-in
|
|
167
|
+
|
|
168
|
+
A route answers its exact hostname. `*.myapp.localhost` reaches
|
|
169
|
+
`myapp.localhost` only if that app asked for it:
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
proxy:
|
|
173
|
+
subdomains: true # this app answers its own subdomains
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
yamine alias tenant1 4001 --wildcard # one route, its subdomains
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Off is the useful default. An unregistered label under a live app is far
|
|
181
|
+
more likely to be a worktree whose stack is stopped than a tenant, and
|
|
182
|
+
handing that label to the parent app means HTTP 200 with the wrong code.
|
|
183
|
+
Instead the request 404s and names the parent app, its directory, and how
|
|
184
|
+
to start it. `yamine status` reports which mode an app is in.
|
|
169
185
|
|
|
170
186
|
## Commands
|
|
171
187
|
|
|
@@ -98,6 +98,17 @@ Only an explicit variant ever looks for an overlay file — a branch name
|
|
|
98
98
|
that happens to match one on disk is ignored. `yamine status` shows
|
|
99
99
|
`variant:` and `overlay:` separately.
|
|
100
100
|
|
|
101
|
+
## Subdomains are opt-in
|
|
102
|
+
|
|
103
|
+
A route answers its exact hostname. `*.myapp.localhost` reaches the app
|
|
104
|
+
only if it asked (`proxy.subdomains: true` in `config/local.yml`, or
|
|
105
|
+
`yamine alias <name> <port> --wildcard` for one route).
|
|
106
|
+
|
|
107
|
+
So a worktree whose stack is not running gets a 404 that names the parent
|
|
108
|
+
app, its directory, and `yamine start` — not the parent app's code. If
|
|
109
|
+
you hit a `.localhost` URL that loads but looks wrong, check you started
|
|
110
|
+
the worktree you think you did; `yamine status` in it prints the URL.
|
|
111
|
+
|
|
101
112
|
## First time on a machine
|
|
102
113
|
|
|
103
114
|
Run `yamine start` — it does the one-shot CA trust, port 443, and
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -243,6 +243,7 @@ module Yamine
|
|
|
243
243
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
244
244
|
port: item[:port], force: opts[:force],
|
|
245
245
|
rails_dev_host: item[:hostname], database_url: db_url,
|
|
246
|
+
subdomains: resolved.subdomains,
|
|
246
247
|
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
247
248
|
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
248
249
|
|
|
@@ -281,7 +282,8 @@ module Yamine
|
|
|
281
282
|
if failed.empty?
|
|
282
283
|
apps.each do |name, slot|
|
|
283
284
|
runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
|
|
284
|
-
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name }
|
|
285
|
+
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name },
|
|
286
|
+
subdomains: resolved.subdomains)
|
|
285
287
|
routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
|
|
286
288
|
say opts, " -> #{slot[:item][:url]}"
|
|
287
289
|
end
|
|
@@ -544,13 +546,7 @@ module Yamine
|
|
|
544
546
|
end
|
|
545
547
|
|
|
546
548
|
def database_template_from_config(resolved)
|
|
547
|
-
|
|
548
|
-
from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
|
|
549
|
-
return from_top if from_top && !from_top.to_s.strip.empty?
|
|
550
|
-
|
|
551
|
-
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
552
|
-
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
553
|
-
clear["DATABASE_URL"]
|
|
549
|
+
Database.template_for(resolved)
|
|
554
550
|
end
|
|
555
551
|
|
|
556
552
|
# Schema-load on first boot uses the app's own command when declared
|
data/lib/yamine/cli/routes.rb
CHANGED
|
@@ -147,14 +147,20 @@ module Yamine
|
|
|
147
147
|
puts "Removed alias #{hostname}."
|
|
148
148
|
return
|
|
149
149
|
end
|
|
150
|
-
name, port_or_url = args
|
|
151
|
-
raise Error, "Usage: yamine alias <name> <port|url>" unless name && port_or_url
|
|
150
|
+
name, port_or_url = args.reject { |a| a.start_with?("--") }
|
|
151
|
+
raise Error, "Usage: yamine alias <name> <port|url> [--wildcard]" unless name && port_or_url
|
|
152
152
|
|
|
153
153
|
hostname = alias_hostname(name)
|
|
154
154
|
target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
|
|
155
155
|
force = args.include?("--force")
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
# --wildcard is the per-route escape hatch for the tenant case:
|
|
157
|
+
# this alias answers its own subdomains. Config `proxy.subdomains`
|
|
158
|
+
# is the same opt-in for a whole app at boot.
|
|
159
|
+
wildcard = args.include?("--wildcard")
|
|
160
|
+
ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force,
|
|
161
|
+
subdomains: wildcard)
|
|
162
|
+
suffix = wildcard ? " (and its subdomains)" : ""
|
|
163
|
+
puts "#{hostname} -> #{target}#{suffix}"
|
|
158
164
|
end
|
|
159
165
|
|
|
160
166
|
# A name containing dots is treated as a full hostname (any TLD);
|
|
@@ -282,6 +288,7 @@ module Yamine
|
|
|
282
288
|
host: resolved.host, host_source: resolved.sources[:host],
|
|
283
289
|
variant: resolved.variant, variant_source: resolved.sources[:variant],
|
|
284
290
|
overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
|
|
291
|
+
subdomains: resolved.subdomains,
|
|
285
292
|
urls: urls,
|
|
286
293
|
processes: resolved.processes.keys,
|
|
287
294
|
framework: Framework.detect(Dir.pwd).to_s
|
|
@@ -302,6 +309,9 @@ module Yamine
|
|
|
302
309
|
# explicit --variant / YAMINE_VARIANT ever sets it, so say so
|
|
303
310
|
# rather than letting the two look like one setting.
|
|
304
311
|
puts "overlay: #{payload[:overlay] ? "#{payload[:overlay]} (merged)" : "(none)"}"
|
|
312
|
+
# Off by default; say when it is on, because it changes what an
|
|
313
|
+
# unregistered label under this app resolves to.
|
|
314
|
+
puts "subdomains: #{payload[:subdomains] ? "own subdomains resolve here" : "exact hostname only"}"
|
|
305
315
|
puts "processes: #{payload[:processes].join(", ")}"
|
|
306
316
|
puts "urls:"
|
|
307
317
|
urls.each { |u| puts " #{u}" }
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -893,9 +893,9 @@ module Yamine
|
|
|
893
893
|
resolved = Resolver.resolve(Dir.pwd)
|
|
894
894
|
name = Database.name_for(Dir.pwd, env: boot_env_name,
|
|
895
895
|
state_dir: ctx.store.dir)
|
|
896
|
-
template =
|
|
896
|
+
template = drop_template
|
|
897
897
|
unless template && !template.strip.empty?
|
|
898
|
-
$stderr.puts "Error:
|
|
898
|
+
$stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml)."
|
|
899
899
|
exit 1
|
|
900
900
|
end
|
|
901
901
|
if Database.ensure_exists(name, template)
|
|
@@ -917,38 +917,41 @@ module Yamine
|
|
|
917
917
|
$stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
|
|
918
918
|
exit 1
|
|
919
919
|
end
|
|
920
|
-
|
|
920
|
+
template = drop_template
|
|
921
|
+
unless template && !template.strip.empty?
|
|
922
|
+
$stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
|
|
923
|
+
exit 1
|
|
924
|
+
end
|
|
925
|
+
case Database.drop(name, template)
|
|
926
|
+
when :dropped
|
|
921
927
|
map.delete(name)
|
|
922
928
|
Database.save_map(ctx.store.dir, map)
|
|
923
929
|
puts "Dropped #{name}."
|
|
924
|
-
|
|
925
|
-
|
|
930
|
+
when :missing
|
|
931
|
+
map.delete(name)
|
|
932
|
+
Database.save_map(ctx.store.dir, map)
|
|
933
|
+
puts "#{name} did not exist — nothing to drop (claim removed)."
|
|
934
|
+
when :failed
|
|
935
|
+
$stderr.puts "Error: could not drop #{name} — is the database server running? The claim is kept."
|
|
926
936
|
exit 1
|
|
927
937
|
end
|
|
928
938
|
end
|
|
929
939
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
940
|
+
# Template for reaching the database server outside of boot:
|
|
941
|
+
# ENV first, then the app's config (env.clear). Runs from the
|
|
942
|
+
# main checkout, so the config's own template is the usual source.
|
|
943
|
+
def drop_template
|
|
944
|
+
env = ENV["DATABASE_URL"]
|
|
945
|
+
return env if env && !env.strip.empty?
|
|
933
946
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
status.success?
|
|
939
|
-
when :mysql
|
|
940
|
-
uri = URI.parse(template)
|
|
941
|
-
args = ["-h", uri.host || "127.0.0.1", "-u",
|
|
942
|
-
URI.decode_www_form_component(uri.user || "root")]
|
|
943
|
-
_out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
|
|
944
|
-
status.success?
|
|
945
|
-
else
|
|
946
|
-
false
|
|
947
|
-
end
|
|
948
|
-
rescue SystemCallError
|
|
949
|
-
false
|
|
947
|
+
config = Config.load(Dir.pwd)
|
|
948
|
+
return nil unless config
|
|
949
|
+
|
|
950
|
+
Database.template_for(TemplateSource.new(config.env_config, config.processes))
|
|
950
951
|
end
|
|
951
952
|
|
|
953
|
+
TemplateSource = Struct.new(:env, :processes)
|
|
954
|
+
|
|
952
955
|
def boot_env_name
|
|
953
956
|
ENV.fetch("RAILS_ENV", "development")
|
|
954
957
|
end
|
|
@@ -974,16 +977,31 @@ module Yamine
|
|
|
974
977
|
puts "No orphaned worktree databases."
|
|
975
978
|
return
|
|
976
979
|
end
|
|
980
|
+
template = drop_template
|
|
981
|
+
unless template && !template.strip.empty?
|
|
982
|
+
$stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot drop the orphaned databases."
|
|
983
|
+
exit 1
|
|
984
|
+
end
|
|
985
|
+
dropped, missing, failed = [], [], []
|
|
977
986
|
orphaned.each do |name, _info|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
987
|
+
case Database.drop(name, template)
|
|
988
|
+
when :dropped then dropped << name
|
|
989
|
+
when :missing then missing << name
|
|
990
|
+
when :failed then failed << name
|
|
982
991
|
end
|
|
983
992
|
end
|
|
993
|
+
dropped.each { |n| puts " dropped #{n}" }
|
|
994
|
+
missing.each { |n| puts " #{n} did not exist — nothing to drop" }
|
|
995
|
+
failed.each { |n| warn " could not drop #{n} — is the server running? Claim kept for a retry." }
|
|
996
|
+
# Forget only the claims we actually handled; a failed drop
|
|
997
|
+
# stays recorded so the next clean can retry it.
|
|
984
998
|
map = Database.load_map(ctx.store.dir)
|
|
985
|
-
|
|
999
|
+
(dropped + missing).each { |n| map.delete(n) }
|
|
986
1000
|
Database.save_map(ctx.store.dir, map)
|
|
1001
|
+
summary = "Cleaned #{orphaned.length} orphaned claim(s): #{dropped.length} dropped, #{missing.length} already gone"
|
|
1002
|
+
summary += ", #{failed.length} failed" if failed.any?
|
|
1003
|
+
puts "#{summary}."
|
|
1004
|
+
exit 1 if failed.any?
|
|
987
1005
|
else
|
|
988
1006
|
raise Error, "Usage: yamine worktree [list|clean]"
|
|
989
1007
|
end
|
data/lib/yamine/config.rb
CHANGED
|
@@ -51,7 +51,12 @@ module Yamine
|
|
|
51
51
|
"service" => "myapp",
|
|
52
52
|
"proxy" => {
|
|
53
53
|
"tld" => "localhost",
|
|
54
|
-
"host" => "myapp.local.example.com"
|
|
54
|
+
"host" => "myapp.local.example.com",
|
|
55
|
+
# Opt this app into answering its own subdomains. Off by default:
|
|
56
|
+
# an unregistered label under a live app is far more likely to be
|
|
57
|
+
# a stopped worktree than a tenant, and resolving it to the wrong
|
|
58
|
+
# app is worse than not resolving it.
|
|
59
|
+
"subdomains" => false
|
|
55
60
|
},
|
|
56
61
|
# db: false opts out of per-worktree databases entirely (exotic
|
|
57
62
|
# setups: manual establish_connection, shared staging DB, ...).
|
|
@@ -178,6 +183,14 @@ module Yamine
|
|
|
178
183
|
data[key]
|
|
179
184
|
end
|
|
180
185
|
|
|
186
|
+
# Whether this app answers its own subdomains. Off unless asked: the
|
|
187
|
+
# ambient fallback used to hand any unregistered label to whichever
|
|
188
|
+
# app owned the parent name, which reads as the right app running the
|
|
189
|
+
# wrong code — the worktree case that motivated the opt-in.
|
|
190
|
+
def subdomains?
|
|
191
|
+
proxy_config["subdomains"] == true
|
|
192
|
+
end
|
|
193
|
+
|
|
181
194
|
private
|
|
182
195
|
|
|
183
196
|
def load_secrets
|
|
@@ -274,6 +287,9 @@ module Yamine
|
|
|
274
287
|
if value["tld"] && !Sanitize.valid_tld?(value["tld"].downcase)
|
|
275
288
|
raise ConfigError, "#{context}: invalid tld #{value["tld"].inspect}"
|
|
276
289
|
end
|
|
290
|
+
if value.key?("subdomains") && ![true, false].include?(value["subdomains"])
|
|
291
|
+
raise ConfigError, "#{context}: subdomains must be a boolean"
|
|
292
|
+
end
|
|
277
293
|
end
|
|
278
294
|
|
|
279
295
|
def validate_db(value, context)
|
|
@@ -364,13 +380,28 @@ module Yamine
|
|
|
364
380
|
elsif example_value.is_a?(Array) && value.is_a?(Array)
|
|
365
381
|
validate_array_of!(value, example_value.first.class) unless example_value.empty?
|
|
366
382
|
elsif !example_value.nil?
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
383
|
+
# Booleans are one type, but the example can only name one class
|
|
384
|
+
# (TrueClass for `true`), so a literal `false` compared as
|
|
385
|
+
# falseclass and was rejected: `proxy: false` on a process
|
|
386
|
+
# raised "expected a boolean, got falseclass". Ask the value
|
|
387
|
+
# what it is instead of the example.
|
|
388
|
+
if boolean?(example_value)
|
|
389
|
+
unless boolean?(value)
|
|
390
|
+
raise ConfigError, "#{current_context}: expected a boolean, got #{value.class.name.downcase}"
|
|
391
|
+
end
|
|
392
|
+
else
|
|
393
|
+
expected = type_description(example_value.class)
|
|
394
|
+
unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
|
|
395
|
+
raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
|
|
396
|
+
end
|
|
370
397
|
end
|
|
371
398
|
end
|
|
372
399
|
end
|
|
373
400
|
|
|
401
|
+
def boolean?(value)
|
|
402
|
+
value == true || value == false
|
|
403
|
+
end
|
|
404
|
+
|
|
374
405
|
def validate_array_of!(array, type)
|
|
375
406
|
array.each_with_index do |value, index|
|
|
376
407
|
with_context(index) do
|
data/lib/yamine/database.rb
CHANGED
|
@@ -100,6 +100,28 @@ module Yamine
|
|
|
100
100
|
nil
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
# The DATABASE_URL template per-worktree names derive from: ENV
|
|
104
|
+
# first, then the config's top-level env.clear, then per-process
|
|
105
|
+
# env.clear (configs written before top-level env existed). Shared
|
|
106
|
+
# by boot, `db create/drop`, and `worktree clean` so they all agree.
|
|
107
|
+
# Takes anything responding to .env (resolver Result / Config) —
|
|
108
|
+
# pass env_config for a bare Config.
|
|
109
|
+
def template_for(resolved)
|
|
110
|
+
source = if resolved.respond_to?(:env)
|
|
111
|
+
resolved.env
|
|
112
|
+
else
|
|
113
|
+
resolved.env_config
|
|
114
|
+
end
|
|
115
|
+
top = source.is_a?(Hash) ? source["clear"] : nil
|
|
116
|
+
from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
|
|
117
|
+
return from_top if from_top && !from_top.to_s.strip.empty?
|
|
118
|
+
|
|
119
|
+
processes = resolved.respond_to?(:processes) ? resolved.processes : {}
|
|
120
|
+
env = processes.values.map { |e| e["env"] || {} }
|
|
121
|
+
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
122
|
+
clear["DATABASE_URL"]
|
|
123
|
+
end
|
|
124
|
+
|
|
103
125
|
# Databases whose worktree dirs no longer exist (for `worktree clean`).
|
|
104
126
|
def orphaned(state_dir)
|
|
105
127
|
load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
|
|
@@ -146,6 +168,73 @@ module Yamine
|
|
|
146
168
|
end
|
|
147
169
|
end
|
|
148
170
|
|
|
171
|
+
# Drop a database with an honest outcome. Returns :dropped,
|
|
172
|
+
# :missing (the database did not exist — nothing to do, NOT an
|
|
173
|
+
# error), or :failed (server unreachable, drop refused). The
|
|
174
|
+
# distinction matters: `worktree clean` used to print "could not
|
|
175
|
+
# drop — remove manually" for a database that never existed,
|
|
176
|
+
# sending the user hunting for locks and permissions that were
|
|
177
|
+
# never the problem.
|
|
178
|
+
def drop(name, database_url)
|
|
179
|
+
case adapter_for(database_url)
|
|
180
|
+
when :postgres then pg_drop(name, database_url)
|
|
181
|
+
when :mysql then mysql_drop(name, database_url)
|
|
182
|
+
else :failed
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def pg_drop(name, database_url)
|
|
187
|
+
reachable, exists = pg_state(name, database_url)
|
|
188
|
+
return :missing if reachable && !exists
|
|
189
|
+
return :failed unless reachable
|
|
190
|
+
|
|
191
|
+
uri = URI.parse(database_url)
|
|
192
|
+
_out, status = Open3.capture2("dropdb", name, env: pg_env(uri))
|
|
193
|
+
return :dropped if status.success?
|
|
194
|
+
|
|
195
|
+
reachable, exists = pg_state(name, database_url)
|
|
196
|
+
reachable && exists ? :failed : :dropped
|
|
197
|
+
rescue SystemCallError
|
|
198
|
+
:failed
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def mysql_drop(name, database_url)
|
|
202
|
+
reachable, exists = mysql_state(name, database_url)
|
|
203
|
+
return :missing if reachable && !exists
|
|
204
|
+
return :failed unless reachable
|
|
205
|
+
|
|
206
|
+
uri = URI.parse(database_url)
|
|
207
|
+
_out, status = Open3.capture2("mysqladmin", *mysql_args(uri), "drop", name, "-f")
|
|
208
|
+
return :dropped if status.success?
|
|
209
|
+
|
|
210
|
+
reachable, exists = mysql_state(name, database_url)
|
|
211
|
+
reachable && exists ? :failed : :dropped
|
|
212
|
+
rescue SystemCallError
|
|
213
|
+
:failed
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# [server_reachable, database_exists] — the split that lets drop
|
|
217
|
+
# tell "nothing to drop" apart from "could not reach the server".
|
|
218
|
+
def pg_state(name, database_url)
|
|
219
|
+
uri = URI.parse(database_url)
|
|
220
|
+
out, status = Open3.capture2(pg_env(uri), "psql", "-lqt")
|
|
221
|
+
return [false, false] unless status.success?
|
|
222
|
+
|
|
223
|
+
[true, out.split("\n").any? { |l| l.split("|").first.to_s.strip == name }]
|
|
224
|
+
rescue SystemCallError
|
|
225
|
+
[false, false]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def mysql_state(name, database_url)
|
|
229
|
+
uri = URI.parse(database_url)
|
|
230
|
+
out, status = Open3.capture2("mysql", *mysql_args(uri), "-e", "SHOW DATABASES;")
|
|
231
|
+
return [false, false] unless status.success?
|
|
232
|
+
|
|
233
|
+
[true, out.split("\n").include?(name)]
|
|
234
|
+
rescue SystemCallError
|
|
235
|
+
[false, false]
|
|
236
|
+
end
|
|
237
|
+
|
|
149
238
|
# Create the database if missing. Uses createdb/mysqladmin when
|
|
150
239
|
# available; returns true/false, never raises (boot reports, not dies).
|
|
151
240
|
def ensure_exists(name, database_url)
|
data/lib/yamine/proxy.rb
CHANGED
|
@@ -106,6 +106,17 @@ module Yamine
|
|
|
106
106
|
end
|
|
107
107
|
|
|
108
108
|
# Pure request-routing core, tested without sockets.
|
|
109
|
+
#
|
|
110
|
+
# Exact hostname first, then an opted-in wildcard. The wildcard is
|
|
111
|
+
# deliberately not ambient: a route only answers its own subdomains
|
|
112
|
+
# when it registered with `subdomains: true` (config
|
|
113
|
+
# `proxy.subdomains`, or `yamine alias --wildcard`).
|
|
114
|
+
#
|
|
115
|
+
# It used to be unconditional, which meant every unregistered label
|
|
116
|
+
# under any live app silently resolved to that app. The expensive
|
|
117
|
+
# case is a worktree: `<branch>.myapp.localhost` answered as the main
|
|
118
|
+
# checkout — a wrong-but-working app, indistinguishable from the
|
|
119
|
+
# right one. An unregistered hostname now 404s, and says so.
|
|
109
120
|
def route(authority, routes)
|
|
110
121
|
host = Hostname.strip_port(authority)
|
|
111
122
|
return nil if host.empty? || host.bytesize > MAX_HOSTNAME_BYTES
|
|
@@ -113,8 +124,7 @@ module Yamine
|
|
|
113
124
|
exact = routes.find { |r| r["hostname"] == host }
|
|
114
125
|
return exact if exact
|
|
115
126
|
|
|
116
|
-
|
|
117
|
-
wildcard
|
|
127
|
+
routes.find { |r| r["subdomains"] && host.end_with?(".#{r["hostname"]}") }
|
|
118
128
|
end
|
|
119
129
|
|
|
120
130
|
def check_hops(headers)
|
|
@@ -452,9 +462,23 @@ module Yamine
|
|
|
452
462
|
return respond(sock, 404, "<h1>Not Found</h1>")
|
|
453
463
|
end
|
|
454
464
|
|
|
465
|
+
# A label in front of a live app is almost always a worktree or a
|
|
466
|
+
# branch whose stack is not running. Name the parent it would fall
|
|
467
|
+
# under and where it lives, so "the app loaded but it's the wrong
|
|
468
|
+
# code" becomes "that worktree isn't running" without a search.
|
|
469
|
+
parent = routes.find { |r| bare.end_with?(".#{r["hostname"]}") }
|
|
470
|
+
hint = if parent
|
|
471
|
+
dir = parent.dig("spec", "dir")
|
|
472
|
+
where = dir ? " in #{escape(dir)}" : ""
|
|
473
|
+
"<p><strong>#{escape(parent["hostname"])}</strong> is running#{where}.</p>" \
|
|
474
|
+
"<p>If #{escape(bare)} is a worktree or branch, start it there " \
|
|
475
|
+
"(<code>yamine start</code>), or open " \
|
|
476
|
+
"<strong>#{escape(parent["hostname"])}</strong> instead.</p>"
|
|
477
|
+
end
|
|
478
|
+
|
|
455
479
|
items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
|
|
456
480
|
body = "<h1>No app registered for #{escape(bare)}</h1>" \
|
|
457
|
-
"<ul>#{items}</ul>"
|
|
481
|
+
"#{hint}<ul>#{items}</ul>"
|
|
458
482
|
respond(sock, 404, body)
|
|
459
483
|
end
|
|
460
484
|
|
data/lib/yamine/resolver.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Yamine
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
Result = Struct.new(:app, :tld, :host, :processes, :secrets,
|
|
12
|
-
:sources, :variant, :overlay, :db, :env, keyword_init: true)
|
|
12
|
+
:sources, :variant, :overlay, :subdomains, :db, :env, keyword_init: true)
|
|
13
13
|
|
|
14
14
|
# Two axes, and keeping them apart is the whole point:
|
|
15
15
|
#
|
|
@@ -69,6 +69,7 @@ module Yamine
|
|
|
69
69
|
sources: sources,
|
|
70
70
|
variant: variant_name,
|
|
71
71
|
overlay: overlay,
|
|
72
|
+
subdomains: config.subdomains?,
|
|
72
73
|
db: config.data["db"],
|
|
73
74
|
env: config.env_config
|
|
74
75
|
)
|
data/lib/yamine/route_store.rb
CHANGED
|
@@ -76,7 +76,12 @@ module Yamine
|
|
|
76
76
|
# `agent` records the owner (defaults to Agent.name); foreign-owned
|
|
77
77
|
# live routes can only be taken with force: true, and the conflict
|
|
78
78
|
# error names the other agent plus its worktree dir.
|
|
79
|
-
|
|
79
|
+
#
|
|
80
|
+
# `subdomains` opts this route into answering its own subdomains.
|
|
81
|
+
# Absent (the default, and what every pre-existing route file has)
|
|
82
|
+
# means exact hostname only — see Proxy#route for why.
|
|
83
|
+
def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil,
|
|
84
|
+
subdomains: false)
|
|
80
85
|
agent ||= Agent.name
|
|
81
86
|
killed = nil
|
|
82
87
|
with_lock do
|
|
@@ -101,6 +106,7 @@ module Yamine
|
|
|
101
106
|
entry = { "hostname" => hostname, "target" => target, "kind" => kind,
|
|
102
107
|
"pid" => pid, "agent" => agent }
|
|
103
108
|
entry["spec"] = spec if spec
|
|
109
|
+
entry["subdomains"] = true if subdomains
|
|
104
110
|
routes << entry
|
|
105
111
|
save_routes(routes)
|
|
106
112
|
end
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Yamine
|
|
|
99
99
|
# win over it, because those describe the boot rather than the app.
|
|
100
100
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
101
101
|
rails_dev_host: nil, register: true, database_url: nil, spec: nil,
|
|
102
|
-
extra_env: nil)
|
|
102
|
+
extra_env: nil, subdomains: false)
|
|
103
103
|
port ||= Ports.find_free
|
|
104
104
|
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
105
105
|
database_url: database_url, extra_env: extra_env)
|
|
@@ -111,7 +111,7 @@ module Yamine
|
|
|
111
111
|
begin
|
|
112
112
|
spec ||= { "dir" => File.expand_path(dir), "proc" => name }
|
|
113
113
|
@store.add_route(hostname, target, Process.pid, kind: "tcp",
|
|
114
|
-
force: force, spec: spec)
|
|
114
|
+
force: force, spec: spec, subdomains: subdomains)
|
|
115
115
|
write_backend_pid(hostname, pid)
|
|
116
116
|
rescue StandardError
|
|
117
117
|
# Registration refused (quota, conflict): the backend is
|
|
@@ -140,9 +140,9 @@ module Yamine
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
# Register an already-spawned backend: route + sidecar, together.
|
|
143
|
-
def adopt(hostname, app, force: false, spec: nil)
|
|
143
|
+
def adopt(hostname, app, force: false, spec: nil, subdomains: false)
|
|
144
144
|
@store.add_route(hostname, app.target, Process.pid, kind: app.kind,
|
|
145
|
-
force: force, spec: spec)
|
|
145
|
+
force: force, spec: spec, subdomains: subdomains)
|
|
146
146
|
write_backend_pid(hostname, app.pid)
|
|
147
147
|
nil
|
|
148
148
|
end
|
data/lib/yamine/version.rb
CHANGED