#!/usr/bin/env nu # Tests for workspace/state.nu — state read/write/transition/decision functions. # Each test creates an isolated temp workspace and cleans up on exit. use std assert use ../workspace/state.nu * # ─── Helpers ───────────────────────────────────────────────────────────────── def mk-tmp-workspace []: nothing -> string { let p = ($"/tmp/prov_state_test_(random chars --length 8)") mkdir $p $p } def with-tmp [body: closure]: nothing -> nothing { let ws = (mk-tmp-workspace) do $body $ws rm -rf $ws } # ─── state-read ────────────────────────────────────────────────────────────── export def test_state_read_missing_file_returns_default [] { with-tmp {|ws| let st = (state-read $ws) assert ($st.servers | is-empty) assert equal $st.schema_version "2.0" } print "✓ state-read: missing file returns all-pending default" } # ─── state-write / roundtrip ───────────────────────────────────────────────── export def test_state_write_read_roundtrip [] { with-tmp {|ws| let initial = { workspace: "test", cluster: "sgoyol", schema_version: "2.0", servers: { "sgoyol-0": { provider_id: "99", provider_state: "running", last_sync: "2026-04-11T10:00:00Z", taskservs: {}, } } } state-write $ws $initial let back = (state-read $ws) assert equal $back.cluster "sgoyol" assert equal ($back.servers."sgoyol-0".provider_id) "99" assert equal ($back.servers."sgoyol-0".provider_state) "running" } print "✓ state-write/read: roundtrip preserves all fields" } export def test_state_write_is_atomic [] { with-tmp {|ws| let st = { workspace: "test", cluster: "c", schema_version: "2.0", servers: {} } state-write $ws $st # tmp file must not remain after write assert not (($ws | path join ".provisioning-state.ncl.tmp") | path exists) assert (state-path $ws | path exists) } print "✓ state-write: no .tmp file left after atomic write" } # ─── state-node-get ────────────────────────────────────────────────────────── export def test_state_node_get_unknown_returns_pending [] { with-tmp {|ws| let node = (state-node-get $ws "sgoyol-0" "etcd") assert equal $node.state "pending" assert equal $node.blocker "" } print "✓ state-node-get: unknown node returns pending default" } # ─── state-node-start ──────────────────────────────────────────────────────── export def test_state_node_start_transitions_to_running [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "jesus" --source "cli" --operation "create" let node = (state-node-get $ws "sgoyol-0" "etcd") assert equal $node.state "running" assert equal $node.actor.identity "jesus" assert equal $node.actor.source "cli" assert ($node.started_at | is-not-empty) assert equal ($node.log | length) 1 assert equal ($node.log | first | get event) "started" } print "✓ state-node-start: pending → running with actor + log entry" } # ─── state-node-finish ─────────────────────────────────────────────────────── export def test_state_node_finish_success [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "system" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success let node = (state-node-get $ws "sgoyol-0" "etcd") assert equal $node.state "completed" assert ($node.ended_at | is-not-empty) assert equal ($node.log | length) 2 assert equal ($node.log | last | get event) "completed" } print "✓ state-node-finish: running → completed with ended_at + log entry" } export def test_state_node_finish_failure [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "containerd" --actor "system" --source "orchestrator" state-node-finish $ws "sgoyol-0" "containerd" let node = (state-node-get $ws "sgoyol-0" "containerd") assert equal $node.state "failed" assert equal ($node.log | last | get event) "failed" } print "✓ state-node-finish: running → failed with log entry" } # ─── state-node-block ──────────────────────────────────────────────────────── export def test_state_node_block [] { with-tmp {|ws| state-node-block $ws "sgoyol-0" "kubernetes" "containerd" let node = (state-node-get $ws "sgoyol-0" "kubernetes") assert equal $node.state "blocked" assert equal $node.blocker "containerd" assert equal ($node.log | last | get event) "blocked-by:containerd" } print "✓ state-node-block: → blocked with blocker field + log entry" } # ─── state-node-reset ──────────────────────────────────────────────────────── export def test_state_node_reset [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "cilium" --actor "jesus" --source "cli" state-node-finish $ws "sgoyol-0" "cilium" --success state-node-reset $ws "sgoyol-0" "cilium" --source "cli" --actor "jesus" let node = (state-node-get $ws "sgoyol-0" "cilium") assert equal $node.state "pending" assert equal $node.blocker "" assert equal $node.started_at "" assert equal $node.ended_at "" assert equal ($node.log | last | get event) "reset" } print "✓ state-node-reset: completed → pending, clears timestamps + blocker" } # ─── state-node-decision ───────────────────────────────────────────────────── export def test_state_node_decision_completed_is_skip [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success assert equal (state-node-decision $ws "sgoyol-0" "etcd") "skip" } print "✓ state-node-decision: completed → skip" } export def test_state_node_decision_failed_is_rerun [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" assert equal (state-node-decision $ws "sgoyol-0" "etcd") "rerun" } print "✓ state-node-decision: failed → rerun" } export def test_state_node_decision_pending_is_run [] { with-tmp {|ws| assert equal (state-node-decision $ws "sgoyol-0" "etcd") "run" } print "✓ state-node-decision: pending → run" } export def test_state_node_decision_blocked_is_blocked [] { with-tmp {|ws| state-node-block $ws "sgoyol-0" "kubernetes" "containerd" assert equal (state-node-decision $ws "sgoyol-0" "kubernetes") "blocked" } print "✓ state-node-decision: blocked → blocked" } # ─── state-dag-check-deps ──────────────────────────────────────────────────── export def test_dag_check_deps_empty_is_ready [] { with-tmp {|ws| let r = (state-dag-check-deps $ws "sgoyol-0" []) assert $r.ready assert equal $r.blocker "" } print "✓ state-dag-check-deps: empty deps → ready" } export def test_dag_check_deps_all_completed_is_ready [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success state-node-start $ws "sgoyol-0" "containerd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "containerd" --success let r = (state-dag-check-deps $ws "sgoyol-0" ["etcd" "containerd"]) assert $r.ready assert equal $r.blocker "" } print "✓ state-dag-check-deps: all completed → ready" } export def test_dag_check_deps_failed_dep_blocks [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "containerd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "containerd" let r = (state-dag-check-deps $ws "sgoyol-0" ["containerd"]) assert not $r.ready assert equal $r.blocker "containerd" } print "✓ state-dag-check-deps: failed dep → not ready, returns blocker" } export def test_dag_check_deps_pending_dep_blocks [] { with-tmp {|ws| # etcd never started → pending let r = (state-dag-check-deps $ws "sgoyol-0" ["etcd"]) assert not $r.ready assert equal $r.blocker "etcd" } print "✓ state-dag-check-deps: pending dep → not ready, returns blocker" } # ─── state-node-decision-with-deps ─────────────────────────────────────────── export def test_decision_with_deps_skips_when_completed [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "cilium" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "cilium" --success # Even with pending deps, skip wins — already done let d = (state-node-decision-with-deps $ws "sgoyol-0" "cilium" ["kubernetes"]) assert equal $d "skip" } print "✓ state-node-decision-with-deps: own completed → skip regardless of deps" } export def test_decision_with_deps_blocked_by_failed_dep [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "containerd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "containerd" let d = (state-node-decision-with-deps $ws "sgoyol-0" "kubernetes" ["etcd" "containerd"]) assert ($d | str starts-with "blocked:") assert ($d | str contains "containerd") # Blocked state must be written to file let node = (state-node-get $ws "sgoyol-0" "kubernetes") assert equal $node.state "blocked" assert equal $node.blocker "containerd" } print "✓ state-node-decision-with-deps: failed dep → blocked, state written to file" } export def test_decision_with_deps_runs_when_all_deps_completed [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success state-node-start $ws "sgoyol-0" "containerd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "containerd" --success let d = (state-node-decision-with-deps $ws "sgoyol-0" "kubernetes" ["etcd" "containerd"]) assert equal $d "run" } print "✓ state-node-decision-with-deps: all deps completed → run" } # ─── log rolling ───────────────────────────────────────────────────────────── export def test_log_rolling_keeps_last_50 [] { with-tmp {|ws| # Write 60 start/finish cycles — log should cap at 50 for i in 1..60 { state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success state-node-reset $ws "sgoyol-0" "etcd" } let node = (state-node-get $ws "sgoyol-0" "etcd") assert ($node.log | length) <= 50 } print "✓ log rolling: capped at 50 entries after 60 cycles" } # ─── state-migrate-from-json ───────────────────────────────────────────────── export def test_state_migrate_from_json [] { with-tmp {|ws| # Write a minimal .provisioning-state.json let json_content = { cluster: "librecloud", timestamp: "2026-02-15 22:05:42", version: "1.0.4", state: { servers: { "sgoyol-0": "12345678" } } } $json_content | to json | save ($ws | path join ".provisioning-state.json") state-migrate-from-json $ws assert (state-path $ws | path exists) let st = (state-read $ws) assert equal $st.cluster "librecloud" assert ($st.servers | columns | any {|c| $c == "sgoyol-0"}) # Migrated servers must start as unknown, not completed assert equal ($st.servers."sgoyol-0".provider_state) "unknown" } print "✓ state-migrate-from-json: JSON → NCL, servers set to unknown" } export def test_state_migrate_errors_if_ncl_exists [] { with-tmp {|ws| let json_content = { cluster: "c", timestamp: "", version: "1.0", state: { servers: {} } } $json_content | to json | save ($ws | path join ".provisioning-state.json") # Pre-create NCL file — migration must error "existing" | save (state-path $ws) let result = (do { state-migrate-from-json $ws } | complete) assert ($result.exit_code != 0) } print "✓ state-migrate-from-json: errors if .ncl already exists" } # ─── state-server-sync ─────────────────────────────────────────────────────── export def test_state_server_sync_updates_provider_state [] { with-tmp {|ws| state-server-sync $ws "sgoyol-0" --provider-id "99" --provider-state "running" let st = (state-read $ws) assert equal ($st.servers."sgoyol-0".provider_id) "99" assert equal ($st.servers."sgoyol-0".provider_state) "running" assert ($st.servers."sgoyol-0".last_sync | is-not-empty) } print "✓ state-server-sync: updates provider_id, provider_state, last_sync" } export def test_state_server_sync_preserves_existing_taskservs [] { with-tmp {|ws| state-node-start $ws "sgoyol-0" "etcd" --actor "s" --source "orchestrator" state-node-finish $ws "sgoyol-0" "etcd" --success state-server-sync $ws "sgoyol-0" --provider-state "running" # etcd must still be completed after sync let node = (state-node-get $ws "sgoyol-0" "etcd") assert equal $node.state "completed" } print "✓ state-server-sync: does not overwrite existing taskserv states" }