1 import Base 2 import bend-kit-files@0.1.0.0/files.bend as Fs 3 import ./effs/io.bend as X 4 import ../type.bend as A 5 import ../ops.bend as O 6 import ./container/effects.bend as CtE 7 import ./deployment/type.bend as D 8 import ./deployment/ops.bend as DO 9 import ./deployment/azure/effects.bend as AzE 10 import ./deployment/nix_darwin/type.bend as ND 11 import ./deployment/nix_darwin/ops.bend as NDO 12 import ./deployment/nix_darwin/effects.bend as NDE 13 import ./deployment/nix_darwin/setting/type.bend as St 14 import ./deployment/nix_darwin/setting/ops.bend as StO 15 import ./deployment/nix_darwin/brew/type.bend as Br 16 import ./deployment/nix_darwin/setting/effects.bend as StE 17 import ./deployment/nix_darwin/brew/effects.bend as BrE 18 import ./deployment/nix_darwin/service/ops.bend as SvO 19 import ./release/type.bend as Rl 20 import ./release/ops.bend as RlO 21 import ./plan/type.bend as P 22 import ./plan/ops.bend as PO 23 import ./ops.bend as SO 24 25 # The system's effects: reading the repository, the Bend hub and GitHub (a snapshot of the world), and 26 # changing them (a deploy). Programs run with their arguments, never through a shell; files are read with 27 # Base's File API. 28 29 # ---- the repository's files ---- 30 def files(+root: String) -> IO(List<&2, String>): 31 do IO>: 32 out : String <- X.run("git", ["-C", root, "ls-files", "-co", "--exclude-standard"]) 33 return X.nonempty(String.lines(out)) 34 35 # ---- what an entry reaches: it and every file it imports, followed in Bend ---- 36 def second_word(+t: String) -> String: 37 Maybe.default(&2, String, List.get(&2, String, X.nonempty(String.split(t, ' ')), 1n), "") 38 39 def unquote(+w: String) -> String: 40 Bool.pick(String, String.starts_with(w, "\""), Maybe.default(&2, String, List.get(&2, String, String.split(w, '"'), 1n), ""), w) 41 42 # The file a line imports, relative to the repository, or "" when it imports none of the repository's. 43 def import_of(+line: String, +dir: String) -> String: 44 +w = unquote(second_word(String.trim(line))) 45 Bool.pick(String, String.starts_with(String.trim(line), "import ") && (String.starts_with(w, "./") || String.starts_with(w, "../")), 46 Fs.path.normalize(Fs.path.join(dir, w)), "") 47 48 def imports(ls: List<&2, String>, +dir: String) -> List<&2, String>: 49 match ls: 50 case []: 51 [] 52 case l <> rest: 53 X.nonempty(import_of(l, dir) <> imports(rest, dir)) 54 55 # Each step reads one file; the number of files squared bounds the walk. 56 def reach(fuel: Nat, +todo: List<&2, String>, +seen: List<&2, String>, +root: String) -> IO(List<&2, String>): 57 match fuel: 58 case 0n: 59 IO.pure(List<&2, String>, seen) 60 case 1n+k: 61 match todo: 62 case []: 63 IO.pure(List<&2, String>, seen) 64 case +f <> rest: 65 do IO>: 66 text : String <- X.read_head(root ++ "/" ++ f, 4096) 67 +known : Bool = List.contains(~String, ~String.eq, seen, f) 68 reach(k, List.append(&2, String, rest, Bool.pick(List<&2, String>, known, [], imports(String.lines(text), Fs.path.parent(f)))), Bool.pick(List<&2, String>, known, seen, f <> seen), root) 69 70 # ---- the Bend hub: has it published this package, with these exact files? ---- 71 def differing(fs: List<&2, String>, +local: String, +hub: String) -> IO(String): 72 match fs: 73 case []: 74 IO.pure(String, "") 75 case +f <> rest: 76 do IO: 77 same : Bool <- X.succeeds("cmp", ["-s", local ++ "/" ++ f, hub ++ "/" ++ f]) 78 more : String <- differing(rest, local, hub) 79 return Bool.pick(String, same, more, f) 80 81 def hub_of(+published: String, +f: String) -> P.Hub: 82 Bool.pick(P.Hub, String.is_empty(published), P.Unpublished{}, Bool.pick(P.Hub, String.is_empty(f), P.Same{}, P.Differs{f})) 83 84 # The package's hash, as Bend's local cache names it. A package not cached yet is fetched first by 85 # checking a file that imports it: the hub has it exactly when that succeeds. 86 def fetch_if(missing: Bool, +home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String): 87 match missing: 88 case False{}: 89 IO.pure(String, cached) 90 case True{}: 91 do IO: 92 X.write("/tmp/v-probe.bend", "import Base\nimport " ++ n ++ "@" ++ v ++ "/" ++ Fs.path.file_name(e) ++ " as P\n") 93 _ : Bool <- X.succeeds(home ++ "/.bend/bin/bend", ["/tmp/v-probe.bend", "--check-only"]) 94 X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v) 95 96 def fetched(+home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String): 97 fetch_if(String.is_empty(String.trim(cached)), home, n, v, e, cached) 98 99 def hub_state(+home: String, +root: String, p: Rl.Package) -> IO(P.Hub): 100 match p: 101 case Rl.Package{+n, +v, +e, _}: 102 do IO: 103 cached : String <- X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v) 104 +hash : String <- fetched(home, n, v, e, cached) 105 +files : List<&2, String> <- reach(1000n, [e], [], root) 106 f : String <- differing(files, root, home ++ "/.bend/lib/" ++ String.trim(hash)) 107 return hub_of(String.trim(hash), f) 108 109 def hub_states(+home: String, +root: String, ps: List<&2, Rl.Package>) -> IO(List<&2, P.Hub>): 110 match ps: 111 case []: 112 IO.pure(List<&2, P.Hub>, []) 113 case p <> rest: 114 do IO>: 115 h : P.Hub <- hub_state(home, root, p) 116 hs : List<&2, P.Hub> <- hub_states(home, root, rest) 117 return h <> hs 118 119 # ---- GitHub ---- 120 def first_word(+t: String) -> String: 121 Maybe.default(&2, String, List.head(&2, String, X.nonempty(String.split(t, '\t'))), "") 122 123 def pushed(+root: String, +sha: String) -> IO(Bool): 124 Bool.pick(IO(Bool), String.is_empty(sha), IO.pure(Bool, False{}), X.succeeds("git", ["-C", root, "merge-base", "--is-ancestor", "HEAD", sha])) 125 126 def remote(+root: String, r: Rl.Repository) -> IO(P.Remote): 127 match r: 128 case Rl.Repository{+name, _}: 129 do IO: 130 origin : String <- X.run("git", ["-C", root, "remote", "get-url", "origin"]) 131 vis : String <- X.run("gh", ["repo", "view", name, "--json", "visibility", "--jq", ".visibility"]) 132 status : String <- X.run("git", ["-C", root, "status", "--porcelain"]) 133 head : String <- X.run("git", ["-C", root, "ls-remote", "origin", "refs/heads/main"]) 134 up : Bool <- pushed(root, first_word(head)) 135 return P.Remote{String.trim(origin), String.to_lower(String.trim(vis)), String.is_empty(String.trim(status)), up} 136 137 # ---- a snapshot: everything a plan needs ---- 138 def root() -> IO(String): 139 do IO: 140 t : String <- X.run("git", ["rev-parse", "--show-toplevel"]) 141 return String.trim(t) 142 143 # The files, what the packages' entries and the system's own entry reach, the hub, and GitHub. 144 def observe(+root: String, +entry: String, r: Rl.Release) -> IO(P.Observed): 145 match r: 146 case Rl.Release{repo, +ps}: 147 do IO: 148 +h : String <- X.home() 149 +fs : List<&2, String> <- files(root) 150 reached : List<&2, String> <- reach(Nat.mul(1n+List.length(&2, String, fs), 1n+List.length(&2, String, fs)), entry <> RlO.entries(ps), [], root) 151 package_reached : List<&2, String> <- reach(Nat.mul(1n+List.length(&2, String, fs), 1n+List.length(&2, String, fs)), RlO.entries(ps), [], root) 152 hs : List<&2, P.Hub> <- hub_states(h, root, ps) 153 rm : P.Remote <- remote(root, repo) 154 return P.Observed{fs, reached, package_reached, hs, rm} 155 156 # ---- a deploy: carry out a plan with nothing blocked ---- 157 def apply(+root: String, +bend: String, s: P.Step) -> IO(Unit): 158 match s: 159 case P.Current{_}: 160 IO.print(PO.show(s)) 161 case P.Publish{+n, +v, +e}: 162 do IO: 163 out : String <- X.run(bend, [root ++ "/" ++ e, "--publish", n ++ "@" ++ v]) 164 IO.print(Bool.pick(String, String.contains(out, "published"), "published " ++ n ++ "@" ++ v, "failed publish " ++ n ++ "@" ++ v)) 165 case P.Push{+r}: 166 do IO: 167 ok : Bool <- X.succeeds("git", ["-C", root, "push", "-q", "origin", "HEAD:main"]) 168 IO.print(Bool.pick(String, ok, "pushed github " ++ r, "failed push github " ++ r)) 169 case P.Rebuild{+h, +fl, +mac}: 170 do IO: 171 _ : Result<&1, &1, U32 & String, Unit> <- Fs.mkdir(root ++ "/nix") 172 X.write(root ++ "/nix/flake.nix", fl) 173 X.write(root ++ "/nix/mac.nix", mac) 174 ok : Bool <- X.succeeds("sudo", ["-n", "/run/current-system/sw/bin/darwin-rebuild", "switch", "--flake", root ++ "/nix#" ++ h]) 175 IO.print(Bool.pick(String, ok, "rebuilt nix-darwin " ++ h, "failed darwin-rebuild switch " ++ h)) 176 case P.ServePort{+p, +t, ts}: 177 do IO: 178 ok : Bool <- X.succeeds(ts, ["serve", "--bg", "--yes", "--https=" ++ U32.show(p), t]) 179 IO.print(Bool.pick(String, ok, "served :" ++ U32.show(p) ++ " -> " ++ t, "failed serve :" ++ U32.show(p))) 180 case P.Change{_, _, _}: 181 IO.pure(Unit, Unit{}) 182 case P.Blocked{_, _}: 183 IO.print(PO.show(s)) 184 185 def apply_all(+root: String, +bend: String, ss: List<&2, P.Step>) -> IO(Unit): 186 match ss: 187 case []: 188 IO.pure(Unit, Unit{}) 189 case s <> rest: 190 do IO: 191 apply(root, bend, s) 192 apply_all(root, bend, rest) 193 194 def deploy_if(blocked: Bool, +root: String, +ss: List<&2, P.Step>) -> IO(Unit): 195 match blocked: 196 case True{}: 197 IO.die(Unit, 1, PO.show_all(ss) ++ "nothing deployed: the plan is blocked") 198 case False{}: 199 do IO: 200 +h : String <- X.home() 201 apply_all(root, h ++ "/.bend/bin/bend", ss) 202 203 def deploy(+root: String, +ss: List<&2, P.Step>) -> IO(Unit): 204 deploy_if(PO.any_blocked(ss), root, ss) 205 206 # ---- the whole plan: the release, the stack, each deployment node, and what the deploy would break ---- 207 # ---- a nix-darwin node: its checks, what a rebuild would change, and the rebuild when anything would ---- 208 def has_change(ss: List<&2, P.Step>) -> Bool: 209 match ss: 210 case []: 211 False{} 212 case s <> rest: 213 match s: 214 case P.Change{_, _, _}: 215 True{} 216 case _: 217 has_change(rest) 218 219 def mac_of(nd: ND.NixDarwin) -> String: 220 match nd: 221 case ND.NixDarwin{_, _, _, _, _, st, fw, br, _, _, _, _, _, _}: 222 StO.mac_nix(st, fw, br) 223 224 def host_of(nd: ND.NixDarwin) -> String: 225 match nd: 226 case ND.NixDarwin{h, _, _, _, _, _, _, _, _, _, _, _, _, _}: 227 h 228 229 def rebuild_if(c: Bool, +h: String, +fl: String, +mac: String) -> List<&2, P.Step>: 230 match c: 231 case True{}: 232 [P.Rebuild{h, fl, mac}] 233 case False{}: 234 [P.Current{"nix-darwin " ++ h}] 235 236 # Whether a generated file differs from the one in the repository (compared by cmp, not in memory). 237 def differs(+path: String, +text: String) -> IO(Bool): 238 do IO: 239 X.write("/tmp/v-generated", text) 240 same : Bool <- X.succeeds("cmp", ["-s", "/tmp/v-generated", path]) 241 return Bool.not(same) 242 243 def darwin_steps(+root: String, +k: String, +nd: ND.NixDarwin) -> IO(List<&2, P.Step>): 244 match nd: 245 case ND.NixDarwin{_, _, _, _, _, +st, +fw, +br, _, _, _, _, _, _}: 246 do IO>: 247 checks : List<&2, P.Step> <- NDE.checks(k, nd) 248 +settings : List<&2, P.Step> <- StE.changes(st, fw) 249 +brews : List<&2, P.Step> <- BrE.changes(br) 250 flake_new : Bool <- differs(root ++ "/nix/flake.nix", NDO.flake(nd)) 251 mac_new : Bool <- differs(root ++ "/nix/mac.nix", mac_of(nd)) 252 return List.append(&2, P.Step, checks, List.append(&2, P.Step, settings, List.append(&2, P.Step, brews, 253 rebuild_if(flake_new || mac_new || has_change(settings) || has_change(brews), host_of(nd), NDO.flake(nd), mac_of(nd))))) 254 255 def platform_steps(p: D.Platform, +root: String, +k: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 256 match p: 257 case D.Unmanaged{}: 258 IO.pure(List<&2, P.Step>, []) 259 case D.Darwin{+nd}: 260 darwin_steps(root, k, nd) 261 case D.AzureCloud{az}: 262 AzE.checks(az, O.agreement(a)) 263 264 def node_steps(ns: List<&2, D.Node>, +root: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 265 match ns: 266 case []: 267 IO.pure(List<&2, P.Step>, []) 268 case n <> rest: 269 match n: 270 case D.Node{+k, _, _, _, _, pf, kids, _}: 271 do IO>: 272 here : List<&2, P.Step> <- platform_steps(pf, root, k, a) 273 below : List<&2, P.Step> <- node_steps(kids, root, a) 274 after : List<&2, P.Step> <- node_steps(rest, root, a) 275 return List.append(&2, P.Step, here, List.append(&2, P.Step, below, after)) 276 277 def deployed_path(+root: String) -> String: 278 root ++ "/.git/v-deployed" 279 280 def breaks_step(+bs: List<&2, D.Break>, +d: D.Deployment) -> List<&2, P.Step>: 281 Bool.pick(List<&2, P.Step>, DO.breaks_eq(bs, DO.accepted(d)), [], 282 [P.Blocked{"deployment", DO.show_breaks(bs) ++ " (declare it as accepted to deploy)"}]) 283 284 # What the deploy would break, against what this repository last deployed (recorded in .git, never committed). 285 def deployed_steps(+root: String, +d: D.Deployment) -> IO(List<&2, P.Step>): 286 do IO>: 287 +text : String <- X.read(deployed_path(root)) 288 return breaks_step(DO.breaks(DO.recorded_address(text), DO.recorded_stores(text), d), d) 289 290 def plan_all(+root: String, +entry: String, +a: A.Architecture) -> IO(List<&2, P.Step>): 291 do IO>: 292 o : P.Observed <- observe(root, entry, SO.release(O.system(a))) 293 stack : List<&2, P.Step> <- CtE.checks(root, O.containers(a), O.externals(a)) 294 nodes : List<&2, P.Step> <- node_steps(O.nodes(a), root, a) 295 breaks : List<&2, P.Step> <- deployed_steps(root, O.deployment(a)) 296 return List.append(&2, P.Step, stack, List.append(&2, P.Step, nodes, List.append(&2, P.Step, breaks, PO.plan(SO.release(O.system(a)), o)))) 297 298 # A deploy of the whole plan; afterwards it records the deployment, so the next plan compares against it. 299 def deploy_all(+root: String, +entry: String, +a: A.Architecture) -> IO(Unit): 300 do IO: 301 +steps : List<&2, P.Step> <- plan_all(root, entry, a) 302 deploy(root, steps) 303 X.write(deployed_path(root), DO.record(O.deployment(a))) 304 305 # ---- import: each nix-darwin node's Mac as it is now, as Bend to declare it with ---- 306 def import_node(p: D.Platform, +root: String) -> IO(Unit): 307 match p: 308 case D.Darwin{nd}: 309 match nd: 310 case ND.NixDarwin{h, _, _, _, _, _, _, _, svs, _, _, _, _, _}: 311 do IO: 312 st : List<&2, St.Setting> <- StE.observed(root ++ "/nix/options.tsv", SvO.labels(svs)) 313 fw : St.Firewall <- StE.firewall_now() 314 br : List<&2, Br.Brew> <- BrE.observed() 315 IO.print("# " ++ h ++ "\n" ++ StO.import_text(st, fw, br)) 316 case _: 317 IO.pure(Unit, Unit{}) 318 319 def import_nodes(ns: List<&2, D.Node>, +root: String) -> IO(Unit): 320 match ns: 321 case []: 322 IO.pure(Unit, Unit{}) 323 case n <> rest: 324 match n: 325 case D.Node{_, _, _, _, _, pf, kids, _}: 326 do IO: 327 import_node(pf, root) 328 import_nodes(kids, root) 329 import_nodes(rest, root) 330 331 def import_all(+root: String, a: A.Architecture) -> IO(Unit): 332 import_nodes(O.nodes(a), root)