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}"