351 lines
15 KiB
Text
351 lines
15 KiB
Text
#!/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"
|
|
}
|