#!/usr/bin/env nu # Deploy a rendered component bundle to a remote host. # # 1. Read bundle.meta.json (component, namespace, secret_name, credentials_required) # 2. tar.gz the bundle locally # 3. scp to remote:/tmp/ # 4. ssh to extract # 5. For each credential in requires: build Secret YAML in-memory, pipe to # ssh stdin → remote kubectl apply (credentials NEVER touch disk) # 6. ssh to run .sh, capture exit code + log # 7. Cleanup remote /tmp unless --debug # # Credentials are sourced from env vars matching their names. # Extra credentials not declared in the bundle can be injected via # --require-credential NAME (repeatable). # # Usage: # nu deploy.nu --host user@host # nu deploy.nu --host user@host --op update # nu deploy.nu --host user@host --op purge --confirm # nu deploy.nu --host user@host --dry-run # nu deploy.nu --host user@host --require-credential FORGEJO_ADMIN_PASSWORD # ─── bundle metadata ────────────────────────────────────────────────── def read_meta [bundle_dir: path]: nothing -> record { let meta_file = ($bundle_dir | path join "bundle.meta.json") if not ($meta_file | path exists) { error make { msg: $"bundle.meta.json missing: ($meta_file)" } } open $meta_file } def resolve_credentials [ meta: record extra: list file_values: record ]: nothing -> list { let file_keys = ($file_values | columns) $meta.credentials_required ++ $extra ++ $file_keys | uniq } def collect_credential_values [ names: list file_values: record ]: nothing -> record { let file_keys = ($file_values | columns) $names | reduce --fold {} {|name, acc| let v = if $name in $file_keys { $file_values | get $name | into string } else { $env | get -o $name | default "" } if ($v | str trim | is-empty) { error make { msg: $"credential ($name) not found in --credentials-file nor in env var" } } $acc | insert $name $v } } # ─── SOPS decryption ────────────────────────────────────────────────── def decrypt_sops_file [path: path]: nothing -> record { if not ($path | path exists) { error make { msg: $"credentials file not found: ($path)" } } let r = (do { ^sops --decrypt $path } | complete) if $r.exit_code != 0 { error make { msg: $"sops decrypt failed for ($path):\n($r.stderr)" } } let ext = ($path | path parse | get extension | str downcase) match $ext { "yaml" | "yml" => ($r.stdout | from yaml) "json" => ($r.stdout | from json) "env" => (parse_env_file $r.stdout) _ => { error make { msg: $"unsupported credentials file extension: .($ext) — use .yaml|.yml|.json|.env" } } } } def parse_env_file [content: string]: nothing -> record { $content | lines | where {|l| (not ($l | str starts-with "#")) and ($l | str contains "=") } | reduce --fold {} {|l, acc| let parts = ($l | split row --number 2 "=") $acc | insert ($parts.0 | str trim) ($parts.1 | str trim | str trim --char '"' | str trim --char "'") } } # ─── secret construction ────────────────────────────────────────────── def build_secret_yaml [ secret_name: string namespace: string kv: record ]: nothing -> string { let ns_doc = ({ apiVersion: "v1" kind: "Namespace" metadata: { name: $namespace labels: { "app.kubernetes.io/managed-by": "provisioning" } } } | to yaml) let secret_doc = ({ apiVersion: "v1" kind: "Secret" metadata: { name: $secret_name namespace: $namespace labels: { "app.kubernetes.io/name": ($secret_name | str replace --regex '-credentials$' "") "app.kubernetes.io/managed-by": "provisioning" } } type: "Opaque" stringData: $kv } | to yaml) $"($ns_doc)---\n($secret_doc)" } # ─── remote operations ──────────────────────────────────────────────── def remote_archive_name [bundle_id: string]: nothing -> string { $"prvng-($bundle_id).tar.gz" } def remote_workdir [bundle_id: string]: nothing -> string { $"/tmp/prvng-($bundle_id)" } def pack_bundle [bundle_dir: path, bundle_id: string]: nothing -> path { let archive = (mktemp --tmpdir $"(remote_archive_name $bundle_id)-XXXX") let parent = ($bundle_dir | path dirname) let name = ($bundle_dir | path basename) let r = (do { ^tar czf $archive -C $parent $name } | complete) if $r.exit_code != 0 { error make { msg: $"tar failed: ($r.stderr)" } } $archive } def ssh_run [host: string, cmd: string, dry_run: bool]: nothing -> record { if $dry_run { print $"(ansi dark_gray) [dry-run] ssh ($host) \"($cmd)\"(ansi reset)" return { exit_code: 0, stdout: "", stderr: "" } } do { ^ssh -o BatchMode=yes $host $cmd } | complete } def ssh_pipe [host: string, cmd: string, stdin: string, dry_run: bool]: nothing -> record { if $dry_run { let preview = ($stdin | lines | length) print $"(ansi dark_gray) [dry-run] ($preview) lines | ssh ($host) \"($cmd)\"(ansi reset)" return { exit_code: 0, stdout: "", stderr: "" } } $stdin | do { ^ssh -o BatchMode=yes $host $cmd } | complete } def scp_put [local: path, host: string, remote_path: string, dry_run: bool]: nothing -> record { if $dry_run { print $"(ansi dark_gray) [dry-run] scp ($local) ($host):($remote_path)(ansi reset)" return { exit_code: 0, stdout: "", stderr: "" } } do { ^scp -o BatchMode=yes $local $"($host):($remote_path)" } | complete } def scp_get [host: string, remote_path: string, local: path, dry_run: bool]: nothing -> record { if $dry_run { print $"(ansi dark_gray) [dry-run] scp ($host):($remote_path) ($local)(ansi reset)" return { exit_code: 0, stdout: "", stderr: "" } } do { ^scp -o BatchMode=yes $"($host):($remote_path)" $local } | complete } # ─── pipeline stages ────────────────────────────────────────────────── def stage_upload [ host: string bundle_dir: path meta: record dry_run: bool ]: nothing -> string { let archive = if $dry_run { "/tmp/stub.tar.gz" } else { (pack_bundle $bundle_dir $meta.bundle_id) } print $"(ansi cyan)[upload](ansi reset) archive=($archive)" let remote_archive = $"/tmp/(remote_archive_name $meta.bundle_id)" let r1 = (scp_put $archive $host $remote_archive $dry_run) if $r1.exit_code != 0 { error make { msg: $"scp failed: ($r1.stderr)" } } let workdir = (remote_workdir $meta.bundle_id) let r2 = (ssh_run $host $"rm -rf ($workdir) && mkdir -p ($workdir) && tar xzf ($remote_archive) -C ($workdir) --strip-components=1" $dry_run) if $r2.exit_code != 0 { error make { msg: $"remote extract failed: ($r2.stderr)" } } if not $dry_run { rm $archive } $workdir } def detect_remote_kubectl [host: string, dry_run: bool]: nothing -> string { if $dry_run { return "kubectl" } let probe = "if command -v kubectl >/dev/null 2>&1; then echo kubectl; elif command -v k0s >/dev/null 2>&1; then echo 'k0s kubectl'; else echo MISSING; fi" let r = (do { ^ssh -o BatchMode=yes $host $probe } | complete) if $r.exit_code != 0 { error make { msg: $"ssh probe failed on ($host): ($r.stderr)" } } let found = ($r.stdout | str trim) if $found == "MISSING" { error make { msg: $"neither kubectl nor k0s found on ($host)" } } $found } def stage_inject_credentials [ host: string meta: record extra_creds: list file_values: record kubectl_cmd: string dry_run: bool ]: nothing -> nothing { let creds = (resolve_credentials $meta $extra_creds $file_values) if ($creds | is-empty) { print $"(ansi yellow)[credentials](ansi reset) none declared — install.sh will fail precheck if secret missing" return } let file_keys = ($file_values | columns) let from_file = ($creds | where {|c| $c in $file_keys } | length) let from_env = (($creds | length) - $from_file) print $"(ansi cyan)[credentials](ansi reset) injecting ($creds | length) into secret ($meta.secret_name) \(from sops: ($from_file), from env: ($from_env)\)" # Validate credential availability even in dry-run; redact values afterwards. let real_kv = (collect_credential_values $creds $file_values) let kv = if $dry_run { $real_kv | transpose k v | reduce --fold {} {|row, acc| $acc | insert $row.k "" } } else { $real_kv } let secret_yaml = (build_secret_yaml $meta.secret_name $meta.namespace $kv) let r = (ssh_pipe $host $"($kubectl_cmd) apply -f -" $secret_yaml $dry_run) if $r.exit_code != 0 { error make { msg: $"credential injection failed:\n($r.stderr)" } } if not $dry_run { print $" ($r.stdout | str trim)" } } def stage_run_op [ host: string workdir: string op: string confirm: bool dry_run: bool ]: nothing -> record { let args = if $op == "purge" and $confirm { "--confirm" } else { "" } let cmd = $"cd ($workdir) && bash ($op).sh ($args)" print $"(ansi cyan)[run](ansi reset) ($op)" let r = (ssh_run $host $cmd $dry_run) if not $dry_run { if ($r.stdout | str trim | is-not-empty) { print $r.stdout } if ($r.stderr | str trim | is-not-empty) { print $"(ansi red)($r.stderr)(ansi reset)" } } $r } def stage_fetch_log [ host: string workdir: string op: string bundle_id: string dry_run: bool ]: nothing -> path { let local_log = $"./logs/($bundle_id)-($op).log" | path expand mkdir ($local_log | path dirname) let r = (scp_get $host $"($workdir)/($op).log" $local_log $dry_run) if $r.exit_code != 0 { print $"(ansi yellow)could not fetch log: ($r.stderr)(ansi reset)" } else if not $dry_run { print $"(ansi cyan)[log](ansi reset) fetched → ($local_log)" } $local_log } def stage_cleanup [host: string, bundle_id: string, debug: bool, dry_run: bool]: nothing -> nothing { if $debug { print $"(ansi yellow)[debug](ansi reset) remote files preserved at (remote_workdir $bundle_id)" return } let workdir = (remote_workdir $bundle_id) let archive = $"/tmp/(remote_archive_name $bundle_id)" let r = (ssh_run $host $"rm -rf ($workdir) ($archive)" $dry_run) if $r.exit_code != 0 { print $"(ansi yellow)cleanup warning: ($r.stderr)(ansi reset)" } } # ─── entry ──────────────────────────────────────────────────────────── def main [ bundle_dir: path --host: string --op: string = "install" --confirm --debug --dry-run --require-credentials: string = "" --credentials-file: path = "" ]: nothing -> nothing { let extra_creds = if ($require_credentials | is-empty) { [] } else { $require_credentials | split row "," | each {|s| $s | str trim } | where {|s| $s | is-not-empty } } let file_values = if ($credentials_file | is-empty) { {} } else { decrypt_sops_file $credentials_file } if not ($bundle_dir | path exists) { error make { msg: $"bundle dir not found: ($bundle_dir)" } } if ($host | is-empty) { error make { msg: "--host required (user@host)" } } if $op not-in ["install", "update", "delete", "purge"] { error make { msg: $"unknown op: ($op). Valid: install|update|delete|purge" } } if $op == "purge" and not $confirm { error make { msg: "purge requires --confirm" } } let meta = (read_meta $bundle_dir) print $"(ansi green_bold)deploy(ansi reset) component=($meta.component) bundle=($meta.bundle_id) host=($host) op=($op)" if $dry_run { print $"(ansi yellow)DRY-RUN(ansi reset) no remote state will be modified" } let workdir = (stage_upload $host $bundle_dir $meta $dry_run) let kubectl_cmd = (detect_remote_kubectl $host $dry_run) if $kubectl_cmd != "kubectl" { print $"(ansi cyan)[kubectl](ansi reset) remote uses: ($kubectl_cmd)" } if $op == "install" { stage_inject_credentials $host $meta $extra_creds $file_values $kubectl_cmd $dry_run } let run_result = (stage_run_op $host $workdir $op $confirm $dry_run) stage_fetch_log $host $workdir $op $meta.bundle_id $dry_run stage_cleanup $host $meta.bundle_id $debug $dry_run if $run_result.exit_code != 0 { error make { msg: $"op ($op) failed on ($host) with exit ($run_result.exit_code)" } } print $"(ansi green_bold)OK(ansi reset) ($op) completed on ($host)" }