provisioning-core/nulib/platform/vm/vm_persistence.nu

444 lines
12 KiB
Text

# VM Persistence Management (Phase 2)
#
# Manages permanent and temporary VMs with lifecycle tracking.
# Rule 1: Single purpose, Rule 4: Pure functions, Rule 5: Atomic operations
# Error handling: Result pattern (hybrid, do/complete for bash operations)
# Selective imports (ADR-025 Phase 3 Layer 2).
# vm/persistence.nu star-import was dead — dropped.
use primitives/result.nu [err is-err json-read json-write map match-result]
use platform/vm/lifecycle.nu [vm-delete]
export def "register-permanent-vm" [
vm_config: record # VM configuration
]: record {
"""
Register a VM as permanent (persists across reboots).
Permanent VMs are automatically started on host reboot.
"""
if not ($vm_config.permanent // false) {
return {
success: false
error: "VM must have permanent=true"
}
}
# Create persistence record
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
let persistence_info = {
vm_name: $vm_config.name
mode: "permanent"
created_at: $now
auto_start: true
restart_policy: "always"
start_order: 100
}
# Save persistence data using json-write helper (no inline try-catch)
let persist_file = (get-persistence-file $vm_config.name)
let write_result = (json-write $persist_file $persistence_info)
# Guard: Check write result
if (is-err $write_result) {
return {success: false, error: $write_result.err}
}
{success: true, vm_name: $vm_config.name, message: "Registered as permanent VM"}
}
export def "register-temporary-vm" [
vm_config: record # VM configuration
ttl_hours: int = 24 # Time to live
]: record {
"""
Register a VM as temporary (auto-cleanup after TTL).
Temporary VMs are automatically deleted after specified hours.
"""
if not ($vm_config.temporary // false) {
return {
success: false
error: "VM must have temporary=true"
}
}
# Calculate cleanup time
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
let cleanup_time = (
$now
+ (($ttl_hours * 60 * 60) * 1_000_000_000) # Convert to nanoseconds
)
let persistence_info = {
vm_name: $vm_config.name
mode: "temporary"
created_at: $now
ttl_hours: $ttl_hours
scheduled_cleanup_at: $cleanup_time
auto_cleanup: true
cleanup_status: "pending"
}
let persist_file = (get-persistence-file $vm_config.name)
let write_result = (json-write $persist_file $persistence_info)
# Guard: Check write result
if (is-err $write_result) {
return {success: false, error: $write_result.err}
}
{
success: true
vm_name: $vm_config.name
ttl_hours: $ttl_hours
cleanup_scheduled_at: $cleanup_time
message: $"Registered as temporary VM (cleanup in ($ttl_hours) hours)"
}
}
export def "get-vm-persistence-info" [
vm_name: string # VM name
]: record {
"""Get persistence information for a VM"""
let persist_file = (get-persistence-file $vm_name)
# Guard: File exists check
if not ($persist_file | path exists) {
return {vm_name: $vm_name, mode: "unknown", error: "No persistence info found"}
}
# Read using json-read helper (no inline try-catch)
(json-read $persist_file)
| match-result
{|data| $data} # On success, return data
{|_err| {vm_name: $vm_name, mode: "unknown", error: "No persistence info found"}} # On error, return default
}
export def "list-permanent-vms" []: table {
"""List all permanent VMs"""
let persist_dir = (get-persistence-dir)
if not ($persist_dir | path exists) {
return []
}
# Use do/complete for bash command (no try-catch)
let ls_result = (do {
bash -c $"ls -1 ($persist_dir)/*.json 2>/dev/null"
} | complete)
if $ls_result.exit_code != 0 {
return []
}
$ls_result.stdout
| lines
| where {|f| ($f | length) > 0}
| map {|f|
# Guard: Check if file can be opened and parsed as JSON
let json_result = (do { open $f | from json } | complete)
if $json_result.exit_code == 0 {
let data = ($json_result.stdout)
if ($data.mode // "unknown") == "permanent" {
$data
} else {
null
}
} else {
null
}
}
| compact
}
export def "list-temporary-vms" []: table {
"""List all temporary VMs"""
let persist_dir = (get-persistence-dir)
if not ($persist_dir | path exists) {
return []
}
# Use do/complete for bash command (no try-catch)
let ls_result = (do {
bash -c $"ls -1 ($persist_dir)/*.json 2>/dev/null"
} | complete)
if $ls_result.exit_code != 0 {
return []
}
$ls_result.stdout
| lines
| where {|f| ($f | length) > 0}
| map {|f|
# Guard: Check if file can be opened and parsed as JSON
let json_result = (do { open $f | from json } | complete)
if $json_result.exit_code == 0 {
let data = ($json_result.stdout)
if ($data.mode // "unknown") == "temporary" {
$data
} else {
null
}
} else {
null
}
}
| compact
}
export def "find-expired-vms" []: table {
"""Find temporary VMs that have expired (TTL exceeded)"""
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
let temp_vms = (list-temporary-vms)
$temp_vms
| filter {|vm|
($vm.scheduled_cleanup_at // 0) <= $now
}
}
export def "cleanup-expired-vms" [
--dry-run = false # Dry-run mode
--force = false # Force cleanup
]: record {
"""Clean up expired temporary VMs"""
let expired = (find-expired-vms)
if ($expired | length) == 0 {
return {
success: true
cleaned_up: 0
message: "No VMs to clean up"
}
}
let results = (
$expired
| map {|vm|
let delete_result = (
if $dry_run {
{
vm_name: $vm.vm_name
success: true
message: "[DRY-RUN] Would delete VM"
}
} else {
vm-delete $vm.vm_name --force=$force
}
)
# Update cleanup status
if $delete_result.success {
update-cleanup-status $vm.vm_name "completed" | ignore
} else {
update-cleanup-status $vm.vm_name "failed" $delete_result.error | ignore
}
$delete_result
}
)
let successful = ($results | where success == true | length)
{
success: true
total_expired: ($expired | length)
cleaned_up: $successful
failed: (($results | length) - $successful)
details: $results
}
}
export def "get-vm-uptime" [
vm_name: string # VM name
]: record {
"""Get VM uptime since creation"""
let persist_info = (get-vm-persistence-info $vm_name)
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
if ("created_at" in $persist_info) {
let uptime_seconds = ($now - $persist_info.created_at)
let uptime_hours = ($uptime_seconds / 3600)
let uptime_days = ($uptime_hours / 24)
{
vm_name: $vm_name
created_at: $persist_info.created_at
uptime_seconds: $uptime_seconds
uptime_hours: $uptime_hours
uptime_days: $uptime_days
formatted: $"($uptime_days | floor)d ($uptime_hours | floor)h ($uptime_seconds % 3600 | floor)s"
}
} else {
{
vm_name: $vm_name
error: "Cannot determine creation time"
}
}
}
export def "get-vm-time-to-cleanup" [
vm_name: string # VM name
]: record {
"""Get time remaining until cleanup for temporary VM"""
let persist_info = (get-vm-persistence-info $vm_name)
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
if ($persist_info.mode // "") != "temporary" {
return {
vm_name: $vm_name
error: "VM is not temporary"
}
}
if not ("scheduled_cleanup_at" in $persist_info) {
return {
vm_name: $vm_name
error: "Cleanup not scheduled"
}
}
let cleanup_time = $persist_info.scheduled_cleanup_at
let time_remaining = ($cleanup_time - $now)
if $time_remaining <= 0 {
return {
vm_name: $vm_name
status: "expired"
time_remaining_seconds: 0
}
}
let hours = ($time_remaining / 3600_000_000_000)
let minutes = (($time_remaining / 60_000_000_000) % 60)
{
vm_name: $vm_name
status: "active"
time_remaining_seconds: ($time_remaining / 1_000_000_000)
time_remaining_formatted: $"($hours | floor)h ($minutes | floor)m"
scheduled_cleanup_at: $cleanup_time
}
}
export def "extend-vm-ttl" [
vm_name: string # VM name
additional_hours: int # Hours to add
]: record {
"""Extend TTL for a temporary VM"""
let persist_info = (get-vm-persistence-info $vm_name)
if ($persist_info.mode // "") != "temporary" {
return {
success: false
error: "VM is not temporary"
}
}
let current_cleanup_time = ($persist_info.scheduled_cleanup_at // 0)
let new_cleanup_time = (
$current_cleanup_time
+ (($additional_hours * 60 * 60) * 1_000_000_000)
)
let updated_info = (
$persist_info
| upsert scheduled_cleanup_at $new_cleanup_time
)
let persist_file = (get-persistence-file $vm_name)
# Use do/complete for bash command (no try-catch)
let write_result = (do {
bash -c $"cat > ($persist_file) << 'EOF'\n($updated_info | to json)\nEOF"
} | complete)
if $write_result.exit_code != 0 {
return {
success: false
error: $write_result.stderr
}
}
{
success: true
vm_name: $vm_name
additional_hours: $additional_hours
new_cleanup_time: $new_cleanup_time
message: $"Extended TTL by ($additional_hours) hours"
}
}
def get-persistence-dir []: string {
"""Get persistence directory"""
$"{{paths.workspace}}/vms/persistence"
}
def get-persistence-file [vm_name: string]: string {
"""Get persistence file for VM"""
$"(get-persistence-dir)/($vm_name).json"
}
def update-cleanup-status [
vm_name: string
status: string
error?: string
]: record {
"""Update cleanup status in persistence file"""
let persist_info = (get-vm-persistence-info $vm_name)
let now = (now | format date "%Y-%m-%dT%H:%M:%SZ")
let updated = (
$persist_info
| upsert cleanup_status $status
| upsert last_cleanup_attempt $now
| if ($error != null) {
upsert cleanup_error $error
} else {
.
}
)
let persist_file = (get-persistence-file $vm_name)
# Use do/complete for bash command (no try-catch)
let write_result = (do {
bash -c $"cat > ($persist_file) << 'EOF'\n($updated | to json)\nEOF"
} | complete)
if $write_result.exit_code != 0 {
return {success: false, error: $write_result.stderr}
}
{success: true}
}
export def "get-vm-persistence-stats" []: record {
"""Get statistics about persistent VMs"""
let permanent = (list-permanent-vms)
let temporary = (list-temporary-vms)
let expired = (find-expired-vms)
{
total_vms: (($permanent | length) + ($temporary | length))
permanent_vms: ($permanent | length)
temporary_vms: ($temporary | length)
expired_vms: ($expired | length)
auto_cleanup_enabled: ($temporary | where auto_cleanup == true | length)
}
}