354 lines
13 KiB
Text
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)"
|
|
}
|