425 lines
14 KiB
Text
425 lines
14 KiB
Text
|
|
# Volume Management System (Phase 4)
|
||
|
|
#
|
||
|
|
# Manages storage volumes for VMs and containers.
|
||
|
|
# Rule 1: Single purpose, Rule 5: Atomic operations
|
||
|
|
|
||
|
|
export def "volume-create" [
|
||
|
|
name: string # Volume name
|
||
|
|
size_gb: int # Size in GB
|
||
|
|
--type: string = "local" # Volume type
|
||
|
|
--mount-path: string = "" # Mount path
|
||
|
|
--readonly = false # Read-only
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Create a storage volume for VM or container.
|
||
|
|
|
||
|
|
Supports local, NFS, CIFS, and cloud storage types.
|
||
|
|
"""
|
||
|
|
|
||
|
|
if ($name | is-empty) {
|
||
|
|
return {success: false, error: "Volume name required"}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($size_gb < 1 or $size_gb > 1000) {
|
||
|
|
return {success: false, error: "Size must be 1-1000 GB"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
bash -c $"mkdir -p ($volume_dir)" | complete
|
||
|
|
|
||
|
|
let volume_meta = {
|
||
|
|
name: $name
|
||
|
|
type: $type
|
||
|
|
size_gb: $size_gb
|
||
|
|
mount_path: $mount_path
|
||
|
|
readonly: $readonly
|
||
|
|
created_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
status: "created"
|
||
|
|
path: $"($volume_dir)/($name).img"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create backing file (no try-catch)
|
||
|
|
let create_result = (do { bash -c $"qemu-img create -f qcow2 ($volume_meta.path) ($size_gb)G" } | complete)
|
||
|
|
if $create_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create volume: ($create_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Save metadata (no try-catch)
|
||
|
|
let mkdir_result = (do { bash -c $"mkdir -p ($volume_dir)/meta" } | complete)
|
||
|
|
if $mkdir_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create metadata directory: ($mkdir_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let save_result = (do { bash -c $"cat > ($volume_dir)/meta/($name).json << 'EOF'\n($volume_meta | to json)\nEOF" } | complete)
|
||
|
|
if $save_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to save metadata: ($save_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
volume_name: $name
|
||
|
|
volume_path: $volume_meta.path
|
||
|
|
size_gb: $size_gb
|
||
|
|
mount_path: $mount_path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-list" []: table {
|
||
|
|
"""
|
||
|
|
List all available volumes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
|
||
|
|
if (not ($volume_dir | path exists)) {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
bash -c $"ls -1 ($volume_dir)/meta/*.json 2>/dev/null"
|
||
|
|
| lines
|
||
|
|
| each {|file|
|
||
|
|
# Guard: Check if file can be opened and parsed as JSON (no try-catch)
|
||
|
|
let json_result = (do { open $file | from json } | complete)
|
||
|
|
if $json_result.exit_code == 0 {
|
||
|
|
let meta = ($json_result.stdout)
|
||
|
|
{
|
||
|
|
name: $meta.name
|
||
|
|
type: $meta.type
|
||
|
|
size_gb: $meta.size_gb
|
||
|
|
mount_path: $meta.mount_path
|
||
|
|
status: $meta.status
|
||
|
|
created: $meta.created_at
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
| compact
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-info" [
|
||
|
|
name: string # Volume name
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Get detailed volume information.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let meta_file = $"($volume_dir)/meta/($name).json"
|
||
|
|
|
||
|
|
if (not ($meta_file | path exists)) {
|
||
|
|
return {success: false, error: "Volume not found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load metadata (no try-catch)
|
||
|
|
let meta_result = (do { open $meta_file | from json } | complete)
|
||
|
|
if $meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load volume metadata: ($meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta = ($meta_result.stdout)
|
||
|
|
let usage = (
|
||
|
|
bash -c $"du -h ($meta.path) 2>/dev/null | cut -f1" | str trim
|
||
|
|
)
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
name: $meta.name
|
||
|
|
type: $meta.type
|
||
|
|
size_gb: $meta.size_gb
|
||
|
|
used: $usage
|
||
|
|
mount_path: $meta.mount_path
|
||
|
|
readonly: $meta.readonly
|
||
|
|
created: $meta.created_at
|
||
|
|
status: $meta.status
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-attach" [
|
||
|
|
volume_name: string # Volume name
|
||
|
|
vm_name: string # VM name
|
||
|
|
--mount-path: string = "" # Mount path
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Attach volume to a VM.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let meta_file = $"($volume_dir)/meta/($volume_name).json"
|
||
|
|
|
||
|
|
if (not ($meta_file | path exists)) {
|
||
|
|
return {success: false, error: "Volume not found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load metadata (no try-catch)
|
||
|
|
let meta_result = (do { open $meta_file | from json } | complete)
|
||
|
|
if $meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load volume metadata: ($meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta = ($meta_result.stdout)
|
||
|
|
let mount = (if ($mount_path | is-empty) {$meta.mount_path} else {$mount_path})
|
||
|
|
|
||
|
|
# Record attachment (no try-catch)
|
||
|
|
let attachment = {
|
||
|
|
vm_name: $vm_name
|
||
|
|
attached_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
mount_path: $mount
|
||
|
|
}
|
||
|
|
|
||
|
|
let mkdir_result = (do { bash -c $"mkdir -p ($volume_dir)/attachments" } | complete)
|
||
|
|
if $mkdir_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create attachments directory: ($mkdir_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let append_result = (do { bash -c $"cat >> ($volume_dir)/attachments/($volume_name).txt << 'EOF'\n($vm_name)|($mount)\nEOF" } | complete)
|
||
|
|
if $append_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to record attachment: ($append_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
volume: $volume_name
|
||
|
|
vm: $vm_name
|
||
|
|
mount_path: $mount
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-detach" [
|
||
|
|
volume_name: string # Volume name
|
||
|
|
vm_name: string # VM name
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Detach volume from VM.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let attachments_file = $"($volume_dir)/attachments/($volume_name).txt"
|
||
|
|
|
||
|
|
if (not ($attachments_file | path exists)) {
|
||
|
|
return {success: false, error: "No attachments found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Remove attachment entry (no try-catch)
|
||
|
|
let detach_result = (do { bash -c $"grep -v ($vm_name) ($attachments_file) > ($attachments_file).tmp && mv ($attachments_file).tmp ($attachments_file)" } | complete)
|
||
|
|
if $detach_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to detach volume: ($detach_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
message: $"Volume detached from VM"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-snapshot" [
|
||
|
|
volume_name: string # Volume name
|
||
|
|
snapshot_name: string # Snapshot name
|
||
|
|
--description: string = "" # Description
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Create snapshot of a volume.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let meta_file = $"($volume_dir)/meta/($volume_name).json"
|
||
|
|
|
||
|
|
if (not ($meta_file | path exists)) {
|
||
|
|
return {success: false, error: "Volume not found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load metadata (no try-catch)
|
||
|
|
let meta_result = (do { open $meta_file | from json } | complete)
|
||
|
|
if $meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load volume metadata: ($meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta = ($meta_result.stdout)
|
||
|
|
let snapshot_path = $"($volume_dir)/snapshots/($volume_name)/($snapshot_name).qcow2"
|
||
|
|
|
||
|
|
let mkdir_result = (do { bash -c $"mkdir -p $(dirname ($snapshot_path))" } | complete)
|
||
|
|
if $mkdir_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create snapshot directory: ($mkdir_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create snapshot (no try-catch)
|
||
|
|
let snapshot_result = (do { bash -c $"qemu-img snapshot -c ($snapshot_name) ($meta.path)" } | complete)
|
||
|
|
if $snapshot_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create snapshot: ($snapshot_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let convert_result = (do { bash -c $"qemu-img convert -f qcow2 -O qcow2 -o backing_file=($meta.path) ($snapshot_path)" } | complete)
|
||
|
|
if $convert_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to convert snapshot: ($convert_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Save snapshot metadata (no try-catch)
|
||
|
|
let snapshot_meta = {
|
||
|
|
name: $snapshot_name
|
||
|
|
volume: $volume_name
|
||
|
|
path: $snapshot_path
|
||
|
|
created_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
|
||
|
|
description: $description
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta_mkdir_result = (do { bash -c $"mkdir -p ($volume_dir)/snapshots/($volume_name)" } | complete)
|
||
|
|
if $meta_mkdir_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to create snapshot metadata directory: ($meta_mkdir_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta_save_result = (do { bash -c $"cat > ($volume_dir)/snapshots/($volume_name)/($snapshot_name).json << 'EOF'\n($snapshot_meta | to json)\nEOF" } | complete)
|
||
|
|
if $meta_save_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to save snapshot metadata: ($meta_save_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
snapshot: $snapshot_name
|
||
|
|
volume: $volume_name
|
||
|
|
path: $snapshot_path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-restore" [
|
||
|
|
volume_name: string # Volume name
|
||
|
|
snapshot_name: string # Snapshot name
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Restore volume from snapshot.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let snapshot_dir = $"($volume_dir)/snapshots/($volume_name)"
|
||
|
|
let snapshot_meta_file = $"($snapshot_dir)/($snapshot_name).json"
|
||
|
|
|
||
|
|
if (not ($snapshot_meta_file | path exists)) {
|
||
|
|
return {success: false, error: "Snapshot not found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load snapshot metadata (no try-catch)
|
||
|
|
let snap_result = (do { open $snapshot_meta_file | from json } | complete)
|
||
|
|
if $snap_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load snapshot metadata: ($snap_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let snapshot_meta = ($snap_result.stdout)
|
||
|
|
let meta_file = $"($volume_dir)/meta/($volume_name).json"
|
||
|
|
|
||
|
|
# Load volume metadata (no try-catch)
|
||
|
|
let meta_result = (do { open $meta_file | from json } | complete)
|
||
|
|
if $meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load volume metadata: ($meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta = ($meta_result.stdout)
|
||
|
|
|
||
|
|
# Restore from snapshot (no try-catch)
|
||
|
|
let restore_result = (do { bash -c $"qemu-img snapshot -a ($snapshot_name) ($meta.path)" } | complete)
|
||
|
|
if $restore_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to restore snapshot: ($restore_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
message: $"Volume restored from snapshot"
|
||
|
|
volume: $volume_name
|
||
|
|
snapshot: $snapshot_name
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-delete" [
|
||
|
|
name: string # Volume name
|
||
|
|
--force = false # Force delete if in use
|
||
|
|
]: record {
|
||
|
|
"""
|
||
|
|
Delete a volume.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let meta_file = $"($volume_dir)/meta/($name).json"
|
||
|
|
|
||
|
|
if (not ($meta_file | path exists)) {
|
||
|
|
return {success: false, error: "Volume not found"}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load metadata (no try-catch)
|
||
|
|
let meta_result = (do { open $meta_file | from json } | complete)
|
||
|
|
if $meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to load volume metadata: ($meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let meta = ($meta_result.stdout)
|
||
|
|
|
||
|
|
# Check if in use (no try-catch)
|
||
|
|
let attachments_file = $"($volume_dir)/attachments/($name).txt"
|
||
|
|
if (($attachments_file | path exists) and (not $force)) {
|
||
|
|
let count_result = (do { bash -c $"wc -l < ($attachments_file)" } | complete)
|
||
|
|
if $count_result.exit_code == 0 {
|
||
|
|
let count = ($count_result.stdout | str trim | into int)
|
||
|
|
return {
|
||
|
|
success: false
|
||
|
|
error: $"Volume in use by ($count) VM(s)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Delete files (no try-catch)
|
||
|
|
let rm_img_result = (do { bash -c $"rm -f ($meta.path)" } | complete)
|
||
|
|
if $rm_img_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to delete volume image: ($rm_img_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let rm_meta_result = (do { bash -c $"rm -f ($meta_file)" } | complete)
|
||
|
|
if $rm_meta_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to delete metadata file: ($rm_meta_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let rm_snapshots_result = (do { bash -c $"rm -rf ($volume_dir)/snapshots/($name)" } | complete)
|
||
|
|
if $rm_snapshots_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to delete snapshots: ($rm_snapshots_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
let rm_attachments_result = (do { bash -c $"rm -f ($attachments_file)" } | complete)
|
||
|
|
if $rm_attachments_result.exit_code != 0 {
|
||
|
|
return {success: false, error: $"Failed to delete attachments: ($rm_attachments_result.stderr)"}
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
success: true
|
||
|
|
message: $"Volume deleted"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export def "volume-stats" []: record {
|
||
|
|
"""
|
||
|
|
Get volume system statistics.
|
||
|
|
"""
|
||
|
|
|
||
|
|
let volume_dir = (get-volumes-directory)
|
||
|
|
let volumes = (volume-list)
|
||
|
|
|
||
|
|
let total_size = (
|
||
|
|
$volumes
|
||
|
|
| each {|v| $v.size_gb}
|
||
|
|
| math sum
|
||
|
|
)
|
||
|
|
|
||
|
|
let total_used = (
|
||
|
|
bash -c $"du -sb ($volume_dir) 2>/dev/null | cut -f1"
|
||
|
|
| into int
|
||
|
|
| math round -p 2
|
||
|
|
) / 1024 / 1024 / 1024
|
||
|
|
|
||
|
|
{
|
||
|
|
total_volumes: ($volumes | length)
|
||
|
|
total_size_gb: $total_size
|
||
|
|
total_used_gb: $total_used
|
||
|
|
utilization_percent: (($total_used / $total_size * 100) | math round -p 1)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def get-volumes-directory []: string {
|
||
|
|
"""Get volumes directory path"""
|
||
|
|
"{{paths.workspace}}/vms/volumes"
|
||
|
|
}
|