Merge pull request #91 from trycua/feature/lume/flexible-location

[Lume] Add multiple VM locations and configurable cache
This commit is contained in:
f-trycua
2025-04-14 08:15:48 +02:00
committed by GitHub
26 changed files with 3013 additions and 887 deletions

View File

@@ -53,6 +53,7 @@ Commands:
lume delete <name> Delete a VM
lume pull <image> Pull a macOS image from container registry
lume clone <name> <new-name> Clone an existing VM
lume config Get or set lume configuration
lume images List available macOS images in local cache
lume ipsw Get the latest macOS restore image URL
lume prune Remove cached images
@@ -70,6 +71,7 @@ Command Options:
--disk-size <size> Disk size, e.g., 50GB (default: 40GB)
--display <res> Display resolution (default: 1024x768)
--ipsw <path> Path to IPSW file or 'latest' for macOS VMs
--storage <name> VM storage location to use
run:
--no-display Do not start the VNC client app
@@ -79,19 +81,48 @@ Command Options:
--organization <org> Organization to pull from (default: trycua)
--vnc-port <port> Port to use for the VNC server (default: 0 for auto-assign)
--recovery-mode <boolean> For MacOS VMs only, start VM in recovery mode (default: false)
--storage <name> VM storage location to use
set:
--cpu <cores> New number of CPU cores (e.g., 4)
--memory <size> New memory size (e.g., 8192MB or 8GB)
--disk-size <size> New disk size (e.g., 40960MB or 40GB)
--display <res> New display resolution in format WIDTHxHEIGHT (e.g., 1024x768)
--storage <name> VM storage location to use
delete:
--force Force deletion without confirmation
--storage <name> VM storage location to use
pull:
--registry <url> Container registry URL (default: ghcr.io)
--organization <org> Organization to pull from (default: trycua)
--storage <name> VM storage location to use
get:
-f, --format <format> Output format (json|text)
--storage <name> VM storage location to use
stop:
--storage <name> VM storage location to use
clone:
--source-storage <name> Source VM storage location
--dest-storage <name> Destination VM storage location
config:
get Get current configuration
storage Manage VM storage locations
add <name> <path> Add a new VM storage location
remove <name> Remove a VM storage location
list List all VM storage locations
default <name> Set the default VM storage location
cache Manage cache settings
get Get current cache directory
set <path> Set cache directory
caching Manage image caching settings
get Show current caching status
set <boolean> Enable or disable image caching
serve:
--port <port> Port to listen on (default: 3000)

View File

