Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116#!/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, each command taking --target ENV and --preview (not workers) to plan without applying:# nix run .#deploy -- --target ENV control, then the nodes, keeping the images running now# nix run .#deploy -- --target ENV (--ref REF | --path DIR) [--rebuild] [--build-on HOST]# build the images and workers first, pin them, then the same# nix run .#deploy -- images --target ENV (--ref REF | --path DIR) [--only bobbin,svfe] [--rebuild] [--build-on HOST]# nix run .#deploy -- workers --target ENV (--ref REF | --path DIR) [--only NAME,..]# nix run .#deploy -- nodes --target ENV [--only KIND,..] from flake.lock as it is# nix run .#deploy -- control --target ENV the blueprint, images kept, nodes left alone# nix run .#deploy -- rollback --target ENV [--to SHA8|r-N] [--list]# an env that was never applied comes up with its first --ref or --path deploy
# ---- output
# every command's output goes to deploy.log in the infra repo, so the terminal only gets the steps.# the file is live, so `tail -f deploy.log` follows a long onedef log-file [] { $env.DEPLOY_LOG? | default "/dev/null" }
# a run appends to the log, and past 2MB the oldest part goesdef --env open-log [infra: string, what: string] { let log = ($infra | path expand | path join deploy.log) 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 print $"== output goes to ($log)"}
# runs the command with its output in the log and hands the output back. a failure raises with the# end of itdef --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 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 or the like wraps what it raises as "Eval block failed", so this digs the# message out of the innermost errordef root-msg [err: record] { mut e = ($err.json | from json) while ($e.inner? | is-not-empty) { $e = ($e.inner | first) } $e.msg}
# for output that came back from a pipe, like the remote build'sdef 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)"}
# what a terraform apply did, from its outputdef tf-summary [out: string] { let lines = ($out | lines) let totals = ($lines | parse --regex '^Apply complete! Resources: (?<added>\d+) added, (?<changed>\d+) changed, (?<destroyed>\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 '^(?<addr>\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))"}
# a terraform apply with its output in the log, and one line saying what it diddef --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}
# the control root knows the env, and the blueprint's last applied revision knows the restdef 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 e = ($out.stdout | from json | get env) let outputs = (applied-outputs $e) # a hostname wins, then a bare lb address, then cloud run's own url let bare = if ($outputs.lb_ips?.value? | is-not-empty) { $outputs.lb_ips.value | items {|svc, ip| { $svc: $"http://($ip)" } } | into record } else { $outputs.services.value | values | first | reject region } let urls = ($bare | merge ($e.hostnames? | default {} | items {|svc, host| { $svc: $"https://($host)" } } | into record)) $e | merge { name: $name root: $root location: ($e.deployment | path dirname | path dirname) registry: $outputs.registry.value urls: $urls }}
def applied-outputs [e: record] { gc infra-manager revisions list --deployment $e.deployment --format json | from json | where state == "APPLIED" | sort-by createTime --reverse | get 0.applyResults.outputs}
# gcloud, failing loudly with its stderr instead of handing back an empty stringdef --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}
# anything that queues behind a running apply can deadlock it at unlock, so wait for ci insteaddef idle-deployment [e: record] { let dep = (gc infra-manager deployments describe $e.deployment --format json | from json) # a failed apply isn't running, and the next apply is how it recovers if $dep.state not-in ["ACTIVE" "FAILED"] or $dep.lockState? == "LOCKED" { error make { msg: $"($e.deployment | path basename) is ($dep.state), ($dep.lockState? | default 'unlocked'), try again once it's done" } } $dep}
def running-refs [dep: record] { $dep.terraformBlueprint?.inputValues?.image_refs?.inputValue? | default {}}
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) }"}
# what infra manager takes as the blueprint's inputs when we go around the control root. like# the control root, no pins means no image_refs at all, not an empty mapdef write-inputs [e: record, refs: record, path: string] { let pins = if ($refs | is-empty) { "" } else { $"image_refs = { (ref-pairs $refs) }\n" } $"env_name = \"($e.name)\"\n($pins)" | save -f $path}
# imports a secret made by hand into control's state, or control's apply fails on the duplicatedef adopt-secrets [e: record] { let c = (tree-env $e) if $c.guest_of? != null { return } let ids = ($c.services? | default {} | values | each {|s| $s.secrets? | default {} | values } | flatten | append (worker-secrets $c []) | uniq) let held = (^terraform $"-chdir=($e.root)" state list | complete | get stdout | lines) for id in $ids { let addr = $'module.control.google_secret_manager_secret.this["($id)"]' if $addr in $held or (^gcloud secrets describe $id --project $e.project | complete).exit_code != 0 { continue } print $"== control takes over the secret ($id)" logged $"import ($id)" terraform $"-chdir=($e.root)" import -input=false -no-color $addr $"projects/($e.project)/secrets/($id)" | ignore }}
# every mode applies the control root through here, so the image pins always go along. an empty# map would put the env back on its floating tagdef apply-control [e: record, refs: record, --yes] { adopt-secrets $e let root = $"-chdir=($e.root)" let vars = if ($refs | is-empty) { [] } else { [-var (refs-var $refs)] } let before = (gc infra-manager deployments describe $e.deployment --format json | from json) if $yes { tf-logged $"control/($e.name)" $root apply -input=false -auto-approve -no-color ...$vars } else { ^terraform $root apply ...$vars } # the same inputs as a failed revision plan as no change, so retry that revision by hand let after = (gc infra-manager deployments describe $e.deployment --format json | from json) if $after.state == "FAILED" and $after.latestRevision == $before.latestRevision { print $"== retrying ($after.latestRevision | path basename), the tree hasn't changed since it failed" roll $e $after }}
# ---- nodes
# no pins to lose here, but every plan builds the vm systems, so this needs nix and an# x86_64-linux builder. a guest's host has its own root, `host` names itdef 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 makes no firewall rules, its host's nodes root makes them for every kind. so a kind the# host hasn't applied since it was added would leave the guest's new vm without sshdef 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. the nodes root makes the empty ones, so make# the missing ones now and stop on any that has no value. a guest's host makes them, or you dodef check-node-secrets [e: record, root: string, kinds: list<string>] { let c = (tree-env $e) let ids = ($c.nodes | values | where {|n| ($kinds | is-empty) or $n.kind in $kinds } | get 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)" }}
def apply-nodes [e: record, --targets: list<string> = [], --kinds: list<string> = []] { let root = (nodes-root $e) if $root == null { return } check-node-secrets $e $root $kinds apply-host-firewalls $e print $"== nodes/($e.name): planning, which builds every node's system" apply-nodes-root $e.name $root $targets finish-cells $e}
# the plan goes ahead when it destroys nothing, other than the redeploy a system change replaces.# anything else asks first, because a replaced vm comes back empty and has to backfill from scratchdef apply-nodes-root [name: string, root: string, targets: list<string>] { let chdir = $"-chdir=($root)" # holds the install key, so it doesn't outlive the apply 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 } }}
# the env's nodes of these kinds, as targets for a nodes plan. the envs module is plain locals, so# terraform evaluates it without state or initdef node-targets [e: record, kinds: list<string>] { let envs = ($e.root | path dirname | path dirname | path join modules envs) let expr = $"jsonencode\({ for name, n in local.envs[\"($e.name)\"].nodes : name => n.kind })" let names = ($expr | ^terraform $"-chdir=($envs)" console | from json | from json | transpose name kind | where kind in $kinds | get name) if ($names | is-empty) { error make { msg: $"($e.name) runs no ($kinds | str join ', ') nodes" } } $names | each {|n| $"-target=module.nodes.module.install[\"($n)\"]" }}
# the blueprint leaves bobbin out of a cell until the hub nodes it needs exist, so once the nodes# made them, roll the blueprint again the way ci does. the control root wouldn't, its plan has# nothing newdef finish-cells [e: record] { let waiting = (applied-outputs $e | get -o waiting.value | 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" roll $e (idle-deployment $e)}
# a new revision of what the deployment already has, the way ci rolls onedef roll [e: record, dep: record] { let vars = $"/tmp/roll-($e.name).tfvars" write-inputs $e (running-refs $dep) $vars (gc infra-manager deployments apply $e.deployment --service-account $e.im_sa --gcs-source $dep.terraformBlueprint.gcsSource --inputs-file $vars --tf-version-constraint $e.tf_version --quiet) rm -f $vars}
# ---- workers
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" }
# control's outputs only know the env as of its last applydef tree-env [e: record] { env-config $e.name ($e.root | path dirname | path dirname | path dirname)}
# not the google keys, control fills those itself. no names means every workerdef worker-secrets [c: record, names: list<string>] { 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}
# the ids that don't exist or have no enabled versiondef valueless [e: record, ids: list<string>] { $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<string>] { $ids | each {|id| let verb = if (^gcloud secrets describe $id --project $e.project | complete).exit_code == 0 { "versions add" } else { "create" } $" printf %s \"$VALUE\" | gcloud secrets ($verb) ($id) --data-file=- --project ($e.project)" } | str join (char nl)}
# an empty secret would only fail the workers apply, after control and the images already rolleddef check-worker-secrets [e: record, names: list<string>] { let empty = (valueless $e (worker-secrets (tree-env $e) $names)) if ($empty | is-empty) { return } error make { msg: $"the workers read secrets that have no value yet:\n(secret-commands $e $empty)" }}
def --wrapped run-in [dir: string, ...cmd: string] { do { cd $dir; logged $"($cmd | first) in ($dir | path basename)" ...$cmd } | ignore}
# every worker asked for or none, because the workers root reads a missing bundle as a worker to# delete. they land outside the work dir so they outlive cleanupdef build-workers [e: record, work: string, only: list<string>] { let names = if ($only | is-empty) { (tree-env $e).workers? | default {} | columns } else { $only } let missing = ($names | where {|w| not (["package.json" "Cargo.toml"] | any {|f| $work | path join $w $f | path exists }) }) if ($missing | is-not-empty) { print $"== the workers stay as they are, this tree has no ($missing | str join ', ')" return {} } let out = (bundle-dir $e) rm -rf $out mkdir $out $names | each {|w| print $"== building worker ($w)" let dir = ($work | path join $w) let bundle = ($out | path join $w) # worker-build bundles the js, so index.js and the wasm are all there is 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}
# a targeted apply, so the other workers are left alonedef worker-targets [e: record, only: list<string>] { 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)"]' }) } | flatten | each {|a| $"-target=module.workers.($a)" }}
# auto-approved like the control root. the bundles build the same from the same source, so an# unchanged worker plans as nothingdef apply-workers [e: record, bundles: record, only: list<string>] { let root = (workers-root $e) if $root == null or ($bundles | is-empty) { return } let pairs = ($bundles | transpose w path | each {|r| $"\"($r.w)\" = \"($r.path)\"" } | str join ", ") tf-logged $"workers/($e.name)" $"-chdir=($root)" apply -input=false -auto-approve -no-color -var $"bundles={ ($pairs) }" ...(worker-targets $e $only) rm -rf (bundle-dir $e)}
# ---- building
const REPO = "https://tangled.org/tangled.org/core"
# cargo profile per env; anything unlisted builds releaseconst 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" }
# the source as a tarball with everything under src/, and the commit it isdef fetch-source [e: record, ref: string, path: string] { let tgz = (src-tgz $e) if ($path | is-not-empty) { return { tgz: (pack-tree $path $tgz), sha: null } } { tgz: $tgz, sha: (archive $ref $tgz) }}
# saves the archive of a ref to out, and says which commit it isdef archive [ref: string, out: string] { let headers = (mktemp -t archive-headers.XXXXXX) let got = (^curl -fsSL -D $headers -o $out $"($REPO)/archive/($ref).tar.gz?prefix=src" | complete) if $got.exit_code != 0 { rm -f $headers; error make { msg: $"no archive of ($ref) at ($REPO): ($got.stderr | str trim)" } } # the appview answers with an immutable link to the commit the ref resolved to let sha = (open --raw $headers | parse -r 'archive/(?<sha>[0-9a-f]{40})' | get -o 0.sha) rm -f $headers if $sha == null { error make { msg: $"couldn't tell which commit ($ref) is" } } $sha}
# the checkout as it is, uncommitted changes included, minus whatever the vcs ignores. tar can't# read .gitignore properly, so git or jj says which files countdef 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}
def src-tree [e: record, tgz: string] { let work = (work-dir $e) rm -rf $work mkdir $work tar -xzf $tgz -C $work --strip-components=1 cd $work # flakes only see files git knows about git init -q git add -A git -c user.email=deploy@local -c user.name=deploy commit -qm src $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)" }
const SERVICES = [bobbin svfe]# what --ref or --path builds, the env's cloudflare workers as oneconst DEPLOYABLES = [bobbin svfe workers]
def build-tags [e: record, src: record] { [$e.name] | append (if $src.sha == null { [] } else { [$"($e.name)-($src.sha | str substring 0..7)"] })}
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 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"; if [ -n "$SHA" ]; then echo "--tag=$1:$TAG-$SHA"; fi; 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 sha = if $src.sha == null { "" } else { $src.sha | str substring 0..7 } 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: $sha, 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}
# ---- skipping unchanged images
# the key is the source (flake store paths and git hashes) plus the build args. an image# already tagged src-<key> came from exactly that, so it gets reuseddef 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 { # the flake's bobbin source is only the crates bobbin depends on, same as what the # Containerfile's sed narrows the workspace to bobbin => ([bobbin/containerfiles/bobbin.Containerfile .dockerignore rust-toolchain.toml] | each $tree | prepend (do $store bobbin.src) | append (bobbin-profile $e)) # the static files come from the flake, so their store path already covers everything in them 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 }}
# a reused image gets this deploy's tags too, so rollback still lists it under the commitdef 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 }}
# ---- bootstrapping
# an env as modules/envs has it, which needs no statedef 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}
# a target whose control root was never applied has nothing for env-of to read, so this names# what a build needs the way the control root and the blueprint are going todef 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 } let location = $"projects/($c.project)/locations/($c.region)" { name: $name root: $root infra: $infra fresh: true project: $c.project region: $c.region suffix: $c.env_suffix location: $location deployment: $"($location)/deployments/tangled-($name)" registry: $"($c.region)-docker.pkg.dev/($c.project)/($c.registry_name)" workers: ($c.workers? | default {} | columns) urls: $urls }}
# a new project's registry comes from its blueprint, which runs after the images are pushed, so# it's made here first, and infra manager adopts it like the runtime accountsdef ensure-registry [e: record] { let name = ($e.registry | path basename) if (^gcloud artifacts repositories describe $name --location $e.region --project $e.project | complete).exit_code != 0 { gc artifacts repositories create $name --repository-format docker --location $e.region --project $e.project | ignore }}
# what the first revision runs on: the deployment identity's roles and read of its blueprint, and# the runtime accounts' reads of their secretsconst FIRST_GRANTS = [ google_project_iam_member.deploy google_storage_bucket_iam_member.deployment_reads_blueprint google_secret_manager_secret_iam_member.readers]
# the first apply of a new env, once its images exist. a first revision that fails leaves the# deployment out of the control root's state, so it gets every grant it runs on beforehand. the# runtime accounts the blueprint makes are made here first so they can be granted, which is fine# because infra manager adopts what already exists while its state is cleandef bootstrap [e: record, refs: record] { for svc in $SERVICES { let sa = $"($svc)-($e.suffix)" if (^gcloud iam service-accounts describe $"($sa)@($e.project).iam.gserviceaccount.com" --project $e.project | complete).exit_code != 0 { gc iam service-accounts create $sa --project $e.project --display-name $"($sa) cloud run runtime" | ignore } } let root = $"-chdir=($e.root)" print $"== ($e.name) is new, so its identities and grants go first" ^terraform $root apply ...($FIRST_GRANTS | each {|g| $"-target=module.control.($g)" }) print "== giving the grants two minutes to land" sleep 2min
let applied = (try { apply-control $e $refs --yes; true } catch { false }) let dep = (^gcloud infra-manager deployments describe $e.deployment --format json | complete) if $dep.exit_code != 0 { error make { msg: $"the control apply failed before it made ($e.deployment | path basename)" } } if $applied and ($dep.stdout | from json).state == "ACTIVE" { return }
print "== the first revision failed, taking the deployment back and rolling it again" let vars = if ($refs | is-empty) { [] } else { [-var (refs-var $refs)] } if "module.control.google_config_deployment.this" not-in (^terraform $root state list | lines) { logged "import the deployment" terraform $root import -input=false -no-color ...$vars module.control.google_config_deployment.this $e.deployment | ignore } apply-control $e $refs --yes let e = (env-of $e.name $e.infra) let dep = (gc infra-manager deployments describe $e.deployment --format json | from json) if $dep.state != "ACTIVE" { roll $e $dep }}
# ---- rolling back
def images [e: record, svc: string] { gc artifacts docker images list (image $e $svc) --include-tags --format json | from json}
# every <env>-<sha8> build of one servicedef 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}
# a deploy that pinned digests keeps them in its revision's inputs, tag or no tagdef pinned [e: record] { gc infra-manager revisions list --deployment $e.deployment --format json | from json | where state == "APPLIED" | where {|r| $r.terraformBlueprint?.inputValues?.image_refs?.inputValue? | is-not-empty } | each {|r| let refs = $r.terraformBlueprint.inputValues.image_refs.inputValue { build: ($r.name | path basename) svfe: ($refs.svfe | str trim --left --char "@") pushed: $r.createTime bobbin: ($refs.bobbin | str trim --left --char "@") } }}
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) let revisions = (pinned $e | where {|r| $r.bobbin in $bob.version and $r.svfe in $svf.version and not ($builds | any {|b| $b.bobbin == $r.bobbin and $b.svfe == $r.svfe }) }) # a commit that doesn't touch either service rebuilds the same images, so one pair can carry several builds let all = ($builds | append $revisions | 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 or pinned revision 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}
# ---- previewing
def preview [e: record, dep: record, --no-nodes] { let refs = (running-refs $dep) let id = $"preview-(random chars --length 8 | str downcase)" let out = $"/tmp/($id)"
# the control plan also zips the tree, which the infra manager preview below needs let root = $"-chdir=($e.root)" let planned = (^terraform $root plan -input=false -lock=false -no-color ...(if ($refs | is-empty) { [] } else { [-var (refs-var $refs)] }) $"-out=($out)-control.tfplan" | complete) if $planned.exit_code != 0 { error make { msg: $"control plan failed: ($planned.stdout | lines | last 30 | str join (char nl))" } } let control = (^terraform $root show -json $"($out)-control.tfplan" | from json | get resource_changes)
let nodes_root = if $no_nodes { null } else { nodes-root $e } let nodes = if $nodes_root == null { [] } else { plan-nodes $nodes_root $"($out)-nodes.tfplan" }
let preview = $"($e.location)/previews/($id)" let obj = $"gs://($e.project)-blueprints/($id).zip" let vars = $"($out).tfvars" write-inputs $e $refs $vars gc storage cp --quiet $"($e.root)/../../.build/($e.name).zip" $obj let result = (try { (gc infra-manager previews create $preview --deployment $e.deployment --gcs-source $obj --service-account $e.im_sa --inputs-file $vars --tf-version-constraint $e.tf_version --quiet) gc infra-manager previews export $preview --file $out { ok: true } } catch {|err| { ok: false, msg: (root-msg $err) } }) do -i { ^gcloud infra-manager previews delete $preview --quiet | complete } do -i { ^gcloud storage rm --quiet $obj | complete } rm -f $vars if not $result.ok { error make --unspanned { msg: $result.msg } }
let all = (changes $control control | append (changes (open $"($out).json" | get resource_changes) blueprint) | append (changes $nodes nodes)) print $"== full plans: ($out)-control.tfplan, ($out).json(if $nodes_root == null { '' } else { $', ($out)-nodes.tfplan' })" if ($all | is-empty) { print "== no changes" } else { $all }}
# the nodes plan --ref and --only KIND would apply, with the lock put back afterdef preview-nodes [e: record, targets: list<string>] { let root = (nodes-root $e) if $root == null { error make { msg: $"($e.name) has no nodes root" } } let out = $"/tmp/preview-(random chars --length 8 | str downcase)-nodes.tfplan" let all = (changes (plan-nodes $root $out --targets $targets) nodes) print $"== full plan: ($out)" if ($all | is-empty) { print "== no changes" } else { $all }}
def plan-nodes [root: string, out: string, --targets: list<string> = []] { let chdir = $"-chdir=($root)" let planned = (^terraform $chdir plan -input=false -lock=false -no-color ...$targets $"-out=($out)" | complete) if $planned.exit_code != 0 { error make { msg: $"nodes plan failed: ($planned.stdout | lines | last 30 | str join (char nl))" } } ^terraform $chdir show -json $out | from json | get -o resource_changes | default []}
def changes [rcs: list, layer: string] { $rcs | where {|c| $c.change.actions != ["no-op"] } | each {|c| { layer: $layer, resource: $c.address, action: ($c.change.actions | str join "/") } }}
# builds the services' images from the source, pins them and deploys the workers, and says which# commit it builtdef deploy-images [ e: record, dep: record, fresh: bool, ref: string, path: string, services: list<string>, rebuild: bool, build_on: string, only_workers: list<string>,] { if "workers" in $services { check-worker-secrets $e $only_workers } let running = (running-refs $dep) let at = $e.urls if $fresh { ensure-registry $e } let src = (fetch-source $e $ref $path) print $"== building (if ($ref | is-empty) { $'the working tree of ($path)' } else { $'($ref) = ($src.sha)' }) for ($e.name)" let built = (try { let work = (src-tree $e $src.tgz) let tags = (build-tags $e $src) let wanted = ($services | where {|s| $s in $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) for r in $reused { print $"== ($r.svc) is unchanged, reusing ($r.digest)" retag $e $r.svc $r.digest $tags } let todo = ($wanted | where digest == null) let fresh = if ($todo | is-empty) { {} } 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 bundles = if "workers" in $services { build-workers $e $work $only_workers } else { {} } { ok: true, digests: ($reused | each {|r| [$r.svc $r.digest] } | into record | merge $fresh), bundles: $bundles } } catch {|err| { ok: false, msg: (root-msg $err) } }) cleanup $e if not $built.ok { error make --unspanned { msg: $built.msg } } let refs = ($running | merge ($built.digests | items {|svc, d| [$svc $"@($d)"] } | into record)) for r in ($refs | transpose svc ref) { print $"== ($r.svc)-($e.suffix)($r.ref)" } if $fresh { bootstrap $e $refs } else { apply-control $e $refs --yes }
let after = (gc infra-manager deployments describe $e.deployment --format json | from json) print $"== deployment: ($after.state) ($after.latestRevision | path basename)" if $after.state != "ACTIVE" { error make { msg: $"($e.deployment | path basename) is ($after.state), not ACTIVE: ($after.stateDetail?)" } } print $"== bobbin: ($at.bobbin)/xrpc/sh.tangled.bobbin.getCoverage" print $"== svfe: ($at.svfe)/" apply-workers $e $built.bundles $only_workers $src.sha}
# ----
# the infra repo this runs indef infra-root [] { let top = (^git rev-parse --show-toplevel | 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}
# ---- commands
# every command starts with the env it was asked for, and the log its output goes todef resolve-infra [target: string, infra: string] { if ($target | is-empty) { error make { msg: "pass --target <env>, like dev or prod" } } 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: deploy.nu --target ($e.name) --ref <ref>, without a subcommand" } }}
def check-source [ref: string, path: string] { if ($ref | is-not-empty) and ($path | is-not-empty) { error make { msg: "pass --ref or --path, not both" } }}
def keep-images [e: record, dep: record] { let running = (running-refs $dep) print $"== keeping (if ($running | is-empty) { $':($e.name)' } else { $running | values | str join ', ' })" apply-control $e $running}
# control and then the nodes. --ref or --path builds the images and workers first, and without# them the images stay. the nodes always come from flake.lockdef main [ --target: string = "" # an env with a control root, like dev or prod --ref: string = "" # a branch, tag or commit of tangled.org/core to build the images and workers from --path: string = "" # or a local checkout to build as it is --rebuild # with --ref/--path, build even when an image of the same source exists --build-on: string = "" # ssh host with podman to build on; empty builds locally --preview # plan the tree against the live env, apply nothing --infra: string = "" # an infra repo to use (default: the one you run from)] { check-source $ref $path let building = ($ref | is-not-empty) or ($path | is-not-empty) if $rebuild and not $building { error make { msg: "--rebuild needs --ref or --path" } } let infra = (resolve-infra $target $infra) open-log $infra $"($target) ($ref) ($path)" let e = (env-of $target $infra) let fresh = ($e.fresh? | default false) if $fresh and ($preview or not $building) { error make { msg: $"($target) was never applied, so its first deploy builds everything, with --ref or --path and not --preview" } } let dep = if $fresh { {} } else { idle-deployment $e } if $preview { return (preview $e $dep) } if $building { deploy-images $e $dep $fresh $ref $path $DEPLOYABLES $rebuild $build_on [] | ignore } else { keep-images $e $dep } apply-nodes $e}
# the blueprint again with the images it runs, for a change to the env's configdef "main control" [ --target: string = "" --preview # plan the tree against the live env, apply nothing --infra: string = ""] { let infra = (resolve-infra $target $infra) open-log $infra $"($target) control" let e = (env-of $target $infra) check-applied $e let dep = (idle-deployment $e) if $preview { return (preview $e $dep --no-nodes) } keep-images $e $dep}
# builds bobbin and svfe from a core commit and pins them. the whole control root applies with# them, so config changes in the tree go out toodef "main images" [ --target: string = "" --ref: string = "" # a branch, tag or commit of tangled.org/core to build --path: string = "" # or a local checkout to build as it is --only: string = "" # bobbin or svfe alone, the other keeps its image --rebuild # build even when an image of the same source exists --build-on: string = "" # ssh host with podman to build on; empty builds locally --preview # plan the tree against the live env, apply nothing --infra: string = ""] { check-source $ref $path if ($ref | is-empty) and ($path | is-empty) { error make { msg: "pass --ref or --path, the images are built from it" } } let services = if ($only | is-empty) { $SERVICES } else { $only | split row "," | str trim } let unknown = ($services | where {|s| $s not-in $SERVICES }) if ($unknown | is-not-empty) { error make { msg: $"no service ($unknown | str join ', '), have: ($SERVICES | str join ', ')" } } let infra = (resolve-infra $target $infra) open-log $infra $"($target) images ($ref) ($path) ($only)" let e = (env-of $target $infra) check-applied $e let dep = (idle-deployment $e) if $preview { return (preview $e $dep --no-nodes) } deploy-images $e $dep false $ref $path $services $rebuild $build_on [] | ignore}
# builds the env's cloudflare workers from a core commit and applies themdef "main workers" [ --target: string = "" --ref: string = "" # a branch, tag or commit of tangled.org/core to build --path: string = "" # or a local checkout to build as it is --only: string = "" # these workers alone (comma-separated), the rest stay as they are --infra: string = ""] { check-source $ref $path if ($ref | is-empty) and ($path | is-empty) { error make { msg: "pass --ref or --path, the workers are built from it" } } let names = if ($only | is-empty) { [] } else { $only | split row "," | str trim } let infra = (resolve-infra $target $infra) open-log $infra $"($target) workers ($ref) ($path) ($only)" let e = (env-of $target $infra) check-applied $e let no_such = ($names | where {|w| $w not-in ((tree-env $e).workers? | default {} | columns) }) if ($no_such | is-not-empty) { error make { msg: $"($target) has no worker ($no_such | str join ', ')" } } deploy-images $e (idle-deployment $e) false $ref $path [workers] false "" $names | ignore}
# the env's nodes, built from flake.lock as it is. to move a node to another core commit, update# the lock first, `nix flake update tangled` for example, and commit itdef "main nodes" [ --target: string = "" --only: string = "" # these kinds alone (comma-separated), like knot or deliberi --preview # plan them against the live env, apply nothing --infra: string = ""] { let kinds = if ($only | is-empty) { [] } else { $only | split row "," | str trim } let infra = (resolve-infra $target $infra) open-log $infra $"($target) nodes ($only)" let e = (env-of $target $infra) check-applied $e let targets = if ($kinds | is-empty) { [] } else { node-targets $e $kinds } if $preview { return (preview-nodes $e $targets) } apply-nodes $e --targets $targets --kinds $kinds}
# pins an earlier build of both imagesdef "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 = ""] { let infra = (resolve-infra $target $infra) open-log $infra $"($target) rollback ($to)" let e = (env-of $target $infra) check-applied $e let all = (candidates $e) if $list { return ($all | select build pushed running) } idle-deployment $e | ignore 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" }}