#!/usr/bin/env nu # build, deploy, preview and roll back the tangled gcp envs, and apply their hub nodes and # cloudflare workers. dev's services also deploy themselves from ci. # # usage: # nix run .#deploy -- --target ENV [--only SEL,..] [--rebuild] [--build-on HOST] [--preview] # nix run .#deploy -- rollback --target ENV [--to SHA8|r-N] [--list] # nix run .#deploy -- doctor [--target ENV] [--build-on HOST] # SEL is service:NAME, worker:NAME, node:KIND or node:NAME, a layer (services, workers, nodes, # control) or all, the default. a bare name works when only one thing has it. images and workers # build from the flake input modules/envs names, at the commit flake.lock pins. # an env that was never applied comes up with its first deploy const LAYERS = [services workers nodes control] const SERVICES = [bobbin svfe] # a kind picks every node of it, a bare word picks whatever single thing has that name def pick-targets [only: string, have: record] { let words = ($only | split row "," | str trim | where {|w| $w != "" }) let all = ($words | is-empty) or ("all" in $words) if $all { return { services: $have.services, workers: $have.workers, nodes: ($have.nodes | columns), control: true all: true, named: [] } } let kinds = ($have.nodes | values | uniq) let picked = ($words | each {|w| let parts = ($w | split row ":") if ($parts | length) == 1 and $w in $LAYERS { return (match $w { services => ($have.services | each {|s| { layer: service, name: $s } }) workers => ($have.workers | each {|x| { layer: worker, name: $x } }) nodes => ($have.nodes | columns | each {|n| { layer: node, name: $n } }) control => [{ layer: control, name: control }] }) } let found = if ($parts | length) == 2 { let name = ($parts | last) match ($parts | first) { service => (if $name in $have.services { [{ layer: service, name: $name }] } else { [] }) worker => (if $name in $have.workers { [{ layer: worker, name: $name }] } else { [] }) node => (node-matches $have.nodes $kinds $name) _ => (error make { msg: $"($w) isn't a selector, want service:, worker: or node: before the name" }) } } else { (if $w in $have.services { [{ layer: service, name: $w }] } else { [] }) | append (if $w in $have.workers { [{ layer: worker, name: $w }] } else { [] }) | append (node-matches $have.nodes $kinds $w) } if ($found | is-empty) { error make { msg: $"nothing called ($w), have: (have-list $have)" } } let layers = ($found.layer | uniq) if ($layers | length) > 1 { error make { msg: $"($w) could be ($layers | each {|l| $'($l):($w)' } | str join ' or '), say which" } } $found } | flatten) { services: ($picked | where layer == service | get name | uniq) workers: ($picked | where layer == worker | get name | uniq) nodes: ($picked | where layer == node | get name | uniq) control: ($picked | any {|p| $p.layer == control }) all: false named: ($picked | where layer in [service worker] | each {|p| $"($p.layer):($p.name)" } | uniq) } } def node-matches [nodes: record, kinds: list, name: string] { let byname = if $name in ($nodes | columns) { [$name] } else { [] } let bykind = ($nodes | transpose node kind | where kind == $name | get node) $byname | append $bykind | uniq | each {|n| { layer: node, name: $n } } } def have-list [have: record] { [ ($have.services | each {|s| $"service:($s)" }) ($have.workers | each {|w| $"worker:($w)" }) ($have.nodes | values | uniq | each {|k| $"node:($k)" }) ($have.nodes | columns | each {|n| $"node:($n)" }) $LAYERS ] | flatten | str join ", " } def rerun-command [target: string, left: list] { [nix run .#deploy -- --target $target --only ($left | str join ",")] | str join " " } def old-command [sub: string, target: string, only: string, flags: list] { let prefix = match $sub { images => "service", workers => "worker", nodes => "node", _ => "" } let picks = if $sub == control { [control] } else if ($only | is-empty) { [($LAYERS | where {|l| $l starts-with $prefix } | first)] } else { $only | split row "," | str trim | each {|o| $"($prefix):($o)" } } [nix run .#deploy -- --target $target --only ($picks | str join ",")] | append $flags | str join " " } # the log is live, `tail -f` follows a long step def log-file [] { $env.DEPLOY_LOG? | default "/dev/null" } def log-path [] { $env.XDG_STATE_HOME? | default ($env.HOME | path join .local state) | path join tangled-deploy deploy.log } def --env open-log [what: string] { let log = (log-path) mkdir ($log | path dirname) let size = if ($log | path exists) { ls -D $log | get 0.size } else { 0B } if $size > 2MB { let keep = (open --raw $log | into binary | bytes at (($size - 1MB | into int)..)) $keep | save -f $log } $"\n# (date now | format date '%F %T') deploy ($what)\n" | save --append $log $env.DEPLOY_LOG = $log } def --wrapped logged [label: string, ...cmd: string] { let log = (log-file) $"\n== ($label): ($cmd | str join ' ')\n" | save --append $log let from = (ls -D $log | get 0.size | into int) let code = (try { ^($cmd | first) ...($cmd | skip 1) o+e>> $log; 0 } catch { $env.LAST_EXIT_CODE }) let out = (open --raw $log | into binary | bytes at $from.. | decode utf-8) if $code != 0 { fail $label $out } $out } def explain [msg: string] { let expired = '(?i)invalid_grant|invalid_rapt|reauth|application-default login|default credentials|determine credentials' if not ($msg =~ $expired) { return $msg } $"google login expired, run `gcloud auth application-default login` and `gcloud auth login`, then run this again\n($msg | lines | first 5 | str join (char nl))" } def die [err: record] { print -e $"deploy: (explain (root-msg $err))" if (log-file) != "/dev/null" { print -e $"all the output is in (log-file)" } if ($env.DEPLOY_TREE? | is-not-empty) { print -e $"the tree it ran from is in ($env.DEPLOY_TREE)" } exit 1 } # terraform only reads the application default login, gcloud the plain one const LOGINS = [ { args: [auth application-default print-access-token], login: "gcloud auth application-default login" } { args: [auth print-access-token], login: "gcloud auth login" } ] def check-auth [] { for l in $LOGINS { let res = (^gcloud ...$l.args | complete) if $res.exit_code != 0 { error make { msg: $"gcloud ($l.args | str join ' ') failed: ($res.stderr | str trim)" } } } } def fail [label: string, out: string] { let tail = ($out | lines | last 25 | str join (char nl)) error make { msg: $"($label) failed, the end of its output, all of it is in (log-file):\n($tail)" } } # a closure run by `each` wraps what it raises as "Eval block failed" def root-msg [err: record] { mut e = ($err.json | from json) while ($e.inner? | is-not-empty) { $e = ($e.inner | first) } $e.msg } def log-text [label: string, text: string] { $"\n== ($label)\n($text)\n" | save --append (log-file) } def took [from: datetime] { let secs = (((date now) - $from) / 1sec | math round) $"\(($secs)s)" } def tf-summary [out: string] { let lines = ($out | lines) let totals = ($lines | parse --regex '^Apply complete! Resources: (?\d+) added, (?\d+) changed, (?\d+) destroyed' | get -o 0) let counts = if $totals == null { [] } else { [added changed destroyed] | each {|k| { k: $k, n: ($totals | get $k | into int) } } | where n > 0 } if ($counts | is-empty) { return "no changes" } let names = ($lines | parse --regex '^(?\S+): (?:Creating|Modifying|Destroying)\.\.\.' | get addr | each {|a| $a | str replace --regex '^module\.\w+\.' "" } | uniq) let shown = ($names | first 4 | str join ", ") let more = if ($names | length) > 4 { $", and (($names | length) - 4) more" } else { "" } let totals = ($counts | each {|c| $"($c.n) ($c.k)" } | str join ", ") $"($totals) \(($shown)($more))" } def --wrapped tf-logged [label: string, ...args: string] { let from = (date now) let out = (logged $label terraform ...$args) print $"== ($label): (tf-summary $out) (took $from)" } def tf-init [root: string] { if ($"($root)/.terraform" | path exists) { return } logged $"init ($root | path basename)" terraform $"-chdir=($root)" init -input=false -no-color | ignore } def env-of [name: string, infra: string] { let root = $"($infra)/terraform/control/($name)" if not ($root | path exists) { let have = (ls $"($infra)/terraform/control" | where type == dir | get name | path basename | str join ', ') error make { msg: $"unknown --target ($name), want one of: ($have)" } } tf-init $root let out = (^terraform $"-chdir=($root)" output -json control | complete) if $out.exit_code != 0 { if ($out.stderr | str contains 'Output "control" not found') { return (fresh-env $name $root $infra) } error make { msg: $"reading control/($name) failed: ($out.stderr | str trim)" } } let c = ($out.stdout | from json) # state from before control had a registry output has none if $c.registry? == null { return (fresh-env $name $root $infra | merge { fresh: false }) } # a hostname wins, then a bare lb address, then cloud run's own url let bare = if ($c.lb_ips? | is-not-empty) { $c.lb_ips | items {|svc, ip| { $svc: $"http://($ip)" } } | into record } else { $c.services | values | first | reject region } let urls = ($bare | merge ($c.env.hostnames? | default {} | items {|svc, host| { $svc: $"https://($host)" } } | into record)) $c.env | merge { name: $name, root: $root, registry: $c.registry, urls: $urls } } def --wrapped gc [...args: string] { let res = (^gcloud ...$args | complete) if $res.exit_code != 0 { error make { msg: $"gcloud ($args | first 3 | str join ' ') failed: ($res.stderr | str trim)" } } $res.stdout } def ref-pairs [refs: record] { $refs | transpose svc ref | each {|r| $"($r.svc) = \"($r.ref)\"" } | str join ", " } def refs-var [refs: record] { $"image_refs={ (ref-pairs $refs) }" } # an empty refs map puts the env back on its floating tag def apply-control [e: record, refs: record, --yes] { let root = $"-chdir=($e.root)" let vars = if ($refs | is-empty) { [] } else { [-var (refs-var $refs)] } if $yes { tf-logged $"control/($e.name)" $root apply -input=false -auto-approve -no-color ...$vars } else { ^terraform $root apply ...$vars } } # ci rolls services outside terraform, so a plan keeps the live image of anything it isn't building def live-refs [e: record] { refs-of-services $e (gc run services list --project $e.project --format json | from json) } # regions on different images means a roll is half done def refs-of-services [e: record, live: list] { mut refs = {} for svc in $SERVICES { let name = $"($svc)-($e.suffix)" let images = ($live | where {|s| $s.metadata.name == $name } | each {|s| $s.spec.template.spec.containers.0.image } | uniq) if ($images | is-empty) { continue } if ($images | length) > 1 { error make { msg: $"($name) runs ($images | length) different images across its regions, wait for the roll that is doing that to finish" } } let prefix = (image $e $svc) if not ($images.0 | str starts-with $prefix) { error make { msg: $"($name) runs ($images.0), which isn't in ($prefix)" } } $refs = ($refs | merge { $svc: ($images.0 | str substring ($prefix | str length)..) }) } $refs } # ci doesn't take the state lock, so a roll between plan and apply would be undone def moved-refs [now: record, planned: record] { $planned | columns | where {|svc| ($now | get -o $svc) != ($planned | get $svc) } } def check-refs-unmoved [e: record, planned: record] { let moved = (moved-refs (live-refs $e) $planned) if ($moved | is-not-empty) { error make { msg: $"($moved | str join ', ') moved since the plan, run this again" } } } # every plan builds the vm systems, so this needs nix and an x86_64-linux builder def nodes-root [e: record, host?: string] { let root = ($e.root | path dirname | path dirname | path join nodes ($host | default $e.name)) if not ($root | path exists) { return null } tf-init $root $root } # a guest's firewall rules come from its host's nodes root, so a kind the host hasn't applied yet leaves the guest's vm without ssh def apply-host-firewalls [e: record] { let host = (tree-env $e | get -o guest_of) if $host == null { return } let root = (nodes-root $e $host) if $root == null { return } print $"== nodes/($host): planning the firewalls ($e.name) uses" let rules = [ssh http reached_by public public_http] | each {|r| $"-target=module.nodes.google_compute_firewall.($r)" } apply-nodes-root $host $root $rules } # a vm can't finish booting without its secrets, so stop on any with no value def check-node-secrets [e: record, root: string, names: list] { let c = (tree-env $e) let ids = ($c.nodes | transpose name n | where {|r| $r.name in $names } | get n.secrets | flatten | uniq) let absent = ($ids | where {|id| (^gcloud secrets describe $id --project $e.project | complete).exit_code != 0 }) if ($absent | is-not-empty) and $c.guest_of? == null { let targets = ($absent | each {|id| $'-target=module.nodes.google_secret_manager_secret.node["($id)"]' }) tf-logged $"nodes/($e.name) secrets" $"-chdir=($root)" apply -input=false -auto-approve -no-color ...$targets } let empty = (valueless $e $ids) if ($empty | is-empty) { return } error make { msg: $"the nodes read secrets that have no value yet, add them and run this again:\n(secret-commands $e $empty)" } } # a replaced vm comes back empty and backfills from scratch, so any destroy besides a redeploy asks first def apply-nodes-root [name: string, root: string, targets: list] { let chdir = $"-chdir=($root)" # the plan holds the install key let plan = (mktemp -t nodes-plan.XXXXXX) let result = (try { logged $"plan nodes/($name)" terraform $chdir plan -input=false -no-color ...$targets $"-out=($plan)" | ignore let changes = (^terraform $chdir show -json $plan | from json | get -o resource_changes | default []) let rebuilds = ($changes | where {|c| $c.name == "nixos-rebuild" and $c.change.actions != ["no-op"] } | length) if $rebuilds > 0 { print $"== nodes/($name): switching ($rebuilds) nodes, touch the key for each" } let destroys = ($changes | where {|c| "delete" in $c.change.actions and not ($c.type == "null_resource" and $c.name == "nixos-rebuild") }) if ($destroys | is-empty) { tf-logged $"nodes/($name)" $chdir apply -input=false -no-color $plan } else { print $"== this destroys ($destroys.address | str join ', '), so it asks" ^terraform $chdir apply ...$targets } { ok: true } } catch {|err| { ok: false, msg: (root-msg $err) } }) rm -f $plan if not $result.ok { error make --unspanned { msg: $result.msg } } } def node-targets [nodes: record, names: list] { $names | each {|n| let module = if ($nodes | get $n | get -o seeded_from) == null { "install" } else { "seeded_rebuild" } $"-target=module.nodes.module.($module)[\"($n)\"]" } } # bobbin is left out of a cell until its hub nodes exist, so the control root needs a second apply after them def finish-cells [e: record] { let waiting = (^terraform $"-chdir=($e.root)" output -json control | from json | get -o waiting | default {}) if ($waiting | is-empty) { return } let made = (gc compute addresses list --project $e.project --format "value(name)" | lines) let ready = ($waiting | transpose cell nodes | where {|w| $w.nodes | all {|n| $"($n)-internal" in $made } }) if ($ready | is-empty) { return } print $"== bobbin can go into ($ready.cell | str join ', ') now" apply-control $e (live-refs $e) --yes } def workers-root [e: record] { let root = ($e.root | path dirname | path dirname | path join workers $e.name) if not ($root | path exists) { return null } tf-init $root $root } def bundle-dir [e: record] { $"/tmp/tangled-($e.name)-workers" } def infra-of [e: record] { $e.root | path dirname | path dirname | path dirname } # control's outputs are as of its last apply def tree-env [e: record] { env-config $e.name (infra-of $e) } # the google keys aren't here, control fills those def worker-secrets [c: record, names: list] { let token = if $c.cloudflare? == null { [] } else { [$c.cloudflare.token_secret] } let all = ($c.workers? | default {}) let picked = if ($names | is-empty) { $all } else { $all | select ...$names } $picked | values | each {|w| $w.secrets? | default {} | values } | flatten | append $token | uniq } def valueless [e: record, ids: list] { $ids | where {|id| let res = (^gcloud secrets versions list $id --project $e.project --filter "state:ENABLED" --limit 1 --format "value(name)" | complete) $res.exit_code != 0 or ($res.stdout | str trim | is-empty) } } def secret-commands [e: record, ids: list] { # a guest's host makes its secrets, a missing one gets a create command instead let guest = ((tree-env $e).guest_of? != null) $ids | each {|id| let held = (^gcloud secrets describe $id --project $e.project | complete).exit_code == 0 let verb = if $guest and not $held { "create" } else { "versions add" } $" printf %s \"$VALUE\" | gcloud secrets ($verb) ($id) --data-file=- --project ($e.project)" } | str join (char nl) } # an empty secret only fails the apply after the images rolled, so stop here def ensure-secrets [e: record, readers: string, ids: list, --make] { let absent = ($ids | where {|id| (^gcloud secrets describe $id --project $e.project | complete).exit_code != 0 }) if $make and ($absent | is-not-empty) and (tree-env $e).guest_of? == null { let targets = ($absent | each {|id| $'-target=module.control.google_secret_manager_secret.this["($id)"]' }) tf-logged $"control/($e.name) secrets" $"-chdir=($e.root)" apply -input=false -auto-approve -no-color ...$targets } let empty = (valueless $e $ids) if ($empty | is-empty) { return } error make { msg: $"the ($readers) read secrets that have no value yet, add them and run this again \(one that doesn't exist yet is made by a deploy without --preview):\n(secret-commands $e $empty)" } } def service-secrets [c: record] { $c.services? | default {} | values | each {|s| $s.secrets? | default {} | values } | flatten | uniq } def --wrapped run-in [dir: string, ...cmd: string] { do { cd $dir; logged $"($cmd | first) in ($dir | path basename)" ...$cmd } | ignore } # a worker with no bundle gets deleted by the workers root, so build all asked for or none def build-workers [work: string, names: list, out: string] { let missing = ($names | where {|w| not (["package.json" "Cargo.toml"] | any {|f| $work | path join $w $f | path exists }) }) if ($missing | is-not-empty) { error make { msg: $"there is no worker ($missing | str join ', ')" } } $names | each {|w| print $"== building worker ($w)" let dir = ($work | path join $w) let bundle = ($out | path join $w) if ($dir | path join Cargo.toml | path exists) { # core's cargo config wants the devshell's arm64-apple-darwin-cc, which deploy doesn't have with-env { CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER: cc } { run-in $dir worker-build --release } mkdir $bundle for f in [index.js index_bg.wasm] { cp ($dir | path join build $f) $bundle } return [$w $bundle] } let exec = if ($dir | path join bun.lock | path exists) { [bun x] } else { [pnpm exec] } run-in $dir ($exec | first) install --frozen-lockfile run-in $dir ...$exec wrangler deploy --dry-run --outdir dist mkdir $bundle let files = (ls ($dir | path join dist) | get name | where {|f| ($f | path basename) != "README.md" and not ($f | str ends-with ".map") }) for f in $files { cp -r $f $bundle } [$w $bundle] } | into record } def worker-targets [e: record, only: list] { let all = ((tree-env $e).workers? | default {}) $only | each {|n| let c = ($all | get $n) [$'cloudflare_workers_script.this["($n)"]' $'cloudflare_workers_script_subdomain.this["($n)"]'] | append ($c.domains? | default [] | each {|h| $'cloudflare_workers_custom_domain.this["($n)/($h)"]' }) | append ($c.routes? | default [] | each {|r| $'cloudflare_workers_route.this["($n)/($r.pattern)"]' }) | append ($c.workflows? | default {} | columns | each {|b| $'cloudflare_workflow.this["($n)/($b)"]' }) | append (if ($c.crons? | default [] | is-empty) { [] } else { [$'cloudflare_workers_cron_trigger.this["($n)"]'] }) | append ($c.kv? | default {} | columns | each {|b| $'cloudflare_workers_kv_namespace.this["($n)/($b)"]' }) | append ($c.r2? | default {} | columns | each {|b| $'cloudflare_r2_bucket.this["($n)/($b)"]' }) | append [$'terraform_data.source["($n)"]'] } | flatten | each {|a| $"-target=module.workers.($a)" } } const BOBBIN_PROFILE = { dev: "dev-deploy", next-dev: "dev-deploy" } def bobbin-profile [e: record] { $BOBBIN_PROFILE | get -o $e.name | default "release" } def src-tgz [e: record] { $"/tmp/tangled-($e.name)-src.tgz" } def work-dir [e: record] { $"/tmp/tangled-($e.name)-build" } def locked [infra: string, input: string] { let lock = (open --raw ($infra | path join flake.lock) | from json) let node = ($lock.nodes.root.inputs | get -o $input) if $node == null { error make { msg: $"flake.lock has no input ($input)" } } let at = ($lock.nodes | get $node | get locked) let res = (^nix eval --raw --expr $"\(builtins.fetchTree \(builtins.fromJSON ''($at | to json -r)''\)).outPath" | complete) if $res.exit_code != 0 { error make { msg: $"fetching ($input) failed: ($res.stderr | str trim | lines | last 5 | str join (char nl))" } } { input: $input, rev: $at.rev, path: ($res.stdout | str trim) } } def src-label [src: record] { $"($src.input)@($src.rev | str substring 0..7)" } def fetch-source [e: record, src: record] { let tgz = (src-tgz $e) ^tar -czf $tgz -C ($src.path | path dirname) ($src.path | path basename) { tgz: $tgz, id: ($src.rev | str substring 0..7) } } def source-groups [c: record, infra: string, services: list, workers: list] { let items = ($services | each {|s| { kind: service, name: $s, input: $c.input? } } | append ($workers | each {|w| { kind: worker, name: $w, input: ($c.workers | get $w | get -o input | default $c.input?) } })) let unsourced = ($items | where input == null) if ($unsourced | is-not-empty) { error make { msg: $"modules/envs names no flake input for ($unsourced.name | str join ', '), add an input and commit the lock" } } $items | group-by input | items {|input, its| { src: (locked $infra $input) services: ($its | where kind == service | get name) workers: ($its | where kind == worker | get name) } } } # tar can't read .gitignore, so git or jj says which files count def pack-tree [path: string, tgz: string] { cd $path let listed = if (".git" | path exists) { ^git ls-files --cached --others --exclude-standard -z | split row (char nul) } else if (".jj" | path exists) { ^jj file list | lines } else { error make { msg: $"($path) is neither a git nor a jj checkout, so there's no telling what to leave out" } } let list = $"($tgz).files" $listed | where {|f| $f | path exists } | str join (char nul) | save -f $list let prefix = if (^tar --version | str contains "GNU") { [--transform "s,^,src/,"] } else { [-s ",^,src/,"] } # macos tar would add a ._ file next to every file for its extended attributes with-env { COPYFILE_DISABLE: "1" } { ^tar -czf $tgz --null -T $list ...$prefix } rm -f $list $tgz } # flakes only read git-tracked files def unpack-repo [tgz: string, dir: string] { mkdir $dir tar -xzf $tgz -C $dir --strip-components=1 ^chmod -R u+w $dir cd $dir git init -q git add -A git -c user.email=deploy@local -c user.name=deploy commit -qm src } def src-tree [e: record, tgz: string] { let work = (work-dir $e) rm -rf $work unpack-repo $tgz $work $work } def cleanup [e: record] { let work = (work-dir $e) if ($work | path exists) { ^chmod -R u+w $work; rm -rf $work } rm -f (src-tgz $e) } def image [e: record, svc: string] { $"($e.registry)/($svc)-($e.suffix)" } def build-tags [e: record, src: record] { [$e.name $"($e.name)-($src.id)"] } def stage-static-files [work: string] { cd $work let out = (nix build .#web-static-files --no-link --print-out-paths | complete | get stdout | lines | last) mkdir $"($work)/web/static" rm -rf $"($work)/web/static/fonts" $"($work)/web/static/logos" ^cp -fr $"($out)/." $"($work)/web/static/" } def build-local [e: record, work: string, todo: list, tags: list, origin: string] { # same as ci, so the same source gives the same digest $env.SOURCE_DATE_EPOCH = "0" $todo | each {|t| let svc = $t.svc # bobbin's Containerfile copies from the repo root, so the root is the context let context = if $svc == "svfe" { stage-static-files $work; $"($work)/web" } else { $work } let args = match $svc { bobbin => [$"--file=($work | path join bobbin containerfiles bobbin.Containerfile)" $"--build-arg=BOBBIN_PROFILE=(bobbin-profile $e)"] svfe => [ $"--file=($work | path join web Containerfile)" $"--build-arg=VITE_OAUTH_CLIENT_ID=($origin)/oauth-client-metadata.json" $"--build-arg=VITE_OAUTH_REDIRECT_URI=($origin)/oauth/callback" $"--build-arg=APP_VERSION=(^git -C $work rev-parse HEAD:web | str trim)" ] } print $"== building ($svc)" logged $"build ($svc)" docker buildx build ...[ "--platform=linux/amd64" ...$args ...($tags | append $"src-($t.key)" | each {|tag| $"--tag=(image $e $svc):($tag)" }) "--provenance=false" $"--cache-from=type=registry,ref=($e.registry)/($svc)-cache:buildcache" $"--cache-to=type=registry,ref=($e.registry)/($svc)-cache:buildcache,mode=max" "--output=type=registry,rewrite-timestamp=true" "--quiet" $"--metadata-file=($work)/($svc).json" ] $context | ignore [$svc (open --raw $"($work)/($svc).json" | from json | get 'containerimage.digest')] } | into record } const REMOTE_BUILD = r#' set -euo pipefail WORK="$HOME/$WORK_NAME" export DOCKER_CONFIG="$HOME/$WORK_NAME-docker" cleanup() { chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK" "$TGZ" "$DOCKER_CONFIG"; } trap cleanup EXIT chmod -R u+w "$WORK" 2>/dev/null || true rm -rf "$WORK" "$DOCKER_CONFIG" mkdir -p "$WORK" mkdir -m 700 "$DOCKER_CONFIG" tar -xzf "$TGZ" -C "$WORK" --strip-components=1 chmod -R u+w "$WORK" cd "$WORK" git init -q git add -A git -c user.email=deploy@local -c user.name=deploy commit -qm src auth=$(printf 'oauth2accesstoken:%s' "$REG_TOKEN" | base64 -w0) printf '{"auths":{"%s":{"auth":"%s"}}}' "$REG_HOST" "$auth" > "$DOCKER_CONFIG/config.json" # buildkit rather than podman, so builds read the registry cache ci writes. # the daemon outlives the deploy, keeping its own cache for the next one; # nothing is exported back, the builder's uplink is too slow for that BX="$(nix build --no-link --print-out-paths nixpkgs#docker-buildx)/bin/docker-buildx" podman container exists tangled-buildkit || podman run -d --name tangled-buildkit --privileged \ -p 127.0.0.1:18234:1234 docker.io/moby/buildkit:v0.33.0 --addr tcp://0.0.0.0:1234 >/dev/null podman start tangled-buildkit >/dev/null "$BX" create --name tangled --driver remote tcp://127.0.0.1:18234 >/dev/null for _ in $(seq 30); do "$BX" --builder tangled inspect --bootstrap >/dev/null 2>&1 && break; sleep 1; done export SOURCE_DATE_EPOCH=0 wants() { [[ " $BUILD " == *" $1 "* ]]; } tags() { echo "--tag=$1:$TAG"; echo "--tag=$1:$TAG-$SHA"; echo "--tag=$1:src-$2"; } digest() { sed -n 's/.*"containerimage\.digest": *"\([^"]*\)".*/\1/p' "$1"; } if wants bobbin; then "$BX" --builder tangled build --platform=linux/amd64 \ --file=bobbin/containerfiles/bobbin.Containerfile \ --build-arg "BOBBIN_PROFILE=$PROFILE" \ $(tags "$BOBBIN" "$KEY_BOBBIN") --provenance=false \ --cache-from "type=registry,ref=$CACHE_REPO/bobbin-cache:buildcache" \ --output type=registry,rewrite-timestamp=true --quiet --metadata-file "$DOCKER_CONFIG/bobbin.json" . echo "DIGEST_BOBBIN=$(digest "$DOCKER_CONFIG/bobbin.json")" fi if wants svfe; then out=$(nix build .#web-static-files --no-link --print-out-paths) static=$(echo "$out" | tail -1) mkdir -p web/static rm -rf web/static/fonts web/static/logos cp -fr "$static"/* web/static "$BX" --builder tangled build --platform=linux/amd64 \ --file=web/Containerfile \ --build-arg "VITE_OAUTH_CLIENT_ID=$CLIENT_ID" \ --build-arg "VITE_OAUTH_REDIRECT_URI=$ORIGIN/oauth/callback" \ --build-arg "APP_VERSION=$(git rev-parse HEAD:web)" \ $(tags "$SVFE" "$KEY_SVFE") --provenance=false \ --cache-from "type=registry,ref=$CACHE_REPO/svfe-cache:buildcache" \ --output type=registry,rewrite-timestamp=true --quiet --metadata-file "$DOCKER_CONFIG/svfe.json" web/ echo "DIGEST_SVFE=$(digest "$DOCKER_CONFIG/svfe.json")" fi '# def build-remote [e: record, src: record, todo: list, origin: string, host: string] { let tgz = $src.tgz let sent = (^scp -q $tgz $"($host):($tgz)" | complete) if $sent.exit_code != 0 { error make { msg: $"copying the source to ($host) failed: ($sent.stderr | str trim)" } } let token = (gc auth print-access-token | str trim) let key = {|svc| $todo | where svc == $svc | get -o 0.key | default "" } # stdin, not the remote command line: there the token would show up in ps on the builder let vars = { REG_TOKEN: $token, REG_HOST: ($e.registry | split row '/' | first), TGZ: $tgz, WORK_NAME: $"tangled-($e.name)-build" BOBBIN: (image $e bobbin), SVFE: (image $e svfe), TAG: $e.name, SHA: $src.id, ORIGIN: $origin, CLIENT_ID: $"($origin)/oauth-client-metadata.json" PROFILE: (bobbin-profile $e), CACHE_REPO: $e.registry BUILD: ($todo.svc | str join " "), KEY_BOBBIN: (do $key bobbin), KEY_SVFE: (do $key svfe) } let env_block = ($vars | transpose k v | each {|r| $"($r.k)='($r.v)'" } | str join (char nl)) print $"== building ($todo.svc | str join ', ') on ($host)" let res = ([$env_block $REMOTE_BUILD] | str join (char nl) | ssh $host "bash -s" | complete) log-text $"remote build on ($host)" $res.stderr if $res.exit_code != 0 { fail "remote build" $res.stderr } $todo.svc | each {|svc| let prefix = $"DIGEST_($svc | str upcase)=" [$svc ($res.stdout | lines | where {|l| $l starts-with $prefix } | first | str replace $prefix "")] } | into record } # an image tagged src- was built from exactly this source and build args def source-key [e: record, work: string, svc: string, origin: string] { let tree = {|p| ^git -C $work rev-parse $"HEAD:($p)" | complete | get stdout | str trim } let store = {|attr| ^nix eval --raw $"git+file://($work)#packages.x86_64-linux.($attr).outPath" } let parts = match $svc { # only the crates bobbin depends on, like the Containerfile's sed narrows to bobbin => ([bobbin/containerfiles/bobbin.Containerfile .dockerignore rust-toolchain.toml] | each $tree | prepend (do $store bobbin.src) | append (bobbin-profile $e)) svfe => [(do $tree web) (do $store web-static-files) $origin] } $parts | prepend $svc | str join (char nl) | hash sha256 | str substring 0..31 } def built-from [e: record, svc: string, key: string] { let res = (^gcloud artifacts docker images describe $"(image $e $svc):src-($key)" --format "value(image_summary.digest)" | complete) if $res.exit_code == 0 { $res.stdout | str trim } else { null } } # rollback lists builds by tag, so a reused image needs this deploy's tags def retag [e: record, svc: string, digest: string, tags: list] { for t in $tags { gc artifacts docker tags add $"(image $e $svc)@($digest)" $"(image $e $svc):($t)" --quiet | ignore } } def env-config [name: string, infra: string] { let res = ('jsonencode(local.envs["' + $name + '"])' | ^terraform $"-chdir=($infra)/terraform/modules/envs" console | complete) if $res.exit_code != 0 { error make { msg: $"modules/envs has no ($name): ($res.stderr | str trim)" } } $res.stdout | from json | from json } # there is no control output yet, so work out the urls it is going to set def fresh-env [name: string, root: string, infra: string] { let c = (env-config $name $infra) let urls = if ($c.hostnames? | is-not-empty) { $c.hostnames | items {|svc, host| { $svc: $"https://($host)" } } | into record } else if $c.lb { error make { msg: $"($name) has an lb but no hostnames, so svfe's address, which its build needs, only exists after the first apply" } } else { let number = (gc projects describe $c.project --format "value(projectNumber)" | str trim) let region = ($c.regions | values | first | get region) $SERVICES | each {|svc| { $svc: $"https://($svc)-($c.env_suffix)-($number).($region).run.app" } } | into record } { name: $name root: $root fresh: true project: $c.project region: $c.region suffix: $c.env_suffix registry: $"($c.region)-docker.pkg.dev/($c.project)/($c.registry_name)" workers: ($c.workers? | default {} | columns) urls: $urls } } def ensure-registry [e: record] { if (tree-env $e).guest_of? != null { return } tf-logged $"control/($e.name) registry" $"-chdir=($e.root)" apply -input=false -auto-approve -no-color -target=module.control.module.environment.google_artifact_registry_repository.this } def images [e: record, svc: string] { gc artifacts docker images list (image $e $svc) --include-tags --format json | from json } def tagged [e: record, imgs: list] { $imgs | each {|img| $img.tags | where {|t| $t =~ $'^($e.name)-[0-9a-f]{8}$' } | each {|t| { build: ($t | str substring (($e.name | str length) + 1)..), digest: $img.version, pushed: $img.createTime } } } | flatten } def running [e: record, svc: string] { let rev = (gc run services describe $"($svc)-($e.suffix)" --region $e.region --project $e.project --format "value(status.latestReadyRevisionName)" | str trim) gc run revisions describe $rev --region $e.region --project $e.project --format "value(status.imageDigest)" | str trim | split row "@" | last } def candidates [e: record] { let now = { bobbin: (running $e bobbin), svfe: (running $e svfe) } let bob = (images $e bobbin) let svf = (images $e svfe) let builds = (tagged $e $svf | rename build svfe pushed | join (tagged $e $bob | select build digest | rename build bobbin) build) # a commit that touches neither service rebuilds the same images, so one pair has several builds let all = ($builds | group-by {|c| $"($c.bobbin) ($c.svfe)" } | values | each {|g| { builds: ($g.build | uniq), pushed: ($g.pushed | math max), bobbin: $g.0.bobbin, svfe: $g.0.svfe } } | sort-by pushed --reverse | insert build {|c| $c.builds | str join " " } | insert running {|c| $c.bobbin == $now.bobbin and $c.svfe == $now.svfe }) if ($all | is-empty) { error make { msg: $"no build of ($e.name) has both images left in the registry" } } $all } def pick [all: list, to: string] { if ($to | is-not-empty) { let hit = ($all | where {|c| $to in $c.builds }) if ($hit | is-empty) { error make { msg: $"no build ($to) with both images; have: ($all.builds | flatten | str join ', ')" } } return ($hit | first) } let labels = ($all | each {|c| let when = ($c.pushed | into datetime | format date "%Y-%m-%d %H:%M") $"($c.build) ($when)(if $c.running { ' (running)' } else { '' })" }) let i = ($labels | input list --index "roll back to") if $i == null { error make { msg: "nothing picked" } } $all | get $i } const KINDS = { google_cloud_run_v2_service: "cloud run" google_compute_instance: "vm" cloudflare_workers_script: "worker" } def changed-fields [before: any, after: any, at: list = []] { let kb = ($before | describe | str replace --regex '<.*' '') let ka = ($after | describe | str replace --regex '<.*' '') if $kb == record and $ka == record { $before | columns | append ($after | columns) | uniq | each {|k| changed-fields ($before | get -o $k) ($after | get -o $k) ($at | append $k) } | flatten } else if $kb in [list table] and $ka in [list table] and ($before | length) == ($after | length) { $before | zip $after | each {|p| changed-fields $p.0 $p.1 $at } | flatten } else if $before == $after { [] } else { [($at | last 2 | str join ".")] } | uniq } def verb-of [actions: list] { if "delete" in $actions and "create" in $actions { return "replace" } $actions | where {|a| $a not-in [no-op read] } | get -o 0 } def key-of [c: record] { $c.address | parse --regex '\["(?[^"]+)"\]' | get -o k | last | default ($c.index? | default $c.name | into string) } def system-of [path: string] { let base = ($path | path basename) { hash: ($base | str substring 0..7), version: ($base | split row "-" | last) } } def describe-change [layer: string, c: record] { let verb = (verb-of $c.change.actions) if $verb == null { return null } let key = (key-of $c) let row = {|line, destroy, touches| { layer: $layer, line: $line, destroy: $destroy, touches: $touches } } if $c.type == "null_resource" and $c.name == "nixos-rebuild" { let to = (system-of ($c.change.after?.triggers?.store_path? | default "")) let from = (system-of ($c.change.before?.triggers?.store_path? | default "")) let was = if $from.version != $to.version and ($from.hash | is-not-empty) { $", was ($from.version)" } else { "" } return (do $row $"switch ($key) to nixos ($to.version) \(($to.hash)($was))" false 1) } if $c.type == "terraform_data" and $c.name == "source" { let was = if $c.change.before?.input? == null { "" } else { $", was ($c.change.before.input)" } return (do $row $"worker ($key) built from ($c.change.after?.input?)($was)" false 0) } if $c.type == "null_resource" and $c.name == "nixos-remote" { return (do $row $"install nixos on ($key)" false 0) } let kind = ($KINDS | get -o $c.type | default ($c.type | str replace --regex '^(google|cloudflare)_' '' | str replace --all "_" " ")) if $c.type == "google_compute_instance" and $verb in [replace delete] { return (do $row $"($verb | str upcase) vm ($key), its disk and everything on it go" true 0) } let changed = if $verb == update { changed-fields $c.change.before? $c.change.after? } else { [] } # infra manager put goog-ccm labels on everything, a plan that only drops them is noise let fields = ($changed | where {|f| $f !~ 'labels\.goog-ccm$' }) if ($changed | is-not-empty) and ($fields | is-empty) { return null } let shown = if ($fields | is-empty) { "" } else { let more = if ($fields | length) > 4 { $", +(($fields | length) - 4) more" } else { "" } $": ($fields | first 4 | str join ', ')($more)" } let name = if $key == "this" { "" } else { $" ($key)" } do $row $"($verb) ($kind)($name)($shown)" ($verb in [replace delete]) 0 } def describe-plan [layer: string, rcs: list] { $rcs | each {|c| describe-change $layer $c } | compact } def render-plan [rows: list] { let width = ($rows | each {|r| $r.layer | str length } | append 0 | math max) let lines = ($rows | each {|r| $" ($r.layer | fill --width $width) ($r.line)" }) let changes = ($rows | where {|r| $r.layer not-in [build reuse keep] } | length) let touches = ($rows | each {|r| $r.touches } | append 0 | math sum) let destroys = ($rows | where destroy | length) let total = ([ (if $changes == 0 { "no changes" } else if $changes == 1 { "1 change" } else { $"($changes) changes" }) (if $destroys == 1 { "1 of them destroys something" } else if $destroys > 1 { $"($destroys) of them destroy something" }) (if $touches == 1 { "1 yubikey touch" } else if $touches > 1 { $"($touches) yubikey touches" }) ] | compact | str join ", ") $lines | append $total | str join (char nl) } # plans hold keys, mktemp -d makes the dir 0700 def plan-dir [] { ^mktemp -d -t deploy-plans.XXXXXX | str trim } # a preview takes no state lock, so it can't block a real run def --wrapped plan-root [label: string, root: string, out: string, preview: bool, ...args: string] { let lock = if $preview { [-lock=false] } else { [] } print $"== planning ($label)" logged $"plan ($label)" terraform $"-chdir=($root)" plan -input=false -no-color ...$lock ...$args $"-out=($out)" | ignore ^terraform $"-chdir=($root)" show -json $out | from json | get -o resource_changes | default [] } def apply-plan [label: string, root: string, plan: string] { let from = (date now) logged $"apply ($label)" terraform $"-chdir=($root)" apply -input=false -no-color $plan | ignore print $"== applied ($label) (took $from)" } def plan-control [e: record, refs: record, out: string, preview: bool] { let vars = if ($refs | is-empty) { [] } else { [-var (refs-var $refs)] } plan-root $"control/($e.name)" $e.root $out $preview ...$vars } # the same source builds the same bundle, so an unchanged worker plans nothing def plan-workers [e: record, root: string, bundles: record, sources: record, out: string, preview: bool] { let map = {|r| $r | transpose k v | each {|p| $"\"($p.k)\" = \"($p.v)\"" } | str join ", " } (plan-root $"workers/($e.name)" $root $out $preview -var $"bundles={ (do $map $bundles) }" -var $"sources={ (do $map $sources) }" ...(worker-targets $e ($bundles | columns))) } def ask [prompt: string] { if (is-terminal --stdin) { return (input $prompt) } print -n $prompt ^head -n 1 | str trim } def confirm [rows: list, yes: bool] { let destroys = ($rows | where destroy) if ($destroys | is-not-empty) { print $"this destroys:\n($destroys | each {|r| $' ($r.line)' } | str join (char nl))" if (ask "type yes to apply it all: ") != "yes" { error make { msg: "nothing applied" } } return } if $yes { return } if (ask "apply? [y/N] " | str downcase) not-in [y yes] { error make { msg: "nothing applied, --yes applies without asking" } } } # a preview builds no images, building them pushes them def build-groups [e: record, groups: list, rebuild: bool, build_on: string, --dry] { let at = $e.urls let out = (bundle-dir $e) rm -rf $out mkdir $out $groups | each {|g| print $"== building ((($g.services | each {|s| $'service:($s)' }) ++ ($g.workers | each {|w| $'worker:($w)' })) | str join ', ') from (src-label $g.src)" let src = (fetch-source $e $g.src) let built = (try { let work = (src-tree $e $src.tgz) let tags = (build-tags $e $src) let wanted = ($g.services | each {|svc| let key = (source-key $e $work $svc $at.svfe) { svc: $svc, key: $key, digest: (if $rebuild { null } else { built-from $e $svc $key }) } }) let reused = ($wanted | where digest != null) if not $dry { for r in $reused { retag $e $r.svc $r.digest $tags } } let todo = ($wanted | where digest == null) let made = if ($todo | is-empty) or $dry { {} } else if ($build_on | is-empty) { build-local $e $work $todo $tags $at.svfe } else { build-remote $e $src $todo $at.svfe $build_on } let images = ($wanted | each {|w| let how = if $w.digest != null { "reused" } else if $dry { "unbuilt" } else { "built" } { svc: $w.svc, digest: ($w.digest | default ($made | get -o $w.svc)), how: $how, from: (src-label $g.src) } }) { ok: true, images: $images, bundles: (build-workers $work $g.workers $out) } } catch {|err| { ok: false, msg: (root-msg $err) } }) cleanup $e if not $built.ok { error make --unspanned { msg: $built.msg } } { images: $built.images, bundles: $built.bundles, from: (src-label $g.src) } } | reduce --fold { images: [], bundles: {}, from: {} } {|b, acc| { images: ($acc.images | append $b.images) bundles: ($acc.bundles | merge $b.bundles) from: ($acc.from | merge ($b.bundles | columns | each {|w| { $w: $b.from } } | into record)) } } } def build-rows [built: record] { let image = {|i| let d = ($i.digest | default "" | str replace "sha256:" "" | str substring 0..7) match $i.how { reused => { layer: reuse, line: $"service ($i.svc) from ($i.from), the image of that source is there, @($d)" } built => { layer: build, line: $"service ($i.svc) from ($i.from), @($d)" } _ => { layer: build, line: $"service ($i.svc) from ($i.from), a preview doesn't build it so control shows its old pin" } } } $built.images | each {|i| do $image $i } | append ($built.bundles | columns | each {|w| { layer: build, line: $"worker ($w) from ($built.from | get $w)" } }) | each {|r| $r | merge { destroy: false, touches: 0 } } } # editing the checkout mid-run would change the flake's hash. mktemp -d makes the copy 0700 def --env freeze-tree [infra: string] { let dir = (^mktemp -d -t deploy-tree.XXXXXX | str trim) let tree = ($dir | path join infra) let tgz = (pack-tree $infra ($dir | path join tree.tgz)) unpack-repo $tgz $tree rm -f $tgz $env.DEPLOY_TREE = $dir # every run's copy is new, so without this each one refetches the providers if ($env.TF_PLUGIN_CACHE_DIR? | is-empty) { $env.TF_PLUGIN_CACHE_DIR = ($env.HOME | path join .cache tangled-deploy terraform-plugins) } mkdir $env.TF_PLUGIN_CACHE_DIR $tree } def drop-tree [] { if ($env.DEPLOY_TREE? | is-not-empty) { rm -rf $env.DEPLOY_TREE } } def infra-root [] { let top = (^git rev-parse --show-toplevel | complete) # a jj workspace has no .git let top = if $top.exit_code == 0 { $top } else { ^jj root | complete } let root = ($top.stdout | str trim) if $top.exit_code != 0 or not ($root | path join deploy.nu | path exists) { error make { msg: "run this from the infra repo, or pass --infra DIR" } } $root } def --env resolve-infra [target: string, infra: string] { if ($target | is-empty) { error make { msg: "pass --target , like dev or prod" } } check-auth freeze-tree (if ($infra | is-empty) { infra-root } else { $infra }) } def check-applied [e: record] { if ($e.fresh? | default false) { error make { msg: $"($e.name) was never applied, so its first deploy builds everything: nix run .#deploy -- --target ($e.name), without a subcommand" } } } def check-ref [ref: string] { if ($ref | is-empty) { return } error make { msg: "--ref is gone, flake.lock says what gets built. move the input (nix flake update tangled) and commit the lock" } } def have-of [c: record] { { services: $SERVICES workers: ($c.workers? | default {} | columns) nodes: ($c.nodes? | default {} | items {|name, n| { $name: $n.kind } } | into record) } } # no input in modules/envs means the service or worker is left as it runs def sourced [e: record, c: record, pick: record, layer: string, names: list] { let input = {|n| if $layer == worker { $c.workers | get $n | get -o input | default $c.input? } else { $c.input? } } let unsourced = ($names | where {|n| (do $input $n) == null }) let asked = ($unsourced | where {|n| $"($layer):($n)" in $pick.named }) if ($asked | is-not-empty) { error make { msg: $"($e.name) names no flake input for ($asked | each {|n| $'($layer):($n)' } | str join ', ') in modules/envs, add an input and commit the lock" } } $names | where {|n| $n not-in $unsourced } } def main [ --target: string = "" # the env, like dev or prod --only: string = "" # what to deploy, like service:bobbin,worker:avatar,node:sitesd,control (default: all) --rebuild # build images even when one of the same source exists --build-on: string = "" # ssh host with podman to build the images on; empty builds locally --preview # plan it against the live env and say what it would do, apply nothing --yes # apply without asking, unless the plan destroys something --infra: string = "" # an infra repo to use (default: the one you run from) --ref: string = "" # gone, flake.lock says what gets built ] { try { check-ref $ref let infra = (resolve-infra $target $infra) open-log $"($target) ($only)" let e = (env-of $target $infra) let c = (tree-env $e) let pick = (pick-targets $only (have-of $c)) let svcs = (sourced $e $c $pick service $pick.services) let workers = (sourced $e $c $pick worker $pick.workers) if $rebuild and ($svcs | is-empty) { error make { msg: "--rebuild needs an image to build" } } let fresh = ($e.fresh? | default false) if $fresh and ($preview or not $pick.all or ($svcs | is-empty)) { error make { msg: $"($target) was never applied, so its first deploy builds everything, from its input and not --preview" } } let controls = $pick.control or ($svcs | is-not-empty) let live = if $fresh or not $controls { {} } else { live-refs $e } let workers_root = if ($workers | is-empty) { null } else { workers-root $e } let nodes_root = if ($pick.nodes | is-empty) { null } else { nodes-root $e } # before any build, so a missing secret fails first if $controls { ensure-secrets $e services (service-secrets $c) --make=(not $preview) } if ($workers | is-not-empty) { ensure-secrets $e workers (worker-secrets $c $workers) --make=(not $preview) } if $nodes_root != null and not $preview { check-node-secrets $e $nodes_root $pick.nodes } if $fresh { ensure-registry $e } let built = if ($svcs | is-empty) and ($workers | is-empty) { { images: [], bundles: {}, from: {} } } else { build-groups $e (source-groups $c $infra $svcs $workers) $rebuild $build_on --dry=$preview } let kept = ($live | reject --optional ...($built.images | where digest != null | get svc)) let refs = ($live | merge ($built.images | where digest != null | each {|i| { $i.svc: $"@($i.digest)" } } | into record)) let dir = (plan-dir) let plans = { control: ($dir | path join control.tfplan) workers: ($dir | path join workers.tfplan) nodes: ($dir | path join nodes.tfplan) } let control = if not $controls { [] } else if $fresh { [{ layer: control, line: $"first apply of ($target), its grants go first", destroy: false, touches: 0 }] } else { describe-plan control (plan-control $e $refs $plans.control $preview) } let worker_rows = if $workers_root == null or ($built.bundles | is-empty) { [] } else { describe-plan workers (plan-workers $e $workers_root $built.bundles $built.from $plans.workers $preview) } let targets = if $pick.all { [] } else { node-targets $c.nodes $pick.nodes } let node_rows = if $nodes_root == null { [] } else { describe-plan nodes (plan-root $"nodes/($target)" $nodes_root $plans.nodes $preview ...$targets) } let rows = (build-rows $built | append $control | append $worker_rows | append $node_rows) print $"($target):" print (render-plan $rows) if $preview { rm -rf (bundle-dir $e) drop-tree print $"the plans are in ($dir), rm -r it once you're done" return } if ($rows | where layer not-in [build reuse] | is-empty) { rm -rf $dir (bundle-dir $e) drop-tree print "nothing to apply" return } confirm $rows $yes let steps = [ (if $controls and ($fresh or ($control | is-not-empty)) { { name: control, redo: (if ($svcs | is-empty) { [control] } else { $svcs | each {|s| $"service:($s)" } }) } }) (if ($worker_rows | is-not-empty) { { name: workers, redo: ($built.bundles | columns | each {|w| $"worker:($w)" }) } }) (if ($node_rows | is-not-empty) { { name: nodes, redo: (if $pick.all { [nodes] } else { $pick.nodes | each {|n| $"node:($n)" } }) } }) ] | compact mut done = [] mut failed: any = null for step in $steps { let outcome = (try { match $step.name { control => { if $fresh { apply-control $e $refs --yes } else { check-refs-unmoved $e $kept apply-plan $"control/($target)" $e.root $plans.control } $done = ($done | append $"control/($target)") } workers => { apply-plan $"workers/($target)" $workers_root $plans.workers $done = ($done | append $"workers ($built.bundles | columns | str join ', ')") } nodes => { apply-host-firewalls $e let touches = ($node_rows | each {|r| $r.touches } | math sum) if $touches > 0 { print $"== touch the key when it blinks, (if $touches == 1 { 'once' } else { $'($touches) times' })" } apply-plan $"nodes/($target)" $nodes_root $plans.nodes finish-cells $e $done = ($done | append ($node_rows | get line)) } } null } catch {|err| { step: $step, msg: (explain (root-msg $err)) } }) if $outcome != null { $failed = $outcome break } } rm -rf (bundle-dir $e) print "" if ($done | is-not-empty) { print $"done:\n($done | each {|d| $' ($d)' } | str join (char nl))" } if $failed == null { rm -rf $dir drop-tree return } let failed = $failed let left = ($steps | skip until {|s| $s.name == $failed.step.name }) print -e $"failed: ($failed.step.name)\n ($failed.msg | lines | str join (char nl) | str replace --all (char nl) $'(char nl) ')" let not_run = ($left | skip 1 | get name) if ($not_run | is-not-empty) { print -e $"not run: ($not_run | str join ', ')" } print -e $"rerun what's left:\n (rerun-command $target ($left | get redo | flatten))" print -e $"all the output is in (log-file), the plans in ($dir), the tree it ran from in ($env.DEPLOY_TREE)" exit 1 } catch {|err| die $err } } def carried [rebuild: bool, build_on: string, preview: bool, infra: string] { [ (if $rebuild { [--rebuild] } else { [] }) (if ($build_on | is-empty) { [] } else { [--build-on $build_on] }) (if $preview { [--preview] } else { [] }) (if ($infra | is-empty) { [] } else { [--infra $infra] }) ] | flatten } def gone [sub: string, target: string, only: string, ref: string, flags: list] { let ref_note = if ($ref | is-empty) { "" } else { "\n--ref is gone too, flake.lock says what gets built" } print -e $"deploy: there's no ($sub) subcommand anymore, run:\n (old-command $sub ($target | default -e '') $only $flags)($ref_note)" exit 1 } def "main images" [ --target: string = "", --only: string = "", --ref: string = "" --rebuild, --build-on: string = "", --preview, --infra: string = "" ] { gone images $target $only $ref (carried $rebuild $build_on $preview $infra) } def "main workers" [ --target: string = "", --only: string = "", --ref: string = "", --infra: string = "" ] { gone workers $target $only $ref (carried false "" false $infra) } def "main nodes" [--target: string = "", --only: string = "", --preview, --infra: string = ""] { gone nodes $target $only "" (carried false "" $preview $infra) } def "main control" [--target: string = "", --preview, --infra: string = ""] { gone control $target "" "" (carried false "" $preview $infra) } def "main rollback" [ --target: string = "" --to: string = "" # the build (8-char commit) or revision (r-N), skipping the menu --list # print the builds and exit --infra: string = "" ] { try { let infra = (resolve-infra $target $infra) open-log $"($target) rollback ($to)" let e = (env-of $target $infra) check-applied $e let all = (candidates $e) if $list { drop-tree return ($all | select build pushed running) } let it = (pick $all $to) print $"== bobbin-($e.suffix)@($it.bobbin)" print $"== svfe-($e.suffix)@($it.svfe)" apply-control $e { bobbin: $"@($it.bobbin)", svfe: $"@($it.svfe)" } if $e.name == "dev" { print "== dev moves on again with the next push to sv-fe" } drop-tree } catch {|err| die $err } } def check-row [name: string, ok: bool, fix: string, --optional] { { name: $name, ok: $ok, fix: $fix, required: (not $optional) } } def doctor-lines [rows: list] { $rows | each {|r| if $r.ok { $"ok ($r.name)" } else if $r.required { $"FAIL ($r.name): ($r.fix)" } else { $"warn ($r.name): ($r.fix)" } } | str join (char nl) } def doctor-failed [rows: list] { $rows | any {|r| $r.required and not $r.ok } } def version-at-least [have: string, want: string] { let nums = {|v| $v | split row "." | each {|p| $p | parse --regex '^(?\d+)' | get -o 0.n | default "0" | into int } } let h = (do $nums $have) let w = (do $nums $want) for i in 0..<($w | length) { let a = ($h | get -o $i | default 0) if $a != ($w | get $i) { return ($a > ($w | get $i)) } } true } # the plain docker driver can only export the registry cache on the containerd snapshotter def buildx-ok [inspect: string, info: string] { let driver = ($inspect | parse --regex '(?m)^Driver:\s*(?\S+)' | get -o 0.d) $driver in [docker-container remote kubernetes] or ($driver == docker and ($info | str contains "io.containerd.snapshotter")) } def registry-helper [config: record, host: string] { ($config.credHelpers? | default {} | get -o $host) == "gcloud" } def backend-bucket [main: string] { $main | parse --regex 'backend\s+"gcs"\s*\{[^}]*bucket\s*=\s*"(?[^"]+)"' | get -o 0.b } def --wrapped runs [cmd: string, ...args: string] { (which $cmd | is-not-empty) and ((^$cmd ...$args | complete).exit_code == 0) } def tool-rows [build_on: string] { let tf = if (which terraform | is-empty) { null } else { ^terraform version -json | from json | get terraform_version } let local = ($build_on | is-empty) let docker = (runs docker info) let agent = ((^ssh-add -l | complete | get stdout | lines | where {|l| $l =~ '^\d+ ' }) | is-not-empty) [ (check-row "nix" (runs nix --version) "install nix with flakes on") (check-row $"nu (version | get version)" true "") (check-row $"terraform ($tf | default '')" ($tf != null and (version-at-least $tf "1.5.0")) "run this through nix run .#deploy, which brings terraform 1.5 or newer") (check-row "gcloud" (which gcloud | is-not-empty) "install the google cloud sdk") ...($LOGINS | each {|l| check-row $l.login (runs gcloud ...$l.args) $"run `($l.login)`" }) (check-row "docker running" $docker "start docker, or build on a host with podman and nix with --build-on" --optional=(not $local)) ...(if not $docker { [] } else { let ok = (buildx-ok (^docker buildx inspect | complete | get stdout) (^docker info | complete | get stdout)) [(check-row "buildx builder" $ok "the registry cache needs a buildkit builder: `docker buildx create --use --driver docker-container`" --optional=(not $local))] }) (check-row "ssh agent key" $agent "ssh-add the key for the --build-on host" --optional=$local) ] } def target-rows [target: string, infra: string, build_on: string] { let c = (env-config $target $infra) let host = $"($c.region)-docker.pkg.dev" let config = ($env.DOCKER_CONFIG? | default ($env.HOME | path join .docker) | path join config.json) let docker = if ($config | path exists) { open --raw $config | from json } else { {} } let buckets = ([control nodes workers] | each {|r| $infra | path join terraform $r $target main.tf } | where {|f| $f | path exists } | each {|f| backend-bucket (open --raw $f) } | compact | uniq) [ (check-row $"docker login to ($host)" ((registry-helper $docker $host) and (which docker-credential-gcloud | is-not-empty)) $"run `gcloud auth configure-docker ($host)`" --optional=($build_on | is-not-empty)) ...($buckets | each {|b| check-row $"read gs://($b)" (runs gcloud storage ls $"gs://($b)") "log in, or get storage.objectViewer on it from a project owner" }) ] } def "main doctor" [ --target: string = "" # also check this env's registry login and state buckets --build-on: string = "" # the images build on this host, so docker here is optional --infra: string = "" ] { try { let tools = (tool-rows $build_on) let rows = if ($target | is-empty) { $tools } else { $tools | append (target-rows $target (if ($infra | is-empty) { infra-root } else { $infra }) $build_on) } print (doctor-lines $rows) if (doctor-failed $rows) { exit 1 } } catch {|err| die $err } }