lib/deploy.bend
near-v-framework@0.2.0.0 · 832 lines · 0 definitions
1import Base
2import near-architecture@0.9.1.0/arc.bend as A
3
4# V's deployment layer for an Arc system: services, routes, how each runs, what a change breaks,
5# and the minimality checks against a machine (launch agents, listeners). A project supplies the data.
6
7def str_eq(a: String, b: String) -> Bool:
8 String.eq(a, b)
9
10def has(+s: String, xs: List<&2, String>) -> Bool:
11 A.has(s, xs)
12
13# A declared stack item or source extension that is still being migrated.
14type Migration is Data:
15 Migration{container: String, missing: String}
16
17def migration(+key: String, +name: String, xs: List<&2, Migration>) -> Bool:
18 match xs:
19 case []:
20 False{}
21 case m <> rest:
22 match m:
23 case Migration{k, n}:
24 (str_eq(key, k) && str_eq(name, n)) || migration(key, name, rest)
25
26# Names on a canvas are readable names; snapshots record package names.
27def package_name(name: String) -> String:
28 match name:
29 case "Next.js": "next"
30 case "Tailwind CSS": "tailwindcss"
31 case "React": "react"
32 case "shadcn/ui": "shadcn/ui"
33 case "Convex": "convex"
34 case "OpenRouter SDK": "@openrouter/sdk"
35 case "Sentry SDK": "@sentry/nextjs"
36 case "whatsmeow": "go.mau.fi/whatsmeow"
37 case _: name
38
39def stack_item(+key: String, +name: String, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
40 has(key ++ ":" ++ package_name(name), deps) || migration(key, name, ms)
41
42def stack_items(+key: String, xs: List<&2, String>, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
43 match xs:
44 case []:
45 True{}
46 case name <> rest:
47 stack_item(key, name, deps, ms) && stack_items(key, rest, deps, ms)
48
49def container_stack(c: A.Container, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
50 match c:
51 case A.Container{+key, _, _, _, _, _, _, source, _, frameworks, libraries, _}:
52 String.is_empty(source) || (stack_items(key, frameworks, deps, ms) && stack_items(key, libraries, deps, ms))
53
54def containers_stack(xs: List<&2, A.Container>, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
55 match xs:
56 case []:
57 True{}
58 case c <> rest:
59 container_stack(c, deps, ms) && containers_stack(rest, deps, ms)
60
61# A source container calling a model API needs the API's declared SDK.
62def model_sdk_caller(caller: A.Container, sdk: List<&2, String>, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
63 match caller:
64 case A.Container{key, _, _, _, _, _, _, source, _, _, _, _}:
65 String.is_empty(source) || stack_items(key, sdk, deps, ms)
66
67def model_sdk_target(target: A.Container, caller: A.Container, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
68 match target:
69 case A.Container{_, _, A.ModelApi{}, _, _, _, _, _, _, _, sdk, _}:
70 model_sdk_caller(caller, sdk, deps, ms)
71 case _:
72 True{}
73
74def model_sdk(link: A.Link, +s: A.System, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
75 match link:
76 case A.Link{src, dst, _, _}:
77 model_sdk_target(A.container(A.home(dst, s), A.containers(s)), A.container(A.home(src, s), A.containers(s)), deps, ms)
78
79def model_sdks(xs: List<&2, A.Link>, +s: A.System, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
80 match xs:
81 case []:
82 True{}
83 case link <> rest:
84 model_sdk(link, s, deps, ms) && model_sdks(rest, s, deps, ms)
85
86def stack_present(+s: A.System, +deps: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
87 containers_stack(A.containers(s), deps, ms) && model_sdks(A.links(s), s, deps, ms)
88
89def language_extension(+name: String, +ext: String) -> Bool:
90 (str_eq(name, "TypeScript") && (str_eq(ext, ".ts") || str_eq(ext, ".tsx"))) ||
91 (str_eq(name, "Go") && str_eq(ext, ".go")) ||
92 (str_eq(name, "JavaScript") && (str_eq(ext, ".js") || str_eq(ext, ".mjs") || str_eq(ext, ".jsx"))) ||
93 (str_eq(name, "Python") && str_eq(ext, ".py"))
94
95def any_language(+ext: String, xs: List<&2, String>) -> Bool:
96 match xs:
97 case []:
98 False{}
99 case name <> rest:
100 language_extension(name, ext) || any_language(ext, rest)
101
102def extension_ok(+key: String, fields: List<&2, String>, +languages: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
103 match fields:
104 case k <> +ext <> _:
105 Bool.not(str_eq(key, k)) || any_language(ext, languages) || migration(key, ext, ms)
106 case _:
107 True{}
108
109def extensions_ok(+key: String, xs: List<&2, String>, +languages: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
110 match xs:
111 case []:
112 True{}
113 case entry <> rest:
114 extension_ok(key, String.split(entry, ':'), languages, ms) && extensions_ok(key, rest, languages, ms)
115
116def container_languages(c: A.Container, +extensions: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
117 match c:
118 case A.Container{key, _, _, _, _, _, _, source, _, _, _, languages}:
119 String.is_empty(source) || extensions_ok(key, extensions, languages, ms)
120
121def stack_languages(xs: List<&2, A.Container>, +extensions: List<&2, String>, +ms: List<&2, Migration>) -> Bool:
122 match xs:
123 case []:
124 True{}
125 case c <> rest:
126 container_languages(c, extensions, ms) && stack_languages(rest, extensions, ms)
127
128# ---- deployment facts ----
129# The address a system is reached at, and the data stores that must survive.
130type Deployment is Data:
131 Deployment{url: String, stores: List<&2, String>}
132
133# ---- compatibility: what a change breaks, compared to what was applied ----
134type Break is Data:
135 UrlChanged{}
136 StoreRemoved{name: String}
137
138def keep(present: Bool, o: String, rest: List<&2, Break>) -> List<&2, Break>:
139 match present:
140 case True{}:
141 rest
142 case False{}:
143 StoreRemoved{o} <> rest
144
145def removed(olds: List<&2, String>, +news: List<&2, String>) -> List<&2, Break>:
146 match olds:
147 case []:
148 []
149 case +o <> rest:
150 keep(has(o, news), o, removed(rest, news))
151
152def url_break(same: Bool) -> List<&2, Break>:
153 match same:
154 case True{}:
155 []
156 case False{}:
157 [UrlChanged{}]
158
159def breaks(old: Deployment, new: Deployment) -> List<&2, Break>:
160 match old new:
161 case Deployment{ou, os} Deployment{nu, ns}:
162 List.append(&2, Break, url_break(str_eq(ou, nu)), removed(os, ns))
163
164def break_eq(a: Break, b: Break) -> Bool:
165 match a b:
166 case UrlChanged{} UrlChanged{}:
167 True{}
168 case StoreRemoved{x} StoreRemoved{y}:
169 str_eq(x, y)
170 case _ _:
171 False{}
172
173def is_empty(b: List<&2, Break>) -> Bool:
174 match b:
175 case []:
176 True{}
177 case _ <> _:
178 False{}
179
180def breaks_eq(a: List<&2, Break>, b: List<&2, Break>) -> Bool:
181 match a:
182 case []:
183 is_empty(b)
184 case x <> xs:
185 match b:
186 case []:
187 False{}
188 case y <> ys:
189 break_eq(x, y) && breaks_eq(xs, ys)
190
191# ---- services: how each container is installed and served ----
192# port: where it listens locally; serve: its private Tailscale HTTPS port (0 = not served).
193type Service is Data:
194 Service{container: A.Container, label: String, script: String, port: U32, serve: U32}
195
196def serve_of(s: Service) -> U32:
197 match s:
198 case Service{_, _, _, _, serve}:
199 serve
200
201def serves(xs: List<&2, Service>) -> List<&2, U32>:
202 match xs:
203 case []:
204 []
205 case s <> rest:
206 serve_of(s) <> serves(rest)
207
208def not_in(+p: U32, xs: List<&2, U32>) -> Bool:
209 match xs:
210 case []:
211 True{}
212 case q <> rest:
213 (U32.is_eq(p, 0) || Bool.not(U32.is_eq(p, q))) && not_in(p, rest)
214
215# No two services claim the same Tailscale port.
216def distinct(xs: List<&2, U32>) -> Bool:
217 match xs:
218 case []:
219 True{}
220 case +p <> +rest:
221 not_in(p, rest) && distinct(rest)
222
223# A private-network HTTPS port served by a path on a service's local port (e.g. an app's URL -> its data API's HTTP routes).
224type Route is Data:
225 Route{container: A.Container, serve: U32, port: U32, path: String}
226
227def route_serves(xs: List<&2, Route>) -> List<&2, U32>:
228 match xs:
229 case []:
230 []
231 case r <> rest:
232 match r:
233 case Route{_, serve, _, _}:
234 serve <> route_serves(rest)
235
236# Every private-network port served: the services' and the routes'.
237def all_serves(ss: List<&2, Service>, rs: List<&2, Route>) -> List<&2, U32>:
238 List.append(&2, U32, serves(ss), route_serves(rs))
239
240# Only private containers on a server run as services.
241def on_server(c: A.Container, +ns: List<&2, A.Node>) -> Bool:
242 A.is_server(A.node_role(A.con(c), ns))
243
244def service_ok(s: Service, +ns: List<&2, A.Node>) -> Bool:
245 match s:
246 case Service{+c, _, _, _, _}:
247 on_server(c, ns) && A.private_ok(c, ns)
248
249# ---- exposure is honest ----
250def eq_bool(a: Bool, b: Bool) -> Bool:
251 match a:
252 case True{}:
253 b
254 case False{}:
255 Bool.not(b)
256
257def served_service(+k: String, xs: List<&2, Service>) -> Bool:
258 match xs:
259 case []:
260 False{}
261 case s <> rest:
262 match s:
263 case Service{c, _, _, _, serve}:
264 (str_eq(k, A.ckey(c)) && Bool.not(U32.is_eq(serve, 0))) || served_service(k, rest)
265
266def served_route(+k: String, xs: List<&2, Route>) -> Bool:
267 match xs:
268 case []:
269 False{}
270 case r <> rest:
271 match r:
272 case Route{c, _, _, _}:
273 str_eq(k, A.ckey(c)) || served_route(k, rest)
274
275# On a server, a container is on the private network exactly when it is served there; otherwise it is local.
276def exposure_honest(c: A.Container, +ss: List<&2, Service>, +rs: List<&2, Route>, +ns: List<&2, A.Node>) -> Bool:
277 match c:
278 case A.Container{+k, _, _, _, on, exposure, _, _, _, _, _, _}:
279 Bool.not(A.is_server(A.node_role(on, ns))) || eq_bool(A.is_tailnet(exposure), served_service(k, ss) || served_route(k, rs))
280
281def all_exposure_honest(xs: List<&2, A.Container>, +ss: List<&2, Service>, +rs: List<&2, Route>, +ns: List<&2, A.Node>) -> Bool:
282 match xs:
283 case []:
284 True{}
285 case c <> rest:
286 exposure_honest(c, ss, rs, ns) && all_exposure_honest(rest, ss, rs, ns)
287
288def route_ok(r: Route, +ns: List<&2, A.Node>) -> Bool:
289 match r:
290 case Route{+c, _, _, _}:
291 on_server(c, ns) && A.private_ok(c, ns)
292
293def all_routes_ok(xs: List<&2, Route>, +ns: List<&2, A.Node>) -> Bool:
294 match xs:
295 case []:
296 True{}
297 case r <> rest:
298 route_ok(r, ns) && all_routes_ok(rest, ns)
299
300def all_services_ok(xs: List<&2, Service>, +ns: List<&2, A.Node>) -> Bool:
301 match xs:
302 case []:
303 True{}
304 case s <> rest:
305 service_ok(s, ns) && all_services_ok(rest, ns)
306
307def port_of(s: Service) -> U32:
308 match s:
309 case Service{_, _, _, port, _}:
310 port
311
312def ports(xs: List<&2, Service>) -> List<&2, U32>:
313 match xs:
314 case []:
315 []
316 case s <> rest:
317 port_of(s) <> ports(rest)
318
319# No declared port (other than 0, "none") is in `taken`.
320def clear_of(xs: List<&2, U32>, +taken: List<&2, U32>) -> Bool:
321 match xs:
322 case []:
323 True{}
324 case +p <> rest:
325 not_in(p, taken) && clear_of(rest, taken)
326
327# ---- how each service runs (pinned by Nix) ----
328type Prog is Data:
329 RunNode{}
330 RunPython{}
331 RunBash{}
332
333# An environment variable: a plain value, or a Nix expression (e.g. a path in a derivation the project defines).
334type Env is Data:
335 Env{key: String, value: String}
336 NixEnv{key: String, expr: String}
337
338# When a service starts: at login (in the user's session: login Keychain, apps) or at boot (no session).
339type Start is Data:
340 AtLogin{}
341 AtBoot{}
342
343type Run is Data:
344 Run{label: String, prog: Prog, args: List<&2, String>, dir: String, env: List<&2, Env>, log: String, start: Start}
345
346# ---- machine minimality: every launch agent is declared ----
347def run_label(r: Run) -> String:
348 match r:
349 case Run{label, _, _, _, _, _, _}:
350 label
351
352# ---- surviving a reboot ----
353def at_login(s: Start) -> Bool:
354 match s:
355 case AtLogin{}:
356 True{}
357 case AtBoot{}:
358 False{}
359
360def needs_login(xs: List<&2, Run>) -> Bool:
361 match xs:
362 case []:
363 False{}
364 case r <> rest:
365 match r:
366 case Run{_, _, _, _, _, _, s}:
367 at_login(s) || needs_login(rest)
368
369# Services that start at login need the machine to log `user` in by itself.
370def survives_reboot(xs: List<&2, Run>, auto_login_user: String, user: String) -> Bool:
371 Bool.not(needs_login(xs)) || String.eq(auto_login_user, user)
372
373def run_labels(xs: List<&2, Run>) -> List<&2, String>:
374 match xs:
375 case []:
376 []
377 case r <> rest:
378 run_label(r) <> run_labels(rest)
379
380# ---- network minimality: every program reachable from other machines is declared ----
381# A program allowed to listen beyond 127.0.0.1, by process name, and what it belongs to: a container's key, or "macos".
382type Listener is Data:
383 Listener{process: String, of: String}
384
385def listener_processes(xs: List<&2, Listener>) -> List<&2, String>:
386 match xs:
387 case []:
388 []
389 case l <> rest:
390 match l:
391 case Listener{p, _}:
392 p <> listener_processes(rest)
393
394def listener_owners(xs: List<&2, Listener>) -> List<&2, String>:
395 match xs:
396 case []:
397 []
398 case l <> rest:
399 match l:
400 case Listener{_, o}:
401 o <> listener_owners(rest)
402
403def keep_listener(known: Bool, e: String, rest: List<&2, String>) -> List<&2, String>:
404 match known:
405 case True{}:
406 rest
407 case False{}:
408 e <> rest
409
410def head_or_empty(xs: List<&2, String>) -> String:
411 match xs:
412 case []:
413 ""
414 case w <> _:
415 w
416
417def first_word(s: String) -> String:
418 head_or_empty(String.split(s, ' '))
419
420# The exposed listeners ("process port") whose process nothing declares.
421def undeclared_listeners(xs: List<&2, String>, +declared: List<&2, String>) -> List<&2, String>:
422 match xs:
423 case []:
424 []
425 case +e <> rest:
426 keep_listener(has(first_word(e), declared), e, undeclared_listeners(rest, declared))
427
428# Launch agents the project declares: its services' and the ones it keeps.
429def declared_agents(rs: List<&2, Run>, kept: List<&2, String>) -> List<&2, String>:
430 List.append(&2, String, run_labels(rs), kept)
431
432def pick_undeclared(known: Bool, a: String, rest: List<&2, String>) -> List<&2, String>:
433 match known:
434 case True{}:
435 rest
436 case False{}:
437 a <> rest
438
439# The loaded agents that nothing declares.
440def undeclared(xs: List<&2, String>, +declared: List<&2, String>) -> List<&2, String>:
441 match xs:
442 case []:
443 []
444 case +a <> rest:
445 pick_undeclared(has(a, declared), a, undeclared(rest, declared))
446
447# ---- the deploy plan, one step per line, for `v system deploy` to execute ----
448# service <launchd label> <install script>
449# serve <private-network https port> <local port>[path]
450def plan_serve(none: Bool, serve: U32, port: U32) -> String:
451 match none:
452 case True{}:
453 ""
454 case False{}:
455 "serve " ++ U32.show(serve) ++ " " ++ U32.show(port) ++ "\n"
456
457def plan_step(s: Service) -> String:
458 match s:
459 case Service{_, label, script, port, +serve}:
460 "service " ++ label ++ " " ++ script ++ "\n" ++ plan_serve(U32.is_eq(serve, 0), serve, port)
461
462def plan_steps(xs: List<&2, Service>) -> String:
463 match xs:
464 case []:
465 ""
466 case s <> rest:
467 plan_step(s) ++ plan_steps(rest)
468
469def plan_routes(xs: List<&2, Route>) -> String:
470 match xs:
471 case []:
472 ""
473 case r <> rest:
474 match r:
475 case Route{_, serve, port, path}:
476 "serve " ++ U32.show(serve) ++ " " ++ U32.show(port) ++ path ++ "\n" ++ plan_routes(rest)
477
478def plan(ss: List<&2, Service>, rs: List<&2, Route>) -> String:
479 plan_steps(ss) ++ plan_routes(rs)
480
481# ---- the deployment as text: its URL, then its stores ----
482def quoted_names(xs: List<&2, String>) -> String:
483 match xs:
484 case []:
485 ""
486 case x <> rest:
487 "\"" ++ x ++ "\", " ++ quoted_names(rest)
488
489def show_deployment(d: Deployment) -> String:
490 match d:
491 case Deployment{url, stores}:
492 url ++ "\n" ++ quoted_names(stores)
493
494# ---- where a project deploys: only what it declares runs ----
495# NixDarwin: configure a Mac with nix-darwin (its flake host). TailscaleServe: serve the plan's ports on the
496# private network. BendHub: publish the project's packages. AzureSnapshot: read-only ARM and DevOps facts.
497# GitHub: push the project's repository (owner/name) to its main branch; public says whether it is open.
498type Target is Data:
499 NixDarwin{host: String}
500 TailscaleServe{}
501 BendHub{}
502 AzureSnapshot{organization: String, project: String}
503 GitHub{repo: String, public: Bool}
504
505# A package published to the Bend hub: its name, version (four numbers), entry file (relative to the
506# project) and the parts of the project it ships.
507type Package is Data:
508 Package{name: String, version: String, entry: String, parts: List<&2, String>}
509
510# ---- no dead code, Bend only ----
511# Every library definition is an entry point for whoever imports it, so a repository of packages has
512# dead code exactly when a source file ships in no package.
513def listed(+x: String, ys: List<&2, String>) -> Bool:
514 match ys:
515 case []:
516 False{}
517 case y <> rest:
518 String.eq(x, y) || listed(x, rest)
519
520def is_source(+f: String) -> Bool:
521 String.ends_with(f, ".bend") || String.ends_with(f, ".c")
522
523def first_or_empty(xs: List<&2, String>) -> String:
524 match xs:
525 case []:
526 ""
527 case x <> _:
528 x
529
530def base_name(f: String) -> String:
531 first_or_empty(List.reverse(&2, String, String.split(f, '/')))
532
533# A file that is neither Bend source nor a C effect: only the license, readme and ignore list may be.
534def allowed(+f: String) -> Bool:
535 is_source(f) || String.eq(base_name(f), "LICENSE") || String.eq(f, "README.md") || String.eq(f, ".gitignore")
536
537def bend_only(fs: List<&2, String>) -> Bool:
538 match fs:
539 case []:
540 True{}
541 case +f <> rest:
542 allowed(f) && bend_only(rest)
543
544# The project's own folder (its architecture) is configuration, not shipped code.
545def needs_shipping(+f: String, +project: String) -> Bool:
546 is_source(f) && Bool.not(String.starts_with(f, project ++ "/"))
547
548def no_dead_code(fs: List<&2, String>, +project: String, +shipped: List<&2, String>) -> Bool:
549 match fs:
550 case []:
551 True{}
552 case +f <> rest:
553 (Bool.not(needs_shipping(f, project)) || listed(f, shipped)) && no_dead_code(rest, project, shipped)
554
555# ---- the repository is deployed through V ----
556def is_github(t: Target) -> Bool:
557 match t:
558 case GitHub{_, _}:
559 True{}
560 case NixDarwin{_}:
561 False{}
562 case TailscaleServe{}:
563 False{}
564 case BendHub{}:
565 False{}
566 case AzureSnapshot{_, _}:
567 False{}
568
569def has_github(ts: List<&2, Target>) -> Bool:
570 match ts:
571 case []:
572 False{}
573 case t <> rest:
574 is_github(t) || has_github(rest)
575
576def vendor_github(vs: List<&2, String>) -> Bool:
577 match vs:
578 case []:
579 False{}
580 case v <> rest:
581 String.eq(v, "GitHub") || vendor_github(rest)
582
583def on_github(cs: List<&2, A.Container>) -> Bool:
584 match cs:
585 case []:
586 False{}
587 case c <> rest:
588 match c:
589 case A.Container{_, _, _, _, _, _, _, _, vs, _, _, _}:
590 vendor_github(vs) || on_github(rest)
591
592# A system whose source lives on GitHub declares a GitHub target, so `v system deploy` is how it gets there.
593def source_deployed(+s: A.System, ts: List<&2, Target>) -> Bool:
594 Bool.not(on_github(A.containers(s))) || has_github(ts)
595
596def visibility(p: Bool) -> String:
597 match p:
598 case True{}:
599 "public"
600 case False{}:
601 "private"
602
603def is_bend_hub(t: Target) -> Bool:
604 match t:
605 case BendHub{}:
606 True{}
607 case GitHub{_, _}:
608 False{}
609 case NixDarwin{_}:
610 False{}
611 case TailscaleServe{}:
612 False{}
613 case AzureSnapshot{_, _}:
614 False{}
615
616def has_bend_hub(ts: List<&2, Target>) -> Bool:
617 match ts:
618 case []:
619 False{}
620 case t <> rest:
621 is_bend_hub(t) || has_bend_hub(rest)
622
623def public_github(ts: List<&2, Target>) -> Bool:
624 match ts:
625 case []:
626 False{}
627 case t <> rest:
628 match t:
629 case GitHub{_, p}:
630 p || public_github(rest)
631 case BendHub{}:
632 public_github(rest)
633 case NixDarwin{_}:
634 public_github(rest)
635 case TailscaleServe{}:
636 public_github(rest)
637 case AzureSnapshot{_, _}:
638 public_github(rest)
639
640# What a project publishes to the Bend hub is open source, so its repository is public too.
641# `v system deploy` checks the repository's real visibility against the declaration.
642def open_source(+ts: List<&2, Target>) -> Bool:
643 Bool.not(has_bend_hub(ts)) || public_github(ts)
644
645def digits_only(cs: List<&2, Char>) -> Bool:
646 match cs:
647 case []:
648 True{}
649 case c <> rest:
650 Char.is_digit(c) && digits_only(rest)
651
652def all_numbers(xs: List<&2, String>) -> Bool:
653 match xs:
654 case []:
655 True{}
656 case +x <> rest:
657 Bool.not(String.is_empty(x)) && digits_only(String.to_list(x)) && all_numbers(rest)
658
659def name_chars(cs: List<&2, Char>) -> Bool:
660 match cs:
661 case []:
662 True{}
663 case +c <> rest:
664 (Char.is_lower(c) || Char.is_digit(c) || Char.is_eq(c, '-')) && name_chars(rest)
665
666# The hub's rules: a name is 12 to 64 characters of a-z, 0-9 and -; a version is four numbers.
667def package_ok(p: Package) -> Bool:
668 match p:
669 case Package{+n, +v, _, _}:
670 Nat.is_ge(String.length(n), 12n) && Nat.is_le(String.length(n), 64n) && name_chars(String.to_list(n)) &&
671 Nat.is_eq(List.length(&2, String, String.split(v, '.')), 4n) && all_numbers(String.split(v, '.'))
672
673def packages_ok(xs: List<&2, Package>) -> Bool:
674 match xs:
675 case []:
676 True{}
677 case p <> rest:
678 package_ok(p) && packages_ok(rest)
679
680def package_parts(xs: List<&2, Package>) -> List<&2, String>:
681 match xs:
682 case []:
683 []
684 case Package{_, _, _, ps} <> rest:
685 List.append(&2, String, ps, package_parts(rest))
686
687# Everything the project is made of ships in a declared package: an open-source project is what it
688# publishes, so an unpublished part cannot be used.
689def parts_published(+s: A.System, xs: List<&2, Package>) -> Bool:
690 A.all_in(A.pkeys(A.parts(s)), package_parts(xs))
691
692# ---- facts `v` reads from a project: where V's library is, its targets, services and packages ----
693def fact_lines(+k: String, xs: List<&2, String>) -> String:
694 match xs:
695 case []:
696 ""
697 case x <> rest:
698 k ++ " " ++ x ++ "\n" ++ fact_lines(k, rest)
699
700def service_ports(xs: List<&2, Service>) -> List<&2, String>:
701 match xs:
702 case []:
703 []
704 case s <> rest:
705 U32.show(port_of(s)) <> service_ports(rest)
706
707def target_line(t: Target) -> String:
708 match t:
709 case NixDarwin{h}:
710 "target nix-darwin " ++ h ++ "\n"
711 case TailscaleServe{}:
712 "target tailscale-serve\n"
713 case BendHub{}:
714 "target bend-hub\n"
715 case AzureSnapshot{org, project}:
716 "target azure " ++ org ++ " " ++ project ++ "\n"
717 case GitHub{r, p}:
718 "target github " ++ r ++ " " ++ visibility(p) ++ "\n"
719
720def target_lines(xs: List<&2, Target>) -> String:
721 match xs:
722 case []:
723 ""
724 case t <> rest:
725 target_line(t) ++ target_lines(rest)
726
727def package_lines(xs: List<&2, Package>) -> String:
728 match xs:
729 case []:
730 ""
731 case p <> rest:
732 match p:
733 case Package{n, v, e, _}:
734 "package " ++ n ++ " " ++ v ++ " " ++ e ++ "\n" ++ package_lines(rest)
735
736def source_line(empty: Bool, key: String, source: String) -> String:
737 match empty:
738 case True{}:
739 ""
740 case False{}:
741 "source " ++ key ++ " " ++ source ++ "\n"
742
743def source_lines(xs: List<&2, A.Container>) -> String:
744 match xs:
745 case []:
746 ""
747 case c <> rest:
748 match c:
749 case A.Container{key, _, _, _, _, _, _, +source, _, _, _, _}:
750 source_line(String.is_empty(source), key, source) ++ source_lines(rest)
751
752def facts(vlib: String, ts: List<&2, Target>, rs: List<&2, Run>, ss: List<&2, Service>, ps: List<&2, Package>, cs: List<&2, A.Container>) -> String:
753 "vlib " ++ vlib ++ "\n" ++ target_lines(ts) ++ fact_lines("label", run_labels(rs)) ++ fact_lines("port", service_ports(ss)) ++ package_lines(ps) ++ source_lines(cs)
754
755# ---- the nix-darwin flake: pinned inputs, the machine's module, and one launchd job per run ----
756def prog(p: Prog) -> String:
757 match p:
758 case RunNode{}:
759 "\"${pkgs.nodejs_24}/bin/node\""
760 case RunPython{}:
761 "\"${pkgs.python3.withPackages (ps: [ ps.watchdog ])}/bin/python3\""
762 case RunBash{}:
763 "\"${pkgs.bash}/bin/bash\""
764
765def strs(xs: List<&2, String>) -> String:
766 match xs:
767 case []:
768 ""
769 case x <> rest:
770 " \"" ++ x ++ "\"" ++ strs(rest)
771
772def env1(e: Env) -> String:
773 match e:
774 case Env{k, v}:
775 " " ++ k ++ " = \"" ++ v ++ "\";"
776 case NixEnv{k, x}:
777 " " ++ k ++ " = " ++ x ++ ";"
778
779def envs(xs: List<&2, Env>) -> String:
780 match xs:
781 case []:
782 ""
783 case e <> rest:
784 env1(e) ++ envs(rest)
785
786# At login: a launch agent in the user's session. At boot: a launch daemon that runs as the user.
787def job_kind(s: Start) -> String:
788 match s:
789 case AtLogin{}:
790 "launchd.user.agents"
791 case AtBoot{}:
792 "launchd.daemons"
793
794def user_name(s: Start, +user: String) -> String:
795 match s:
796 case AtLogin{}:
797 ""
798 case AtBoot{}:
799 " UserName = \"" ++ user ++ "\";"
800
801def job(r: Run, +user: String, +logs: String) -> String:
802 match r:
803 case Run{+label, p, args, dir, env, +log, +start}:
804 " " ++ job_kind(start) ++ ".\"" ++ label ++ "\".serviceConfig = {\n" ++
805 " Label = \"" ++ label ++ "\";" ++ user_name(start, user) ++ "\n" ++
806 " ProgramArguments = [ " ++ prog(p) ++ strs(args) ++ " ];\n" ++
807 " WorkingDirectory = \"" ++ dir ++ "\";\n" ++
808 " EnvironmentVariables = {" ++ envs(env) ++ " };\n" ++
809 " RunAtLoad = true; KeepAlive = true; ThrottleInterval = 10;\n" ++
810 " StandardOutPath = \"" ++ logs ++ "/" ++ log ++ ".log\";\n" ++
811 " StandardErrorPath = \"" ++ logs ++ "/" ++ log ++ ".log\";\n" ++
812 " };\n"
813
814def jobs(xs: List<&2, Run>, +user: String, +logs: String) -> String:
815 match xs:
816 case []:
817 ""
818 case r <> rest:
819 job(r, user, logs) ++ jobs(rest, user, logs)
820
821# `lets` are Nix bindings the jobs may use (e.g. a derivation); `config` is extra nix-darwin config lines.
822def flake(host: String, system: String, lets: String, config: String, +user: String, +logs: String, rs: List<&2, Run>) -> String:
823 "# Generated by V. Do not edit; change the project and run `v system deploy`.\n" ++
824 "{\n" ++
825 " inputs.nixpkgs.url = \"github:NixOS/nixpkgs/nixpkgs-unstable\";\n" ++
826 " inputs.nix-darwin.url = \"github:nix-darwin/nix-darwin/master\";\n" ++
827 " inputs.nix-darwin.inputs.nixpkgs.follows = \"nixpkgs\";\n" ++
828 " outputs = { nixpkgs, nix-darwin, ... }: {\n" ++
829 " darwinConfigurations." ++ host ++ " = nix-darwin.lib.darwinSystem {\n" ++
830 " system = \"" ++ system ++ "\";\n" ++
831 " modules = [ ./mac.nix ({ pkgs, ... }: let\n" ++ lets ++
832 " in {\n" ++ config ++ jobs(rs, user, logs) ++ " }) ];\n };\n };\n}"