1 import Base 2 import 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 7 def str_eq(a: String, b: String) -> Bool: 8 String.eq(a, b) 9 10 def 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. 14 type Migration is Data: 15 Migration{container: String, missing: String} 16 17 def 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. 27 def 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 39 def 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 42 def 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 49 def 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 54 def 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. 62 def 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 67 def 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 74 def 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 79 def 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 86 def 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 89 def 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 95 def 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 102 def 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 109 def 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 116 def 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 121 def 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. 130 type Deployment is Data: 131 Deployment{url: String, stores: List<&2, String>} 132 133 # ---- compatibility: what a change breaks, compared to what was applied ---- 134 type Break is Data: 135 UrlChanged{} 136 StoreRemoved{name: String} 137 138 def 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 145 def 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 152 def url_break(same: Bool) -> List<&2, Break>: 153 match same: 154 case True{}: 155 [] 156 case False{}: 157 [UrlChanged{}] 158 159 def 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 164 def 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 173 def is_empty(b: List<&2, Break>) -> Bool: 174 match b: 175 case []: 176 True{} 177 case _ <> _: 178 False{} 179 180 def 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). 193 type Service is Data: 194 Service{container: A.Container, label: String, script: String, port: U32, serve: U32} 195 196 def serve_of(s: Service) -> U32: 197 match s: 198 case Service{_, _, _, _, serve}: 199 serve 200 201 def 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 208 def 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. 216 def 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). 224 type Route is Data: 225 Route{container: A.Container, serve: U32, port: U32, path: String} 226 227 def 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'. 237 def 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. 241 def on_server(c: A.Container, +ns: List<&2, A.Node>) -> Bool: 242 A.is_server(A.node_role(A.con(c), ns)) 243 244 def 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 ---- 250 def eq_bool(a: Bool, b: Bool) -> Bool: 251 match a: 252 case True{}: 253 b 254 case False{}: 255 Bool.not(b) 256 257 def 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 266 def 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. 276 def 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 281 def 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 288 def 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 293 def 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 300 def 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 307 def port_of(s: Service) -> U32: 308 match s: 309 case Service{_, _, _, port, _}: 310 port 311 312 def 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`. 320 def 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) ---- 328 type 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). 334 type 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). 339 type Start is Data: 340 AtLogin{} 341 AtBoot{} 342 343 type 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 ---- 347 def run_label(r: Run) -> String: 348 match r: 349 case Run{label, _, _, _, _, _, _}: 350 label 351 352 # ---- surviving a reboot ---- 353 def at_login(s: Start) -> Bool: 354 match s: 355 case AtLogin{}: 356 True{} 357 case AtBoot{}: 358 False{} 359 360 def 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. 370 def 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 373 def 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". 382 type Listener is Data: 383 Listener{process: String, of: String} 384 385 def 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 394 def 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 403 def 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 410 def head_or_empty(xs: List<&2, String>) -> String: 411 match xs: 412 case []: 413 "" 414 case w <> _: 415 w 416 417 def first_word(s: String) -> String: 418 head_or_empty(String.split(s, ' ')) 419 420 # The exposed listeners ("process port") whose process nothing declares. 421 def 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. 429 def declared_agents(rs: List<&2, Run>, kept: List<&2, String>) -> List<&2, String>: 430 List.append(&2, String, run_labels(rs), kept) 431 432 def 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. 440 def 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 449 # serve [path] 450 def 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 457 def 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 462 def 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 469 def 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 478 def 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 ---- 482 def quoted_names(xs: List<&2, String>) -> String: 483 match xs: 484 case []: 485 "" 486 case x <> rest: 487 "\"" ++ x ++ "\", " ++ quoted_names(rest) 488 489 def 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. 498 type 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. 507 type 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. 513 def 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 520 def is_source(+f: String) -> Bool: 521 String.ends_with(f, ".bend") || String.ends_with(f, ".c") 522 523 def first_or_empty(xs: List<&2, String>) -> String: 524 match xs: 525 case []: 526 "" 527 case x <> _: 528 x 529 530 def 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. 534 def allowed(+f: String) -> Bool: 535 is_source(f) || String.eq(base_name(f), "LICENSE") || String.eq(f, "README.md") || String.eq(f, ".gitignore") 536 537 def 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. 545 def needs_shipping(+f: String, +project: String) -> Bool: 546 is_source(f) && Bool.not(String.starts_with(f, project ++ "/")) 547 548 def 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 ---- 556 def 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 569 def 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 576 def 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 583 def 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. 593 def source_deployed(+s: A.System, ts: List<&2, Target>) -> Bool: 594 Bool.not(on_github(A.containers(s))) || has_github(ts) 595 596 def visibility(p: Bool) -> String: 597 match p: 598 case True{}: 599 "public" 600 case False{}: 601 "private" 602 603 def 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 616 def 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 623 def 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. 642 def open_source(+ts: List<&2, Target>) -> Bool: 643 Bool.not(has_bend_hub(ts)) || public_github(ts) 644 645 def 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 652 def 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 659 def 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. 667 def 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 673 def 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 680 def 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. 689 def 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 ---- 693 def 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 700 def 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 707 def 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 720 def 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 727 def 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 736 def 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 743 def 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 752 def 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 ---- 756 def 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 765 def strs(xs: List<&2, String>) -> String: 766 match xs: 767 case []: 768 "" 769 case x <> rest: 770 " \"" ++ x ++ "\"" ++ strs(rest) 771 772 def env1(e: Env) -> String: 773 match e: 774 case Env{k, v}: 775 " " ++ k ++ " = \"" ++ v ++ "\";" 776 case NixEnv{k, x}: 777 " " ++ k ++ " = " ++ x ++ ";" 778 779 def 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. 787 def job_kind(s: Start) -> String: 788 match s: 789 case AtLogin{}: 790 "launchd.user.agents" 791 case AtBoot{}: 792 "launchd.daemons" 793 794 def user_name(s: Start, +user: String) -> String: 795 match s: 796 case AtLogin{}: 797 "" 798 case AtBoot{}: 799 " UserName = \"" ++ user ++ "\";" 800 801 def 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 814 def 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. 822 def 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}"