yamine 0.11.1 → 0.13.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 +96 -4
- data/README.md +40 -4
- data/lib/ask/skills/yamine/SKILL.md +24 -3
- data/lib/yamine/cli/boot.rb +8 -5
- data/lib/yamine/cli/routes.rb +30 -7
- data/lib/yamine/config.rb +35 -4
- data/lib/yamine/proxy.rb +27 -3
- data/lib/yamine/resolver.rb +39 -9
- data/lib/yamine/route_store.rb +7 -1
- data/lib/yamine/runner.rb +4 -4
- data/lib/yamine/variant.rb +22 -5
- 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: 6f7c7127a262069469056cf55f94b5785de8e1890bc0814d84453159e1a01096
|
|
4
|
+
data.tar.gz: 0001bfbad73772c3caa7e615384a0c70dfa82732987174f55cbfbffac703dd15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 702d9b80482ac8f541f16ab5946b8916b0c23750774e398eabe85cd940a5d39306bdc188e010065e17ecf91081feb79b94d021a24c71186978ba8d1e4ede7dba
|
|
7
|
+
data.tar.gz: f0f12d6cc5b32bcc46804ef3009a72ab4fac500f792b6d6ea39f05324857031b4cb0f06fe131ef291a61f2faf1d1fd87eb85520d6698b63a6a5b6dde394e637d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.0] — 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **The subdomain fallback is now opt-in: `proxy.subdomains`.** A route
|
|
8
|
+
used to answer its own subdomains unconditionally, so *any* label under
|
|
9
|
+
a live app silently resolved to that app. The expensive case is a
|
|
10
|
+
worktree: once `<branch>.myapp.localhost` became the worktree URL, the
|
|
11
|
+
same hostname with that worktree **stopped** answered as the main
|
|
12
|
+
checkout — HTTP 200, a real app, the wrong code, and nothing to
|
|
13
|
+
distinguish it from the right answer. You had to notice the branch
|
|
14
|
+
was running to know you were looking at someone else's build.
|
|
15
|
+
|
|
16
|
+
Unregistered hostnames now 404 instead, and the page names the parent
|
|
17
|
+
app and its directory: "`myapp.localhost` is running in `/code/myapp`.
|
|
18
|
+
If `my-branch.myapp.localhost` is a worktree or branch, start it there
|
|
19
|
+
(`yamine start`)." The failure states its own fix.
|
|
20
|
+
|
|
21
|
+
Opting in is one line per app that genuinely wants it:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
proxy:
|
|
25
|
+
subdomains: true # this app answers *.myapp.localhost
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`yamine alias <name> <port> --wildcard` is the same opt-in for a
|
|
29
|
+
single ad-hoc route. `yamine status` now reports which mode an app is
|
|
30
|
+
in, since it changes what an unregistered label resolves to.
|
|
31
|
+
|
|
32
|
+
Nothing depended on the ambient behavior: no sibling app config used
|
|
33
|
+
subdomains, and the only artifact was one test, now the opt-in test.
|
|
34
|
+
Route files written before this need no migration — the flag is absent
|
|
35
|
+
unless set, and absent means exact hostname only.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **`proxy: false` on a process named `web` no longer fails validation.**
|
|
40
|
+
The EXAMPLE can only name one class per key (`true` → TrueClass), so a
|
|
41
|
+
literal `false` was compared as falseclass and rejected with the
|
|
42
|
+
self-contradictory "expected a boolean, got falseclass". Booleans are
|
|
43
|
+
now checked as booleans.
|
|
44
|
+
|
|
45
|
+
## [0.12.0] — 2026-09-11
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Worktrees get their own URL, as the README always claimed.**
|
|
50
|
+
`Variant.resolve`, the worktree detector, `Hostname.compose`'s variant
|
|
51
|
+
axis and their unit tests were all present, correct, and never
|
|
52
|
+
assembled: `Resolver.resolve` never called `Variant.resolve`, and
|
|
53
|
+
`hostname_for` built `{service}.{app}.{tld}` by hand, bypassing the
|
|
54
|
+
composer. So every worktree of a project answered on the *same*
|
|
55
|
+
hostname as its main checkout, and the ownership gate refused to boot
|
|
56
|
+
the second one — while telling the user to "work in your own worktree
|
|
57
|
+
(each branch gets its own URL)", advice that could not work.
|
|
58
|
+
|
|
59
|
+
A linked worktree is now reachable at `<branch>.<app>.<tld>`
|
|
60
|
+
(`work/next` → `work-next.myapp.localhost`) and boots beside its main
|
|
61
|
+
checkout, which keeps the bare name. Cross-checked by serving two
|
|
62
|
+
branches at once: distinct ports, distinct asset digests, no shared
|
|
63
|
+
route.
|
|
64
|
+
|
|
65
|
+
Found while trying to run a second checkout of a Rails app for UI work,
|
|
66
|
+
which is exactly the case the feature existed for.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- **The variant axis is split in two: `overlay` and `variant`.** They
|
|
71
|
+
were one setting, which meant that as soon as a worktree supplied its
|
|
72
|
+
own variant, `config/local.<branch>.yml` became an implied file lookup
|
|
73
|
+
— so a branch named like a file on disk would silently change the
|
|
74
|
+
config. Now only an explicit `--variant` / `YAMINE_VARIANT` sets the
|
|
75
|
+
overlay to merge; a worktree branch supplies the hostname label and
|
|
76
|
+
nothing else. `status` prints both lines, so the two can no longer be
|
|
77
|
+
mistaken for each other.
|
|
78
|
+
|
|
79
|
+
- **A branch label is the whole branch, not its last path segment.**
|
|
80
|
+
`feature/login` and `bugfix/login` are different worktrees and used to
|
|
81
|
+
collapse to the same `login` hostname. Overlong branches still resolve
|
|
82
|
+
through the existing hash-suffix truncation.
|
|
83
|
+
|
|
84
|
+
- A worktree on a detached HEAD falls back to its directory name (the
|
|
85
|
+
same identity the per-worktree database uses) instead of returning no
|
|
86
|
+
prefix and quietly colliding with the main checkout.
|
|
87
|
+
|
|
88
|
+
- `yamine status` now honors `--variant` / `--tld`, and accepts
|
|
89
|
+
`--branch`, which the README documented but `parse_flags` rejected as
|
|
90
|
+
an unknown flag.
|
|
91
|
+
|
|
92
|
+
- `proxy.host` is never rewritten by a variant: an explicitly written
|
|
93
|
+
hostname is left exactly as written.
|
|
94
|
+
|
|
3
95
|
## [0.11.1] — 2026-09-11
|
|
4
96
|
|
|
5
97
|
### Fixed
|
|
@@ -34,10 +126,10 @@
|
|
|
34
126
|
|
|
35
127
|
- **Ruby, git, and curl now trust the local CA — so an app can call
|
|
36
128
|
another app at `https://<name>.localhost` with no configuration.** The
|
|
37
|
-
gap surfaced when anywaye (a Rails app) tried to reach
|
|
38
|
-
|
|
39
|
-
CA was trusted everywhere a human looks (Safari, Chrome,
|
|
40
|
-
nowhere a child process looks — only Node was covered, via
|
|
129
|
+
gap surfaced when anywaye (a Rails app) tried to reach a second
|
|
130
|
+
checkout of anymark at its `.localhost` URL: `certificate verify
|
|
131
|
+
failed`. The CA was trusted everywhere a human looks (Safari, Chrome,
|
|
132
|
+
curl) and nowhere a child process looks — only Node was covered, via
|
|
41
133
|
`NODE_EXTRA_CA_CERTS`.
|
|
42
134
|
|
|
43
135
|
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
|
|
@@ -117,7 +118,8 @@ whole tree, and cleans up when one exits.
|
|
|
117
118
|
|
|
118
119
|
Variants are file overlays: `config/local.<variant>.yml` deep-merges on
|
|
119
120
|
top of `config/local.yml`, selected by `YAMINE_VARIANT` (Kamal's
|
|
120
|
-
destination pattern).
|
|
121
|
+
destination pattern). Naming a variant also prefixes the hostname — see
|
|
122
|
+
below for how that differs from a worktree's automatic prefix.
|
|
121
123
|
|
|
122
124
|
`.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
|
|
123
125
|
no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
|
|
@@ -135,10 +137,24 @@ no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
|
|
|
135
137
|
| variant | `fix-ui.myapp` | `--variant`, `YAMINE_VARIANT`, linked worktree branch |
|
|
136
138
|
| tld | `myapp.preview.example.com` | `--tld` (default `localhost`) |
|
|
137
139
|
|
|
140
|
+
`proxy.host` is the one exception: an explicitly written full hostname
|
|
141
|
+
bypasses composition entirely, variant included.
|
|
142
|
+
|
|
138
143
|
Linked git worktrees get a branch prefix automatically
|
|
139
|
-
(`
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
(`ui-onboarding.myapp.localhost`); the main checkout keeps the bare name,
|
|
145
|
+
so a worktree and its main checkout run side by side. The label is the
|
|
146
|
+
whole branch (`feature/login` → `feature-login.myapp.localhost`), so two
|
|
147
|
+
branches that share a last segment never share a hostname. A detached
|
|
148
|
+
HEAD has no branch to name it and falls back to the worktree's directory
|
|
149
|
+
— the same identity its per-worktree database uses. `main`/`master`
|
|
150
|
+
never prefix. Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by the
|
|
151
|
+
current branch outside worktrees.
|
|
152
|
+
|
|
153
|
+
A variant is a hostname label, not a config file. Only an explicit
|
|
154
|
+
`--variant` / `YAMINE_VARIANT` goes looking for
|
|
155
|
+
`config/local.<name>.yml` to merge; a worktree branch never does, so a
|
|
156
|
+
branch named like a file on disk cannot change your config by accident.
|
|
157
|
+
`yamine status` prints both lines so the two are never confused.
|
|
142
158
|
|
|
143
159
|
```bash
|
|
144
160
|
yamine # -> https://myapp.localhost
|
|
@@ -147,6 +163,26 @@ yamine --variant demo # -> https://demo.myapp.localhos
|
|
|
147
163
|
yamine --tld preview.example.com # your own domain (OAuth parity)
|
|
148
164
|
```
|
|
149
165
|
|
|
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.
|
|
185
|
+
|
|
150
186
|
## Commands
|
|
151
187
|
|
|
152
188
|
```bash
|
|
@@ -78,16 +78,37 @@ yamine get backend --variant demo
|
|
|
78
78
|
Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
|
|
79
79
|
targets. Do not guess ports.
|
|
80
80
|
|
|
81
|
-
## Variants are files
|
|
81
|
+
## Variants are files; worktrees are not
|
|
82
82
|
|
|
83
83
|
A variant is a file overlay, Kamal-style: `config/local.<variant>.yml`
|
|
84
|
-
deep-merges over `config/local.yml
|
|
85
|
-
|
|
84
|
+
deep-merges over `config/local.yml`, selected with `YAMINE_VARIANT` or
|
|
85
|
+
`--variant`. Naming one also prefixes the hostname.
|
|
86
|
+
|
|
87
|
+
A git worktree needs none of that: it gets a branch prefix automatically
|
|
88
|
+
(`ui-onboarding.myapp.localhost`) and boots alongside its main checkout,
|
|
89
|
+
which keeps the bare name. The whole branch is the label, so
|
|
90
|
+
`feature/login` and `bugfix/login` stay distinct. A detached worktree
|
|
91
|
+
falls back to its directory name.
|
|
86
92
|
|
|
87
93
|
```bash
|
|
88
94
|
YAMINE_VARIANT=fix-ui yamine # boots with config/local.fix-ui.yml merged
|
|
89
95
|
```
|
|
90
96
|
|
|
97
|
+
Only an explicit variant ever looks for an overlay file — a branch name
|
|
98
|
+
that happens to match one on disk is ignored. `yamine status` shows
|
|
99
|
+
`variant:` and `overlay:` separately.
|
|
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
|
+
|
|
91
112
|
## First time on a machine
|
|
92
113
|
|
|
93
114
|
Run `yamine start` — it does the one-shot CA trust, port 443, and
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -16,8 +16,9 @@ module Yamine
|
|
|
16
16
|
# boots every process, supervises the tree, cleans up on exit.
|
|
17
17
|
def run_inferred(ctx, args)
|
|
18
18
|
variant = ENV["YAMINE_VARIANT"]
|
|
19
|
-
opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
|
|
20
|
-
resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld]
|
|
19
|
+
opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json branch])
|
|
20
|
+
resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld],
|
|
21
|
+
use_branch: opts[:branch])
|
|
21
22
|
# Ownership gate before any side effects: no proxy spawn, no
|
|
22
23
|
# port allocation when we'd refuse anyway.
|
|
23
24
|
check_worktree_ownership!(ctx, resolved, force: opts[:force])
|
|
@@ -242,6 +243,7 @@ module Yamine
|
|
|
242
243
|
url: item[:url], dir: Dir.pwd, command: item[:command],
|
|
243
244
|
port: item[:port], force: opts[:force],
|
|
244
245
|
rails_dev_host: item[:hostname], database_url: db_url,
|
|
246
|
+
subdomains: resolved.subdomains,
|
|
245
247
|
extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
|
|
246
248
|
routes_registered << { hostnames: item[:hostnames], app: app }
|
|
247
249
|
|
|
@@ -280,7 +282,8 @@ module Yamine
|
|
|
280
282
|
if failed.empty?
|
|
281
283
|
apps.each do |name, slot|
|
|
282
284
|
runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
|
|
283
|
-
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name }
|
|
285
|
+
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name },
|
|
286
|
+
subdomains: resolved.subdomains)
|
|
284
287
|
routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
|
|
285
288
|
say opts, " -> #{slot[:item][:url]}"
|
|
286
289
|
end
|
|
@@ -653,10 +656,10 @@ module Yamine
|
|
|
653
656
|
end
|
|
654
657
|
end
|
|
655
658
|
|
|
656
|
-
def resolve!(ctx, variant: nil, tld: nil)
|
|
659
|
+
def resolve!(ctx, variant: nil, tld: nil, use_branch: false)
|
|
657
660
|
# The resolver calls Config.load, which raises ConfigError if
|
|
658
661
|
# config/local.yml is missing — exactly what we want.
|
|
659
|
-
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
|
|
662
|
+
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld, use_branch: use_branch)
|
|
660
663
|
end
|
|
661
664
|
|
|
662
665
|
# json: keeps stdout free for the machine-readable stream — this runs
|
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);
|
|
@@ -175,6 +181,9 @@ module Yamine
|
|
|
175
181
|
# agent's cleanup). --force overrides and names the previous owner.
|
|
176
182
|
def stop(ctx, args, out: $stdout)
|
|
177
183
|
force = args.delete("--force")
|
|
184
|
+
# In a worktree this resolves that worktree's own hostname (the
|
|
185
|
+
# branch is the variant), so `stop` here never reaches across
|
|
186
|
+
# into the main checkout's running app.
|
|
178
187
|
resolved = Resolver.resolve(Dir.pwd)
|
|
179
188
|
hostnames = Resolver.hostnames(resolved)
|
|
180
189
|
mine = Agent.name
|
|
@@ -261,9 +270,14 @@ module Yamine
|
|
|
261
270
|
# `yamine` would boot here and why. Answers "why did I get
|
|
262
271
|
# this URL" without booting anything. --json emits stable keys
|
|
263
272
|
# for agents instead of prose.
|
|
264
|
-
def status(
|
|
273
|
+
def status(ctx, args)
|
|
265
274
|
json = args.delete("--json")
|
|
266
|
-
|
|
275
|
+
# The flags exist so `status --variant x` answers the same
|
|
276
|
+
# question boot would; in a worktree the branch supplies the
|
|
277
|
+
# variant anyway, so plain `status` is already the right answer.
|
|
278
|
+
opts = ctx.parse_flags(args, %i[variant tld branch])
|
|
279
|
+
resolved = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld],
|
|
280
|
+
use_branch: opts[:branch])
|
|
267
281
|
hostnames = Resolver.hostnames(resolved)
|
|
268
282
|
urls = hostnames.map do |h|
|
|
269
283
|
Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
|
|
@@ -273,6 +287,8 @@ module Yamine
|
|
|
273
287
|
tld: resolved.tld, tld_source: resolved.sources[:tld],
|
|
274
288
|
host: resolved.host, host_source: resolved.sources[:host],
|
|
275
289
|
variant: resolved.variant, variant_source: resolved.sources[:variant],
|
|
290
|
+
overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
|
|
291
|
+
subdomains: resolved.subdomains,
|
|
276
292
|
urls: urls,
|
|
277
293
|
processes: resolved.processes.keys,
|
|
278
294
|
framework: Framework.detect(Dir.pwd).to_s
|
|
@@ -288,7 +304,14 @@ module Yamine
|
|
|
288
304
|
else
|
|
289
305
|
puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
|
|
290
306
|
end
|
|
291
|
-
puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no
|
|
307
|
+
puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no flag, env, or worktree branch"})"
|
|
308
|
+
# The overlay is a different thing from the variant and only an
|
|
309
|
+
# explicit --variant / YAMINE_VARIANT ever sets it, so say so
|
|
310
|
+
# rather than letting the two look like one setting.
|
|
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"}"
|
|
292
315
|
puts "processes: #{payload[:processes].join(", ")}"
|
|
293
316
|
puts "urls:"
|
|
294
317
|
urls.each { |u| puts " #{u}" }
|
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/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,10 +9,26 @@ module Yamine
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
Result = Struct.new(:app, :tld, :host, :processes, :secrets,
|
|
12
|
-
:sources, :variant, :db, :env, keyword_init: true)
|
|
12
|
+
:sources, :variant, :overlay, :subdomains, :db, :env, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
# Two axes, and keeping them apart is the whole point:
|
|
15
|
+
#
|
|
16
|
+
# overlay — an explicit `config/local.<name>.yml` to deep-merge.
|
|
17
|
+
# Only --variant / YAMINE_VARIANT ever set it, so a
|
|
18
|
+
# branch name can never select config by surprise.
|
|
19
|
+
# variant — the leading hostname label, from that same explicit
|
|
20
|
+
# name OR a linked worktree's branch. It is a name, not
|
|
21
|
+
# a file: a worktree is reachable at
|
|
22
|
+
# <branch>.<app>.localhost without inventing config.
|
|
23
|
+
#
|
|
24
|
+
# They share a value when the user asks for one explicitly. Only
|
|
25
|
+
# then does a variant mean both "merge this file" and "prefix this
|
|
26
|
+
# hostname", which is the documented `--variant` behavior.
|
|
27
|
+
def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil, use_branch: false)
|
|
28
|
+
overlay = (variant || ENV["YAMINE_VARIANT"])&.strip
|
|
29
|
+
overlay = nil if overlay&.empty?
|
|
30
|
+
|
|
31
|
+
config = Config.load(dir, variant: overlay)
|
|
16
32
|
unless config
|
|
17
33
|
raise ConfigError, Config.missing_message(dir)
|
|
18
34
|
end
|
|
@@ -21,8 +37,7 @@ module Yamine
|
|
|
21
37
|
proxy = config.proxy_config
|
|
22
38
|
processes = config.processes
|
|
23
39
|
|
|
24
|
-
variant_name = (variant
|
|
25
|
-
variant_name = nil if variant_name&.empty?
|
|
40
|
+
variant_name, variant_source = Variant.resolve(dir, explicit: variant, use_branch: use_branch)
|
|
26
41
|
|
|
27
42
|
# host or tld: config proxy.host wins over proxy.tld; flags override.
|
|
28
43
|
if host || proxy["host"]
|
|
@@ -41,7 +56,8 @@ module Yamine
|
|
|
41
56
|
app: config.path.to_s,
|
|
42
57
|
tld: proxy["tld"] ? "#{config.path} proxy.tld" : "default (localhost)",
|
|
43
58
|
host: proxy["host"] ? "#{config.path} proxy.host" : nil,
|
|
44
|
-
variant:
|
|
59
|
+
variant: variant_source,
|
|
60
|
+
overlay: overlay ? "config/local.#{overlay}.yml" : nil
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
Result.new(
|
|
@@ -52,20 +68,34 @@ module Yamine
|
|
|
52
68
|
secrets: config.secrets,
|
|
53
69
|
sources: sources,
|
|
54
70
|
variant: variant_name,
|
|
71
|
+
overlay: overlay,
|
|
72
|
+
subdomains: config.subdomains?,
|
|
55
73
|
db: config.data["db"],
|
|
56
74
|
env: config.env_config
|
|
57
75
|
)
|
|
58
76
|
end
|
|
59
77
|
|
|
60
78
|
# Hostname for one process: primary (first proxy:true) is bare;
|
|
61
|
-
# others are proc.app.tld; proxy:false have none.
|
|
79
|
+
# others are proc.app.tld; proxy:false have none. A variant — the
|
|
80
|
+
# explicit name or a worktree's branch — leads every one of them.
|
|
62
81
|
def hostname_for(result, proc_name)
|
|
63
82
|
entry = result.processes[proc_name]
|
|
64
83
|
return nil unless entry
|
|
65
84
|
return nil if entry["proxy"] == false
|
|
66
85
|
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
# An explicit proxy.host is a full hostname, so composition does
|
|
87
|
+
# not apply — and a variant must never silently rewrite a host
|
|
88
|
+
# the user wrote out in full.
|
|
89
|
+
if result.host
|
|
90
|
+
return proc_name.to_s == primary_proc(result) ? result.host : "#{proc_name}.#{result.host}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Hostname.compose(
|
|
94
|
+
app: result.app,
|
|
95
|
+
tld: result.tld,
|
|
96
|
+
service: (proc_name.to_s == primary_proc(result) ? nil : proc_name.to_s),
|
|
97
|
+
variant: result.variant
|
|
98
|
+
)
|
|
69
99
|
end
|
|
70
100
|
|
|
71
101
|
def primary_proc(result)
|
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/variant.rb
CHANGED
|
@@ -51,12 +51,27 @@ module Yamine
|
|
|
51
51
|
label.empty? ? nil : [label, source]
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The whole branch, not just its last path segment. `feature/login`
|
|
55
|
+
# and `bugfix/login` are different worktrees and must not end up
|
|
56
|
+
# sharing a hostname; taking the last segment alone collapsed them
|
|
57
|
+
# both to `login`.
|
|
54
58
|
def branch_to_prefix(branch)
|
|
55
59
|
return nil if branch.nil? || branch.empty?
|
|
56
60
|
return nil if branch == "HEAD" || DEFAULT_BRANCHES.include?(branch)
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
label
|
|
62
|
+
label = Sanitize.hostname_label(branch)
|
|
63
|
+
label.empty? ? nil : label
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A detached HEAD has no branch to name it, so the worktree's
|
|
67
|
+
# directory does — the same identity the per-worktree database is
|
|
68
|
+
# keyed on. Without this, a detached worktree would fall back to the
|
|
69
|
+
# bare app name and answer on the main checkout's hostname.
|
|
70
|
+
def worktree_dir_prefix(cwd)
|
|
71
|
+
top, status = git(cwd, "rev-parse", "--show-toplevel")
|
|
72
|
+
return nil unless status.success?
|
|
73
|
+
|
|
74
|
+
label = Sanitize.hostname_label(File.basename(top.strip))
|
|
60
75
|
label.empty? ? nil : label
|
|
61
76
|
end
|
|
62
77
|
|
|
@@ -81,7 +96,7 @@ module Yamine
|
|
|
81
96
|
branch, s3 = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
82
97
|
return nil unless s3.success?
|
|
83
98
|
|
|
84
|
-
branch_to_prefix(branch.strip)
|
|
99
|
+
branch_to_prefix(branch.strip) || worktree_dir_prefix(cwd)
|
|
85
100
|
rescue SystemCallError
|
|
86
101
|
filesystem_worktree_prefix(cwd)
|
|
87
102
|
end
|
|
@@ -98,8 +113,10 @@ module Yamine
|
|
|
98
113
|
if match && match[1].match?(%r{[/\\]worktrees[/\\][^/\\]+\z})
|
|
99
114
|
head = File.join(File.expand_path(match[1], dir.to_s), "HEAD")
|
|
100
115
|
branch = read_branch_from_head(head)
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
# Same rule as the git path: branch when there is one, the
|
|
117
|
+
# worktree directory otherwise (detached HEAD).
|
|
118
|
+
prefix = branch_to_prefix(branch.to_s) || Sanitize.hostname_label(dir.basename.to_s)
|
|
119
|
+
return prefix.empty? ? nil : prefix
|
|
103
120
|
end
|
|
104
121
|
return nil
|
|
105
122
|
end
|
data/lib/yamine/version.rb
CHANGED