yamine 0.13.0 → 0.13.2
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 +48 -0
- data/README.md +3 -1
- data/lib/ask/skills/yamine/SKILL.md +4 -2
- data/lib/yamine/cli/boot.rb +2 -8
- data/lib/yamine/cli/context.rb +15 -4
- data/lib/yamine/cli/system.rb +47 -29
- data/lib/yamine/database.rb +89 -0
- data/lib/yamine/doctor.rb +16 -4
- data/lib/yamine/hosts.rb +56 -21
- 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: 122503332845c1bbee6034e7262b763d38c2d4c849723e9519dfc7e91f0739b6
|
|
4
|
+
data.tar.gz: 5ae28759ce2ba01f7c9239056ca49d0ceda6f0958e26a534561fdcab76a485f3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35f7dc98e1beaa8fcbf46dc2dbb735656789c193d2ed713e395fc038fa9a7801fcec6ea552c3affc67c82f0a3101b3200eb6126459f0588b723245318e257981
|
|
7
|
+
data.tar.gz: c3f54ddcc3cb3d0f6b99bca81770fc4addd61cadb22baf176c81042b9609edb079b69f2f41dd265378b08c44eb1a322dc00cffca580496741086750671950007
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.2] — 2026-09-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **"Resolves" now means "resolves for every client", not "the system
|
|
8
|
+
resolver said yes".** `Hosts.resolves?` asked `Addrinfo.getaddrinfo`,
|
|
9
|
+
which on macOS answers any `.localhost` name unconditionally — so
|
|
10
|
+
doctor and boot reported every `.localhost` route as fully resolved
|
|
11
|
+
even with no `/etc/hosts` entry, while a client that resolves only
|
|
12
|
+
from the hosts file (a CGO-disabled Go binary; AnyGit is the case
|
|
13
|
+
that found it) could not resolve any of them. That invisible gap is
|
|
14
|
+
why `sudo yamine hosts sync` existed as a manual step at all.
|
|
15
|
+
|
|
16
|
+
The check is now state-based. `Hosts.hosts_entry?` reads the hosts
|
|
17
|
+
file directly — any loopback mapping counts, hand-added entries
|
|
18
|
+
included, not just yamine's managed block — and `Hosts.resolution`
|
|
19
|
+
classifies each route as `ok` (every client resolves it), `warn`
|
|
20
|
+
(browsers and curl fine via the RFC 6761 `.localhost` special case;
|
|
21
|
+
file-only resolvers cannot see it), or `fail` (nothing resolves it).
|
|
22
|
+
Doctor renders the warn class with its existing `[warn]` state instead
|
|
23
|
+
of a failure — the 0.7.0 lesson, kept by severity and wording rather
|
|
24
|
+
than by a probe — and boot names the two gaps separately.
|
|
25
|
+
|
|
26
|
+
`.localhost` names are classified on file absence alone: the system
|
|
27
|
+
resolver's unconditional yes carries no information, and acting on it
|
|
28
|
+
was the bug. getaddrinfo now runs only for custom-TLD names the file
|
|
29
|
+
does not answer (real DNS or `/etc/resolver` setups stay `ok`), so
|
|
30
|
+
the common doctor path does no probing at all.
|
|
31
|
+
|
|
32
|
+
## [0.13.1] — 2026-09-12
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`worktree clean` no longer reports "could not drop — remove manually"
|
|
37
|
+
for a database that never existed.** `dropdb` fails on a missing
|
|
38
|
+
database, and the old code collapsed that into a generic failure. Now
|
|
39
|
+
`Database.drop` returns an honest three-state outcome — `:dropped`,
|
|
40
|
+
`:missing` (nothing to drop, not an error), `:failed` (server
|
|
41
|
+
unreachable or drop refused) — by splitting "server reachable" from
|
|
42
|
+
"database present". Clean prints each outcome (`dropped N`,
|
|
43
|
+
`N did not exist — nothing to drop`) plus a summary line, exits 1 only
|
|
44
|
+
when something really failed, and **keeps the claim of a failed drop**
|
|
45
|
+
so the next clean can retry it (the old code deleted all claims
|
|
46
|
+
regardless, losing track of databases it failed to drop).
|
|
47
|
+
- `db drop` and `db create` resolve the DATABASE_URL template the way
|
|
48
|
+
boot does (ENV, then config env.clear) instead of ENV-only, so they
|
|
49
|
+
work from a shell without the variable exported.
|
|
50
|
+
|
|
3
51
|
## [0.13.0] — 2026-09-11
|
|
4
52
|
|
|
5
53
|
### Changed
|
data/README.md
CHANGED
|
@@ -122,7 +122,9 @@ destination pattern). Naming a variant also prefixes the hostname — see
|
|
|
122
122
|
below for how that differs from a worktree's automatic prefix.
|
|
123
123
|
|
|
124
124
|
`.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
|
|
125
|
-
no DNS server, no `/etc/resolver`. Safari
|
|
125
|
+
no DNS server, no `/etc/resolver`. Safari, custom TLDs, and resolvers
|
|
126
|
+
that read only `/etc/hosts` (CGO-disabled Go binaries are the common
|
|
127
|
+
case) need `yamine hosts sync`.
|
|
126
128
|
|
|
127
129
|
## Hostname shape
|
|
128
130
|
|
|
@@ -144,8 +144,10 @@ yamine prune # clear stale routes from crashed sessions
|
|
|
144
144
|
yamine start --json # boot readiness payload (pass/fail + log tail)
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
If a hostname does not resolve
|
|
148
|
-
|
|
147
|
+
If a hostname does not resolve — or boot/doctor report it as not in
|
|
148
|
+
`/etc/hosts` and you use clients that read only that file (CGO-disabled
|
|
149
|
+
Go binaries): `yamine hosts sync`. If the browser warns about TLS:
|
|
150
|
+
`yamine trust`.
|
|
149
151
|
|
|
150
152
|
## When NOT to use yamine
|
|
151
153
|
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -157,7 +157,7 @@ module Yamine
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
say opts
|
|
160
|
-
ctx.
|
|
160
|
+
ctx.report_resolution_gaps(routes_registered.flat_map { |r| r[:hostnames] })
|
|
161
161
|
|
|
162
162
|
# Supervisor: exit when ANY child dies (loud cleanup). pid => name
|
|
163
163
|
# so the message names the casualty instead of "a process".
|
|
@@ -546,13 +546,7 @@ module Yamine
|
|
|
546
546
|
end
|
|
547
547
|
|
|
548
548
|
def database_template_from_config(resolved)
|
|
549
|
-
|
|
550
|
-
from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
|
|
551
|
-
return from_top if from_top && !from_top.to_s.strip.empty?
|
|
552
|
-
|
|
553
|
-
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
554
|
-
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
555
|
-
clear["DATABASE_URL"]
|
|
549
|
+
Database.template_for(resolved)
|
|
556
550
|
end
|
|
557
551
|
|
|
558
552
|
# Schema-load on first boot uses the app's own command when declared
|
data/lib/yamine/cli/context.rb
CHANGED
|
@@ -35,11 +35,22 @@ module Yamine
|
|
|
35
35
|
ProxyControl.proxy_tls(store)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
# Boot-time DNS report. Two distinct gaps, named separately: a fail
|
|
39
|
+
# resolves nowhere; a warn resolves in browsers and curl (the RFC
|
|
40
|
+
# 6761 .localhost special case) but not for clients that read only
|
|
41
|
+
# /etc/hosts — which is exactly the gap `hosts sync` closes.
|
|
42
|
+
def report_resolution_gaps(hostnames)
|
|
43
|
+
groups = Hosts.resolution(hostnames)
|
|
44
|
+
return if groups[:fail].empty? && groups[:warn].empty?
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
unless groups[:fail].empty?
|
|
47
|
+
warn "Warning: #{groups[:fail].join(", ")} will not resolve. Run: yamine hosts sync"
|
|
48
|
+
end
|
|
49
|
+
unless groups[:warn].empty?
|
|
50
|
+
warn "Warning: #{groups[:warn].join(", ")} not in /etc/hosts — browsers and curl " \
|
|
51
|
+
"resolve them anyway, but clients that only read /etc/hosts (CGO-disabled Go " \
|
|
52
|
+
"binaries) cannot. Run: yamine hosts sync if you use such clients."
|
|
53
|
+
end
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
def wait_for_exit(pid, timeout:)
|
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/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/doctor.rb
CHANGED
|
@@ -160,12 +160,24 @@ module Yamine
|
|
|
160
160
|
hostnames = store.load_routes.map { |r| r["hostname"] }
|
|
161
161
|
return Check.new(name: "dns", ok: true, message: "no routes to resolve") if hostnames.empty?
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
groups = Hosts.resolution(hostnames)
|
|
164
|
+
problems = []
|
|
165
|
+
problems << "#{groups[:fail].join(", ")} do not resolve — run: yamine hosts sync" unless groups[:fail].empty?
|
|
166
|
+
unless groups[:warn].empty?
|
|
167
|
+
problems << "#{groups[:warn].join(", ")} not in /etc/hosts — browsers and curl " \
|
|
168
|
+
"resolve them anyway, but clients that only read the hosts file " \
|
|
169
|
+
"(CGO-disabled Go binaries) cannot — run: yamine hosts sync if you use such clients"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if problems.empty?
|
|
165
173
|
Check.new(name: "dns", ok: true, message: "all #{hostnames.length} hostname(s) resolve")
|
|
174
|
+
elsif groups[:fail].empty?
|
|
175
|
+
# Only the file-only-resolver gap: most clients are fine, so this
|
|
176
|
+
# is a warn, not a failure — failing here sent browser-only users
|
|
177
|
+
# to an elevated hosts write they did not need (the 0.7.0 lesson).
|
|
178
|
+
Check.new(name: "dns", ok: true, warn: true, message: problems.join("; "))
|
|
166
179
|
else
|
|
167
|
-
Check.new(name: "dns", ok: false,
|
|
168
|
-
message: "#{missing.join(", ")} do not resolve — run: yamine hosts sync")
|
|
180
|
+
Check.new(name: "dns", ok: false, message: problems.join("; "))
|
|
169
181
|
end
|
|
170
182
|
end
|
|
171
183
|
|
data/lib/yamine/hosts.rb
CHANGED
|
@@ -4,9 +4,11 @@ require "socket"
|
|
|
4
4
|
require "timeout"
|
|
5
5
|
|
|
6
6
|
module Yamine
|
|
7
|
-
# /etc/hosts sync for
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# /etc/hosts sync for the client classes that cannot resolve the routes
|
|
8
|
+
# on their own: custom TLDs, and resolvers that read only the hosts file
|
|
9
|
+
# (a CGO-disabled Go binary is the common case — no system resolver, no
|
|
10
|
+
# RFC 6761 special-casing). Same managed-block approach as portless
|
|
11
|
+
# hosts.ts.
|
|
10
12
|
module Hosts
|
|
11
13
|
BEGIN_MARKER = "# --- yamine begin ---"
|
|
12
14
|
END_MARKER = "# --- yamine end ---"
|
|
@@ -44,14 +46,13 @@ module Yamine
|
|
|
44
46
|
# Bring the managed block to exactly these hostnames. Idempotent: when
|
|
45
47
|
# the block already matches, this is a no-op returning true.
|
|
46
48
|
#
|
|
47
|
-
# The short-circuit is load-bearing, not an optimization. /etc/hosts is
|
|
49
|
+
# The sync short-circuit is load-bearing, not an optimization. /etc/hosts is
|
|
48
50
|
# root-owned on a normal machine, so a rewrite fails without sudo — and
|
|
49
51
|
# `setup` and every boot's workstation check run this. Without the
|
|
50
52
|
# guard, a machine whose hosts file was already correct got a
|
|
51
53
|
# "could not write /etc/hosts (try sudo yamine hosts sync)" warning on
|
|
52
54
|
# every run, sending people to an elevated write for a file that
|
|
53
|
-
# needed nothing.
|
|
54
|
-
# /etc/hosts only matters for Safari and custom TLDs.)
|
|
55
|
+
# needed nothing.
|
|
55
56
|
def sync(hostnames, path = PATH)
|
|
56
57
|
return true if synced?(hostnames, path)
|
|
57
58
|
|
|
@@ -85,26 +86,60 @@ module Yamine
|
|
|
85
86
|
false
|
|
86
87
|
end
|
|
87
88
|
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
#
|
|
92
|
-
#
|
|
89
|
+
# Loopback mappings that count as "this name reaches the proxy".
|
|
90
|
+
LOOPBACK = %w[127.0.0.1 ::1].freeze
|
|
91
|
+
|
|
92
|
+
# Does the hosts file map this hostname to loopback? That is the one
|
|
93
|
+
# resolution source every client shares — browsers and curl, the
|
|
94
|
+
# system resolver, and resolvers that read only the file (a
|
|
95
|
+
# CGO-disabled Go binary is the common case). The whole file is
|
|
96
|
+
# searched, not just the managed block, so a hand-added entry counts;
|
|
97
|
+
# only loopback mappings count, since the proxy serves on loopback
|
|
98
|
+
# and any other address points the name away from yamine.
|
|
99
|
+
def hosts_entry?(hostname, path = PATH)
|
|
100
|
+
target = hostname.to_s.downcase
|
|
101
|
+
File.foreach(path) do |line|
|
|
102
|
+
parts = line.split("#", 2).first.to_s.split
|
|
103
|
+
next unless parts.length > 1 && LOOPBACK.include?(parts.first.downcase)
|
|
104
|
+
return true if parts[1..].any? { |name| name.downcase == target }
|
|
105
|
+
end
|
|
106
|
+
false
|
|
107
|
+
rescue SystemCallError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Where one hostname stands for the clients that will actually use
|
|
112
|
+
# it — a question with three answers no single resolver probe can
|
|
113
|
+
# produce:
|
|
114
|
+
#
|
|
115
|
+
# :ok — every client resolves it: mapped in the hosts file, or
|
|
116
|
+
# found by the system resolver without it (real DNS or an
|
|
117
|
+
# /etc/resolver entry for a custom TLD).
|
|
118
|
+
# :warn — browsers and curl handle it (the RFC 6761 .localhost
|
|
119
|
+
# special case), but file-only resolvers cannot see it.
|
|
120
|
+
# :fail — nothing resolves it.
|
|
93
121
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
def
|
|
122
|
+
# .localhost names are classified :warn on file absence alone. The
|
|
123
|
+
# system resolver answers any .localhost unconditionally, so probing
|
|
124
|
+
# it carries no information — and acting on that probe is how doctor
|
|
125
|
+
# and boot once reported every .localhost as fully resolved while a
|
|
126
|
+
# CGO-disabled Go binary could not resolve any of them.
|
|
127
|
+
def classification(hostname, path = PATH)
|
|
128
|
+
return :ok if hosts_entry?(hostname, path)
|
|
129
|
+
return :warn if hostname.to_s.downcase.end_with?(".localhost")
|
|
130
|
+
|
|
100
131
|
Timeout.timeout(2) { Addrinfo.getaddrinfo(hostname, nil) }
|
|
101
|
-
|
|
132
|
+
:ok
|
|
102
133
|
rescue SocketError, SystemCallError, Timeout::Error
|
|
103
|
-
|
|
134
|
+
:fail
|
|
104
135
|
end
|
|
105
136
|
|
|
106
|
-
|
|
107
|
-
|
|
137
|
+
# Partition hostnames into { ok:, warn:, fail: } for the consumers
|
|
138
|
+
# that report one line per state (doctor, boot).
|
|
139
|
+
def resolution(hostnames, path = PATH)
|
|
140
|
+
groups = { ok: [], warn: [], fail: [] }
|
|
141
|
+
hostnames.each { |h| groups[classification(h, path)] << h }
|
|
142
|
+
groups
|
|
108
143
|
end
|
|
109
144
|
end
|
|
110
145
|
end
|
data/lib/yamine/version.rb
CHANGED