445 lines
12 KiB
Text
445 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)
|
||
|
|
}
|
||
|
|
}
|