mirror of
https://github.com/trycua/computer.git
synced 2026-01-02 03:20:22 -06:00
Merge pull request #91 from trycua/feature/lume/flexible-location
[Lume] Add multiple VM locations and configurable cache
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
libs/lume/src/Commands/Config.swift
Normal file
224
libs/lume/src/Commands/Config.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
424
libs/lume/src/FileSystem/Settings.swift
Normal file
424
libs/lume/src/FileSystem/Settings.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
69
libs/lume/src/FileSystem/VMLocation.swift
Normal file
69
libs/lume/src/FileSystem/VMLocation.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ enum CommandRegistry {
|
||||
IPSW.self,
|
||||
Serve.self,
|
||||
Delete.self,
|
||||
Prune.self
|
||||
Prune.self,
|
||||
Config.self,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) ?? []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user