@@ -15,7 +15,8 @@ curl --connect-timeout 6000 \
"memory": "4GB",
"diskSize": "64GB",
"display": "1024x768",
"ipsw": "latest"
"ipsw": "latest",
"storage": "ssd"
}' \
http://localhost:3000/lume/vms
```
@@ -44,7 +45,8 @@ curl --connect-timeout 6000 \
"readOnly": false
}
],
"recoveryMode": false
"recoveryMode": false,
"storage": "ssd"
}' \
http://localhost:3000/lume/vms/lume_vm/run
```
@@ -84,9 +86,15 @@ curl --connect-timeout 6000 \
<summary><strong>Get VM Details</strong> - GET /vms/:name</summary>
```bash
# Basic get
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/vms/lume_vm\
http://localhost:3000/lume/vms/lume_vm
# Get with storage location specified
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/vms/lume_vm?storage=ssd
```
```
{
@@ -111,7 +119,8 @@ curl --connect-timeout 6000 \
-d '{
"cpu": 4,
"memory": "8GB",
"diskSize": "128GB"
"diskSize": "128GB",
"storage": "ssd"
}' \
http://localhost:3000/lume/vms/my-vm-name
```
@@ -121,10 +130,17 @@ curl --connect-timeout 6000 \
<summary><strong>Stop VM</strong> - POST /vms/:name/stop</summary>
```bash
# Basic stop
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/vms/my-vm-name/stop
# Stop with storage location specified
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/vms/my-vm-name/stop?storage=ssd
```
</details>
@@ -132,10 +148,17 @@ curl --connect-timeout 6000 \
<summary><strong>Delete VM</strong> - DELETE /vms/:name</summary>
```bash
# Basic delete
curl --connect-timeout 6000 \
--max-time 5000 \
-X DELETE \
http://localhost:3000/lume/vms/my-vm-name
# Delete with storage location specified
curl --connect-timeout 6000 \
--max-time 5000 \
-X DELETE \
http://localhost:3000/lume/vms/my-vm-name?storage=ssd
```
</details>
@@ -152,7 +175,7 @@ curl --connect-timeout 6000 \
"name": "my-vm-name",
"registry": "ghcr.io",
"organization": "trycua",
"noCache": false
"storage": "ssd"
}' \
http://localhost:3000/lume/pull
```
@@ -180,7 +203,9 @@ curl --connect-timeout 6000 \
-H "Content-Type: application/json" \
-d '{
"name": "source-vm",
"newName": "cloned-vm"
"newName": "cloned-vm",
"sourceLocation": "default",
"destLocation": "ssd"
}' \
http://localhost:3000/lume/vms/clone
```
@@ -226,3 +251,101 @@ curl --connect-timeout 6000 \
http://localhost:3000/lume/prune
```
</details>
<details open>
<summary><strong>Get Configuration</strong> - GET /lume/config</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/config
```
```json
{
"homeDirectory": "~/.lume",
"cacheDirectory": "~/.lume/cache",
"cachingEnabled": true
}
```
</details>
<details open>
<summary><strong>Update Configuration</strong> - POST /lume/config</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"homeDirectory": "~/custom/lume",
"cacheDirectory": "~/custom/lume/cache",
"cachingEnabled": true
}' \
http://localhost:3000/lume/config
```
</details>
<details open>
<summary><strong>Get VM Storage Locations</strong> - GET /lume/config/locations</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/config/locations
```
```json
[
{
"name": "default",
"path": "~/.lume/vms",
"isDefault": true
},
{
"name": "ssd",
"path": "/Volumes/SSD/lume/vms",
"isDefault": false
}
]
```
</details>
<details open>
<summary><strong>Add VM Storage Location</strong> - POST /lume/config/locations</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"name": "ssd",
"path": "/Volumes/SSD/lume/vms"
}' \
http://localhost:3000/lume/config/locations
```
</details>
<details open>
<summary><strong>Remove VM Storage Location</strong> - DELETE /lume/config/locations/:name</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X DELETE \
http://localhost:3000/lume/config/locations/ssd
```
</details>
<details open>
<summary><strong>Set Default VM Storage Location</strong> - POST /lume/config/locations/default/:name</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/config/locations/default/ssd
```
</details>

View File

@@ -2,12 +2,71 @@
### Where are the VMs stored?
VMs are stored in `~/.lume`.
VMs are stored in `~/.lume` by default. You can configure additional storage locations using the `lume config` command.
### How are images cached?
Images are cached in `~/.lume/cache`. When doing `lume pull <image>`, it will check if the image is already cached. If not, it will download the image and cache it, removing any older versions.
### Where is the configuration file stored?
Lume follows the XDG Base Directory specification for the configuration file:
- Configuration is stored in `$XDG_CONFIG_HOME/lume/config.yaml` (defaults to `~/.config/lume/config.yaml`)
By default, other data is stored in:
- VM data: `~/.lume`
- Cache files: `~/.lume/cache`
The config file contains settings for:
- VM storage locations and the default location
- Cache directory location
- Whether caching is enabled
You can view and modify these settings using the `lume config` commands:
```bash
# View current configuration
lume config get
# Manage VM storage locations
lume config storage list # List all VM storage locations
lume config storage add <name> <path> # Add a new VM storage location
lume config storage remove <name> # Remove a VM storage location
lume config storage default <name> # Set the default VM storage location
# Manage cache settings
lume config cache get # Get current cache directory
lume config cache set <path> # Set cache directory
# Manage image caching settings
lume config caching get # Show current caching status
lume config caching set <boolean> # Enable or disable image caching
```
### How do I use multiple VM storage locations?
Lume supports storing VMs in different locations (e.g., internal drive, external SSD). After configuring storage locations, you can specify which location to use with the `--storage` parameter in various commands:
```bash
# Create a VM in a specific storage location
lume create my-vm --os macos --ipsw latest --storage ssd
# Run a VM from a specific storage location
lume run my-vm --storage ssd
# Delete a VM from a specific storage location
lume delete my-vm --storage ssd
# Pull an image to a specific storage location
lume pull macos-sequoia-vanilla:latest --name my-vm --storage ssd
# Clone a VM between storage locations
lume clone source-vm cloned-vm --source-storage default --dest-storage ssd
```
If you don't specify a storage location, Lume will use the default one or search across all configured locations.
### Are VM disks taking up all the disk space?
No, macOS uses sparse files, which only allocate space as needed. For example, VM disks totaling 50 GB may only use 20 GB on disk.

View File

@@ -5,18 +5,29 @@ struct Clone: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Clone an existing virtual machine"
)
@Argument(help: "Name of the source virtual machine", completion: .custom(completeVMName))
var name: String
@Argument(help: "Name for the cloned virtual machine")
var newName: String
@Option(name: .customLong("source-storage"), help: "Source VM storage location")
var sourceStorage: String?
@Option(name: .customLong("dest-storage"), help: "Destination VM storage location")
var destStorage: String?
init() {}
@MainActor
func run() async throws {
let vmController = LumeController()
try vmController.clone(name: name, newName: newName)
try vmController.clone(
name: name,
newName: newName,
sourceLocation: sourceStorage,
destLocation: destStorage
)
}
}
}

View File

@@ -0,0 +1,224 @@
import ArgumentParser
import Foundation
struct Config: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Get or set lume configuration",
subcommands: [Get.self, Storage.self, Cache.self, Caching.self],
defaultSubcommand: Get.self
)
// MARK: - Basic Configuration Subcommands
struct Get: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "get",
abstract: "Get current configuration"
)
func run() throws {
let controller = LumeController()
let settings = controller.getSettings()
// Display default location
print(
"Default VM storage: \(settings.defaultLocationName) (\(settings.defaultLocation?.path ?? "not set"))"
)
// Display cache directory
print("Cache directory: \(settings.cacheDirectory)")
// Display caching enabled status
print("Caching enabled: \(settings.cachingEnabled)")
// Display all locations
if !settings.vmLocations.isEmpty {
print("\nConfigured VM storage locations:")
for location in settings.sortedLocations {
let isDefault = location.name == settings.defaultLocationName
let defaultMark = isDefault ? " (default)" : ""
print(" - \(location.name): \(location.path)\(defaultMark)")
}
}
}
}
// MARK: - Debug Command
struct Debug: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "debug",
abstract: "Output detailed debug information about current configuration",
shouldDisplay: false
)
func run() throws {
let debugInfo = SettingsManager.shared.debugSettings()
print(debugInfo)
}
}
// MARK: - Caching Management Subcommands
struct Caching: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "caching",
abstract: "Manage image caching settings",
subcommands: [GetCaching.self, SetCaching.self]
)
struct GetCaching: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "get",
abstract: "Show current caching status"
)
func run() throws {
let controller = LumeController()
let cachingEnabled = controller.isCachingEnabled()
print("Caching enabled: \(cachingEnabled)")
}
}
struct SetCaching: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Enable or disable image caching"
)
@Argument(help: "Enable or disable caching (true/false)")
var enabled: Bool
func run() throws {
let controller = LumeController()
try controller.setCachingEnabled(enabled)
print("Caching \(enabled ? "enabled" : "disabled")")
}
}
}
// MARK: - Cache Management Subcommands
struct Cache: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cache",
abstract: "Manage cache settings",
subcommands: [GetCache.self, SetCache.self]
)
struct GetCache: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "get",
abstract: "Get current cache directory"
)
func run() throws {
let controller = LumeController()
let cacheDir = controller.getCacheDirectory()
print("Cache directory: \(cacheDir)")
}
}
struct SetCache: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set cache directory"
)
@Argument(help: "Path to cache directory")
var path: String
func run() throws {
let controller = LumeController()
try controller.setCacheDirectory(path: path)
print("Cache directory set to: \(path)")
}
}
}
// MARK: - Storage Management Subcommands
struct Storage: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "storage",
abstract: "Manage VM storage locations",
subcommands: [Add.self, Remove.self, List.self, Default.self]
)
struct Add: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "add",
abstract: "Add a new VM storage location"
)
@Argument(help: "Storage name (alphanumeric with dashes/underscores)")
var name: String
@Argument(help: "Path to VM storage directory")
var path: String
func run() throws {
let controller = LumeController()
try controller.addLocation(name: name, path: path)
print("Added VM storage location: \(name) at \(path)")
}
}
struct Remove: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "remove",
abstract: "Remove a VM storage location"
)
@Argument(help: "Storage name to remove")
var name: String
func run() throws {
let controller = LumeController()
try controller.removeLocation(name: name)
print("Removed VM storage location: \(name)")
}
}
struct List: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List all VM storage locations"
)
func run() throws {
let controller = LumeController()
let settings = controller.getSettings()
if settings.vmLocations.isEmpty {
print("No VM storage locations configured")
return
}
print("VM Storage Locations:")
for location in settings.sortedLocations {
let isDefault = location.name == settings.defaultLocationName
let defaultMark = isDefault ? " (default)" : ""
print(" - \(location.name): \(location.path)\(defaultMark)")
}
}
}
struct Default: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "default",
abstract: "Set the default VM storage location"
)
@Argument(help: "Storage name to set as default")
var name: String
func run() throws {
let controller = LumeController()
try controller.setDefaultLocation(name: name)
print("Set default VM storage location to: \(name)")
}
}
}
}

View File

@@ -8,45 +8,56 @@ struct Create: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Create a new virtual machine"
)
@Argument(help: "Name for the virtual machine")
var name: String
@Option(help: "Operating system to install. Defaults to macOS.", completion: .list(["macOS", "linux"]))
@Option(
help: "Operating system to install. Defaults to macOS.",
completion: .list(["macOS", "linux"]))
var os: String = "macOS"
@Option(help: "Number of CPU cores", transform: { Int($0) ?? 4 })
var cpu: Int = 4
@Option(help: "Memory size, e.g., 8192MB or 8GB. Defaults to 8GB.", transform: { try parseSize($0) })
@Option(
help: "Memory size, e.g., 8192MB or 8GB. Defaults to 8GB.", transform: { try parseSize($0) }
)
var memory: UInt64 = 8 * 1024 * 1024 * 1024
@Option(help: "Disk size, e.g., 20480MB or 20GB. Defaults to 50GB.", transform: { try parseSize($0) })
@Option(
help: "Disk size, e.g., 20480MB or 20GB. Defaults to 50GB.",
transform: { try parseSize($0) })
var diskSize: UInt64 = 50 * 1024 * 1024 * 1024
@Option(help: "Display resolution in format WIDTHxHEIGHT. Defaults to 1024x768.")
var display: VMDisplayResolution = VMDisplayResolution(string: "1024x768")!
@Option(
help: "Path to macOS restore image (IPSW), or 'latest' to download the latest supported version. Required for macOS VMs.",
help:
"Path to macOS restore image (IPSW), or 'latest' to download the latest supported version. Required for macOS VMs.",
completion: .file(extensions: ["ipsw"])
)
var ipsw: String?
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
try await vmController.create(
let controller = LumeController()
try await controller.create(
name: name,
os: os,
diskSize: diskSize,
cpuCount: cpu,
memorySize: memory,
display: display.string,
ipsw: ipsw
ipsw: ipsw,
storage: storage
)
}
}
}

View File

@@ -5,27 +5,33 @@ struct Delete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Delete a virtual machine"
)
@Argument(help: "Name of the virtual machine to delete", completion: .custom(completeVMName))
var name: String
@Flag(name: .long, help: "Force deletion without confirmation")
var force = false
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {}
@MainActor
func run() async throws {
if !force {
print("Are you sure you want to delete the virtual machine '\(name)'? [y/N] ", terminator: "")
print(
"Are you sure you want to delete the virtual machine '\(name)'? [y/N] ",
terminator: "")
guard let response = readLine()?.lowercased(),
response == "y" || response == "yes" else {
response == "y" || response == "yes"
else {
print("Deletion cancelled")
return
}
}
let vmController = LumeController()
try await vmController.delete(name: name)
try await vmController.delete(name: name, storage: storage)
}
}
}

View File

@@ -8,17 +8,20 @@ struct Get: AsyncParsableCommand {
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
var name: String
@Option(name: [.long, .customShort("f")], help: "Output format (json|text)")
var format: FormatOption = .text
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
let vm = try vmController.get(name: name)
let vm = try vmController.get(name: name, storage: storage)
try VMDetailsPrinter.printStatus([vm.details], format: self.format)
}
}
}

View File

@@ -5,11 +5,12 @@ struct Pull: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Pull a macOS image from GitHub Container Registry"
)
@Argument(help: "Image to pull (format: name:tag)")
var image: String
@Argument(help: "Name for the VM (defaults to image name without tag)", transform: { Optional($0) })
@Argument(
help: "Name for the VM (defaults to image name without tag)", transform: { Optional($0) })
var name: String?
@Option(help: "Github Container Registry to pull from. Defaults to ghcr.io")
@@ -18,14 +19,20 @@ struct Pull: AsyncParsableCommand {
@Option(help: "Organization to pull from. Defaults to trycua")
var organization: String = "trycua"
@Flag(help: "Pull image without creating .cache. Defaults to false")
var noCache: Bool = false
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {}
@MainActor
func run() async throws {
let vmController = LumeController()
try await vmController.pullImage(image: image, name: name, registry: registry, organization: organization, noCache: noCache)
let controller = LumeController()
try await controller.pullImage(
image: image,
name: name,
registry: registry,
organization: organization,
storage: storage
)
}
}

View File

@@ -6,37 +6,57 @@ struct Run: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Run a virtual machine"
)
@Argument(help: "Name of the virtual machine or image to pull and run (format: name or name:tag)", completion: .custom(completeVMName))
@Argument(
help: "Name of the virtual machine or image to pull and run (format: name or name:tag)",
completion: .custom(completeVMName))
var name: String
@Flag(name: [.short, .long], help: "Do not start the VNC client")
var noDisplay: Bool = false
@Option(name: [.customLong("shared-dir")], help: "Directory to share with the VM. Can be just a path for read-write access (e.g. ~/src) or path:tag where tag is 'ro' for read-only or 'rw' for read-write (e.g. ~/src:ro)")
@Option(
name: [.customLong("shared-dir")],
help:
"Directory to share with the VM. Can be just a path for read-write access (e.g. ~/src) or path:tag where tag is 'ro' for read-only or 'rw' for read-write (e.g. ~/src:ro)"
)
var sharedDirectories: [String] = []
@Option(help: "For Linux VMs only, a read-only disk image to attach to the VM (e.g. --mount=\"ubuntu.iso\")", completion: .file())
var mount: Path?
@Option(
help:
"For Linux VMs only, a read-only disk image to attach to the VM (e.g. --mount=\"ubuntu.iso\")",
completion: .file())
var mount: String?
@Option(
name: [.customLong("usb-storage")],
help: "Disk image to attach as a USB mass storage device (e.g. --usb-storage=\"disk.img\")",
completion: .file())
var usbStorageDevices: [String] = []
@Option(help: "Github Container Registry to pull the images from. Defaults to ghcr.io")
var registry: String = "ghcr.io"
@Option(help: "Organization to pull the images from. Defaults to trycua")
var organization: String = "trycua"
@Option(name: [.customLong("vnc-port")], help: "Port to use for the VNC server. Defaults to 0 (auto-assign)")
@Option(
name: [.customLong("vnc-port")],
help: "Port to use for the VNC server. Defaults to 0 (auto-assign)")
var vncPort: Int = 0
@Option(help: "For MacOS VMs only, boot into the VM in recovery mode")
var recoveryMode: Bool = false
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
private var parsedSharedDirectories: [SharedDirectory] {
get throws {
try sharedDirectories.map { dirString -> SharedDirectory in
let components = dirString.split(separator: ":", maxSplits: 1)
let hostPath = String(components[0])
// If no tag is provided, default to read-write
if components.count == 1 {
return SharedDirectory(
@@ -45,7 +65,7 @@ struct Run: AsyncParsableCommand {
readOnly: false
)
}
// Parse the tag if provided
let tag = String(components[1])
let readOnly: Bool
@@ -55,9 +75,11 @@ struct Run: AsyncParsableCommand {
case "rw":
readOnly = false
default:
throw ValidationError("Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write")
throw ValidationError(
"Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write"
)
}
return SharedDirectory(
hostPath: hostPath,
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
@@ -66,24 +88,27 @@ struct Run: AsyncParsableCommand {
}
}
}
private var parsedUSBStorageDevices: [Path] {
usbStorageDevices.map { Path($0) }
}
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
let dirs = try parsedSharedDirectories
try await vmController.runVM(
try await LumeController().runVM(
name: name,
noDisplay: noDisplay,
sharedDirectories: dirs,
mount: mount,
sharedDirectories: parsedSharedDirectories,
mount: mount.map { Path($0) },
registry: registry,
organization: organization,
vncPort: vncPort,
recoveryMode: recoveryMode
recoveryMode: recoveryMode,
storage: storage,
usbMassStoragePaths: parsedUSBStorageDevices.isEmpty ? nil : parsedUSBStorageDevices
)
}
}

View File

@@ -21,6 +21,9 @@ struct Set: AsyncParsableCommand {
@Option(help: "New display resolution in format WIDTHxHEIGHT.")
var display: VMDisplayResolution?
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {
}
@@ -32,7 +35,8 @@ struct Set: AsyncParsableCommand {
cpu: cpu,
memory: memory,
diskSize: diskSize,
display: display?.string
display: display?.string,
storage: storage
)
}
}

View File

@@ -8,13 +8,16 @@ struct Stop: AsyncParsableCommand {
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
var name: String
@Option(name: .customLong("storage"), help: "VM storage location to use")
var storage: String?
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
try await vmController.stopVM(name: name)
try await vmController.stopVM(name: name, storage: storage)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,35 +4,58 @@ import Foundation
/// Responsible for creating, accessing, and validating the application's directory structure.
final class Home {
// MARK: - Constants
private enum Constants {
static let defaultDirectoryName = ".lume"
static let homeDirPath = "~/\(defaultDirectoryName)"
}
// MARK: - Properties
let homeDir: Path
private var _homeDir: Path
private let settingsManager: SettingsManager
private let fileManager: FileManager
// MARK: - Initialization
init(fileManager: FileManager = .default) {
self.fileManager = fileManager
self.homeDir = Path(Constants.homeDirPath)
private var locations: [String: VMLocation] = [:]
// Current home directory based on default location
var homeDir: Path {
return _homeDir
}
// MARK: - Initialization
init(
settingsManager: SettingsManager = SettingsManager.shared,
fileManager: FileManager = .default
) {
self.settingsManager = settingsManager
self.fileManager = fileManager
// Get home directory path from settings or use default
let settings = settingsManager.getSettings()
guard let defaultLocation = settings.defaultLocation else {
fatalError("No default VM location found")
}
self._homeDir = Path(defaultLocation.path)
// Cache all locations
for location in settings.vmLocations {
locations[location.name] = location
}
}
// MARK: - VM Directory Management
/// Creates a temporary VM directory with a unique identifier
/// - Returns: A VMDirectory instance representing the created directory
/// - Throws: HomeError if directory creation fails
func createTempVMDirectory() throws -> VMDirectory {
let uuid = UUID().uuidString
let tempDir = homeDir.directory(uuid)
Logger.info("Creating temporary directory", metadata: ["path": tempDir.path])
do {
try createDirectory(at: tempDir.url)
return VMDirectory(tempDir)
@@ -40,107 +63,234 @@ final class Home {
throw HomeError.directoryCreationFailed(path: tempDir.path)
}
}
/// Returns a VMDirectory instance for the given name
/// - Parameter name: Name of the VM directory
/// Gets a VM directory for a specific VM name and optional location
///
/// - Parameters:
/// - name: Name of the VM directory
/// - storage: Optional name of the VM location (default: default location)
/// - Returns: A VMDirectory instance
func getVMDirectory(_ name: String) -> VMDirectory {
VMDirectory(homeDir.directory(name))
}
/// Returns all initialized VM directories
/// - Returns: An array of VMDirectory instances
/// - Throws: HomeError if directory access is denied
func getAllVMDirectories() throws -> [VMDirectory] {
guard homeDir.exists() else { return [] }
do {
let allFolders = try fileManager.contentsOfDirectory(
at: homeDir.url,
includingPropertiesForKeys: nil
)
let folders = allFolders
.compactMap { url in
let sanitizedName = sanitizeFileName(url.lastPathComponent)
let dir = getVMDirectory(sanitizedName)
let dir1 = dir.initialized() ? dir : nil
return dir1
}
return folders
} catch {
throw HomeError.directoryAccessDenied(path: homeDir.path)
/// - Throws: HomeError if location not found
func getVMDirectory(_ name: String, storage: String? = nil) throws -> VMDirectory {
let location: VMLocation
if let storage = storage {
// Get a specific location
guard let loc = locations[storage] else {
throw VMLocationError.locationNotFound(name: storage)
}
location = loc
} else {
// Use default location
let settings = settingsManager.getSettings()
guard let defaultLocation = settings.defaultLocation else {
throw HomeError.invalidHomeDirectory
}
location = defaultLocation
}
let baseDir = Path(location.expandedPath)
return VMDirectory(baseDir.directory(name))
}
/// Returns all initialized VM directories across all locations
/// - Returns: An array of VMDirectory instances with location info
/// - Throws: HomeError if directory access is denied
func getAllVMDirectories() throws -> [VMDirectoryWithLocation] {
var results: [VMDirectoryWithLocation] = []
// Loop through all locations
let settings = settingsManager.getSettings()
for location in settings.vmLocations {
let locationPath = Path(location.expandedPath)
// Skip non-existent locations
if !locationPath.exists() {
continue
}
do {
let allFolders = try fileManager.contentsOfDirectory(
at: locationPath.url,
includingPropertiesForKeys: nil
)
let folders =
allFolders
.compactMap { url in
let sanitizedName = sanitizeFileName(url.lastPathComponent)
let dir = VMDirectory(locationPath.directory(sanitizedName))
let dirWithLoc =
dir.initialized()
? VMDirectoryWithLocation(directory: dir, locationName: location.name)
: nil
return dirWithLoc
}
results.append(contentsOf: folders)
} catch {
Logger.error(
"Failed to access VM location",
metadata: [
"location": location.name,
"error": error.localizedDescription,
])
// Continue to next location rather than failing completely
}
}
return results
}
/// Copies a VM directory to a new location with a new name
/// - Parameters:
/// - sourceName: Name of the source VM
/// - destName: Name for the destination VM
/// - sourceLocation: Optional name of the source location
/// - destLocation: Optional name of the destination location
/// - Throws: HomeError if the copy operation fails
func copyVMDirectory(from sourceName: String, to destName: String) throws {
let sourceDir = getVMDirectory(sourceName)
let destDir = getVMDirectory(destName)
func copyVMDirectory(
from sourceName: String,
to destName: String,
sourceLocation: String? = nil,
destLocation: String? = nil
) throws {
let sourceDir = try getVMDirectory(sourceName, storage: sourceLocation)
let destDir = try getVMDirectory(destName, storage: destLocation)
if destDir.initialized() {
throw HomeError.directoryAlreadyExists(path: destDir.dir.path)
}
do {
try fileManager.copyItem(atPath: sourceDir.dir.path, toPath: destDir.dir.path)
} catch {
throw HomeError.directoryCreationFailed(path: destDir.dir.path)
}
}
// MARK: - Directory Validation
/// Validates and ensures the existence of the home directory
/// - Throws: HomeError if validation fails or directory creation fails
func validateHomeDirectory() throws {
if !homeDir.exists() {
try createHomeDirectory()
return
// MARK: - Location Management
/// Adds a new VM location
/// - Parameters:
/// - name: Location name
/// - path: Location path
/// - Throws: Error if location cannot be added
func addLocation(name: String, path: String) throws {
let location = VMLocation(name: name, path: path)
try settingsManager.addLocation(location)
// Update cache
locations[name] = location
}
/// Removes a VM location
/// - Parameter name: Location name
/// - Throws: Error if location cannot be removed
func removeLocation(name: String) throws {
try settingsManager.removeLocation(name: name)
// Update cache
locations.removeValue(forKey: name)
}
/// Sets the default VM location
/// - Parameter name: Location name
/// - Throws: Error if location cannot be set as default
func setDefaultLocation(name: String) throws {
try settingsManager.setDefaultLocation(name: name)
// Update home directory
guard let location = locations[name] else {
throw VMLocationError.locationNotFound(name: name)
}
guard isValidDirectory(at: homeDir.path) else {
// Update homeDir to reflect the new default
self._homeDir = Path(location.path)
}
/// Gets all available VM locations
/// - Returns: Array of VM locations
func getLocations() -> [VMLocation] {
return settingsManager.getSettings().sortedLocations
}
/// Gets the default VM location
/// - Returns: Default VM location
/// - Throws: HomeError if no default location
func getDefaultLocation() throws -> VMLocation {
guard let location = settingsManager.getSettings().defaultLocation else {
throw HomeError.invalidHomeDirectory
}
return location
}
// MARK: - Private Helpers
private func createHomeDirectory() throws {
do {
try createDirectory(at: homeDir.url)
} catch {
throw HomeError.directoryCreationFailed(path: homeDir.path)
// MARK: - Directory Validation
/// Validates and ensures the existence of all VM locations
/// - Throws: HomeError if validation fails or directory creation fails
func validateHomeDirectory() throws {
let settings = settingsManager.getSettings()
for location in settings.vmLocations {
let path = location.expandedPath
if !fileExists(at: path) {
try createVMLocation(at: path)
} else if !isValidDirectory(at: path) {
throw HomeError.invalidHomeDirectory
}
}
}
// MARK: - Private Helpers
private func createVMLocation(at path: String) throws {
do {
try fileManager.createDirectory(
atPath: path,
withIntermediateDirectories: true
)
} catch {
throw HomeError.directoryCreationFailed(path: path)
}
}
private func createDirectory(at url: URL) throws {
try fileManager.createDirectory(
at: url,
withIntermediateDirectories: true
)
}
private func isValidDirectory(at path: String) -> Bool {
var isDirectory: ObjCBool = false
return fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
&& isDirectory.boolValue
return fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
&& isDirectory.boolValue
&& Path(path).writable()
}
private func fileExists(at path: String) -> Bool {
return fileManager.fileExists(atPath: path)
}
private func sanitizeFileName(_ name: String) -> String {
// Only decode percent encoding (e.g., %20 for spaces)
return name.removingPercentEncoding ?? name
}
}
// MARK: - VM Directory with Location
/// Represents a VM directory with its location information
struct VMDirectoryWithLocation {
let directory: VMDirectory
let locationName: String
}
// MARK: - Home + CustomStringConvertible
extension Home: CustomStringConvertible {
var description: String {
"Home(path: \(homeDir.path))"
}
}
}

View File

@@ -0,0 +1,424 @@
import Foundation
/// Manages the application settings using a config file
struct LumeSettings: Codable, Sendable {
var vmLocations: [VMLocation]
var defaultLocationName: String
var cacheDirectory: String
var cachingEnabled: Bool
var defaultLocation: VMLocation? {
vmLocations.first { $0.name == defaultLocationName }
}
// For backward compatibility
var homeDirectory: String {
defaultLocation?.path ?? "~/.lume"
}
static let defaultSettings = LumeSettings(
vmLocations: [
VMLocation(name: "default", path: "~/.lume")
],
defaultLocationName: "default",
cacheDirectory: "~/.lume/cache",
cachingEnabled: true
)
/// Gets all locations sorted by name
var sortedLocations: [VMLocation] {
vmLocations.sorted { $0.name < $1.name }
}
}
final class SettingsManager: @unchecked Sendable {
// MARK: - Constants
private enum Constants {
// Default path for config
static let fallbackConfigDir = "~/.config/lume"
static let configFileName = "config.yaml"
}
// MARK: - Properties
static let shared = SettingsManager()
private let fileManager: FileManager
// Get the config directory following XDG spec
private var configDir: String {
// Check XDG_CONFIG_HOME environment variable first
if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] {
return "\(xdgConfigHome)/lume"
}
// Fall back to default
return (Constants.fallbackConfigDir as NSString).expandingTildeInPath
}
// Path to config file
private var configFilePath: String {
return "\(configDir)/\(Constants.configFileName)"
}
// MARK: - Initialization
init(fileManager: FileManager = .default) {
self.fileManager = fileManager
ensureConfigDirectoryExists()
}
// MARK: - Settings Access
func getSettings() -> LumeSettings {
if let settings = readSettingsFromFile() {
return settings
}
// No settings file found, use defaults
let defaultSettings = LumeSettings(
vmLocations: [
VMLocation(name: "default", path: "~/.lume")
],
defaultLocationName: "default",
cacheDirectory: "~/.lume/cache",
cachingEnabled: true
)
// Try to save default settings
try? saveSettings(defaultSettings)
return defaultSettings
}
func saveSettings(_ settings: LumeSettings) throws {
try fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true)
// Create a human-readable YAML-like configuration file
var yamlContent = "# Lume Configuration\n\n"
// Default location
yamlContent += "defaultLocationName: \"\(settings.defaultLocationName)\"\n"
// Cache directory
yamlContent += "cacheDirectory: \"\(settings.cacheDirectory)\"\n"
// Caching enabled flag
yamlContent += "cachingEnabled: \(settings.cachingEnabled)\n"
// VM locations
yamlContent += "\n# VM Locations\nvmLocations:\n"
for location in settings.vmLocations {
yamlContent += " - name: \"\(location.name)\"\n"
yamlContent += " path: \"\(location.path)\"\n"
}
// Write YAML content to file
try yamlContent.write(
to: URL(fileURLWithPath: configFilePath), atomically: true, encoding: .utf8)
}
// MARK: - VM Location Management
func addLocation(_ location: VMLocation) throws {
var settings = getSettings()
// Validate location name (alphanumeric, dash, underscore)
let nameRegex = try NSRegularExpression(pattern: "^[a-zA-Z0-9_-]+$")
let nameRange = NSRange(location.name.startIndex..., in: location.name)
if nameRegex.firstMatch(in: location.name, range: nameRange) == nil {
throw VMLocationError.invalidLocationName(name: location.name)
}
// Check for duplicate name
if settings.vmLocations.contains(where: { $0.name == location.name }) {
throw VMLocationError.duplicateLocationName(name: location.name)
}
// Validate location path
try location.validate()
// Add location
settings.vmLocations.append(location)
try saveSettings(settings)
}
func removeLocation(name: String) throws {
var settings = getSettings()
// Check location exists
guard settings.vmLocations.contains(where: { $0.name == name }) else {
throw VMLocationError.locationNotFound(name: name)
}
// Prevent removing default location
if name == settings.defaultLocationName {
throw VMLocationError.defaultLocationCannotBeRemoved(name: name)
}
// Remove location
settings.vmLocations.removeAll(where: { $0.name == name })
try saveSettings(settings)
}
func setDefaultLocation(name: String) throws {
var settings = getSettings()
// Check location exists
guard settings.vmLocations.contains(where: { $0.name == name }) else {
throw VMLocationError.locationNotFound(name: name)
}
// Set default
settings.defaultLocationName = name
try saveSettings(settings)
}
func getLocation(name: String) throws -> VMLocation {
let settings = getSettings()
if let location = settings.vmLocations.first(where: { $0.name == name }) {
return location
}
throw VMLocationError.locationNotFound(name: name)
}
// MARK: - Legacy Home Directory Compatibility
func setHomeDirectory(path: String) throws {
var settings = getSettings()
let defaultLocation = VMLocation(name: "default", path: path)
try defaultLocation.validate()
// Replace default location
if let index = settings.vmLocations.firstIndex(where: { $0.name == "default" }) {
settings.vmLocations[index] = defaultLocation
} else {
settings.vmLocations.append(defaultLocation)
settings.defaultLocationName = "default"
}
try saveSettings(settings)
}
// MARK: - Cache Directory Management
func setCacheDirectory(path: String) throws {
var settings = getSettings()
// Validate path
let expandedPath = (path as NSString).expandingTildeInPath
var isDir: ObjCBool = false
// If directory exists, check if it's writable
if fileManager.fileExists(atPath: expandedPath, isDirectory: &isDir) {
if !isDir.boolValue {
throw SettingsError.notADirectory(path: expandedPath)
}
if !fileManager.isWritableFile(atPath: expandedPath) {
throw SettingsError.directoryNotWritable(path: expandedPath)
}
} else {
// Try to create the directory
do {
try fileManager.createDirectory(
atPath: expandedPath,
withIntermediateDirectories: true
)
} catch {
throw SettingsError.directoryCreationFailed(path: expandedPath, error: error)
}
}
// Update settings
settings.cacheDirectory = path
try saveSettings(settings)
}
func getCacheDirectory() -> String {
return getSettings().cacheDirectory
}
func setCachingEnabled(_ enabled: Bool) throws {
var settings = getSettings()
settings.cachingEnabled = enabled
try saveSettings(settings)
}
func isCachingEnabled() -> Bool {
return getSettings().cachingEnabled
}
// MARK: - Private Helpers
private func ensureConfigDirectoryExists() {
try? fileManager.createDirectory(atPath: configDir, withIntermediateDirectories: true)
}
private func readSettingsFromFile() -> LumeSettings? {
// Read from YAML file
if fileExists(at: configFilePath) {
do {
let yamlString = try String(
contentsOf: URL(fileURLWithPath: configFilePath), encoding: .utf8)
return parseYamlSettings(yamlString)
} catch {
Logger.error(
"Failed to read settings from YAML file",
metadata: ["error": error.localizedDescription]
)
}
}
return nil
}
private func parseYamlSettings(_ yamlString: String) -> LumeSettings? {
// This is a very basic YAML parser for our specific config format
// A real implementation would use a proper YAML library
var defaultLocationName = "default"
var cacheDirectory = "~/.lume/cache"
var cachingEnabled = true // default to true for backward compatibility
var vmLocations: [VMLocation] = []
var inLocationsSection = false
var currentLocation: (name: String?, path: String?) = (nil, nil)
let lines = yamlString.split(separator: "\n")
for (_, line) in lines.enumerated() {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
// Skip comments and empty lines
if trimmedLine.hasPrefix("#") || trimmedLine.isEmpty {
continue
}
// Check for section marker
if trimmedLine == "vmLocations:" {
inLocationsSection = true
continue
}
// In the locations section, handle line indentation more carefully
if inLocationsSection {
if trimmedLine.hasPrefix("-") || trimmedLine.contains("- name:") {
// Process the previous location before starting a new one
if let name = currentLocation.name, let path = currentLocation.path {
vmLocations.append(VMLocation(name: name, path: path))
}
currentLocation = (nil, nil)
}
// Process the key-value pairs within a location
if let colonIndex = trimmedLine.firstIndex(of: ":") {
let key = trimmedLine[..<colonIndex].trimmingCharacters(in: .whitespaces)
let rawValue = trimmedLine[trimmedLine.index(after: colonIndex)...]
.trimmingCharacters(in: .whitespaces)
let value = extractValueFromYaml(rawValue)
if key.hasSuffix("name") {
currentLocation.name = value
} else if key.hasSuffix("path") {
currentLocation.path = value
}
}
} else {
// Process top-level keys outside the locations section
if let colonIndex = trimmedLine.firstIndex(of: ":") {
let key = trimmedLine[..<colonIndex].trimmingCharacters(in: .whitespaces)
let rawValue = trimmedLine[trimmedLine.index(after: colonIndex)...]
.trimmingCharacters(in: .whitespaces)
let value = extractValueFromYaml(rawValue)
if key == "defaultLocationName" {
defaultLocationName = value
} else if key == "cacheDirectory" {
cacheDirectory = value
} else if key == "cachingEnabled" {
cachingEnabled = value.lowercased() == "true"
}
}
}
}
// Don't forget to add the last location
if let name = currentLocation.name, let path = currentLocation.path {
vmLocations.append(VMLocation(name: name, path: path))
}
// Ensure at least one location exists
if vmLocations.isEmpty {
vmLocations.append(VMLocation(name: "default", path: "~/.lume"))
}
return LumeSettings(
vmLocations: vmLocations,
defaultLocationName: defaultLocationName,
cacheDirectory: cacheDirectory,
cachingEnabled: cachingEnabled
)
}
// Helper method to extract a value from YAML, handling quotes
private func extractValueFromYaml(_ rawValue: String) -> String {
if rawValue.hasPrefix("\"") && rawValue.hasSuffix("\"") && rawValue.count >= 2 {
// Remove the surrounding quotes
let startIndex = rawValue.index(after: rawValue.startIndex)
let endIndex = rawValue.index(before: rawValue.endIndex)
return String(rawValue[startIndex..<endIndex])
}
return rawValue
}
// Helper method to output debug information about the current settings
func debugSettings() -> String {
let settings = getSettings()
var output = "Current Settings:\n"
output += "- Default VM storage: \(settings.defaultLocationName)\n"
output += "- Cache directory: \(settings.cacheDirectory)\n"
output += "- VM Locations (\(settings.vmLocations.count)):\n"
for (i, location) in settings.vmLocations.enumerated() {
let isDefault = location.name == settings.defaultLocationName
let defaultMark = isDefault ? " (default)" : ""
output += " \(i+1). \(location.name): \(location.path)\(defaultMark)\n"
}
// Also add raw file content
if fileExists(at: configFilePath) {
if let content = try? String(contentsOf: URL(fileURLWithPath: configFilePath)) {
output += "\nRaw YAML file content:\n"
output += content
}
}
return output
}
private func fileExists(at path: String) -> Bool {
fileManager.fileExists(atPath: path)
}
}
// MARK: - Errors
enum SettingsError: Error, LocalizedError {
case notADirectory(path: String)
case directoryNotWritable(path: String)
case directoryCreationFailed(path: String, error: Error)
var errorDescription: String? {
switch self {
case .notADirectory(let path):
return "Path is not a directory: \(path)"
case .directoryNotWritable(let path):
return "Directory is not writable: \(path)"
case .directoryCreationFailed(let path, let error):
return "Failed to create directory at \(path): \(error.localizedDescription)"
}
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
/// Represents a location where VMs can be stored
struct VMLocation: Codable, Equatable, Sendable {
let name: String
let path: String
var expandedPath: String {
(path as NSString).expandingTildeInPath
}
/// Validates the location path exists and is writable
func validate() throws {
let fullPath = expandedPath
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDir) {
if !isDir.boolValue {
throw VMLocationError.notADirectory(path: fullPath)
}
if !FileManager.default.isWritableFile(atPath: fullPath) {
throw VMLocationError.directoryNotWritable(path: fullPath)
}
} else {
// Try to create the directory
do {
try FileManager.default.createDirectory(
atPath: fullPath,
withIntermediateDirectories: true
)
} catch {
throw VMLocationError.directoryCreationFailed(path: fullPath, error: error)
}
}
}
}
// MARK: - Errors
enum VMLocationError: Error, LocalizedError {
case notADirectory(path: String)
case directoryNotWritable(path: String)
case directoryCreationFailed(path: String, error: Error)
case locationNotFound(name: String)
case duplicateLocationName(name: String)
case invalidLocationName(name: String)
case defaultLocationCannotBeRemoved(name: String)
var errorDescription: String? {
switch self {
case .notADirectory(let path):
return "Path is not a directory: \(path)"
case .directoryNotWritable(let path):
return "Directory is not writable: \(path)"
case .directoryCreationFailed(let path, let error):
return "Failed to create directory at \(path): \(error.localizedDescription)"
case .locationNotFound(let name):
return "VM location not found: \(name)"
case .duplicateLocationName(let name):
return "VM location with name '\(name)' already exists"
case .invalidLocationName(let name):
return
"Invalid location name: \(name). Names should be alphanumeric with underscores or dashes."
case .defaultLocationCannotBeRemoved(let name):
return "Cannot remove the default location '\(name)'. Set a new default location first."
}
}
}

View File

@@ -8,17 +8,17 @@ import Virtualization
final class SharedVM {
static let shared: SharedVM = SharedVM()
private var runningVMs: [String: VM] = [:]
private init() {}
func getVM(name: String) -> VM? {
return runningVMs[name]
}
func setVM(name: String, vm: VM) {
runningVMs[name] = vm
}
func removeVM(name: String) {
runningVMs.removeValue(forKey: name)
}
@@ -50,8 +50,10 @@ final class LumeController {
@MainActor
public func list() throws -> [VMDetails] {
do {
let statuses = try home.getAllVMDirectories().map { directory in
let vm = try self.get(name: directory.name)
let vmLocations = try home.getAllVMDirectories()
let statuses = try vmLocations.map { vmWithLoc in
let vm = try self.get(
name: vmWithLoc.directory.name, storage: vmWithLoc.locationName)
return vm.details
}
return statuses
@@ -62,22 +64,39 @@ final class LumeController {
}
@MainActor
public func clone(name: String, newName: String) throws {
public func clone(
name: String, newName: String, sourceLocation: String? = nil, destLocation: String? = nil
) throws {
let normalizedName = normalizeVMName(name: name)
let normalizedNewName = normalizeVMName(name: newName)
Logger.info("Cloning VM", metadata: ["source": normalizedName, "destination": normalizedNewName])
Logger.info(
"Cloning VM",
metadata: [
"source": normalizedName,
"destination": normalizedNewName,
"sourceLocation": sourceLocation ?? "default",
"destLocation": destLocation ?? "default",
])
do {
try self.validateVMExists(normalizedName)
// Validate source VM exists
_ = try self.validateVMExists(normalizedName, storage: sourceLocation)
// Copy the VM directory
try home.copyVMDirectory(from: normalizedName, to: normalizedNewName)
try home.copyVMDirectory(
from: normalizedName,
to: normalizedNewName,
sourceLocation: sourceLocation,
destLocation: destLocation
)
// Update MAC address in the cloned VM to ensure uniqueness
let clonedVM = try get(name: normalizedNewName)
let clonedVM = try get(name: normalizedNewName, storage: destLocation)
try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
Logger.info("VM cloned successfully", metadata: ["source": normalizedName, "destination": normalizedNewName])
Logger.info(
"VM cloned successfully",
metadata: ["source": normalizedName, "destination": normalizedNewName])
} catch {
Logger.error("Failed to clone VM", metadata: ["error": error.localizedDescription])
throw error
@@ -85,12 +104,15 @@ final class LumeController {
}
@MainActor
public func get(name: String) throws -> VM {
public func get(name: String, storage: String? = nil) throws -> VM {
let normalizedName = normalizeVMName(name: name)
do {
try self.validateVMExists(normalizedName)
// Try to find the VM and get its actual location
let actualLocation = try self.validateVMExists(
normalizedName, storage: storage)
let vm = try self.loadVM(name: normalizedName)
// Load the VM from its actual location
let vm = try self.loadVM(name: normalizedName, storage: actualLocation)
return vm
} catch {
Logger.error("Failed to get VM", metadata: ["error": error.localizedDescription])
@@ -107,13 +129,15 @@ final class LumeController {
cpuCount: Int,
memorySize: UInt64,
display: String,
ipsw: String?
ipsw: String?,
storage: String? = nil
) async throws {
Logger.info(
"Creating VM",
metadata: [
"name": name,
"os": os,
"location": storage ?? "default",
"disk_size": "\(diskSize / 1024 / 1024)MB",
"cpu_count": "\(cpuCount)",
"memory_size": "\(memorySize / 1024 / 1024)MB",
@@ -122,7 +146,7 @@ final class LumeController {
])
do {
try validateCreateParameters(name: name, os: os, ipsw: ipsw)
try validateCreateParameters(name: name, os: os, ipsw: ipsw, storage: storage)
let vm = try await createTempVMConfig(
os: os,
@@ -140,7 +164,7 @@ final class LumeController {
display: display
)
try vm.finalize(to: name, home: home)
try vm.finalize(to: name, home: home, storage: storage)
Logger.info("VM created successfully", metadata: ["name": name])
} catch {
@@ -150,19 +174,26 @@ final class LumeController {
}
@MainActor
public func delete(name: String) async throws {
public func delete(name: String, storage: String? = nil) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info("Deleting VM", metadata: ["name": normalizedName])
Logger.info(
"Deleting VM",
metadata: [
"name": normalizedName,
"location": storage ?? "default",
])
do {
try self.validateVMExists(normalizedName)
// Find the actual location of the VM
let actualLocation = try self.validateVMExists(
normalizedName, storage: storage)
// Stop VM if it's running
if SharedVM.shared.getVM(name: normalizedName) != nil {
try await stopVM(name: normalizedName)
}
let vmDir = home.getVMDirectory(normalizedName)
let vmDir = try home.getVMDirectory(normalizedName, storage: actualLocation)
try vmDir.delete()
Logger.info("VM deleted successfully", metadata: ["name": normalizedName])
@@ -181,22 +212,26 @@ final class LumeController {
cpu: Int? = nil,
memory: UInt64? = nil,
diskSize: UInt64? = nil,
display: String? = nil
display: String? = nil,
storage: String? = nil
) throws {
let normalizedName = normalizeVMName(name: name)
Logger.info(
"Updating VM settings",
metadata: [
"name": normalizedName,
"location": storage ?? "default",
"cpu": cpu.map { "\($0)" } ?? "unchanged",
"memory": memory.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
"disk_size": diskSize.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
"display": display ?? "unchanged",
])
do {
try self.validateVMExists(normalizedName)
// Find the actual location of the VM
let actualLocation = try self.validateVMExists(
normalizedName, storage: storage)
let vm = try get(name: normalizedName)
let vm = try get(name: normalizedName, storage: actualLocation)
// Apply settings in order
if let cpu = cpu {
@@ -221,19 +256,21 @@ final class LumeController {
}
@MainActor
public func stopVM(name: String) async throws {
public func stopVM(name: String, storage: String? = nil) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info("Stopping VM", metadata: ["name": normalizedName])
do {
try self.validateVMExists(normalizedName)
// Find the actual location of the VM
let actualLocation = try self.validateVMExists(
normalizedName, storage: storage)
// Try to get VM from cache first
let vm: VM
if let cachedVM = SharedVM.shared.getVM(name: normalizedName) {
vm = cachedVM
} else {
vm = try get(name: normalizedName)
vm = try get(name: normalizedName, storage: actualLocation)
}
try await vm.stop()
@@ -257,18 +294,24 @@ final class LumeController {
registry: String = "ghcr.io",
organization: String = "trycua",
vncPort: Int = 0,
recoveryMode: Bool = false
recoveryMode: Bool = false,
storage: String? = nil,
usbMassStoragePaths: [Path]? = nil
) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info(
"Running VM",
metadata: [
"name": normalizedName,
"location": storage ?? "default",
"no_display": "\(noDisplay)",
"shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
"shared_directories":
"\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
"mount": mount?.path ?? "none",
"vnc_port": "\(vncPort)",
"recovery_mode": "\(recoveryMode)",
"storage_param": storage ?? "default",
"usb_storage_devices": "\(usbMassStoragePaths?.count ?? 0)",
])
do {
@@ -276,19 +319,51 @@ final class LumeController {
let components = name.split(separator: ":")
if components.count == 2 {
do {
try self.validateVMExists(normalizedName)
_ = try self.validateVMExists(normalizedName, storage: storage)
} catch {
// If the VM doesn't exist, try to pull the image
try await pullImage(image: name, name: nil, registry: registry, organization: organization)
try await pullImage(
image: name,
name: nil,
registry: registry,
organization: organization,
storage: storage
)
}
}
try validateRunParameters(
name: normalizedName, sharedDirectories: sharedDirectories, mount: mount)
// Find VM and get its actual location
let actualLocation = try validateVMExists(normalizedName, storage: storage)
// Log if we found the VM in a different location than default
if actualLocation != storage && actualLocation != nil {
Logger.info(
"Found VM in location",
metadata: [
"name": normalizedName,
"location": actualLocation ?? "default",
])
}
try validateRunParameters(
name: normalizedName,
sharedDirectories: sharedDirectories,
mount: mount,
storage: actualLocation,
usbMassStoragePaths: usbMassStoragePaths
)
// Use the actual VM location that we found
let vm = try get(name: normalizedName, storage: actualLocation)
let vm = try get(name: normalizedName)
SharedVM.shared.setVM(name: normalizedName, vm: vm)
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount, vncPort: vncPort, recoveryMode: recoveryMode)
try await vm.run(
noDisplay: noDisplay,
sharedDirectories: sharedDirectories,
mount: mount,
vncPort: vncPort,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths)
Logger.info("VM started successfully", metadata: ["name": normalizedName])
} catch {
SharedVM.shared.removeVM(name: normalizedName)
@@ -316,9 +391,13 @@ final class LumeController {
}
@MainActor
public func pullImage(image: String, name: String?, registry: String, organization: String, noCache: Bool = false)
async throws
{
public func pullImage(
image: String,
name: String?,
registry: String,
organization: String,
storage: String? = nil
) async throws {
do {
let vmName: String = name ?? normalizeVMName(name: image)
@@ -329,19 +408,33 @@ final class LumeController {
"name": name ?? "default",
"registry": registry,
"organization": organization,
"location": storage ?? "default",
])
try self.validatePullParameters(
image: image, name: vmName, registry: registry, organization: organization)
image: image,
name: vmName,
registry: registry,
organization: organization,
storage: storage
)
let imageContainerRegistry = ImageContainerRegistry(
registry: registry, organization: organization)
try await imageContainerRegistry.pull(image: image, name: vmName, noCache: noCache)
try await imageContainerRegistry.pull(
image: image,
name: vmName,
locationName: storage)
Logger.info("Setting new VM mac address")
Logger.info(
"Setting new VM mac address",
metadata: [
"vm_name": vmName,
"location": storage ?? "default",
])
// Update MAC address in the cloned VM to ensure uniqueness
let vm = try get(name: vmName)
let vm = try get(name: vmName, storage: storage)
try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
Logger.info(
@@ -351,6 +444,7 @@ final class LumeController {
"name": vmName,
"registry": registry,
"organization": organization,
"location": storage ?? "default",
])
} catch {
Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription])
@@ -361,14 +455,17 @@ final class LumeController {
@MainActor
public func pruneImages() async throws {
Logger.info("Pruning cached images")
do {
let home = FileManager.default.homeDirectoryForCurrentUser
let cacheDir = home.appendingPathComponent(".lume/cache/ghcr")
if FileManager.default.fileExists(atPath: cacheDir.path) {
try FileManager.default.removeItem(at: cacheDir)
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
// Use configured cache directory
let cacheDir = (SettingsManager.shared.getCacheDirectory() as NSString)
.expandingTildeInPath
let ghcrDir = URL(fileURLWithPath: cacheDir).appendingPathComponent("ghcr")
if FileManager.default.fileExists(atPath: ghcrDir.path) {
try FileManager.default.removeItem(at: ghcrDir)
try FileManager.default.createDirectory(
at: ghcrDir, withIntermediateDirectories: true)
Logger.info("Successfully removed cached images")
} else {
Logger.info("No cached images found")
@@ -392,21 +489,94 @@ final class LumeController {
@MainActor
public func getImages(organization: String = "trycua") async throws -> ImageList {
Logger.info("Listing local images", metadata: ["organization": organization])
let imageContainerRegistry = ImageContainerRegistry(registry: "ghcr.io", organization: organization)
let imageContainerRegistry = ImageContainerRegistry(
registry: "ghcr.io", organization: organization)
let cachedImages = try await imageContainerRegistry.getImages()
let imageInfos = cachedImages.map { image in
ImageInfo(
repository: image.repository,
imageId: String(image.manifestId.prefix(12))
)
}
ImagesPrinter.print(images: imageInfos.map { "\($0.repository):\($0.imageId)" })
return ImageList(local: imageInfos, remote: [])
}
// MARK: - Settings Management
public func getSettings() -> LumeSettings {
return SettingsManager.shared.getSettings()
}
public func setHomeDirectory(_ path: String) throws {
// Try to set the home directory in settings
try SettingsManager.shared.setHomeDirectory(path: path)
// Force recreate home instance to use the new path
try home.validateHomeDirectory()
Logger.info("Home directory updated", metadata: ["path": path])
}
// MARK: - VM Location Management
public func addLocation(name: String, path: String) throws {
Logger.info("Adding VM location", metadata: ["name": name, "path": path])
try home.addLocation(name: name, path: path)
Logger.info("VM location added successfully", metadata: ["name": name])
}
public func removeLocation(name: String) throws {
Logger.info("Removing VM location", metadata: ["name": name])
try home.removeLocation(name: name)
Logger.info("VM location removed successfully", metadata: ["name": name])
}
public func setDefaultLocation(name: String) throws {
Logger.info("Setting default VM location", metadata: ["name": name])
try home.setDefaultLocation(name: name)
Logger.info("Default VM location set successfully", metadata: ["name": name])
}
public func getLocations() -> [VMLocation] {
return home.getLocations()
}
// MARK: - Cache Directory Management
public func setCacheDirectory(path: String) throws {
Logger.info("Setting cache directory", metadata: ["path": path])
try SettingsManager.shared.setCacheDirectory(path: path)
Logger.info("Cache directory updated", metadata: ["path": path])
}
public func getCacheDirectory() -> String {
return SettingsManager.shared.getCacheDirectory()
}
public func isCachingEnabled() -> Bool {
return SettingsManager.shared.isCachingEnabled()
}
public func setCachingEnabled(_ enabled: Bool) throws {
Logger.info("Setting caching enabled", metadata: ["enabled": "\(enabled)"])
try SettingsManager.shared.setCachingEnabled(enabled)
Logger.info("Caching setting updated", metadata: ["enabled": "\(enabled)"])
}
// MARK: - Private Helper Methods
/// Normalizes a VM name by replacing colons with underscores
@@ -435,7 +605,8 @@ final class LumeController {
let vmDirContext = VMDirContext(
dir: try home.createTempVMDirectory(),
config: config,
home: home
home: home,
storage: nil
)
let imageLoader = os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
@@ -443,14 +614,15 @@ final class LumeController {
}
@MainActor
private func loadVM(name: String) throws -> VM {
let vmDir = home.getVMDirectory(name)
private func loadVM(name: String, storage: String? = nil) throws -> VM {
let vmDir = try home.getVMDirectory(name, storage: storage)
guard vmDir.initialized() else {
throw VMError.notInitialized(name)
}
let config: VMConfig = try vmDir.loadConfig()
let vmDirContext = VMDirContext(dir: vmDir, config: config, home: home)
let vmDirContext = VMDirContext(
dir: vmDir, config: config, home: home, storage: storage)
let imageLoader =
config.os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
@@ -459,7 +631,9 @@ final class LumeController {
// MARK: - Validation Methods
private func validateCreateParameters(name: String, os: String, ipsw: String?) throws {
private func validateCreateParameters(
name: String, os: String, ipsw: String?, storage: String?
) throws {
if os.lowercased() == "macos" {
guard let ipsw = ipsw else {
throw ValidationError("IPSW path required for macOS VM")
@@ -475,7 +649,7 @@ final class LumeController {
throw ValidationError("Unsupported OS type: \(os)")
}
let vmDir = home.getVMDirectory(name)
let vmDir = try home.getVMDirectory(name, storage: storage)
if vmDir.exists() {
throw VMError.alreadyExists(name)
}
@@ -493,15 +667,33 @@ final class LumeController {
}
}
public func validateVMExists(_ name: String) throws {
let vmDir = home.getVMDirectory(name)
guard vmDir.initialized() else {
throw VMError.notFound(name)
public func validateVMExists(_ name: String, storage: String? = nil) throws -> String? {
// If location is specified, only check that location
if let storage = storage {
let vmDir = try home.getVMDirectory(name, storage: storage)
guard vmDir.initialized() else {
throw VMError.notFound(name)
}
return storage
}
// If no location specified, try to find the VM in any location
let allVMs = try home.getAllVMDirectories()
if let foundVM = allVMs.first(where: { $0.directory.name == name }) {
// VM found, return its location
return foundVM.locationName
}
// VM not found in any location
throw VMError.notFound(name)
}
private func validatePullParameters(
image: String, name: String, registry: String, organization: String
image: String,
name: String,
registry: String,
organization: String,
storage: String? = nil
) throws {
guard !image.isEmpty else {
throw ValidationError("Image name cannot be empty")
@@ -516,20 +708,38 @@ final class LumeController {
throw ValidationError("Organization cannot be empty")
}
let vmDir = home.getVMDirectory(name)
let vmDir = try home.getVMDirectory(name, storage: storage)
if vmDir.exists() {
throw VMError.alreadyExists(name)
}
}
private func validateRunParameters(
name: String, sharedDirectories: [SharedDirectory]?, mount: Path?
name: String, sharedDirectories: [SharedDirectory]?, mount: Path?,
storage: String? = nil, usbMassStoragePaths: [Path]? = nil
) throws {
try self.validateVMExists(name)
if let dirs: [SharedDirectory] = sharedDirectories {
_ = try self.validateVMExists(name, storage: storage)
if let dirs = sharedDirectories {
try self.validateSharedDirectories(dirs)
}
let vmConfig = try home.getVMDirectory(name).loadConfig()
// Validate USB mass storage paths
if let usbPaths = usbMassStoragePaths {
for path in usbPaths {
if !FileManager.default.fileExists(atPath: path.path) {
throw ValidationError("USB mass storage image not found: \(path.path)")
}
}
if #available(macOS 15.0, *) {
// USB mass storage is supported
} else {
Logger.info(
"USB mass storage devices require macOS 15.0 or later. They will be ignored.")
}
}
let vmConfig = try home.getVMDirectory(name, storage: storage).loadConfig()
switch vmConfig.os.lowercased() {
case "macos":
if mount != nil {

View File

@@ -1,11 +1,11 @@
import ArgumentParser
import Foundation
import Virtualization
import ArgumentParser
@MainActor
extension Server {
// MARK: - VM Management Handlers
func handleListVMs() async throws -> HTTPResponse {
do {
let vmController = LumeController()
@@ -15,27 +15,28 @@ extension Server {
return .badRequest(message: error.localizedDescription)
}
}
func handleGetVM(name: String) async throws -> HTTPResponse {
func handleGetVM(name: String, storage: String? = nil) async throws -> HTTPResponse {
do {
let vmController = LumeController()
let vm = try vmController.get(name: name)
let vm = try vmController.get(name: name, storage: storage)
return try .json(vm.details)
} catch {
return .badRequest(message: error.localizedDescription)
}
}
func handleCreateVM(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(CreateVMRequest.self, from: body) else {
let request = try? JSONDecoder().decode(CreateVMRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let sizes = try request.parse()
let vmController = LumeController()
@@ -46,54 +47,15 @@ extension Server {
cpuCount: request.cpu,
memorySize: sizes.memory,
display: request.display,
ipsw: request.ipsw
ipsw: request.ipsw,
storage: request.storage
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "VM created successfully", "name": request.name])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleDeleteVM(name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.delete(name: name)
return HTTPResponse(statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data())
} catch {
return HTTPResponse(statusCode: .badRequest, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode(APIError(message: error.localizedDescription)))
}
}
func handleCloneVM(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(CloneRequest.self, from: body) else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
try vmController.clone(name: request.name, newName: request.newName)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode([
"message": "VM cloned successfully",
"source": request.name,
"destination": request.newName
"message": "VM created successfully", "name": request.name,
])
)
} catch {
@@ -104,19 +66,71 @@ extension Server {
)
}
}
// MARK: - VM Operation Handlers
func handleSetVM(name: String, body: Data?) async throws -> HTTPResponse {
func handleDeleteVM(name: String, storage: String? = nil) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.delete(name: name, storage: storage)
return HTTPResponse(
statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data())
} catch {
return HTTPResponse(
statusCode: .badRequest, headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription)))
}
}
func handleCloneVM(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(SetVMRequest.self, from: body) else {
let request = try? JSONDecoder().decode(CloneRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
try vmController.clone(
name: request.name,
newName: request.newName,
sourceLocation: request.sourceLocation,
destLocation: request.destLocation
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode([
"message": "VM cloned successfully",
"source": request.name,
"destination": request.newName,
])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
// MARK: - VM Operation Handlers
func handleSetVM(name: String, body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(SetVMRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
let sizes = try request.parse()
@@ -125,9 +139,10 @@ extension Server {
cpu: request.cpu,
memory: sizes.memory,
diskSize: sizes.diskSize,
display: sizes.display?.string
display: sizes.display?.string,
storage: request.storage
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
@@ -141,11 +156,11 @@ extension Server {
)
}
}
func handleStopVM(name: String) async throws -> HTTPResponse {
func handleStopVM(name: String, storage: String? = nil) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.stopVM(name: name)
try await vmController.stopVM(name: name, storage: storage)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
@@ -161,19 +176,22 @@ extension Server {
}
func handleRunVM(name: String, body: Data?) async throws -> HTTPResponse {
let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil)
let request =
body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) }
?? RunVMRequest(noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil)
do {
let dirs = try request.parse()
// Start VM in background
startVM(
name: name,
noDisplay: request.noDisplay ?? false,
sharedDirectories: dirs,
recoveryMode: request.recoveryMode ?? false
recoveryMode: request.recoveryMode ?? false,
storage: request.storage
)
// Return response immediately
return HTTPResponse(
statusCode: .accepted,
@@ -181,7 +199,7 @@ extension Server {
body: try JSONEncoder().encode([
"message": "VM start initiated",
"name": name,
"status": "pending"
"status": "pending",
])
)
} catch {
@@ -192,9 +210,9 @@ extension Server {
)
}
}
// MARK: - Image Management Handlers
func handleIPSW() async throws -> HTTPResponse {
do {
let vmController = LumeController()
@@ -215,7 +233,8 @@ extension Server {
func handlePull(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(PullRequest.self, from: body) else {
let request = try? JSONDecoder().decode(PullRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
@@ -225,11 +244,22 @@ extension Server {
do {
let vmController = LumeController()
try await vmController.pullImage(image: request.image, name: request.name, registry: request.registry, organization: request.organization, noCache: request.noCache)
try await vmController.pullImage(
image: request.image,
name: request.name,
registry: request.registry,
organization: request.organization,
storage: request.storage
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Image pulled successfully"])
body: try JSONEncoder().encode([
"message": "Image pulled successfully",
"image": request.image,
"name": request.name ?? "default",
])
)
} catch {
return HTTPResponse(
@@ -257,30 +287,34 @@ extension Server {
)
}
}
func handleGetImages(_ request: HTTPRequest) async throws -> HTTPResponse {
let pathAndQuery = request.path.split(separator: "?", maxSplits: 1)
let queryParams = pathAndQuery.count > 1 ? pathAndQuery[1]
.split(separator: "&")
.reduce(into: [String: String]()) { dict, param in
let parts = param.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
dict[String(parts[0])] = String(parts[1])
}
} : [:]
let queryParams =
pathAndQuery.count > 1
? pathAndQuery[1]
.split(separator: "&")
.reduce(into: [String: String]()) { dict, param in
let parts = param.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
dict[String(parts[0])] = String(parts[1])
}
} : [:]
let organization = queryParams["organization"] ?? "trycua"
do {
let vmController = LumeController()
let imageList = try await vmController.getImages(organization: organization)
// Create a response format that matches the CLI output
let response = imageList.local.map { [
"repository": $0.repository,
"imageId": $0.imageId
] }
let response = imageList.local.map {
[
"repository": $0.repository,
"imageId": $0.imageId,
]
}
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
@@ -294,14 +328,157 @@ extension Server {
)
}
}
// MARK: - Config Management Handlers
func handleGetConfig() async throws -> HTTPResponse {
do {
let vmController = LumeController()
let settings = vmController.getSettings()
return try .json(settings)
} catch {
return .badRequest(message: error.localizedDescription)
}
}
struct ConfigRequest: Codable {
let homeDirectory: String?
let cacheDirectory: String?
let cachingEnabled: Bool?
}
func handleUpdateConfig(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(ConfigRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
if let homeDir = request.homeDirectory {
try vmController.setHomeDirectory(homeDir)
}
if let cacheDir = request.cacheDirectory {
try vmController.setCacheDirectory(path: cacheDir)
}
if let cachingEnabled = request.cachingEnabled {
try vmController.setCachingEnabled(cachingEnabled)
}
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Configuration updated successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleGetLocations() async throws -> HTTPResponse {
do {
let vmController = LumeController()
let locations = vmController.getLocations()
return try .json(locations)
} catch {
return .badRequest(message: error.localizedDescription)
}
}
struct LocationRequest: Codable {
let name: String
let path: String
}
func handleAddLocation(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(LocationRequest.self, from: body)
else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
try vmController.addLocation(name: request.name, path: request.path)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode([
"message": "Location added successfully",
"name": request.name,
"path": request.path,
])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleRemoveLocation(_ name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try vmController.removeLocation(name: name)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Location removed successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleSetDefaultLocation(_ name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try vmController.setDefaultLocation(name: name)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Default location set successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
// MARK: - Private Helper Methods
nonisolated private func startVM(
name: String,
noDisplay: Bool,
sharedDirectories: [SharedDirectory] = [],
recoveryMode: Bool = false
recoveryMode: Bool = false,
storage: String? = nil
) {
Task.detached { @MainActor @Sendable in
Logger.info("Starting VM in background", metadata: ["name": name])
@@ -311,14 +488,17 @@ extension Server {
name: name,
noDisplay: noDisplay,
sharedDirectories: sharedDirectories,
recoveryMode: recoveryMode
recoveryMode: recoveryMode,
storage: storage
)
Logger.info("VM started successfully in background", metadata: ["name": name])
} catch {
Logger.error("Failed to start VM in background", metadata: [
"name": name,
"error": error.localizedDescription
])
Logger.error(
"Failed to start VM in background",
metadata: [
"name": name,
"error": error.localizedDescription,
])
}
}
}

View File

@@ -1,28 +1,31 @@
import Foundation
import ArgumentParser
import Foundation
import Virtualization
struct RunVMRequest: Codable {
let noDisplay: Bool?
let sharedDirectories: [SharedDirectoryRequest]?
let recoveryMode: Bool?
let storage: String?
struct SharedDirectoryRequest: Codable {
let hostPath: String
let readOnly: Bool?
}
func parse() throws -> [SharedDirectory] {
guard let sharedDirectories = sharedDirectories else { return [] }
return try sharedDirectories.map { dir -> SharedDirectory in
// Validate that the host path exists and is a directory
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: dir.hostPath, isDirectory: &isDirectory),
isDirectory.boolValue else {
throw ValidationError("Host path does not exist or is not a directory: \(dir.hostPath)")
isDirectory.boolValue
else {
throw ValidationError(
"Host path does not exist or is not a directory: \(dir.hostPath)")
}
return SharedDirectory(
hostPath: dir.hostPath,
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
@@ -37,19 +40,19 @@ struct PullRequest: Codable {
let name: String?
var registry: String
var organization: String
var noCache: Bool
let storage: String?
enum CodingKeys: String, CodingKey {
case image, name, registry, organization, noCache
case image, name, registry, organization, storage
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
image = try container.decode(String.self, forKey: .image)
name = try container.decodeIfPresent(String.self, forKey: .name)
registry = try container.decodeIfPresent(String.self, forKey: .registry) ?? "ghcr.io"
organization = try container.decodeIfPresent(String.self, forKey: .organization) ?? "trycua"
noCache = try container.decodeIfPresent(Bool.self, forKey: .noCache) ?? false
storage = try container.decodeIfPresent(String.self, forKey: .storage)
}
}
@@ -61,7 +64,8 @@ struct CreateVMRequest: Codable {
let diskSize: String
let display: String
let ipsw: String?
let storage: String?
func parse() throws -> (memory: UInt64, diskSize: UInt64) {
return (
memory: try parseSize(memory),
@@ -75,14 +79,16 @@ struct SetVMRequest: Codable {
let memory: String?
let diskSize: String?
let display: String?
let storage: String?
func parse() throws -> (memory: UInt64?, diskSize: UInt64?, display: VMDisplayResolution?) {
return (
memory: try memory.map { try parseSize($0) },
diskSize: try diskSize.map { try parseSize($0) },
display: try display.map {
display: try display.map {
guard let resolution = VMDisplayResolution(string: $0) else {
throw ValidationError("Invalid display resolution format: \($0). Expected format: WIDTHxHEIGHT")
throw ValidationError(
"Invalid display resolution format: \($0). Expected format: WIDTHxHEIGHT")
}
return resolution
}
@@ -93,4 +99,6 @@ struct SetVMRequest: Codable {
struct CloneRequest: Codable {
let name: String
let newName: String
let sourceLocation: String?
let destLocation: String?
}

View File

@@ -1,11 +1,11 @@
import Darwin
import Foundation
import Network
import Darwin
// MARK: - Error Types
enum PortError: Error, LocalizedError {
case alreadyInUse(port: UInt16)
var errorDescription: String? {
switch self {
case .alreadyInUse(let port):
@@ -17,147 +17,263 @@ enum PortError: Error, LocalizedError {
// MARK: - Server Class
@MainActor
final class Server {
// MARK: - Route Type
private struct Route {
let method: String
let path: String
let handler: (HTTPRequest) async throws -> HTTPResponse
func matches(_ request: HTTPRequest) -> Bool {
if method != request.method { return false }
// Handle path parameters
let routeParts = path.split(separator: "/")
let requestParts = request.path.split(separator: "/")
if routeParts.count != requestParts.count { return false }
for (routePart, requestPart) in zip(routeParts, requestParts) {
if routePart.hasPrefix(":") { continue } // Path parameter
if routePart != requestPart { return false }
}
return true
}
func extractParams(_ request: HTTPRequest) -> [String: String] {
var params: [String: String] = [:]
let routeParts = path.split(separator: "/")
let requestParts = request.path.split(separator: "/")
for (routePart, requestPart) in zip(routeParts, requestParts) {
if routePart.hasPrefix(":") {
let paramName = String(routePart.dropFirst())
params[paramName] = String(requestPart)
}
}
return params
}
}
// MARK: - Properties
private let port: NWEndpoint.Port
private let controller: LumeController
private var isRunning = false
private var listener: NWListener?
private var routes: [Route]
// MARK: - Initialization
init(port: UInt16 = 3000) {
self.port = NWEndpoint.Port(rawValue: port)!
self.controller = LumeController()
self.routes = []
// Define API routes after self is fully initialized
self.setupRoutes()
}
// MARK: - Route Setup
private func setupRoutes() {
routes = [
Route(method: "GET", path: "/lume/vms", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleListVMs()
}),
Route(method: "GET", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "GET", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleGetVM(name: name)
}),
Route(method: "DELETE", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "DELETE", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleDeleteVM(name: name)
}),
Route(method: "POST", path: "/lume/vms", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCreateVM(request.body)
}),
Route(method: "POST", path: "/lume/vms/clone", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCloneVM(request.body)
}),
Route(method: "PATCH", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "PATCH", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleSetVM(name: name, body: request.body)
}),
Route(method: "POST", path: "/lume/vms/:name/run", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "POST", path: "/lume/vms/:name/run", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleRunVM(name: name, body: request.body)
}),
Route(method: "POST", path: "/lume/vms/:name/stop", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "POST", path: "/lume/vms/:name/stop", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleStopVM(name: name)
}),
Route(method: "GET", path: "/lume/ipsw", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleIPSW()
}),
Route(method: "POST", path: "/lume/pull", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handlePull(request.body)
}),
Route(method: "POST", path: "/lume/prune", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handlePruneImages()
}),
Route(method: "GET", path: "/lume/images", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleGetImages(request)
})
Route(
method: "GET", path: "/lume/vms",
handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleListVMs()
}),
Route(
method: "GET", path: "/lume/vms/:name",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "GET", path: "/lume/vms/:name",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
// Extract storage from query params if present
let storage = self.extractQueryParam(request: request, name: "storage")
return try await self.handleGetVM(name: name, storage: storage)
}),
Route(
method: "DELETE", path: "/lume/vms/:name",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "DELETE", path: "/lume/vms/:name",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
// Extract storage from query params if present
let storage = self.extractQueryParam(request: request, name: "storage")
return try await self.handleDeleteVM(name: name, storage: storage)
}),
Route(
method: "POST", path: "/lume/vms",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCreateVM(request.body)
}),
Route(
method: "POST", path: "/lume/vms/clone",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCloneVM(request.body)
}),
Route(
method: "PATCH", path: "/lume/vms/:name",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "PATCH", path: "/lume/vms/:name",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleSetVM(name: name, body: request.body)
}),
Route(
method: "POST", path: "/lume/vms/:name/run",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "POST", path: "/lume/vms/:name/run",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleRunVM(name: name, body: request.body)
}),
Route(
method: "POST", path: "/lume/vms/:name/stop",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "POST", path: "/lume/vms/:name/stop",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
// Extract storage from query params if present
let storage = self.extractQueryParam(request: request, name: "storage")
return try await self.handleStopVM(name: name, storage: storage)
}),
Route(
method: "GET", path: "/lume/ipsw",
handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleIPSW()
}),
Route(
method: "POST", path: "/lume/pull",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handlePull(request.body)
}),
Route(
method: "POST", path: "/lume/prune",
handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handlePruneImages()
}),
Route(
method: "GET", path: "/lume/images",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleGetImages(request)
}),
// New config endpoint
Route(
method: "GET", path: "/lume/config",
handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleGetConfig()
}),
Route(
method: "POST", path: "/lume/config",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleUpdateConfig(request.body)
}),
Route(
method: "GET", path: "/lume/config/locations",
handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleGetLocations()
}),
Route(
method: "POST", path: "/lume/config/locations",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleAddLocation(request.body)
}),
Route(
method: "DELETE", path: "/lume/config/locations/:name",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "DELETE", path: "/lume/config/locations/:name",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing location name")
}
return try await self.handleRemoveLocation(name)
}),
Route(
method: "POST", path: "/lume/config/locations/default/:name",
handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(
method: "POST", path: "/lume/config/locations/default/:name",
handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}
).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing location name")
}
return try await self.handleSetDefaultLocation(name)
}),
]
}
// Helper to extract query parameters from the URL
private func extractQueryParam(request: HTTPRequest, name: String) -> String? {
if let urlComponents = URLComponents(string: request.path),
let queryItems = urlComponents.queryItems
{
return queryItems.first(where: { $0.name == name })?.value
}
return nil
}
// MARK: - Port Utilities
private func isPortAvailable(port: Int) async -> Bool {
// Create a socket
@@ -165,34 +281,36 @@ final class Server {
if socketFD == -1 {
return false
}
// Set socket options to allow reuse
var value: Int32 = 1
if setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout<Int32>.size)) == -1 {
if setsockopt(
socketFD, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout<Int32>.size)) == -1
{
close(socketFD)
return false
}
// Set up the address structure
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = UInt16(port).bigEndian
addr.sin_addr.s_addr = INADDR_ANY.bigEndian
// Bind to the port
let bindResult = withUnsafePointer(to: &addr) { addrPtr in
addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { addrPtr in
Darwin.bind(socketFD, addrPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
// Clean up
close(socketFD)
// If bind failed, the port is in use
return bindResult == 0
}
// MARK: - Server Lifecycle
func start() async throws {
// First check if the port is already in use
@@ -200,31 +318,31 @@ final class Server {
// Don't log anything here, just throw the error
throw PortError.alreadyInUse(port: port.rawValue)
}
let parameters = NWParameters.tcp
listener = try NWListener(using: parameters, on: port)
// Create an actor to safely manage state transitions
actor StartupState {
var error: Error?
var isComplete = false
func setError(_ error: Error) {
self.error = error
self.isComplete = true
}
func setComplete() {
self.isComplete = true
}
func checkStatus() -> (isComplete: Bool, error: Error?) {
return (isComplete, error)
}
}
let startupState = StartupState()
// Set up a state update handler to detect port binding errors
listener?.stateUpdateHandler = { state in
Task {
@@ -235,24 +353,29 @@ final class Server {
break
case .waiting(let error):
// Log the full error details to see what we're getting
Logger.error("Listener waiting", metadata: [
"error": error.localizedDescription,
"debugDescription": error.debugDescription,
"localizedDescription": error.localizedDescription,
"port": "\(self.port.rawValue)"
])
Logger.error(
"Listener waiting",
metadata: [
"error": error.localizedDescription,
"debugDescription": error.debugDescription,
"localizedDescription": error.localizedDescription,
"port": "\(self.port.rawValue)",
])
// Check for different port in use error messages
if error.debugDescription.contains("Address already in use") ||
error.localizedDescription.contains("in use") ||
error.localizedDescription.contains("address already in use") {
Logger.error("Port conflict detected", metadata: ["port": "\(self.port.rawValue)"])
await startupState.setError(PortError.alreadyInUse(port: self.port.rawValue))
if error.debugDescription.contains("Address already in use")
|| error.localizedDescription.contains("in use")
|| error.localizedDescription.contains("address already in use")
{
Logger.error(
"Port conflict detected", metadata: ["port": "\(self.port.rawValue)"])
await startupState.setError(
PortError.alreadyInUse(port: self.port.rawValue))
} else {
// Wait for a short period to see if the listener recovers
// Some network errors are transient
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
// If we're still waiting after delay, consider it an error
if case .waiting = await self.listener?.state {
await startupState.setError(error)
@@ -260,11 +383,13 @@ final class Server {
}
case .failed(let error):
// Log the full error details
Logger.error("Listener failed", metadata: [
"error": error.localizedDescription,
"debugDescription": error.debugDescription,
"port": "\(self.port.rawValue)"
])
Logger.error(
"Listener failed",
metadata: [
"error": error.localizedDescription,
"debugDescription": error.debugDescription,
"port": "\(self.port.rawValue)",
])
await startupState.setError(error)
case .ready:
// Listener successfully bound to port
@@ -275,49 +400,51 @@ final class Server {
Logger.info("Listener cancelled", metadata: ["port": "\(self.port.rawValue)"])
break
@unknown default:
Logger.info("Unknown listener state", metadata: ["state": "\(state)", "port": "\(self.port.rawValue)"])
Logger.info(
"Unknown listener state",
metadata: ["state": "\(state)", "port": "\(self.port.rawValue)"])
break
}
}
}
listener?.newConnectionHandler = { [weak self] connection in
Task { @MainActor [weak self] in
guard let self else { return }
self.handleConnection(connection)
}
}
listener?.start(queue: .main)
// Wait for either successful startup or an error
var status: (isComplete: Bool, error: Error?) = (false, nil)
repeat {
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
status = await startupState.checkStatus()
} while !status.isComplete
// If there was a startup error, throw it
if let error = status.error {
self.stop()
throw error
}
isRunning = true
Logger.info("Server started", metadata: ["port": "\(port.rawValue)"])
// Keep the server running
while isRunning {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
}
func stop() {
isRunning = false
listener?.cancel()
}
// MARK: - Connection Handling
private func handleConnection(_ connection: NWConnection) {
connection.stateUpdateHandler = { [weak self] state in
@@ -339,22 +466,23 @@ final class Server {
}
connection.start(queue: .main)
}
private func receiveData(_ connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) {
[weak self] content, _, isComplete, error in
if let error = error {
Logger.error("Receive error", metadata: ["error": error.localizedDescription])
connection.cancel()
return
}
guard let data = content, !data.isEmpty else {
if isComplete {
connection.cancel()
}
return
}
Task { @MainActor [weak self] in
guard let self else { return }
do {
@@ -367,55 +495,64 @@ final class Server {
}
}
}
private func send(_ response: HTTPResponse, on connection: NWConnection) {
let data = response.serialize()
Logger.info("Serialized response", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
connection.send(content: data, completion: .contentProcessed { [weak connection] error in
if let error = error {
Logger.error("Failed to send response", metadata: ["error": error.localizedDescription])
} else {
Logger.info("Response sent successfully")
}
if connection?.state != .cancelled {
connection?.cancel()
}
})
Logger.info(
"Serialized response", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
connection.send(
content: data,
completion: .contentProcessed { [weak connection] error in
if let error = error {
Logger.error(
"Failed to send response", metadata: ["error": error.localizedDescription])
} else {
Logger.info("Response sent successfully")
}
if connection?.state != .cancelled {
connection?.cancel()
}
})
}
// MARK: - Request Handling
private func handleRequest(_ data: Data) async throws -> HTTPResponse {
Logger.info("Received request data", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
Logger.info(
"Received request data", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
guard let request = HTTPRequest(data: data) else {
Logger.error("Failed to parse request")
return HTTPResponse(statusCode: .badRequest, body: "Invalid request")
}
Logger.info("Parsed request", metadata: [
"method": request.method,
"path": request.path,
"headers": "\(request.headers)",
"body": String(data: request.body ?? Data(), encoding: .utf8) ?? ""
])
Logger.info(
"Parsed request",
metadata: [
"method": request.method,
"path": request.path,
"headers": "\(request.headers)",
"body": String(data: request.body ?? Data(), encoding: .utf8) ?? "",
])
// Find matching route
guard let route = routes.first(where: { $0.matches(request) }) else {
return HTTPResponse(statusCode: .notFound, body: "Not found")
}
// Handle the request
let response = try await route.handler(request)
Logger.info("Sending response", metadata: [
"statusCode": "\(response.statusCode.rawValue)",
"headers": "\(response.headers)",
"body": String(data: response.body ?? Data(), encoding: .utf8) ?? ""
])
Logger.info(
"Sending response",
metadata: [
"statusCode": "\(response.statusCode.rawValue)",
"headers": "\(response.headers)",
"body": String(data: response.body ?? Data(), encoding: .utf8) ?? "",
])
return response
}
private func errorResponse(_ error: Error) -> HTTPResponse {
HTTPResponse(
statusCode: .internalServerError,
@@ -423,4 +560,4 @@ final class Server {
body: try! JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
}

View File

@@ -15,7 +15,8 @@ enum CommandRegistry {
IPSW.self,
Serve.self,
Delete.self,
Prune.self
Prune.self,
Config.self,
]
}
}

View File

@@ -2,5 +2,5 @@ import ArgumentParser
import Foundation
func completeVMName(_ arguments: [String]) -> [String] {
(try? Home().getAllVMDirectories().map(\.name)) ?? []
}
(try? Home().getAllVMDirectories().map { $0.directory.name }) ?? []
}

View File

@@ -7,22 +7,23 @@ struct VMDirContext {
let dir: VMDirectory
var config: VMConfig
let home: Home
let storage: String?
func saveConfig() throws {
try dir.saveConfig(config)
}
var name: String { dir.name }
var initialized: Bool { dir.initialized() }
var diskPath: Path { dir.diskPath }
var nvramPath: Path { dir.nvramPath }
func setDisk(_ size: UInt64) throws {
try dir.setDisk(size)
}
func finalize(to name: String) throws {
let vmDir = home.getVMDirectory(name)
let vmDir = try home.getVMDirectory(name)
try FileManager.default.moveItem(at: dir.dir.url, to: vmDir.dir.url)
}
}
@@ -33,21 +34,25 @@ struct VMDirContext {
@MainActor
class VM {
// MARK: - Properties
var vmDirContext: VMDirContext
@MainActor
private var virtualizationService: VMVirtualizationService?
private let vncService: VNCService
internal let virtualizationServiceFactory: (VMVirtualizationServiceContext) throws -> VMVirtualizationService
internal let virtualizationServiceFactory:
(VMVirtualizationServiceContext) throws -> VMVirtualizationService
private let vncServiceFactory: (VMDirectory) -> VNCService
// MARK: - Initialization
init(
vmDirContext: VMDirContext,
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) },
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) }
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws ->
VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) },
vncServiceFactory: @escaping (VMDirectory) -> VNCService = {
DefaultVNCService(vmDirectory: $0)
}
) {
self.vmDirContext = vmDirContext
self.virtualizationServiceFactory = virtualizationServiceFactory
@@ -58,13 +63,14 @@ class VM {
}
// MARK: - VM State Management
private var isRunning: Bool {
// First check if we have an IP address
guard let ipAddress = DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) else {
guard let ipAddress = DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!)
else {
return false
}
// Then check if it's reachable
return NetworkUtils.isReachable(ipAddress: ipAddress)
}
@@ -72,7 +78,7 @@ class VM {
var details: VMDetails {
let isRunning: Bool = self.isRunning
let vncUrl = isRunning ? getVNCUrl() : nil
return VMDetails(
name: vmDirContext.name,
os: getOSType(),
@@ -82,19 +88,25 @@ class VM {
display: vmDirContext.config.display.string,
status: isRunning ? "running" : "stopped",
vncUrl: vncUrl,
ipAddress: isRunning ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil
ipAddress: isRunning
? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil,
locationName: vmDirContext.storage ?? "default"
)
}
// MARK: - VM Lifecycle Management
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0, recoveryMode: Bool = false) async throws {
func run(
noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0,
recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil
) async throws {
guard vmDirContext.initialized else {
throw VMError.notInitialized(vmDirContext.name)
}
guard let cpuCount = vmDirContext.config.cpuCount,
let memorySize = vmDirContext.config.memorySize else {
let memorySize = vmDirContext.config.memorySize
else {
throw VMError.notInitialized(vmDirContext.name)
}
@@ -105,16 +117,35 @@ class VM {
throw VMError.alreadyRunning(vmDirContext.name)
}
Logger.info("Running VM with configuration", metadata: [
"cpuCount": "\(cpuCount)",
"memorySize": "\(memorySize)",
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
"sharedDirectories": sharedDirectories.map(
{ $0.string }
).joined(separator: ", "),
"vncPort": "\(vncPort)",
"recoveryMode": "\(recoveryMode)"
])
Logger.info(
"Running VM with configuration",
metadata: [
"cpuCount": "\(cpuCount)",
"memorySize": "\(memorySize)",
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
"sharedDirectories": sharedDirectories.map(
{ $0.string }
).joined(separator: ", "),
"vncPort": "\(vncPort)",
"recoveryMode": "\(recoveryMode)",
"usbMassStorageDeviceCount": "\(usbMassStoragePaths?.count ?? 0)",
])
// Log disk paths and existence for debugging
Logger.info(
"VM disk paths",
metadata: [
"diskPath": vmDirContext.diskPath.path,
"diskExists":
"\(FileManager.default.fileExists(atPath: vmDirContext.diskPath.path))",
"nvramPath": vmDirContext.nvramPath.path,
"nvramExists":
"\(FileManager.default.fileExists(atPath: vmDirContext.nvramPath.path))",
"configPath": vmDirContext.dir.configPath.path,
"configExists":
"\(FileManager.default.fileExists(atPath: vmDirContext.dir.configPath.path))",
"locationName": vmDirContext.storage ?? "default",
])
// Create and configure the VM
do {
@@ -124,23 +155,30 @@ class VM {
display: vmDirContext.config.display.string,
sharedDirectories: sharedDirectories,
mount: mount,
recoveryMode: recoveryMode
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths
)
virtualizationService = try virtualizationServiceFactory(config)
let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort)
Logger.info("VNC info", metadata: ["vncInfo": vncInfo])
// Start the VM
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}
try await service.start()
while true {
try await Task.sleep(nanoseconds: UInt64(1e9))
}
} catch {
Logger.error(
"Failed to create/start VM",
metadata: [
"error": "\(error)",
"errorType": "\(type(of: error))",
])
virtualizationService = nil
vncService.stop()
// Release lock
@@ -164,13 +202,17 @@ class VM {
try await service.stop()
virtualizationService = nil
vncService.stop()
Logger.info("VM stopped successfully via virtualization service", metadata: ["name": vmDirContext.name])
Logger.info(
"VM stopped successfully via virtualization service",
metadata: ["name": vmDirContext.name])
return
} catch let error {
Logger.error("Failed to stop VM via virtualization service, falling back to process termination", metadata: [
"name": vmDirContext.name,
"error": "\(error)"
])
} catch let error {
Logger.error(
"Failed to stop VM via virtualization service, falling back to process termination",
metadata: [
"name": vmDirContext.name,
"error": "\(error)",
])
// Fall through to process termination
}
}
@@ -178,68 +220,76 @@ class VM {
// Try to open config file to get file descriptor - note that this matches with the serve process - so this is only for the command line
let fileHandle = try? FileHandle(forReadingFrom: vmDirContext.dir.configPath.url)
guard let fileHandle = fileHandle else {
Logger.error("Failed to open config file - VM not running", metadata: ["name": vmDirContext.name])
Logger.error(
"Failed to open config file - VM not running", metadata: ["name": vmDirContext.name]
)
throw VMError.notRunning(vmDirContext.name)
}
// Get the PID of the process holding the lock using lsof command
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
task.arguments = ["-F", "p", vmDirContext.dir.configPath.path]
let outputPipe = Pipe()
task.standardOutput = outputPipe
try task.run()
task.waitUntilExit()
let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data()
guard let outputString = String(data: outputData, encoding: .utf8),
let pidString = outputString.split(separator: "\n").first?.dropFirst(), // Drop the 'p' prefix
let pid = pid_t(pidString) else {
let pidString = outputString.split(separator: "\n").first?.dropFirst(), // Drop the 'p' prefix
let pid = pid_t(pidString)
else {
try? fileHandle.close()
Logger.error("Failed to find VM process - VM not running", metadata: ["name": vmDirContext.name])
Logger.error(
"Failed to find VM process - VM not running", metadata: ["name": vmDirContext.name])
throw VMError.notRunning(vmDirContext.name)
}
// First try graceful shutdown with SIGINT
if kill(pid, SIGINT) == 0 {
Logger.info("Sent SIGINT to VM process", metadata: ["name": vmDirContext.name, "pid": "\(pid)"])
Logger.info(
"Sent SIGINT to VM process", metadata: ["name": vmDirContext.name, "pid": "\(pid)"])
}
// Wait for process to stop with timeout
var attempts = 0
while attempts < 10 {
try await Task.sleep(nanoseconds: 1_000_000_000)
// Check if process still exists
if kill(pid, 0) != 0 {
// Process is gone, do final cleanup
virtualizationService = nil
vncService.stop()
try? fileHandle.close()
Logger.info("VM stopped successfully via process termination", metadata: ["name": vmDirContext.name])
Logger.info(
"VM stopped successfully via process termination",
metadata: ["name": vmDirContext.name])
return
}
attempts += 1
}
// If graceful shutdown failed, force kill the process
Logger.info("Graceful shutdown failed, forcing termination", metadata: ["name": vmDirContext.name])
Logger.info(
"Graceful shutdown failed, forcing termination", metadata: ["name": vmDirContext.name])
if kill(pid, SIGKILL) == 0 {
// Wait a moment for the process to be fully killed
try await Task.sleep(nanoseconds: 2_000_000_000)
// Do final cleanup
virtualizationService = nil
vncService.stop()
try? fileHandle.close()
Logger.info("VM forcefully stopped", metadata: ["name": vmDirContext.name])
return
}
// If we get here, something went very wrong
try? fileHandle.close()
Logger.error("Failed to stop VM", metadata: ["name": vmDirContext.name, "pid": "\(pid)"])
@@ -252,24 +302,25 @@ class VM {
vmDirContext.config = vmConfig
try vmDirContext.saveConfig()
}
private func getDiskSize() throws -> DiskSize {
let resourceValues = try vmDirContext.diskPath.url.resourceValues(forKeys: [
.totalFileAllocatedSizeKey,
.totalFileSizeKey
.totalFileSizeKey,
])
guard let allocated = resourceValues.totalFileAllocatedSize,
let total = resourceValues.totalFileSize else {
let total = resourceValues.totalFileSize
else {
throw VMConfigError.invalidDiskSize
}
return DiskSize(allocated: UInt64(allocated), total: UInt64(total))
}
func resizeDisk(_ newSize: UInt64) throws {
let currentSize = try getDiskSize()
guard newSize >= currentSize.total else {
throw VMError.resizeTooSmall(current: currentSize.total, requested: newSize)
}
@@ -335,18 +386,18 @@ class VM {
}
// MARK: - VNC Management
func getVNCUrl() -> String? {
return vncService.url
}
private func setupVNC(noDisplay: Bool, port: Int = 0) async throws -> String {
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}
try await vncService.start(port: port, virtualMachine: service.getVirtualMachine())
guard let url = vncService.url else {
throw VMError.vncNotConfigured
}
@@ -360,19 +411,23 @@ class VM {
}
// MARK: - Platform-specific Methods
func getOSType() -> String {
fatalError("Must be implemented by subclass")
}
}
func createVMVirtualizationServiceContext(
cpuCount: Int,
memorySize: UInt64,
display: String,
sharedDirectories: [SharedDirectory] = [],
mount: Path? = nil,
recoveryMode: Bool = false
recoveryMode: Bool = false,
usbMassStoragePaths: [Path]? = nil
) throws -> VMVirtualizationServiceContext {
// This is a diagnostic log to track actual file paths on disk for debugging
try validateDiskState()
return VMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
@@ -384,10 +439,54 @@ class VM {
macAddress: vmDirContext.config.macAddress!,
diskPath: vmDirContext.diskPath,
nvramPath: vmDirContext.nvramPath,
recoveryMode: recoveryMode
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths
)
}
/// Validates the disk state to help diagnose storage attachment issues
private func validateDiskState() throws {
// Check disk image state
let diskPath = vmDirContext.diskPath.path
let diskExists = FileManager.default.fileExists(atPath: diskPath)
var diskSize: UInt64 = 0
var diskPermissions = ""
if diskExists {
if let attrs = try? FileManager.default.attributesOfItem(atPath: diskPath) {
diskSize = attrs[.size] as? UInt64 ?? 0
let posixPerms = attrs[.posixPermissions] as? Int ?? 0
diskPermissions = String(format: "%o", posixPerms)
}
}
// Check disk container directory permissions
let diskDir = (diskPath as NSString).deletingLastPathComponent
let dirPerms =
try? FileManager.default.attributesOfItem(atPath: diskDir)[.posixPermissions] as? Int
?? 0
let dirPermsString = dirPerms != nil ? String(format: "%o", dirPerms!) : "unknown"
// Log detailed diagnostics
Logger.info(
"Validating VM disk state",
metadata: [
"diskPath": diskPath,
"diskExists": "\(diskExists)",
"diskSize":
"\(ByteCountFormatter.string(fromByteCount: Int64(diskSize), countStyle: .file))",
"diskPermissions": diskPermissions,
"dirPermissions": dirPermsString,
"locationName": vmDirContext.storage ?? "default",
])
if !diskExists {
Logger.error("VM disk image does not exist", metadata: ["diskPath": diskPath])
} else if diskSize == 0 {
Logger.error("VM disk image exists but has zero size", metadata: ["diskPath": diskPath])
}
}
func setup(
ipswPath: String,
cpuCount: Int,
@@ -399,9 +498,83 @@ class VM {
}
// MARK: - Finalization
/// Post-installation step to move the VM directory to the home directory
func finalize(to name: String, home: Home) throws {
try vmDirContext.finalize(to: name)
func finalize(to name: String, home: Home, storage: String? = nil) throws {
let vmDir = try home.getVMDirectory(name, storage: storage)
try FileManager.default.moveItem(at: vmDirContext.dir.dir.url, to: vmDir.dir.url)
}
// Method to run VM with additional USB mass storage devices
func runWithUSBStorage(
noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0,
recoveryMode: Bool = false, usbImagePaths: [Path]
) async throws {
guard vmDirContext.initialized else {
throw VMError.notInitialized(vmDirContext.name)
}
guard let cpuCount = vmDirContext.config.cpuCount,
let memorySize = vmDirContext.config.memorySize
else {
throw VMError.notInitialized(vmDirContext.name)
}
// Try to acquire lock on config file
let fileHandle = try FileHandle(forWritingTo: vmDirContext.dir.configPath.url)
guard flock(fileHandle.fileDescriptor, LOCK_EX | LOCK_NB) == 0 else {
try? fileHandle.close()
throw VMError.alreadyRunning(vmDirContext.name)
}
Logger.info(
"Running VM with USB storage devices",
metadata: [
"cpuCount": "\(cpuCount)",
"memorySize": "\(memorySize)",
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
"usbImageCount": "\(usbImagePaths.count)",
"recoveryMode": "\(recoveryMode)",
])
// Create and configure the VM
do {
let config = try createVMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
display: vmDirContext.config.display.string,
sharedDirectories: sharedDirectories,
mount: mount,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbImagePaths
)
virtualizationService = try virtualizationServiceFactory(config)
let vncInfo = try await setupVNC(noDisplay: noDisplay, port: vncPort)
Logger.info("VNC info", metadata: ["vncInfo": vncInfo])
// Start the VM
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}
try await service.start()
while true {
try await Task.sleep(nanoseconds: UInt64(1e9))
}
} catch {
Logger.error(
"Failed to create/start VM with USB storage",
metadata: [
"error": "\(error)",
"errorType": "\(type(of: error))",
])
virtualizationService = nil
vncService.stop()
// Release lock
flock(fileHandle.fileDescriptor, LOCK_UN)
try? fileHandle.close()
throw error
}
}
}

View File

@@ -10,21 +10,21 @@ extension DiskSize {
var formattedAllocated: String {
formatBytes(allocated)
}
var formattedTotal: String {
formatBytes(total)
}
private func formatBytes(_ bytes: UInt64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var size = Double(bytes)
var unitIndex = 0
while size >= 1024 && unitIndex < units.count - 1 {
size /= 1024
unitIndex += 1
}
return String(format: "%.1f%@", size, units[unitIndex])
}
}
@@ -39,7 +39,8 @@ struct VMDetails: Codable {
let status: String
let vncUrl: String?
let ipAddress: String?
let locationName: String
init(
name: String,
os: String,
@@ -49,7 +50,8 @@ struct VMDetails: Codable {
display: String,
status: String,
vncUrl: String?,
ipAddress: String?
ipAddress: String?,
locationName: String
) {
self.name = name
self.os = os
@@ -60,5 +62,6 @@ struct VMDetails: Codable {
self.status = status
self.vncUrl = vncUrl
self.ipAddress = ipAddress
self.locationName = locationName
}
}
}

View File

@@ -8,33 +8,46 @@ enum VMDetailsPrinter {
let width: Int
let getValue: @Sendable (VMDetails) -> String
}
/// Configuration for all columns in the status table
private static let columns: [Column] = [
Column(header: "name", width: 34, getValue: { $0.name }),
Column(header: "os", width: 8, getValue: { $0.os }),
Column(header: "cpu", width: 8, getValue: { String($0.cpuCount) }),
Column(header: "memory", width: 8, getValue: {
String(format: "%.2fG", Float($0.memorySize) / (1024 * 1024 * 1024))
}),
Column(header: "disk", width: 16, getValue: {
"\($0.diskSize.formattedAllocated)/\($0.diskSize.formattedTotal)"
}),
Column(
header: "memory", width: 8,
getValue: {
String(format: "%.2fG", Float($0.memorySize) / (1024 * 1024 * 1024))
}),
Column(
header: "disk", width: 16,
getValue: {
"\($0.diskSize.formattedAllocated)/\($0.diskSize.formattedTotal)"
}),
Column(header: "display", width: 12, getValue: { $0.display }),
Column(header: "status", width: 16, getValue: {
$0.status
}),
Column(header: "ip", width: 16, getValue: {
$0.ipAddress ?? "-"
}),
Column(header: "vnc", width: 50, getValue: {
$0.vncUrl ?? "-"
})
Column(
header: "status", width: 16,
getValue: {
$0.status
}),
Column(header: "storage", width: 16, getValue: { $0.locationName }),
Column(
header: "ip", width: 16,
getValue: {
$0.ipAddress ?? "-"
}),
Column(
header: "vnc", width: 50,
getValue: {
$0.vncUrl ?? "-"
}),
]
/// Prints the status of all VMs in a formatted table
/// - Parameter vms: Array of VM status objects to display
static func printStatus(_ vms: [VMDetails], format: FormatOption, print: (String) -> () = { print($0) }) throws {
static func printStatus(
_ vms: [VMDetails], format: FormatOption, print: (String) -> Void = { print($0) }
) throws {
if format == .json {
let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .prettyPrinted
@@ -43,15 +56,15 @@ enum VMDetailsPrinter {
print(jsonString)
} else {
printHeader(print: print)
vms.forEach({ printVM($0, print: print)})
vms.forEach({ printVM($0, print: print) })
}
}
private static func printHeader(print: (String) -> () = { print($0) }) {
private static func printHeader(print: (String) -> Void = { print($0) }) {
let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) }
print(paddedHeaders.joined())
}
private static func printVM(_ vm: VMDetails, print: (String) -> Void = { print($0) }) {
let paddedColumns = columns.map { column in
column.getValue(vm).paddedToWidth(column.width)
@@ -60,11 +73,11 @@ enum VMDetailsPrinter {
}
}
private extension String {
extension String {
/// Pads the string to the specified width with spaces
/// - Parameter width: Target width for padding
/// - Returns: Padded string
func paddedToWidth(_ width: Int) -> String {
fileprivate func paddedToWidth(_ width: Int) -> String {
padding(toLength: width, withPad: " ", startingAt: 0)
}
}

View File

@@ -14,6 +14,7 @@ struct VMVirtualizationServiceContext {
let diskPath: Path
let nvramPath: Path
let recoveryMode: Bool
let usbMassStoragePaths: [Path]?
}
/// Protocol defining the interface for virtualization operations
@@ -32,18 +33,19 @@ protocol VMVirtualizationService {
class BaseVirtualizationService: VMVirtualizationService {
let virtualMachine: VZVirtualMachine
let recoveryMode: Bool // Store whether we should start in recovery mode
var state: VZVirtualMachine.State {
virtualMachine.state
}
init(virtualMachine: VZVirtualMachine, recoveryMode: Bool = false) {
self.virtualMachine = virtualMachine
self.recoveryMode = recoveryMode
}
func start() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
Task { @MainActor in
if #available(macOS 13, *) {
let startOptions = VZMacOSVirtualMachineStartOptions()
@@ -74,7 +76,8 @@ class BaseVirtualizationService: VMVirtualizationService {
}
func stop() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
virtualMachine.stop { error in
if let error = error {
continuation.resume(throwing: error)
@@ -84,9 +87,10 @@ class BaseVirtualizationService: VMVirtualizationService {
}
}
}
func pause() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
virtualMachine.start { result in
switch result {
case .success:
@@ -97,9 +101,10 @@ class BaseVirtualizationService: VMVirtualizationService {
}
}
}
func resume() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
virtualMachine.start { result in
switch result {
case .success:
@@ -110,13 +115,15 @@ class BaseVirtualizationService: VMVirtualizationService {
}
}
}
func getVirtualMachine() -> Any {
return virtualMachine
}
// Helper methods for creating common configurations
static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws -> VZStorageDeviceConfiguration {
static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws
-> VZStorageDeviceConfiguration
{
return VZVirtioBlockDeviceConfiguration(
attachment: try VZDiskImageStorageDeviceAttachment(
url: diskPath.url,
@@ -126,8 +133,29 @@ class BaseVirtualizationService: VMVirtualizationService {
)
)
}
static func createNetworkDeviceConfiguration(macAddress: String) throws -> VZNetworkDeviceConfiguration {
static func createUSBMassStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false)
throws
-> VZStorageDeviceConfiguration
{
if #available(macOS 15.0, *) {
return VZUSBMassStorageDeviceConfiguration(
attachment: try VZDiskImageStorageDeviceAttachment(
url: diskPath.url,
readOnly: readOnly,
cachingMode: VZDiskImageCachingMode.automatic,
synchronizationMode: VZDiskImageSynchronizationMode.fsync
)
)
} else {
// Fallback to normal storage device if USB mass storage not available
return try createStorageDeviceConfiguration(diskPath: diskPath, readOnly: readOnly)
}
}
static func createNetworkDeviceConfiguration(macAddress: String) throws
-> VZNetworkDeviceConfiguration
{
let network = VZVirtioNetworkDeviceConfiguration()
guard let vzMacAddress = VZMACAddress(string: macAddress) else {
throw VMConfigError.invalidMachineIdentifier
@@ -136,12 +164,15 @@ class BaseVirtualizationService: VMVirtualizationService {
network.macAddress = vzMacAddress
return network
}
static func createDirectorySharingDevices(sharedDirectories: [SharedDirectory]?) -> [VZDirectorySharingDeviceConfiguration] {
static func createDirectorySharingDevices(sharedDirectories: [SharedDirectory]?)
-> [VZDirectorySharingDeviceConfiguration]
{
return sharedDirectories?.map { sharedDir in
let device = VZVirtioFileSystemDeviceConfiguration(tag: sharedDir.tag)
let url = URL(fileURLWithPath: sharedDir.hostPath)
device.share = VZSingleDirectoryShare(directory: VZSharedDirectory(url: url, readOnly: sharedDir.readOnly))
device.share = VZSingleDirectoryShare(
directory: VZSharedDirectory(url: url, readOnly: sharedDir.readOnly))
return device
} ?? []
}
@@ -150,7 +181,9 @@ class BaseVirtualizationService: VMVirtualizationService {
/// macOS-specific virtualization service
@MainActor
final class DarwinVirtualizationService: BaseVirtualizationService {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws
-> VZVirtualMachineConfiguration
{
let vzConfig = VZVirtualMachineConfiguration()
vzConfig.cpuCount = config.cpuCount
vzConfig.memorySize = config.memorySize
@@ -163,7 +196,7 @@ final class DarwinVirtualizationService: BaseVirtualizationService {
guard let hardwareModel = config.hardwareModel else {
throw VMConfigError.emptyHardwareModel
}
let platform = VZMacPlatformConfiguration()
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: config.nvramPath.url)
Logger.info("Pre-VZMacHardwareModel: hardwareModel=\(hardwareModel)")
@@ -171,7 +204,9 @@ final class DarwinVirtualizationService: BaseVirtualizationService {
throw VMConfigError.invalidHardwareModel
}
platform.hardwareModel = vzHardwareModel
guard let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier) else {
guard
let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier)
else {
throw VMConfigError.invalidMachineIdentifier
}
platform.machineIdentifier = vzMachineIdentifier
@@ -195,59 +230,83 @@ final class DarwinVirtualizationService: BaseVirtualizationService {
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
if let mount = config.mount {
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
storageDevices.append(
try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
}
// Add USB mass storage devices if specified
if #available(macOS 15.0, *), let usbPaths = config.usbMassStoragePaths, !usbPaths.isEmpty {
for usbPath in usbPaths {
storageDevices.append(
try createUSBMassStorageDeviceConfiguration(diskPath: usbPath, readOnly: true))
}
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
vzConfig.networkDevices = [
try createNetworkDeviceConfiguration(macAddress: config.macAddress)
]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory sharing
let directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
let directorySharingDevices = createDirectorySharingDevices(
sharedDirectories: config.sharedDirectories)
if !directorySharingDevices.isEmpty {
vzConfig.directorySharingDevices = directorySharingDevices
}
// USB Controller configuration
if #available(macOS 15.0, *) {
let usbControllerConfiguration = VZXHCIControllerConfiguration()
vzConfig.usbControllers = [usbControllerConfiguration]
}
try vzConfig.validate()
return vzConfig
}
static func generateMacAddress() -> String {
VZMACAddress.randomLocallyAdministered().string
}
static func generateMachineIdentifier() -> Data {
VZMacMachineIdentifier().dataRepresentation
}
func createAuxiliaryStorage(at path: Path, hardwareModel: Data) throws {
guard let vzHardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModel) else {
throw VMConfigError.invalidHardwareModel
}
_ = try VZMacAuxiliaryStorage(creatingStorageAt: path.url, hardwareModel: vzHardwareModel)
}
init(configuration: VMVirtualizationServiceContext) throws {
let vzConfig = try Self.createConfiguration(configuration)
super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig), recoveryMode: configuration.recoveryMode)
super.init(
virtualMachine: VZVirtualMachine(configuration: vzConfig),
recoveryMode: configuration.recoveryMode)
}
func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws {
func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws
{
var observers: [NSKeyValueObservation] = [] // must hold observer references during installation to print process
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
Task {
let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: imagePath.url)
let installer = VZMacOSInstaller(
virtualMachine: virtualMachine, restoringFromImageAt: imagePath.url)
Logger.info("Starting macOS installation")
if let progressHandler = progressHandler {
let observer = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
let observer = installer.progress.observe(
\.fractionCompleted, options: [.initial, .new]
) { (progress, change) in
if let newValue = change.newValue {
progressHandler(newValue)
}
}
observers.append(observer)
}
installer.install { result in
switch result {
case .success:
@@ -266,7 +325,9 @@ final class DarwinVirtualizationService: BaseVirtualizationService {
/// Linux-specific virtualization service
@MainActor
final class LinuxVirtualizationService: BaseVirtualizationService {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws
-> VZVirtualMachineConfiguration
{
let vzConfig = VZVirtualMachineConfiguration()
vzConfig.cpuCount = config.cpuCount
vzConfig.memorySize = config.memorySize
@@ -274,10 +335,11 @@ final class LinuxVirtualizationService: BaseVirtualizationService {
// Platform configuration
let platform = VZGenericPlatformConfiguration()
if #available(macOS 15, *) {
platform.isNestedVirtualizationEnabled = VZGenericPlatformConfiguration.isNestedVirtualizationSupported
platform.isNestedVirtualizationEnabled =
VZGenericPlatformConfiguration.isNestedVirtualizationSupported
}
vzConfig.platform = platform
let bootLoader = VZEFIBootLoader()
bootLoader.variableStore = VZEFIVariableStore(url: config.nvramPath.url)
vzConfig.bootLoader = bootLoader
@@ -298,16 +360,27 @@ final class LinuxVirtualizationService: BaseVirtualizationService {
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
if let mount = config.mount {
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
storageDevices.append(
try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
}
// Add USB mass storage devices if specified
if #available(macOS 15.0, *), let usbPaths = config.usbMassStoragePaths, !usbPaths.isEmpty {
for usbPath in usbPaths {
storageDevices.append(
try createUSBMassStorageDeviceConfiguration(diskPath: usbPath, readOnly: true))
}
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
vzConfig.networkDevices = [
try createNetworkDeviceConfiguration(macAddress: config.macAddress)
]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory sharing
var directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
var directorySharingDevices = createDirectorySharingDevices(
sharedDirectories: config.sharedDirectories)
// Add Rosetta support if available
if #available(macOS 13.0, *) {
if VZLinuxRosettaDirectoryShare.availability == .installed {
@@ -324,23 +397,29 @@ final class LinuxVirtualizationService: BaseVirtualizationService {
Logger.info("Rosetta not installed, skipping Rosetta support")
}
}
if !directorySharingDevices.isEmpty {
vzConfig.directorySharingDevices = directorySharingDevices
}
// USB Controller configuration
if #available(macOS 15.0, *) {
let usbControllerConfiguration = VZXHCIControllerConfiguration()
vzConfig.usbControllers = [usbControllerConfiguration]
}
try vzConfig.validate()
return vzConfig
}
func generateMacAddress() -> String {
VZMACAddress.randomLocallyAdministered().string
}
func createNVRAM(at path: Path) throws {
_ = try VZEFIVariableStore(creatingVariableStoreAt: path.url)
}
init(configuration: VMVirtualizationServiceContext) throws {
let vzConfig = try Self.createConfiguration(configuration)
super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig))