domain/architecture/system/deployment/nix_darwin/effects.bend
near-v-framework@0.4.0.0 · 276 lines · 39 definitions
1import Base
2import bend-kit-files@0.1.0.0/files.bend as Fs
3import bend-net-json@0.3.0.0/json.bend as Json
4import near-v-framework@0.4.0.0/domain/architecture/system/effs/io.bend as X
5import near-v-framework@0.4.0.0/domain/architecture/system/plan/type.bend as P
6import near-v-framework@0.4.0.0/domain/architecture/system/deployment/nix_darwin/type.bend as ND
7import near-v-framework@0.4.0.0/domain/architecture/system/deployment/nix_darwin/ops.bend as NO
8import near-v-framework@0.4.0.0/domain/architecture/system/deployment/nix_darwin/service/type.bend as Sv
9import near-v-framework@0.4.0.0/domain/architecture/system/deployment/nix_darwin/service/ops.bend as SvO
10
11
12
13
14def dedupe(xs: List<&2, String>) -> List<&2, String>:
15 Set.to_list(Set.from_list(xs))
16
17
18
19def unreverse(xs: List<&2, String>, acc: List<&2, String>) -> List<&2, String>:
20 match xs:
21 case []:
22 acc
23 case x <> rest:
24 unreverse(rest, String.reverse(x) <> acc)
25
26def lines_go(s: String, +line: String, +acc: List<&2, String>) -> List<&2, String>:
27 match s:
28 case SNil{}:
29 unreverse(line <> acc, [])
30 case SCon{+c, t}:
31 +nl = Char.is_eq(c, '\n')
32 lines_go(t, Bool.pick(String, nl, "", SCon{c, line}), Bool.pick(List<&2, String>, nl, line <> acc, acc))
33
34def lines(s: String) -> List<&2, String>:
35 lines_go(s, "", [])
36
37def last_of(+s: String, c: Char) -> String:
38 Maybe.default(&2, String, List.last(&2, String, String.split(s, c)), "")
39
40
41def pid_of(ls: List<&2, String>) -> String:
42 match ls:
43 case []:
44 ""
45 case l <> rest:
46 +t = String.trim(l)
47 Bool.pick(String, String.starts_with(t, "pid = "), String.drop(t, 6n), pid_of(rest))
48
49def pids(ts: List<&2, String>) -> IO(List<&2, String>):
50 match ts:
51 case []:
52 IO.pure(List<&2, String>, [])
53 case t <> rest:
54 do IO<List<&2, String>>:
55 out : String <- X.run("/bin/launchctl", ["print", t])
56 more : List<&2, String> <- pids(rest)
57 +p : String = pid_of(lines(out))
58 return X.keep_filled(Bool.not(String.is_empty(p)), p, more)
59
60
61def service_targets(xs: List<&2, Sv.Service>, +uid: String) -> List<&2, String>:
62 match xs:
63 case []:
64 []
65 case +s <> rest:
66 (Bool.pick(String, SvO.at_login(SvO.start(s)), "gui/" ++ uid, "system") ++ "/" ++ SvO.label(s)) <> service_targets(rest, uid)
67
68def agent_targets(xs: List<&2, String>, +uid: String) -> List<&2, String>:
69 match xs:
70 case []:
71 []
72 case l <> rest:
73 ("gui/" ++ uid ++ "/" ++ l) <> agent_targets(rest, uid)
74
75def plist_label(+f: String) -> String:
76 Bool.pick(String, String.ends_with(f, ".plist"), String.take(f, Nat.sub(String.length(f), 6n)), f)
77
78
79def installed(fs: List<&2, String>) -> List<&2, String>:
80 match fs:
81 case []:
82 []
83 case +f <> rest:
84 plist_label(f) <> installed(rest)
85
86
87
88def field(+l: String) -> String:
89 String.drop(l, 1n)
90
91def sockets(ls: List<&2, String>, +pid: String, +cmd: String) -> List<&2, String>:
92 match ls:
93 case []:
94 []
95 case +l <> rest:
96 X.keep_filled(String.starts_with(l, "n"), pid ++ "\t" ++ cmd ++ "\t" ++ field(l),
97 sockets(rest, Bool.pick(String, String.starts_with(l, "p"), field(l), pid), Bool.pick(String, String.starts_with(l, "c"), field(l), cmd)))
98
99def part(+x: String, n: Nat) -> String:
100 Maybe.default(&2, String, List.get(&2, String, String.split(x, '\t'), n), "")
101
102def loopback(+addr: String) -> Bool:
103 String.starts_with(addr, "127.") || String.starts_with(addr, "[::1]:")
104
105
106def holders(xs: List<&2, String>, +port: String, +owned: List<&2, String>, +tailscale: String) -> List<&2, String>:
107 match xs:
108 case []:
109 []
110 case +x <> rest:
111 +pid = part(x, 0n)
112 +cmd = part(x, 1n)
113 X.keep_filled(String.eq(last_of(part(x, 2n), ':'), port) && Bool.not(String.eq(cmd, tailscale)) && Bool.not(List.contains(~String, ~String.eq, owned, pid)),
114 cmd ++ " (pid " ++ pid ++ ")", holders(rest, port, owned, tailscale))
115
116
117def owned_ports(xs: List<&2, String>, +owned: List<&2, String>) -> List<&2, String>:
118 match xs:
119 case []:
120 []
121 case +x <> rest:
122 X.keep_filled(List.contains(~String, ~String.eq, owned, part(x, 0n)), last_of(part(x, 2n), ':'), owned_ports(rest, owned))
123
124
125def undeclared_listeners(xs: List<&2, String>, +allowed: List<&2, String>) -> List<&2, String>:
126 match xs:
127 case []:
128 []
129 case +x <> rest:
130 +cmd = part(x, 1n)
131 +addr = part(x, 2n)
132 X.keep_filled(Bool.not(loopback(addr)) && Bool.not(List.contains(~String, ~String.eq, allowed, cmd)),
133 cmd ++ " " ++ last_of(addr, ':'), undeclared_listeners(rest, allowed))
134
135
136def foreign_agent(+a: String) -> Bool:
137 Bool.not(String.is_empty(a)) && Bool.not(String.starts_with(a, "com.apple.")) && Bool.not(String.starts_with(a, "application.")) && Bool.not(String.eq(a, "com.openssh.ssh-agent"))
138
139def undeclared_agents(ls: List<&2, String>, +declared: List<&2, String>) -> List<&2, String>:
140 match ls:
141 case []:
142 []
143 case l <> rest:
144 +a = Maybe.default(&2, String, List.get(&2, String, String.split(l, '\t'), 2n), "")
145 X.keep_filled(foreign_agent(a) && Bool.not(List.contains(~String, ~String.eq, declared, a)), a, undeclared_agents(rest, declared))
146
147
148
149def or_null(m: Maybe<&2, Json.Val>) -> Json.Val:
150 match m:
151 case None{}:
152 Json.Null{}
153 case Some{v}:
154 v
155
156def at(v: Json.Val, k: String) -> Json.Val:
157 or_null(Json.get(v, k))
158
159def text(v: Json.Val) -> String:
160 match v:
161 case Json.Str{s}:
162 s
163 case _:
164 ""
165
166def keys(v: Json.Val) -> List<&2, String>:
167 match v:
168 case Json.Obj{m}:
169 Map.keys(&2, Json.Val, m)
170 case _:
171 []
172
173
174def url_port(+w: String) -> String:
175 Maybe.default(&2, String, List.head(&2, String, String.split(last_of(w, ':'), '/')), "")
176
177def proxy(+web: Json.Val, k: String) -> String:
178 text(at(at(at(at(web, k), "Handlers"), "/"), "Proxy"))
179
180
181def proxy_on(+web: Json.Val, ks: List<&2, String>, +port: String) -> String:
182 match ks:
183 case []:
184 ""
185 case +k <> rest:
186 Bool.pick(String, String.eq(url_port(k), port), proxy(web, k), proxy_on(web, rest, port))
187
188
189def serve_step(s: ND.Serve, +web: Json.Val, +ours: List<&2, String>, +tailscale: String, +what: String) -> P.Step:
190 match s:
191 case ND.Serve{_, +h, p, path}:
192 +target = "http://127.0.0.1:" ++ U32.show(p) ++ path
193 +now = proxy_on(web, keys(web), U32.show(h))
194 Bool.pick(P.Step, String.is_empty(now) || List.contains(~String, ~String.eq, ours, url_port(now)),
195 Bool.pick(P.Step, String.eq(now, target), P.Current{"serve :" ++ U32.show(h)}, P.ServePort{h, target, tailscale}),
196 P.Blocked{what, "tailnet port " ++ U32.show(h) ++ " is served to " ++ now ++ ", which is not V's"})
197
198def serve_steps(xs: List<&2, ND.Serve>, +web: Json.Val, +ours: List<&2, String>, +tailscale: String, +what: String) -> List<&2, P.Step>:
199 match xs:
200 case []:
201 []
202 case s <> rest:
203 serve_step(s, web, ours, tailscale, what) <> serve_steps(rest, web, ours, tailscale, what)
204
205
206def ports(xs: List<&2, U32>) -> List<&2, String>:
207 match xs:
208 case []:
209 []
210 case +p <> rest:
211 X.keep_filled(Bool.not(U32.is_eq(p, 0)), U32.show(p), ports(rest))
212
213def service_ports(xs: List<&2, Sv.Service>) -> List<&2, U32>:
214 match xs:
215 case []:
216 []
217 case s <> rest:
218 SvO.port(s) <> service_ports(rest)
219
220def serve_ports(xs: List<&2, ND.Serve>) -> List<&2, U32>:
221 match xs:
222 case []:
223 []
224 case s <> rest:
225 NO.serve_port(s) <> serve_ports(rest)
226
227def blocked_by(+xs: List<&2, String>, +what: String, +why: String) -> List<&2, P.Step>:
228 Bool.pick(List<&2, P.Step>, List.is_empty(&2, String, xs), [], [P.Blocked{what, why ++ String.join(xs, ", ")}])
229
230
231def local_steps(ps: List<&2, String>, +socks: List<&2, String>, +owned: List<&2, String>, +tailscale: String, +what: String) -> List<&2, P.Step>:
232 match ps:
233 case []:
234 []
235 case +p <> rest:
236 List.append(&2, P.Step, blocked_by(dedupe(holders(socks, p, owned, tailscale)), what, "local port " ++ p ++ " is held by "),
237 local_steps(rest, socks, owned, tailscale, what))
238
239def tailscale_process(on: Bool) -> String:
240 Bool.pick(String, on, "tailscaled", "IPNExtension")
241
242def steps(+what: String, +nd: ND.NixDarwin, +installed: List<&2, String>, +owned: List<&2, String>, +socks: List<&2, String>, status: String, list: String, auto: String) -> List<&2, P.Step>:
243 +ts = tailscale_process(NO.tailscale(nd))
244 +ours = List.append(&2, String, ports(List.append(&2, U32, service_ports(NO.services(nd)), serve_ports(NO.serves(nd)))), owned_ports(socks, owned))
245 +declared = List.append(&2, String, SvO.labels(NO.services(nd)), List.append(&2, String, NO.kept(nd), installed))
246 List.concat(&2, P.Step, [
247 local_steps(ports(service_ports(NO.services(nd))), socks, owned, ts, what),
248 blocked_by(dedupe(undeclared_agents(List.drop(&2, String, lines(list), 1n), declared)), what, "undeclared launch agents: "),
249 blocked_by(dedupe(undeclared_listeners(socks, NO.listening_processes(NO.listeners(nd)))), what, "undeclared listeners: "),
250 Bool.pick(List<&2, P.Step>, NO.survives_reboot(nd, String.trim(auto)), [],
251 [P.Blocked{what, "services start at login, but the Mac does not log " ++ NO.user(nd) ++ " in by itself"}]),
252 serve_steps(NO.serves(nd), at(or_null(Json.parse(status)), "Web"), ours, NO.tailscale_command(nd), what)])
253
254def observe(+what: String, +nd: ND.NixDarwin, +uid: String, lsof: String) -> IO(List<&2, P.Step>):
255 do IO<List<&2, P.Step>>:
256 r : Result<&1, &1, U32 & String, List<&2, String>> <- Fs.list_dir("/run/current-system/user/Library/LaunchAgents")
257 +inst : List<&2, String> = installed(Result.default(&1, &1, U32 & String, List<&2, String>, r, []))
258 owned : List<&2, String> <- pids(List.append(&2, String, service_targets(NO.services(nd), uid), agent_targets(inst, uid)))
259 status : String <- X.run(NO.tailscale_command(nd), ["serve", "status", "--json"])
260 list : String <- X.run("/bin/launchctl", ["list"])
261 auto : String <- X.run("/usr/bin/defaults", ["read", "/Library/Preferences/com.apple.loginwindow", "autoLoginUser"])
262 return steps(what, nd, inst, owned, sockets(lines(lsof), "", ""), status, list, auto)
263
264def read_or(unread: Bool, +what: String, nd: ND.NixDarwin, uid: String, lsof: String) -> IO(List<&2, P.Step>):
265 match unread:
266 case True{}:
267 IO.pure(List<&2, P.Step>, [P.Blocked{what, "cannot read the Mac: `id -u` or `lsof` failed"}])
268 case False{}:
269 observe(what, nd, uid, lsof)
270
271
272def checks(+node: String, nd: ND.NixDarwin) -> IO(List<&2, P.Step>):
273 do IO<List<&2, P.Step>>:
274 +uid : String <- X.run("/usr/bin/id", ["-u"])
275 +lsof : String <- X.run("/usr/sbin/lsof", ["+c", "0", "-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
276 read_or(String.is_empty(String.trim(uid)) || String.is_empty(lsof), "nix-darwin " ++ node, nd, String.trim(uid), lsof)