provisioning-catalog/components/_renderer/deploy.nu

354 lines
13 KiB
Text

#!/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 <op>.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 <bundle-dir> --host user@host
# nu deploy.nu <bundle-dir> --host user@host --op update
# nu deploy.nu <bundle-dir> --host user@host --op purge --confirm
# nu deploy.nu <bundle-dir> --host user@host --dry-run
# nu deploy.nu <bundle-dir> --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<string>
file_values: record
]: nothing -> list<string> {
let file_keys = ($file_values | columns)
$meta.credentials_required ++ $extra ++ $file_keys | uniq
}
def collect_credential_values [
names: list<string>
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<string>
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 "<redacted>" }
} 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)"
}