mirror of
https://github.com/trycua/lume.git
synced 2026-01-06 04:20:03 -06:00
Initial public release
This commit is contained in:
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# Xcode
|
||||
#
|
||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||
|
||||
.DS_Store
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
|
||||
## Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
## App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
## Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Swift Package Manager
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
||||
# Packages/
|
||||
# Package.pins
|
||||
# Package.resolved
|
||||
# *.xcodeproj
|
||||
#
|
||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
||||
# hence it is not needed unless you have added a package configuration file to your project
|
||||
# .swiftpm
|
||||
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
# We recommend against adding the Pods directory to your .gitignore. However
|
||||
# you should judge for yourself, the pros and cons are mentioned at:
|
||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||
#
|
||||
# Pods/
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||
# *.xcworkspace
|
||||
|
||||
# Carthage
|
||||
#
|
||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||
# Carthage/Checkouts
|
||||
|
||||
Carthage/Build/
|
||||
|
||||
# fastlane
|
||||
#
|
||||
# It is recommended to not store the screenshots in the git repo.
|
||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||
# For more information about the recommended setup visit:
|
||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
|
||||
# Local environment variables
|
||||
.env.local
|
||||
|
||||
# Ignore folder
|
||||
ignore
|
||||
|
||||
# .release
|
||||
.release/
|
||||
|
||||
# Swift Package Manager
|
||||
.swiftpm/
|
||||
227
.vscode/launch.json
vendored
Normal file
227
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "bashdb",
|
||||
"request": "launch",
|
||||
"name": "Bash-Debug (select script from list of sh files)",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"program": "${command:SelectScriptName}",
|
||||
"pathBash": "/opt/homebrew/bin/bash",
|
||||
"args": []
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"serve"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume serve",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"create",
|
||||
"macos-vm",
|
||||
"--cpu",
|
||||
"4",
|
||||
"--memory",
|
||||
"4GB",
|
||||
"--disk-size",
|
||||
"40GB",
|
||||
"--ipsw",
|
||||
"/Users/<USER>/Downloads/UniversalMac_15.2_24C101_Restore.ipsw"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume create --os macos --ipsw 'path/to/ipsw' (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"create",
|
||||
"macos-vm",
|
||||
"--cpu",
|
||||
"4",
|
||||
"--memory",
|
||||
"4GB",
|
||||
"--disk-size",
|
||||
"20GB",
|
||||
"--ipsw",
|
||||
"latest"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume create --os macos --ipsw latest (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"create",
|
||||
"linux-vm",
|
||||
"--os",
|
||||
"linux",
|
||||
"--cpu",
|
||||
"4",
|
||||
"--memory",
|
||||
"4GB",
|
||||
"--disk-size",
|
||||
"20GB"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume create --os linux (linux)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"pull",
|
||||
"macos-sequoia-vanilla:15.2",
|
||||
"--name",
|
||||
"macos-vm-cloned"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume pull (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"run",
|
||||
"macos-vm",
|
||||
"--shared-dir",
|
||||
"/Users/<USER>/repos/trycua/lume/shared_folder:rw",
|
||||
"--start-vnc"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume run (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"run",
|
||||
"linux-vm",
|
||||
"--start-vnc",
|
||||
"--mount",
|
||||
"/Users/<USER>/Downloads/ubuntu-24.04.1-live-server-arm64.iso"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume run with setup mount (linux)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"run",
|
||||
"linux-vm",
|
||||
"--start-vnc"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume run (linux)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"get",
|
||||
"macos-vm"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume get (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"ls"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume ls",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"sourceLanguages": [
|
||||
"swift"
|
||||
],
|
||||
"args": [
|
||||
"stop",
|
||||
"macos-vm"
|
||||
],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume stop (macos)",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "build-debug"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Debug lume",
|
||||
"program": "${workspaceFolder:lume}/.build/debug/lume",
|
||||
"preLaunchTask": "swift: Build Debug lume"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:lume}",
|
||||
"name": "Release lume",
|
||||
"program": "${workspaceFolder:lume}/.build/release/lume",
|
||||
"preLaunchTask": "swift: Build Release lume"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build-debug",
|
||||
"type": "shell",
|
||||
"command": "${workspaceFolder:lume}/scripts/build/build-debug.sh",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
39
CONTRIBUTING.md
Normal file
39
CONTRIBUTING.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Contributing to lume
|
||||
|
||||
We deeply appreciate your interest in contributing to lume! Whether you're reporting bugs, suggesting enhancements, improving docs, or submitting pull requests, your contributions help improve the project for everyone.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
If you've encountered a bug in the project, we encourage you to report it. Please follow these steps:
|
||||
|
||||
1. **Check the Issue Tracker**: Before submitting a new bug report, please check our issue tracker to see if the bug has already been reported.
|
||||
2. **Create a New Issue**: If the bug hasn't been reported, create a new issue with:
|
||||
- A clear title and detailed description
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs actual behavior
|
||||
- Your environment (macOS version, lume version)
|
||||
- Any relevant logs or error messages
|
||||
3. **Label Your Issue**: Label your issue as a `bug` to help maintainers identify it quickly.
|
||||
|
||||
## Suggesting Enhancements
|
||||
|
||||
We're always looking for suggestions to make lume better. If you have an idea:
|
||||
|
||||
1. **Check Existing Issues**: See if someone else has already suggested something similar.
|
||||
2. **Create a New Issue**: If your enhancement is new, create an issue describing:
|
||||
- The problem your enhancement solves
|
||||
- How your enhancement would work
|
||||
- Any potential implementation details
|
||||
- Why this enhancement would benefit lume users
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation improvements are always welcome. You can:
|
||||
- Fix typos or unclear explanations
|
||||
- Add examples and use cases
|
||||
- Improve API documentation
|
||||
- Add tutorials or guides
|
||||
|
||||
For detailed instructions on setting up your development environment and submitting code contributions, please see our [Development.md](docs/Development.md) guide.
|
||||
|
||||
Feel free to join our [Discord community](https://discord.com/channels/1328377437301641247) to discuss ideas or get help with your contributions.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 trycua
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
69
Package.resolved
Normal file
69
Package.resolved
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"originHash" : "81a9d169da3c391b981b894044911091d11285486aab463e32222490c931ba45",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dynamic",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mhdhejazi/Dynamic",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-cmark.git",
|
||||
"state" : {
|
||||
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
|
||||
"version" : "0.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-format",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-format.git",
|
||||
"state" : {
|
||||
"branch" : "release/5.10",
|
||||
"revision" : "3191b8f3109730af449c6332d0b1ca6653b857a0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-markdown.git",
|
||||
"state" : {
|
||||
"revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993",
|
||||
"version" : "0.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-syntax.git",
|
||||
"state" : {
|
||||
"branch" : "release/5.10",
|
||||
"revision" : "cdd571f366a4298bb863a9dcfe1295bb595041d5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
35
Package.swift
Normal file
35
Package.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "lume",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||
.package(url: "https://github.com/apple/swift-format.git", branch: ("release/5.10")),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.executableTarget(
|
||||
name: "lume",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Dynamic", package: "Dynamic")
|
||||
],
|
||||
path: "src"),
|
||||
.testTarget(
|
||||
name: "lumeTests",
|
||||
dependencies: [
|
||||
"lume"
|
||||
],
|
||||
path: "tests")
|
||||
]
|
||||
)
|
||||
140
README.md
Normal file
140
README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<div align="center">
|
||||
<h1>
|
||||
<div class="image-wrapper" style="display: inline-block;">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" alt="logo" height="150" srcset="img/logo_white.png" style="display: block; margin: auto;">
|
||||
<source media="(prefers-color-scheme: light)" alt="logo" height="150" srcset="img/logo_black.png" style="display: block; margin: auto;">
|
||||
<img alt="Shows my svg">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#install)
|
||||
[](https://discord.gg/8p56E2KJ)
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
**lume** is a lightweight Command Line Interface and local API server to create, run and manage macOS and Linux virtual machines (VMs) with near-native performance on Apple Silicon, using Apple's `Virtualization.Framework`.
|
||||
|
||||
### Run prebuilt macOS images in just 1 step
|
||||
|
||||
<div align="center">
|
||||
<img src="img/cli.png" alt="lume cli">
|
||||
</div>
|
||||
|
||||
|
||||
```bash
|
||||
lume run macos-sequoia-vanilla:latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
lume <command>
|
||||
|
||||
Commands:
|
||||
lume create <name> Create a new macOS or Linux VM
|
||||
lume run <name> Run a VM
|
||||
lume ls List all VMs
|
||||
lume get <name> Get detailed information about a VM
|
||||
lume set <name> Modify VM configuration
|
||||
lume stop <name> Stop a running VM
|
||||
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 images List available macOS images in local cache
|
||||
lume ipsw Get the latest macOS restore image URL
|
||||
lume prune Remove cached images
|
||||
lume serve Start the API server
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
|
||||
Command Options:
|
||||
create:
|
||||
--os <os> Operating system to install (macOS or linux, default: macOS)
|
||||
--cpu <cores> Number of CPU cores (default: 4)
|
||||
--memory <size> Memory size, e.g., 8GB (default: 4GB)
|
||||
--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
|
||||
|
||||
run:
|
||||
--no-display Do not start the VNC client app
|
||||
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
|
||||
--mount <path> For Linux VMs only, attach a read-only disk image
|
||||
|
||||
set:
|
||||
--cpu <cores> New number of CPU cores
|
||||
--memory <size> New memory size
|
||||
--disk-size <size> New disk size
|
||||
|
||||
delete:
|
||||
--force Force deletion without confirmation
|
||||
|
||||
pull:
|
||||
--registry <url> Container registry URL (default: ghcr.io)
|
||||
--organization <org> Organization to pull from (default: trycua)
|
||||
|
||||
serve:
|
||||
--port <port> Port to listen on (default: 3000)
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
brew tap trycua/lume
|
||||
brew install lume
|
||||
```
|
||||
|
||||
You can also download the `lume.pkg.tar.gz` archive from the [latest release](https://github.com/trycua/lume/releases), extract it, and install the package manually.
|
||||
|
||||
## Prebuilt Images
|
||||
|
||||
Pre-built images are available on [ghcr.io/trycua](https://github.com/orgs/trycua/packages).
|
||||
These images come with an SSH server pre-configured and auto-login enabled.
|
||||
|
||||
| Image | Tag | Description | Size |
|
||||
|-------|------------|-------------|------|
|
||||
| `macos-sequoia-vanilla` | `latest`, `15.2` | macOS Sonoma 15.2 | 40GB |
|
||||
| `macos-sequoia-xcode` | `latest`, `15.2` | macOS Sonoma 15.2 with Xcode command line tools | 50GB |
|
||||
| `ubuntu-vanilla` | `latest`, `24.04.1` | [Ubuntu Server for ARM 24.04.1 LTS](https://ubuntu.com/download/server/arm) with Ubuntu Desktop | 20GB |
|
||||
|
||||
For additional disk space, resize the VM disk after pulling the image using the `lume set <name> --disk-size <size>` command.
|
||||
|
||||
## Local API Server
|
||||
|
||||
`lume` exposes a local HTTP API server that listens on `http://localhost:3000/lume`, enabling automated management of VMs.
|
||||
|
||||
```bash
|
||||
lume serve
|
||||
```
|
||||
|
||||
For detailed API documentation, please refer to [API Reference](docs/API-Reference.md).
|
||||
|
||||
## Docs
|
||||
|
||||
- [API Reference](docs/API-Reference.md)
|
||||
- [Development](docs/Development.md)
|
||||
- [FAQ](docs/FAQ.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome and greatly appreciate contributions to lume! Whether you're improving documentation, adding new features, fixing bugs, or adding new VM images, your efforts help make lume better for everyone. For detailed instructions on how to contribute, please refer to our [Contributing Guidelines](CONTRIBUTING.md).
|
||||
|
||||
Join our [Discord community](https://discord.com/channels/1328377437301641247) to discuss ideas or get assistance.
|
||||
|
||||
## License
|
||||
|
||||
lume is open-sourced under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Trademarks
|
||||
|
||||
Apple, macOS, and Apple Silicon are trademarks of Apple Inc. Ubuntu and Canonical are registered trademarks of Canonical Ltd. This project is not affiliated with, endorsed by, or sponsored by Apple Inc. or Canonical Ltd.
|
||||
|
||||
## Stargazers over time
|
||||
|
||||
[](https://starchart.cc/trycua/lume)
|
||||
226
docs/API-Reference.md
Normal file
226
docs/API-Reference.md
Normal file
@@ -0,0 +1,226 @@
|
||||
## API Reference
|
||||
|
||||
<details open>
|
||||
<summary><strong>Create VM</strong> - POST /vms</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "lume_vm",
|
||||
"os": "macOS",
|
||||
"cpu": 2,
|
||||
"memory": "4GB",
|
||||
"diskSize": "64GB",
|
||||
"display": "1024x768",
|
||||
"ipsw": "latest"
|
||||
}' \
|
||||
http://localhost:3000/lume/vms
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Run VM</strong> - POST /vms/:name/run</summary>
|
||||
|
||||
```bash
|
||||
# Basic run
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
http://localhost:3000/lume/vms/my-vm-name/run
|
||||
|
||||
# Run with VNC client started and shared directory
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"noDisplay": false,
|
||||
"sharedDirectory": [
|
||||
{
|
||||
"hostPath": "~/Projects",
|
||||
"readOnly": false
|
||||
}
|
||||
]
|
||||
}' \
|
||||
http://localhost:3000/lume/vms/lume_vm/run
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>List VMs</strong> - GET /vms</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
http://localhost:3000/lume/vms
|
||||
```
|
||||
```
|
||||
[
|
||||
{
|
||||
"name": "my-vm",
|
||||
"state": "stopped",
|
||||
"os": "macOS",
|
||||
"cpu": 2,
|
||||
"memory": "4GB",
|
||||
"diskSize": "64GB"
|
||||
},
|
||||
{
|
||||
"name": "my-vm-2",
|
||||
"state": "stopped",
|
||||
"os": "linux",
|
||||
"cpu": 2,
|
||||
"memory": "4GB",
|
||||
"diskSize": "64GB"
|
||||
}
|
||||
]
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Get VM Details</strong> - GET /vms/:name</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
http://localhost:3000/lume/vms/lume_vm\
|
||||
```
|
||||
```
|
||||
{
|
||||
"name": "lume_vm",
|
||||
"state": "running",
|
||||
"os": "macOS",
|
||||
"cpu": 2,
|
||||
"memory": "4GB",
|
||||
"diskSize": "64GB"
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Update VM Settings</strong> - PATCH /vms/:name</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X PATCH \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"cpu": 4,
|
||||
"memory": "8GB",
|
||||
"diskSize": "128GB"
|
||||
}' \
|
||||
http://localhost:3000/lume/vms/my-vm-name
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Stop VM</strong> - POST /vms/:name/stop</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
http://localhost:3000/lume/vms/my-vm-name/stop
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Delete VM</strong> - DELETE /vms/:name</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X DELETE \
|
||||
http://localhost:3000/lume/vms/my-vm-name
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Pull Image</strong> - POST /pull</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"image": "monterey:latest",
|
||||
"name": "my-vm-name", # Optional, defaults to image name
|
||||
"registry": "ghcr.io", # Optional, defaults to ghcr.io
|
||||
"organization": "trycua" # Optional, defaults to trycua
|
||||
}' \
|
||||
http://localhost:3000/lume/pull
|
||||
```
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"image": "macos-sequoia-vanilla:15.2",
|
||||
"name": "macos-sequoia-vanilla"
|
||||
}' \
|
||||
http://localhost:3000/lume/pull
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Clone VM</strong> - POST /vms/:name/clone</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "source-vm",
|
||||
"newName": "cloned-vm"
|
||||
}' \
|
||||
http://localhost:3000/lume/vms/source-vm/clone
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Get Latest IPSW URL</strong> - GET /ipsw</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
http://localhost:3000/lume/ipsw
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>List Images</strong> - GET /images</summary>
|
||||
|
||||
```bash
|
||||
# List images with default organization (trycua)
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
http://localhost:3000/lume/images
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"local": [
|
||||
"macos-sequoia-xcode:latest",
|
||||
"macos-sequoia-vanilla:latest"
|
||||
]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>Prune Images</strong> - POST /lume/prune</summary>
|
||||
|
||||
```bash
|
||||
curl --connect-timeout 6000 \
|
||||
--max-time 5000 \
|
||||
-X POST \
|
||||
http://localhost:3000/lume/prune
|
||||
```
|
||||
</details>
|
||||
45
docs/Development.md
Normal file
45
docs/Development.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Development Guide
|
||||
|
||||
This guide will help you set up your development environment and understand the process for contributing code to lume.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Lume development requires:
|
||||
- Swift 6 or higher
|
||||
- Xcode 15 or higher
|
||||
- macOS Sequoia 15.2 or higher
|
||||
- (Optional) VS Code with Swift extension
|
||||
|
||||
## Setting Up the Repository Locally
|
||||
|
||||
1. **Fork the Repository**: Create your own fork of lume
|
||||
2. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://github.com/trycua/lume.git
|
||||
cd lume
|
||||
```
|
||||
3. **Install Dependencies**:
|
||||
```bash
|
||||
swift package resolve
|
||||
```
|
||||
4. **Build the Project**:
|
||||
```bash
|
||||
swift build
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Create a new branch for your changes
|
||||
2. Make your changes
|
||||
3. Run the tests: `swift test`
|
||||
4. Build and test your changes locally
|
||||
5. Commit your changes with clear commit messages
|
||||
|
||||
## Submitting Pull Requests
|
||||
|
||||
1. Push your changes to your fork
|
||||
2. Open a Pull Request with:
|
||||
- A clear title and description
|
||||
- Reference to any related issues
|
||||
- Screenshots or logs if relevant
|
||||
3. Respond to any feedback from maintainers
|
||||
37
docs/FAQ.md
Normal file
37
docs/FAQ.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# FAQs
|
||||
|
||||
### Where are the VMs stored?
|
||||
|
||||
VMs are stored in `~/.lume`.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### How do I get the latest macOS restore image URL?
|
||||
|
||||
```bash
|
||||
lume ipsw
|
||||
```
|
||||
|
||||
### How do I delete a VM?
|
||||
|
||||
```bash
|
||||
lume delete <name>
|
||||
```
|
||||
|
||||
### How do I install a custom linux image?
|
||||
|
||||
The process for creating a custom linux image differs than macOS, with IPSW restore files not being used. You need to create a linux VM first, then mount a setup image file to the VM for the first boot.
|
||||
|
||||
```bash
|
||||
lume create <name> --os linux
|
||||
|
||||
lume run <name> --mount <path-to-setup-image> --start-vnc
|
||||
|
||||
lume run <name> --start-vnc
|
||||
```
|
||||
BIN
img/cli.png
Normal file
BIN
img/cli.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
BIN
img/logo_black.png
Normal file
BIN
img/logo_black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
img/logo_white.png
Normal file
BIN
img/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
8
resources/lume.entitlements
Normal file
8
resources/lume.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
4
scripts/build/build-debug.sh
Executable file
4
scripts/build/build-debug.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
swift build --product lume
|
||||
codesign --force --entitlement resources/lume.entitlements --sign - .build/debug/lume
|
||||
99
scripts/build/build-release-notarized.sh
Executable file
99
scripts/build/build-release-notarized.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check required environment variables
|
||||
required_vars=(
|
||||
"CERT_APPLICATION_NAME"
|
||||
"CERT_INSTALLER_NAME"
|
||||
"APPLE_ID"
|
||||
"TEAM_ID"
|
||||
"APP_SPECIFIC_PASSWORD"
|
||||
)
|
||||
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "Error: $var is not set"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Move to the project root directory
|
||||
pushd ../../
|
||||
|
||||
# Build the release version
|
||||
swift build -c release --product lume
|
||||
|
||||
# Sign the binary with hardened runtime entitlements
|
||||
codesign --force --options runtime \
|
||||
--entitlement ./resources/lume.entitlements \
|
||||
--sign "$CERT_APPLICATION_NAME" \
|
||||
.build/release/lume
|
||||
|
||||
# Create a temporary directory for packaging
|
||||
TEMP_ROOT=$(mktemp -d)
|
||||
mkdir -p "$TEMP_ROOT/usr/local/bin"
|
||||
cp -f .build/release/lume "$TEMP_ROOT/usr/local/bin/"
|
||||
|
||||
# Build the installer package
|
||||
pkgbuild --root "$TEMP_ROOT" \
|
||||
--identifier "com.trycua.lume" \
|
||||
--version "1.0" \
|
||||
--install-location "/" \
|
||||
--sign "$CERT_INSTALLER_NAME" \
|
||||
./.release/lume.pkg
|
||||
|
||||
# Submit for notarization using stored credentials
|
||||
xcrun notarytool submit ./.release/lume.pkg \
|
||||
--apple-id "${APPLE_ID}" \
|
||||
--team-id "${TEAM_ID}" \
|
||||
--password "${APP_SPECIFIC_PASSWORD}" \
|
||||
--wait
|
||||
|
||||
# Staple the notarization ticket
|
||||
xcrun stapler staple ./.release/lume.pkg
|
||||
|
||||
# Create temporary directory for package extraction
|
||||
EXTRACT_ROOT=$(mktemp -d)
|
||||
PKG_PATH="$(pwd)/.release/lume.pkg"
|
||||
|
||||
# Extract the pkg using xar
|
||||
cd "$EXTRACT_ROOT"
|
||||
xar -xf "$PKG_PATH"
|
||||
|
||||
# Verify Payload exists before proceeding
|
||||
if [ ! -f "Payload" ]; then
|
||||
echo "Error: Payload file not found after xar extraction"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a directory for the extracted contents
|
||||
mkdir -p extracted
|
||||
cd extracted
|
||||
|
||||
# Extract the Payload
|
||||
cat ../Payload | gunzip -dc | cpio -i
|
||||
|
||||
# Verify the binary exists
|
||||
if [ ! -f "usr/local/bin/lume" ]; then
|
||||
echo "Error: lume binary not found in expected location"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy extracted lume to ./.release/lume
|
||||
cp -f usr/local/bin/lume "$(dirname "$PKG_PATH")/lume"
|
||||
|
||||
# Create symbolic link in /usr/local/bin
|
||||
cd "$(dirname "$PKG_PATH")"
|
||||
sudo ln -sf "$(pwd)/lume" /usr/local/bin/lume
|
||||
|
||||
# Create zip archive of the package
|
||||
tar -czvf lume.tar.gz lume
|
||||
tar -czvf lume.pkg.tar.gz lume.pkg
|
||||
|
||||
# Create sha256 checksum for the lume tarball
|
||||
shasum -a 256 lume.tar.gz
|
||||
|
||||
popd
|
||||
|
||||
# Clean up
|
||||
rm -rf "$TEMP_ROOT"
|
||||
rm -rf "$EXTRACT_ROOT"
|
||||
15
scripts/build/build-release.sh
Executable file
15
scripts/build/build-release.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
pushd ../../
|
||||
|
||||
swift build -c release --product lume
|
||||
codesign --force --entitlement ./resources/lume.entitlements --sign - .build/release/lume
|
||||
|
||||
mkdir -p ./.release
|
||||
cp -f .build/release/lume ./.release/lume
|
||||
|
||||
# Create symbolic link in /usr/local/bin
|
||||
sudo mkdir -p /usr/local/bin
|
||||
sudo ln -sf "$(pwd)/.release/lume" /usr/local/bin/lume
|
||||
|
||||
popd
|
||||
205
scripts/ghcr/pull-ghcr.sh
Executable file
205
scripts/ghcr/pull-ghcr.sh
Executable file
@@ -0,0 +1,205 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
# Default parameters
|
||||
organization=""
|
||||
image_name=""
|
||||
image_version=""
|
||||
target_folder_path=""
|
||||
|
||||
# Parse the command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--organization)
|
||||
organization="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-name)
|
||||
image_name="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-version)
|
||||
image_version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target-folder-path)
|
||||
target_folder_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: $0 [options]"
|
||||
echo "Options:"
|
||||
echo " --organization <organization> : GitHub organization (required)"
|
||||
echo " --image-name <name> : Name of the image to pull (required)"
|
||||
echo " --image-version <version> : Version of the image to pull (required)"
|
||||
echo " --target-folder-path <path> : Path where to extract the files (required)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure required arguments
|
||||
if [[ -z "$organization" || -z "$image_name" || -z "$image_version" || -z "$target_folder_path" ]]; then
|
||||
echo "Error: Missing required arguments. Use --help for usage."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check and install required tools
|
||||
for tool in "jq" "pv" "parallel"; do
|
||||
if ! command -v "$tool" &> /dev/null; then
|
||||
echo "$tool is not installed. Installing using Homebrew..."
|
||||
if ! command -v brew &> /dev/null; then
|
||||
echo "Homebrew is not installed. Please install Homebrew first: https://brew.sh/"
|
||||
exit 1
|
||||
fi
|
||||
brew install "$tool"
|
||||
fi
|
||||
done
|
||||
|
||||
# Create target folder if it doesn't exist
|
||||
mkdir -p "$target_folder_path"
|
||||
|
||||
# Create a temporary directory for processing files
|
||||
work_dir=$(mktemp -d)
|
||||
echo "Working directory: $work_dir"
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
# Registry details
|
||||
REGISTRY="ghcr.io"
|
||||
REPOSITORY="$organization/$image_name"
|
||||
TAG="$image_version"
|
||||
|
||||
# Get anonymous token
|
||||
echo "Getting authentication token..."
|
||||
curl -s "https://$REGISTRY/token?service=ghcr.io&scope=repository:$REPOSITORY:pull" -o "$work_dir/token.json"
|
||||
TOKEN=$(cat "$work_dir/token.json" | jq -r ".token")
|
||||
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "Failed to obtain token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Token obtained successfully"
|
||||
|
||||
# Fetch manifest
|
||||
echo "Fetching manifest..."
|
||||
MANIFEST_RESPONSE=$(curl -s \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
|
||||
"https://$REGISTRY/v2/$REPOSITORY/manifests/$TAG")
|
||||
|
||||
echo "Processing manifest..."
|
||||
|
||||
# Create a directory for all files
|
||||
cd "$work_dir"
|
||||
|
||||
# Create a download function for parallel execution
|
||||
download_layer() {
|
||||
local media_type="$1"
|
||||
local digest="$2"
|
||||
local output_file="$3"
|
||||
|
||||
echo "Downloading $output_file..."
|
||||
curl -s -L \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: $media_type" \
|
||||
"https://$REGISTRY/v2/$REPOSITORY/blobs/$digest" | \
|
||||
pv > "$output_file"
|
||||
}
|
||||
export -f download_layer
|
||||
export TOKEN REGISTRY REPOSITORY
|
||||
|
||||
# Process layers and create download jobs
|
||||
echo "$MANIFEST_RESPONSE" | jq -c '.layers[]' | while read -r layer; do
|
||||
media_type=$(echo "$layer" | jq -r '.mediaType')
|
||||
digest=$(echo "$layer" | jq -r '.digest')
|
||||
|
||||
# Skip empty layers
|
||||
if [[ "$media_type" == "application/vnd.oci.empty.v1+json" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract part information if present
|
||||
if [[ $media_type =~ part\.number=([0-9]+)\;part\.total=([0-9]+) ]]; then
|
||||
part_num="${BASH_REMATCH[1]}"
|
||||
total_parts="${BASH_REMATCH[2]}"
|
||||
echo "Found part $part_num of $total_parts"
|
||||
output_file="disk.img.part.$part_num"
|
||||
else
|
||||
case "$media_type" in
|
||||
"application/vnd.oci.image.layer.v1.tar")
|
||||
output_file="disk.img"
|
||||
;;
|
||||
"application/vnd.oci.image.config.v1+json")
|
||||
output_file="config.json"
|
||||
;;
|
||||
"application/octet-stream")
|
||||
output_file="nvram.bin"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown media type: $media_type"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Add to download queue
|
||||
echo "$media_type"$'\t'"$digest"$'\t'"$output_file" >> download_queue.txt
|
||||
done
|
||||
|
||||
# Download all files in parallel
|
||||
echo "Downloading files in parallel..."
|
||||
parallel --colsep $'\t' -a download_queue.txt download_layer {1} {2} {3}
|
||||
|
||||
# Check if we have disk parts to reassemble
|
||||
if ls disk.img.part.* 1> /dev/null 2>&1; then
|
||||
echo "Found disk parts, reassembling..."
|
||||
|
||||
# Get total parts from the first part's filename
|
||||
first_part=$(ls disk.img.part.* | head -n 1)
|
||||
total_parts=$(echo "$MANIFEST_RESPONSE" | jq -r '.layers[] | select(.mediaType | contains("part.total")) | .mediaType' | grep -o 'part\.total=[0-9]*' | cut -d= -f2 | head -n 1)
|
||||
|
||||
echo "Total parts to reassemble: $total_parts"
|
||||
|
||||
# Concatenate parts in order
|
||||
echo "Reassembling disk image..."
|
||||
{
|
||||
for i in $(seq 1 "$total_parts"); do
|
||||
part_file="disk.img.part.$i"
|
||||
if [ -f "$part_file" ]; then
|
||||
cat "$part_file"
|
||||
else
|
||||
echo "Error: Missing part $i"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
} | pv > "$target_folder_path/disk.img"
|
||||
|
||||
echo "Disk image reassembled successfully"
|
||||
else
|
||||
# If no parts found, just copy disk.img if it exists
|
||||
if [ -f disk.img ]; then
|
||||
echo "Copying disk image..."
|
||||
pv disk.img > "$target_folder_path/disk.img"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy config.json if it exists
|
||||
if [ -f config.json ]; then
|
||||
echo "Copying config.json..."
|
||||
cp config.json "$target_folder_path/"
|
||||
fi
|
||||
|
||||
# Copy nvram.bin if it exists
|
||||
if [ -f nvram.bin ]; then
|
||||
echo "Copying nvram.bin..."
|
||||
cp nvram.bin "$target_folder_path/"
|
||||
fi
|
||||
|
||||
echo "Download complete: Files extracted to $target_folder_path"
|
||||
208
scripts/ghcr/push-ghcr.sh
Executable file
208
scripts/ghcr/push-ghcr.sh
Executable file
@@ -0,0 +1,208 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
# Default parameters
|
||||
organization=""
|
||||
folder_path=""
|
||||
image_name=""
|
||||
image_versions=""
|
||||
chunk_size="500M" # Default chunk size for splitting large files
|
||||
|
||||
# Parse the command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--organization)
|
||||
organization="$2"
|
||||
shift 2
|
||||
;;
|
||||
--folder-path)
|
||||
folder_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-name)
|
||||
image_name="$2"
|
||||
shift 2
|
||||
;;
|
||||
--image-versions)
|
||||
image_versions="$2"
|
||||
shift 2
|
||||
;;
|
||||
--chunk-size)
|
||||
chunk_size="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
echo "Usage: $0 [options]"
|
||||
echo "Options:"
|
||||
echo " --organization <organization> : GitHub organization (required if not using token)"
|
||||
echo " --folder-path <path> : Path to the folder to upload (required)"
|
||||
echo " --image-name <name> : Name of the image to publish (required)"
|
||||
echo " --image-versions <versions> : Comma separated list of versions of the image to publish (required)"
|
||||
echo " --chunk-size <size> : Size of chunks for large files (e.g., 500M, default: 500M)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure required arguments
|
||||
if [[ -z "$organization" || -z "$folder_path" || -z "$image_name" || -z "$image_versions" ]]; then
|
||||
echo "Error: Missing required arguments. Use --help for usage."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the GITHUB_TOKEN variable is set
|
||||
if [[ -z "$GITHUB_TOKEN" ]]; then
|
||||
echo "Error: GITHUB_TOKEN is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the folder exists
|
||||
if [[ ! -d "$folder_path" ]]; then
|
||||
echo "Error: Folder $folder_path does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check and install required tools
|
||||
for tool in "oras" "split" "pv" "gzip"; do
|
||||
if ! command -v "$tool" &> /dev/null; then
|
||||
echo "$tool is not installed. Installing using Homebrew..."
|
||||
if ! command -v brew &> /dev/null; then
|
||||
echo "Homebrew is not installed. Please install Homebrew first: https://brew.sh/"
|
||||
exit 1
|
||||
fi
|
||||
brew install "$tool"
|
||||
fi
|
||||
done
|
||||
|
||||
# Authenticate with GitHub Container Registry
|
||||
echo "$GITHUB_TOKEN" | oras login ghcr.io -u "$organization" --password-stdin
|
||||
|
||||
# Create a temporary directory for processing files
|
||||
work_dir=$(mktemp -d)
|
||||
echo "Working directory: $work_dir"
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
|
||||
# Create a directory for all files
|
||||
mkdir -p "$work_dir/files"
|
||||
cd "$work_dir/files"
|
||||
|
||||
# Copy config.json if it exists
|
||||
if [ -f "$folder_path/config.json" ]; then
|
||||
echo "Copying config.json..."
|
||||
cp "$folder_path/config.json" config.json
|
||||
fi
|
||||
|
||||
# Copy nvram.bin if it exists
|
||||
nvram_bin="$folder_path/nvram.bin"
|
||||
if [ -f "$nvram_bin" ]; then
|
||||
echo "Copying nvram.bin..."
|
||||
cp "$nvram_bin" nvram.bin
|
||||
fi
|
||||
|
||||
# Process disk.img if it exists and needs splitting
|
||||
disk_img="$folder_path/disk.img"
|
||||
if [ -f "$disk_img" ]; then
|
||||
file_size=$(stat -f%z "$disk_img")
|
||||
if [ $file_size -gt 524288000 ]; then # 500MB in bytes
|
||||
echo "Splitting large file: disk.img"
|
||||
echo "Original disk.img size: $(du -h "$disk_img" | cut -f1)"
|
||||
|
||||
# Copy and split the file with progress monitoring
|
||||
echo "Copying disk image..."
|
||||
pv "$disk_img" > disk.img
|
||||
|
||||
echo "Splitting file..."
|
||||
split -b "$chunk_size" disk.img disk.img.part.
|
||||
rm disk.img
|
||||
|
||||
# Get original file size for verification
|
||||
original_size=$(stat -f%z "$disk_img")
|
||||
echo "Original disk.img size: $(awk -v size=$original_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
|
||||
|
||||
# Verify split parts total size
|
||||
total_size=0
|
||||
total_parts=$(ls disk.img.part.* | wc -l | tr -d ' ')
|
||||
part_num=0
|
||||
|
||||
# Create array for files and their annotations
|
||||
files=()
|
||||
for part in disk.img.part.*; do
|
||||
part_size=$(stat -f%z "$part")
|
||||
total_size=$((total_size + part_size))
|
||||
part_num=$((part_num + 1))
|
||||
echo "Part $part: $(awk -v size=$part_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
|
||||
files+=("$part:application/vnd.oci.image.layer.v1.tar;part.number=$part_num;part.total=$total_parts")
|
||||
done
|
||||
|
||||
echo "Total size of parts: $(awk -v size=$total_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
|
||||
|
||||
# Verify total size matches original
|
||||
if [ $total_size -ne $original_size ]; then
|
||||
echo "ERROR: Size mismatch!"
|
||||
echo "Original file size: $(awk -v size=$original_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
|
||||
echo "Sum of parts size: $(awk -v size=$total_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
|
||||
echo "Difference: $(awk -v orig=$original_size -v total=$total_size 'BEGIN {printf "%.2f GB", (orig-total)/1024/1024/1024}')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add remaining files
|
||||
if [ -f "config.json" ]; then
|
||||
files+=("config.json:application/vnd.oci.image.config.v1+json")
|
||||
fi
|
||||
|
||||
if [ -f "nvram.bin" ]; then
|
||||
files+=("nvram.bin:application/octet-stream")
|
||||
fi
|
||||
|
||||
# Push versions in parallel
|
||||
push_pids=()
|
||||
for version in $image_versions; do
|
||||
(
|
||||
echo "Pushing version $version..."
|
||||
oras push --disable-path-validation \
|
||||
"ghcr.io/$organization/$image_name:$version" \
|
||||
"${files[@]}"
|
||||
echo "Completed push for version $version"
|
||||
) &
|
||||
push_pids+=($!)
|
||||
done
|
||||
|
||||
# Wait for all pushes to complete
|
||||
for pid in "${push_pids[@]}"; do
|
||||
wait "$pid"
|
||||
done
|
||||
else
|
||||
# Push disk.img directly if it's small enough
|
||||
echo "Copying disk image..."
|
||||
pv "$disk_img" > disk.img
|
||||
|
||||
# Push all files together
|
||||
echo "Pushing all files..."
|
||||
files=("disk.img:application/vnd.oci.image.layer.v1.tar")
|
||||
|
||||
if [ -f "config.json" ]; then
|
||||
files+=("config.json:application/vnd.oci.image.config.v1+json")
|
||||
fi
|
||||
|
||||
if [ -f "nvram.bin" ]; then
|
||||
files+=("nvram.bin:application/octet-stream")
|
||||
fi
|
||||
|
||||
for version in $image_versions; do
|
||||
# Push all files in one command
|
||||
oras push --disable-path-validation \
|
||||
"ghcr.io/$organization/$image_name:$version" \
|
||||
"${files[@]}"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
for version in $image_versions; do
|
||||
echo "Upload complete: ghcr.io/$organization/$image_name:$version"
|
||||
done
|
||||
22
src/Commands/Clone.swift
Normal file
22
src/Commands/Clone.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
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
|
||||
|
||||
init() {}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
try vmController.clone(name: name, newName: newName)
|
||||
}
|
||||
}
|
||||
52
src/Commands/Create.swift
Normal file
52
src/Commands/Create.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
// MARK: - Create Command
|
||||
|
||||
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"]))
|
||||
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) })
|
||||
var memory: UInt64 = 8 * 1024 * 1024 * 1024
|
||||
|
||||
@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.",
|
||||
completion: .file(extensions: ["ipsw"])
|
||||
)
|
||||
var ipsw: String?
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
try await vmController.create(
|
||||
name: name,
|
||||
os: os,
|
||||
diskSize: diskSize,
|
||||
cpuCount: cpu,
|
||||
memorySize: memory,
|
||||
display: display.string,
|
||||
ipsw: ipsw
|
||||
)
|
||||
}
|
||||
}
|
||||
31
src/Commands/Delete.swift
Normal file
31
src/Commands/Delete.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
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
|
||||
|
||||
init() {}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
if !force {
|
||||
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 {
|
||||
print("Deletion cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let vmController = LumeController()
|
||||
try await vmController.delete(name: name)
|
||||
}
|
||||
}
|
||||
21
src/Commands/Get.swift
Normal file
21
src/Commands/Get.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Get: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Get detailed information about a virtual machine"
|
||||
)
|
||||
|
||||
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
|
||||
var name: String
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
let vm = try vmController.get(name: name)
|
||||
VMDetailsPrinter.printStatus([vm.details])
|
||||
}
|
||||
}
|
||||
20
src/Commands/IPSW.swift
Normal file
20
src/Commands/IPSW.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct IPSW: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Get macOS restore image IPSW URL",
|
||||
discussion: "Download IPSW file manually, then use in create command with --ipsw"
|
||||
)
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
let url = try await vmController.getLatestIPSWURL()
|
||||
print(url.absoluteString)
|
||||
}
|
||||
}
|
||||
19
src/Commands/Images.swift
Normal file
19
src/Commands/Images.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Images: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "List available macOS images from local cache"
|
||||
)
|
||||
|
||||
@Option(help: "Organization to list from. Defaults to trycua")
|
||||
var organization: String = "trycua"
|
||||
|
||||
init() {}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
_ = try await vmController.getImages(organization: organization)
|
||||
}
|
||||
}
|
||||
19
src/Commands/List.swift
Normal file
19
src/Commands/List.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
static let configuration: CommandConfiguration = CommandConfiguration(
|
||||
commandName: "ls",
|
||||
abstract: "List virtual machines"
|
||||
)
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let manager = LumeController()
|
||||
let vms = try manager.list()
|
||||
VMDetailsPrinter.printStatus(vms)
|
||||
}
|
||||
}
|
||||
19
src/Commands/Prune.swift
Normal file
19
src/Commands/Prune.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static let configuration: CommandConfiguration = CommandConfiguration(
|
||||
commandName: "prune",
|
||||
abstract: "Remove cached images"
|
||||
)
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let manager = LumeController()
|
||||
try await manager.pruneImages()
|
||||
print("Successfully removed cached images")
|
||||
}
|
||||
}
|
||||
30
src/Commands/Pull.swift
Normal file
30
src/Commands/Pull.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
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) })
|
||||
var name: String?
|
||||
|
||||
@Option(help: "Github Container Registry to pull from. Defaults to ghcr.io")
|
||||
var registry: String = "ghcr.io"
|
||||
|
||||
@Option(help: "Organization to pull from. Defaults to trycua")
|
||||
var organization: String = "trycua"
|
||||
|
||||
init() {}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
let components = image.split(separator: ":")
|
||||
let vmName = name ?? (components.count == 2 ? "\(components[0])_\(components[1])" : image)
|
||||
try await vmController.pullImage(image: image, name: vmName, registry: registry, organization: organization)
|
||||
}
|
||||
}
|
||||
97
src/Commands/Run.swift
Normal file
97
src/Commands/Run.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
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))
|
||||
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)")
|
||||
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: "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"
|
||||
|
||||
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(
|
||||
hostPath: hostPath,
|
||||
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
|
||||
readOnly: false
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the tag if provided
|
||||
let tag = String(components[1])
|
||||
let readOnly: Bool
|
||||
switch tag.lowercased() {
|
||||
case "ro":
|
||||
readOnly = true
|
||||
case "rw":
|
||||
readOnly = false
|
||||
default:
|
||||
throw ValidationError("Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write")
|
||||
}
|
||||
|
||||
return SharedDirectory(
|
||||
hostPath: hostPath,
|
||||
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
|
||||
readOnly: readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
let dirs = try parsedSharedDirectories
|
||||
|
||||
var vmName = name
|
||||
|
||||
// Shorthand for pulling an image directly during run
|
||||
let components = name.split(separator: ":")
|
||||
if components.count == 2 {
|
||||
// This is an image reference, try to pull it first
|
||||
let image = name
|
||||
vmName = "\(components[0])_\(components[1])"
|
||||
|
||||
do {
|
||||
try vmController.validateVMExists(vmName)
|
||||
}
|
||||
catch {
|
||||
// If the VM doesn't exist, try to pull the image
|
||||
try await vmController.pullImage(image: image, name: vmName, registry: registry, organization: organization)
|
||||
}
|
||||
}
|
||||
|
||||
try await vmController.runVM(
|
||||
name: vmName,
|
||||
noDisplay: noDisplay,
|
||||
sharedDirectories: dirs,
|
||||
mount: mount
|
||||
)
|
||||
}
|
||||
}
|
||||
17
src/Commands/Serve.swift
Normal file
17
src/Commands/Serve.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Serve: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Start the VM management server"
|
||||
)
|
||||
|
||||
@Option(help: "Port to listen on")
|
||||
var port: UInt16 = 3000
|
||||
|
||||
func run() async throws {
|
||||
let server = await Server(port: port)
|
||||
Logger.info("Starting server", metadata: ["port": "\(port)"])
|
||||
try await server.start()
|
||||
}
|
||||
}
|
||||
34
src/Commands/Set.swift
Normal file
34
src/Commands/Set.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Set new values for CPU, memory, and disk size of a virtual machine"
|
||||
)
|
||||
|
||||
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
|
||||
var name: String
|
||||
|
||||
@Option(help: "New number of CPU cores")
|
||||
var cpu: Int?
|
||||
|
||||
@Option(help: "New memory size, e.g., 8192MB or 8GB.", transform: { try parseSize($0) })
|
||||
var memory: UInt64?
|
||||
|
||||
@Option(help: "New disk size, e.g., 20480MB or 20GB.", transform: { try parseSize($0) })
|
||||
var diskSize: UInt64?
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
try vmController.updateSettings(
|
||||
name: name,
|
||||
cpu: cpu,
|
||||
memory: memory,
|
||||
diskSize: diskSize
|
||||
)
|
||||
}
|
||||
}
|
||||
20
src/Commands/Stop.swift
Normal file
20
src/Commands/Stop.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Stop: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Stop a virtual machine"
|
||||
)
|
||||
|
||||
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
|
||||
var name: String
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmController = LumeController()
|
||||
try await vmController.stopVM(name: name)
|
||||
}
|
||||
}
|
||||
764
src/ContainerRegistry/ImageContainerRegistry.swift
Normal file
764
src/ContainerRegistry/ImageContainerRegistry.swift
Normal file
@@ -0,0 +1,764 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Swift
|
||||
|
||||
struct Layer: Codable, Equatable {
|
||||
let mediaType: String
|
||||
let digest: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
struct Manifest: Codable {
|
||||
let layers: [Layer]
|
||||
let config: Layer?
|
||||
let mediaType: String
|
||||
let schemaVersion: Int
|
||||
}
|
||||
|
||||
struct RepositoryTag: Codable {
|
||||
let name: String
|
||||
let tags: [String]
|
||||
}
|
||||
|
||||
struct RepositoryList: Codable {
|
||||
let repositories: [String]
|
||||
}
|
||||
|
||||
struct RepositoryTags: Codable {
|
||||
let name: String
|
||||
let tags: [String]
|
||||
}
|
||||
|
||||
struct CachedImage {
|
||||
let repository: String
|
||||
let tag: String
|
||||
let manifestId: String
|
||||
}
|
||||
|
||||
actor ProgressTracker {
|
||||
private var totalBytes: Int64 = 0
|
||||
private var downloadedBytes: Int64 = 0
|
||||
private var progressLogger = ProgressLogger(threshold: 0.01)
|
||||
private var totalFiles: Int = 0
|
||||
private var completedFiles: Int = 0
|
||||
|
||||
func setTotal(_ total: Int64, files: Int) {
|
||||
totalBytes = total
|
||||
totalFiles = files
|
||||
}
|
||||
|
||||
func addProgress(_ bytes: Int64) {
|
||||
downloadedBytes += bytes
|
||||
let progress = Double(downloadedBytes) / Double(totalBytes)
|
||||
progressLogger.logProgress(current: progress, context: "Downloading Image")
|
||||
}
|
||||
}
|
||||
|
||||
actor TaskCounter {
|
||||
private var count: Int = 0
|
||||
|
||||
func increment() { count += 1 }
|
||||
func decrement() { count -= 1 }
|
||||
func current() -> Int { count }
|
||||
}
|
||||
|
||||
class ImageContainerRegistry: @unchecked Sendable {
|
||||
private let registry: String
|
||||
private let organization: String
|
||||
private let progress = ProgressTracker()
|
||||
private let cacheDirectory: URL
|
||||
private let downloadLock = NSLock()
|
||||
private var activeDownloads: [String] = []
|
||||
|
||||
init(registry: String, organization: String) {
|
||||
self.registry = registry
|
||||
self.organization = organization
|
||||
|
||||
// Setup cache directory in user's home
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
self.cacheDirectory = home.appendingPathComponent(".lume/cache/ghcr")
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Create organization directory
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
try? FileManager.default.createDirectory(at: orgDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func getManifestIdentifier(_ manifest: Manifest) -> String {
|
||||
// Use config digest if available, otherwise create a hash from layers
|
||||
if let config = manifest.config {
|
||||
return config.digest.replacingOccurrences(of: ":", with: "_")
|
||||
}
|
||||
// If no config layer, create a hash from all layer digests
|
||||
let layerHash = manifest.layers.map { $0.digest }.joined(separator: "+")
|
||||
return layerHash.replacingOccurrences(of: ":", with: "_")
|
||||
}
|
||||
|
||||
private func getImageCacheDirectory(manifestId: String) -> URL {
|
||||
return cacheDirectory
|
||||
.appendingPathComponent(organization)
|
||||
.appendingPathComponent(manifestId)
|
||||
}
|
||||
|
||||
private func getCachedManifestPath(manifestId: String) -> URL {
|
||||
return getImageCacheDirectory(manifestId: manifestId).appendingPathComponent("manifest.json")
|
||||
}
|
||||
|
||||
private func getCachedLayerPath(manifestId: String, digest: String) -> URL {
|
||||
return getImageCacheDirectory(manifestId: manifestId).appendingPathComponent(digest.replacingOccurrences(of: ":", with: "_"))
|
||||
}
|
||||
|
||||
private func setupImageCache(manifestId: String) throws {
|
||||
let cacheDir = getImageCacheDirectory(manifestId: manifestId)
|
||||
// Remove existing cache if it exists
|
||||
if FileManager.default.fileExists(atPath: cacheDir.path) {
|
||||
try FileManager.default.removeItem(at: cacheDir)
|
||||
// Ensure it's completely removed
|
||||
while FileManager.default.fileExists(atPath: cacheDir.path) {
|
||||
try? FileManager.default.removeItem(at: cacheDir)
|
||||
}
|
||||
}
|
||||
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func loadCachedManifest(manifestId: String) -> Manifest? {
|
||||
let manifestPath = getCachedManifestPath(manifestId: manifestId)
|
||||
guard let data = try? Data(contentsOf: manifestPath) else { return nil }
|
||||
return try? JSONDecoder().decode(Manifest.self, from: data)
|
||||
}
|
||||
|
||||
private func validateCache(manifest: Manifest, manifestId: String) -> Bool {
|
||||
// First check if manifest exists and matches
|
||||
guard let cachedManifest = loadCachedManifest(manifestId: manifestId),
|
||||
cachedManifest.layers == manifest.layers else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Then verify all layer files exist
|
||||
for layer in manifest.layers {
|
||||
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
|
||||
if !FileManager.default.fileExists(atPath: cachedLayer.path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func saveManifest(_ manifest: Manifest, manifestId: String) throws {
|
||||
let manifestPath = getCachedManifestPath(manifestId: manifestId)
|
||||
try JSONEncoder().encode(manifest).write(to: manifestPath)
|
||||
}
|
||||
|
||||
private func isDownloading(_ digest: String) -> Bool {
|
||||
downloadLock.lock()
|
||||
defer { downloadLock.unlock() }
|
||||
return activeDownloads.contains(digest)
|
||||
}
|
||||
|
||||
private func markDownloadStarted(_ digest: String) {
|
||||
downloadLock.lock()
|
||||
if !activeDownloads.contains(digest) {
|
||||
activeDownloads.append(digest)
|
||||
}
|
||||
downloadLock.unlock()
|
||||
}
|
||||
|
||||
private func markDownloadComplete(_ digest: String) {
|
||||
downloadLock.lock()
|
||||
activeDownloads.removeAll { $0 == digest }
|
||||
downloadLock.unlock()
|
||||
}
|
||||
|
||||
private func waitForExistingDownload(_ digest: String, cachedLayer: URL) async throws {
|
||||
while isDownloading(digest) {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
|
||||
if FileManager.default.fileExists(atPath: cachedLayer.path) {
|
||||
return // File is now available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pull(image: String, name: String?) async throws {
|
||||
// Validate home directory
|
||||
let home = Home()
|
||||
try home.validateHomeDirectory()
|
||||
|
||||
// Use provided name or derive from image
|
||||
let vmName = name ?? image.split(separator: ":").first.map(String.init) ?? ""
|
||||
let vmDir = home.getVMDirectory(vmName)
|
||||
|
||||
// Parse image name and tag
|
||||
let components = image.split(separator: ":")
|
||||
guard components.count == 2 else {
|
||||
throw PullError.invalidImageFormat
|
||||
}
|
||||
let imageName = String(components[0])
|
||||
let tag = String(components[1])
|
||||
|
||||
// Get anonymous token
|
||||
Logger.info("Getting registry authentication token")
|
||||
let token = try await getToken(repository: "\(self.organization)/\(imageName)")
|
||||
|
||||
// Fetch manifest
|
||||
Logger.info("Fetching Image manifest")
|
||||
let manifest: Manifest = try await fetchManifest(
|
||||
repository: "\(self.organization)/\(imageName)",
|
||||
tag: tag,
|
||||
token: token
|
||||
)
|
||||
|
||||
// Get manifest identifier
|
||||
let manifestId = getManifestIdentifier(manifest)
|
||||
|
||||
// Create VM directory
|
||||
try FileManager.default.createDirectory(at: URL(fileURLWithPath: vmDir.dir.path), withIntermediateDirectories: true)
|
||||
|
||||
// Check if we have a valid cached version
|
||||
if validateCache(manifest: manifest, manifestId: manifestId) {
|
||||
Logger.info("Using cached version of image")
|
||||
try await copyFromCache(manifest: manifest, manifestId: manifestId, to: URL(fileURLWithPath: vmDir.dir.path))
|
||||
return
|
||||
}
|
||||
|
||||
// Setup new cache directory
|
||||
try setupImageCache(manifestId: manifestId)
|
||||
|
||||
// Save new manifest
|
||||
try saveManifest(manifest, manifestId: manifestId)
|
||||
|
||||
// Create temporary directory for new downloads
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
// Set total size and file count
|
||||
let totalFiles = manifest.layers.filter { $0.mediaType != "application/vnd.oci.empty.v1+json" }.count
|
||||
await progress.setTotal(
|
||||
manifest.layers.reduce(0) { $0 + Int64($1.size) },
|
||||
files: totalFiles
|
||||
)
|
||||
|
||||
// Process layers with limited concurrency
|
||||
Logger.info("Processing Image layers")
|
||||
var diskParts: [(Int, URL)] = []
|
||||
var totalParts = 0
|
||||
let maxConcurrentTasks = 5
|
||||
let counter = TaskCounter()
|
||||
|
||||
try await withThrowingTaskGroup(of: Int64.self) { group in
|
||||
for layer in manifest.layers {
|
||||
if layer.mediaType == "application/vnd.oci.empty.v1+json" {
|
||||
continue
|
||||
}
|
||||
|
||||
while await counter.current() >= maxConcurrentTasks {
|
||||
_ = try await group.next()
|
||||
await counter.decrement()
|
||||
}
|
||||
|
||||
let outputURL: URL
|
||||
if let partInfo = extractPartInfo(from: layer.mediaType) {
|
||||
let (partNum, total) = partInfo
|
||||
totalParts = total
|
||||
outputURL = tempDir.appendingPathComponent("disk.img.part.\(partNum)")
|
||||
diskParts.append((partNum, outputURL))
|
||||
} else {
|
||||
switch layer.mediaType {
|
||||
case "application/vnd.oci.image.layer.v1.tar":
|
||||
outputURL = tempDir.appendingPathComponent("disk.img")
|
||||
case "application/vnd.oci.image.config.v1+json":
|
||||
outputURL = tempDir.appendingPathComponent("config.json")
|
||||
case "application/octet-stream":
|
||||
outputURL = tempDir.appendingPathComponent("nvram.bin")
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask { @Sendable [self] in
|
||||
await counter.increment()
|
||||
|
||||
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
|
||||
|
||||
if FileManager.default.fileExists(atPath: cachedLayer.path) {
|
||||
try FileManager.default.copyItem(at: cachedLayer, to: outputURL)
|
||||
await progress.addProgress(Int64(layer.size))
|
||||
} else {
|
||||
// Check if this layer is already being downloaded
|
||||
if isDownloading(layer.digest) {
|
||||
try await waitForExistingDownload(layer.digest, cachedLayer: cachedLayer)
|
||||
if FileManager.default.fileExists(atPath: cachedLayer.path) {
|
||||
try FileManager.default.copyItem(at: cachedLayer, to: outputURL)
|
||||
await progress.addProgress(Int64(layer.size))
|
||||
return Int64(layer.size)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new download
|
||||
markDownloadStarted(layer.digest)
|
||||
defer { markDownloadComplete(layer.digest) }
|
||||
|
||||
try await self.downloadLayer(
|
||||
repository: "\(self.organization)/\(imageName)",
|
||||
digest: layer.digest,
|
||||
mediaType: layer.mediaType,
|
||||
token: token,
|
||||
to: outputURL,
|
||||
maxRetries: 5,
|
||||
progress: progress
|
||||
)
|
||||
|
||||
// Cache the downloaded layer
|
||||
if FileManager.default.fileExists(atPath: cachedLayer.path) {
|
||||
try FileManager.default.removeItem(at: cachedLayer)
|
||||
}
|
||||
try FileManager.default.copyItem(at: outputURL, to: cachedLayer)
|
||||
}
|
||||
|
||||
return Int64(layer.size)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining tasks
|
||||
for try await _ in group { }
|
||||
}
|
||||
Logger.info("") // New line after progress
|
||||
|
||||
// Handle disk parts if present
|
||||
if !diskParts.isEmpty {
|
||||
Logger.info("Reassembling disk image...")
|
||||
let outputURL = URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent("disk.img")
|
||||
try FileManager.default.createDirectory(at: outputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
|
||||
// Create empty output file
|
||||
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
|
||||
let outputHandle = try FileHandle(forWritingTo: outputURL)
|
||||
defer { try? outputHandle.close() }
|
||||
|
||||
var totalWritten: UInt64 = 0
|
||||
let expectedTotalSize = UInt64(manifest.layers.filter { extractPartInfo(from: $0.mediaType) != nil }.reduce(0) { $0 + $1.size })
|
||||
|
||||
// Process parts in order
|
||||
for partNum in 1...totalParts {
|
||||
guard let (_, partURL) = diskParts.first(where: { $0.0 == partNum }) else {
|
||||
throw PullError.missingPart(partNum)
|
||||
}
|
||||
|
||||
let inputHandle = try FileHandle(forReadingFrom: partURL)
|
||||
defer {
|
||||
try? inputHandle.close()
|
||||
try? FileManager.default.removeItem(at: partURL)
|
||||
}
|
||||
|
||||
// Read and write in chunks to minimize memory usage
|
||||
let chunkSize = 10 * 1024 * 1024 // 10MB chunks
|
||||
while let chunk = try inputHandle.read(upToCount: chunkSize) {
|
||||
try outputHandle.write(contentsOf: chunk)
|
||||
totalWritten += UInt64(chunk.count)
|
||||
let progress: Double = Double(totalWritten) / Double(expectedTotalSize) * 100
|
||||
Logger.info("Reassembling disk image: \(Int(progress))%")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify final size
|
||||
let finalSize = try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64 ?? 0
|
||||
Logger.info("Final disk image size: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))")
|
||||
Logger.info("Expected size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))")
|
||||
|
||||
if finalSize != expectedTotalSize {
|
||||
Logger.info("Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)")
|
||||
}
|
||||
|
||||
Logger.info("Disk image reassembled successfully")
|
||||
} else {
|
||||
// Copy single disk image if it exists
|
||||
let diskURL = tempDir.appendingPathComponent("disk.img")
|
||||
if FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
try FileManager.default.copyItem(
|
||||
at: diskURL,
|
||||
to: URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent("disk.img")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy config and nvram files if they exist
|
||||
for file in ["config.json", "nvram.bin"] {
|
||||
let sourceURL = tempDir.appendingPathComponent(file)
|
||||
if FileManager.default.fileExists(atPath: sourceURL.path) {
|
||||
try FileManager.default.copyItem(
|
||||
at: sourceURL,
|
||||
to: URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent(file)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info("Download complete: Files extracted to \(vmDir.dir.path)")
|
||||
|
||||
// If this was a "latest" tag pull and we successfully downloaded and cached the new version,
|
||||
// clean up any old versions
|
||||
if tag.lowercased() == "latest" {
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
if FileManager.default.fileExists(atPath: orgDir.path) {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
|
||||
for item in contents {
|
||||
// Skip if it's the current manifest
|
||||
if item == manifestId { continue }
|
||||
|
||||
let itemPath = orgDir.appendingPathComponent(item)
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue else { continue }
|
||||
|
||||
// Check for manifest.json
|
||||
let manifestPath = itemPath.appendingPathComponent("manifest.json")
|
||||
guard let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let oldManifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
|
||||
let config = oldManifest.config else { continue }
|
||||
let configPath = getCachedLayerPath(manifestId: item, digest: config.digest)
|
||||
guard let configData = try? Data(contentsOf: configPath),
|
||||
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let labels = configJson["config"] as? [String: Any],
|
||||
let imageConfig = labels["Labels"] as? [String: String],
|
||||
let oldRepository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last else { continue }
|
||||
|
||||
// Only delete if it's from the same repository
|
||||
if oldRepository == imageName {
|
||||
try FileManager.default.removeItem(at: itemPath)
|
||||
Logger.info("Removed outdated cached version", metadata: [
|
||||
"old_manifest_id": item,
|
||||
"repository": imageName
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func copyFromCache(manifest: Manifest, manifestId: String, to destination: URL) async throws {
|
||||
Logger.info("Copying from cache...")
|
||||
var diskParts: [(Int, URL)] = []
|
||||
var totalParts = 0
|
||||
var expectedTotalSize: UInt64 = 0
|
||||
|
||||
for layer in manifest.layers {
|
||||
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
|
||||
|
||||
if let partInfo = extractPartInfo(from: layer.mediaType) {
|
||||
let (partNum, total) = partInfo
|
||||
totalParts = total
|
||||
let partURL = destination.appendingPathComponent("disk.img.part.\(partNum)")
|
||||
try FileManager.default.copyItem(at: cachedLayer, to: partURL)
|
||||
diskParts.append((partNum, partURL))
|
||||
expectedTotalSize += UInt64(layer.size)
|
||||
} else {
|
||||
let fileName: String
|
||||
switch layer.mediaType {
|
||||
case "application/vnd.oci.image.layer.v1.tar":
|
||||
fileName = "disk.img"
|
||||
case "application/vnd.oci.image.config.v1+json":
|
||||
fileName = "config.json"
|
||||
case "application/octet-stream":
|
||||
fileName = "nvram.bin"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
try FileManager.default.copyItem(
|
||||
at: cachedLayer,
|
||||
to: destination.appendingPathComponent(fileName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reassemble disk parts if needed
|
||||
if !diskParts.isEmpty {
|
||||
Logger.info("Reassembling disk image from cached parts...")
|
||||
let outputURL = destination.appendingPathComponent("disk.img")
|
||||
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
|
||||
let outputHandle = try FileHandle(forWritingTo: outputURL)
|
||||
defer { try? outputHandle.close() }
|
||||
|
||||
var totalWritten: UInt64 = 0
|
||||
|
||||
for partNum in 1...totalParts {
|
||||
guard let (_, partURL) = diskParts.first(where: { $0.0 == partNum }) else {
|
||||
throw PullError.missingPart(partNum)
|
||||
}
|
||||
|
||||
let inputHandle = try FileHandle(forReadingFrom: partURL)
|
||||
while let data = try inputHandle.read(upToCount: 1024 * 1024 * 10) {
|
||||
try outputHandle.write(contentsOf: data)
|
||||
totalWritten += UInt64(data.count)
|
||||
}
|
||||
try inputHandle.close()
|
||||
try FileManager.default.removeItem(at: partURL)
|
||||
}
|
||||
|
||||
// Verify final size
|
||||
let finalSize = try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64 ?? 0
|
||||
Logger.info("Final disk image size: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))")
|
||||
Logger.info("Expected size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))")
|
||||
|
||||
if finalSize != expectedTotalSize {
|
||||
Logger.info("Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info("Cache copy complete")
|
||||
}
|
||||
|
||||
private func getToken(repository: String) async throws -> String {
|
||||
let url = URL(string: "https://\(self.registry)/token")!
|
||||
.appending(queryItems: [
|
||||
URLQueryItem(name: "service", value: self.registry),
|
||||
URLQueryItem(name: "scope", value: "repository:\(repository):pull")
|
||||
])
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
guard let token = json?["token"] as? String else {
|
||||
throw PullError.tokenFetchFailed
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
private func fetchManifest(repository: String, tag: String, token: String) async throws -> Manifest {
|
||||
var request = URLRequest(url: URL(string: "https://\(self.registry)/v2/\(repository)/manifests/\(tag)")!)
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/vnd.oci.image.manifest.v1+json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(Manifest.self, from: data)
|
||||
}
|
||||
|
||||
private func downloadLayer(
|
||||
repository: String,
|
||||
digest: String,
|
||||
mediaType: String,
|
||||
token: String,
|
||||
to url: URL,
|
||||
maxRetries: Int = 5,
|
||||
progress: isolated ProgressTracker
|
||||
) async throws {
|
||||
var lastError: Error?
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
do {
|
||||
var request = URLRequest(url: URL(string: "https://\(self.registry)/v2/\(repository)/blobs/\(digest)")!)
|
||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue(mediaType, forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 60
|
||||
|
||||
// Configure session for better reliability
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
config.timeoutIntervalForResource = 3600
|
||||
config.waitsForConnectivity = true
|
||||
config.httpMaximumConnectionsPerHost = 1
|
||||
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
let (tempURL, response) = try await session.download(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw PullError.layerDownloadFailed(digest)
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try FileManager.default.moveItem(at: tempURL, to: url)
|
||||
progress.addProgress(Int64(httpResponse.expectedContentLength))
|
||||
return
|
||||
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt < maxRetries {
|
||||
let delay = Double(attempt) * 5
|
||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? PullError.layerDownloadFailed(digest)
|
||||
}
|
||||
|
||||
private func decompressGzipFile(at source: URL, to destination: URL) throws {
|
||||
Logger.info("Decompressing \(source.lastPathComponent)...")
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/gunzip")
|
||||
process.arguments = ["-c"]
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
process.standardInput = inputPipe
|
||||
process.standardOutput = outputPipe
|
||||
|
||||
try process.run()
|
||||
|
||||
// Read and pipe the gzipped file in chunks to avoid memory issues
|
||||
let inputHandle = try FileHandle(forReadingFrom: source)
|
||||
let outputHandle = try FileHandle(forWritingTo: destination)
|
||||
defer {
|
||||
try? inputHandle.close()
|
||||
try? outputHandle.close()
|
||||
}
|
||||
|
||||
// Create the output file
|
||||
FileManager.default.createFile(atPath: destination.path, contents: nil)
|
||||
|
||||
// Process in 10MB chunks
|
||||
let chunkSize = 10 * 1024 * 1024
|
||||
while let chunk = try inputHandle.read(upToCount: chunkSize) {
|
||||
try inputPipe.fileHandleForWriting.write(contentsOf: chunk)
|
||||
|
||||
// Read and write output in chunks as well
|
||||
while let decompressedChunk = try outputPipe.fileHandleForReading.read(upToCount: chunkSize) {
|
||||
try outputHandle.write(contentsOf: decompressedChunk)
|
||||
}
|
||||
}
|
||||
|
||||
try inputPipe.fileHandleForWriting.close()
|
||||
|
||||
// Read any remaining output
|
||||
while let decompressedChunk = try outputPipe.fileHandleForReading.read(upToCount: chunkSize) {
|
||||
try outputHandle.write(contentsOf: decompressedChunk)
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
throw PullError.decompressionFailed(source.lastPathComponent)
|
||||
}
|
||||
|
||||
// Verify the decompressed size
|
||||
let decompressedSize = try FileManager.default.attributesOfItem(atPath: destination.path)[.size] as? UInt64 ?? 0
|
||||
Logger.info("Decompressed size: \(ByteCountFormatter.string(fromByteCount: Int64(decompressedSize), countStyle: .file))")
|
||||
}
|
||||
|
||||
private func extractPartInfo(from mediaType: String) -> (partNum: Int, total: Int)? {
|
||||
let pattern = #"part\.number=(\d+);part\.total=(\d+)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(
|
||||
in: mediaType,
|
||||
range: NSRange(mediaType.startIndex..., in: mediaType)
|
||||
),
|
||||
let partNumRange = Range(match.range(at: 1), in: mediaType),
|
||||
let totalRange = Range(match.range(at: 2), in: mediaType),
|
||||
let partNum = Int(mediaType[partNumRange]),
|
||||
let total = Int(mediaType[totalRange]) else {
|
||||
return nil
|
||||
}
|
||||
return (partNum, total)
|
||||
}
|
||||
|
||||
private func listRepositories() async throws -> [String] {
|
||||
var request = URLRequest(url: URL(string: "https://\(registry)/v2/\(organization)/repositories/list")!)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 404 {
|
||||
return []
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
let repoList = try JSONDecoder().decode(RepositoryList.self, from: data)
|
||||
return repoList.repositories
|
||||
}
|
||||
|
||||
func getImages() async throws -> [CachedImage] {
|
||||
var images: [CachedImage] = []
|
||||
let orgDir = cacheDirectory.appendingPathComponent(organization)
|
||||
|
||||
if FileManager.default.fileExists(atPath: orgDir.path) {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
|
||||
for item in contents {
|
||||
let itemPath = orgDir.appendingPathComponent(item)
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
// Check if it's a directory
|
||||
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue else { continue }
|
||||
|
||||
// Check for manifest.json
|
||||
let manifestPath = itemPath.appendingPathComponent("manifest.json")
|
||||
guard FileManager.default.fileExists(atPath: manifestPath.path),
|
||||
let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData) else { continue }
|
||||
|
||||
// The directory name is now just the manifest ID
|
||||
let manifestId = item
|
||||
|
||||
// Verify the manifest ID matches
|
||||
let currentManifestId = getManifestIdentifier(manifest)
|
||||
if currentManifestId == manifestId {
|
||||
// Add the image with just the manifest ID for now
|
||||
images.append(CachedImage(
|
||||
repository: "unknown",
|
||||
tag: "unknown",
|
||||
manifestId: manifestId
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each cached image, try to find its repository and tag by checking the config
|
||||
for i in 0..<images.count {
|
||||
let manifestId = images[i].manifestId
|
||||
let manifestPath = getCachedManifestPath(manifestId: manifestId)
|
||||
|
||||
if let manifestData = try? Data(contentsOf: manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
|
||||
let config = manifest.config,
|
||||
let configData = try? Data(contentsOf: getCachedLayerPath(manifestId: manifestId, digest: config.digest)),
|
||||
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
|
||||
let labels = configJson["config"] as? [String: Any],
|
||||
let imageConfig = labels["Labels"] as? [String: String],
|
||||
let repository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last,
|
||||
let tag = imageConfig["org.opencontainers.image.version"] {
|
||||
|
||||
// Found repository and tag information in the config
|
||||
images[i] = CachedImage(
|
||||
repository: repository,
|
||||
tag: tag,
|
||||
manifestId: manifestId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return images.sorted { $0.repository == $1.repository ? $0.tag < $1.tag : $0.repository < $1.repository }
|
||||
}
|
||||
|
||||
private func listRemoteImageTags(repository: String) async throws -> [String] {
|
||||
var request = URLRequest(url: URL(string: "https://\(registry)/v2/\(organization)/\(repository)/tags/list")!)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 404 {
|
||||
return []
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
throw PullError.manifestFetchFailed
|
||||
}
|
||||
|
||||
let repoTags = try JSONDecoder().decode(RepositoryTags.self, from: data)
|
||||
return repoTags.tags
|
||||
}
|
||||
}
|
||||
4
src/ContainerRegistry/ImageList.swift
Normal file
4
src/ContainerRegistry/ImageList.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
public struct ImageList: Codable {
|
||||
public let local: [String]
|
||||
public let remote: [String]
|
||||
}
|
||||
42
src/ContainerRegistry/ImagesPrinter.swift
Normal file
42
src/ContainerRegistry/ImagesPrinter.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct ImagesPrinter {
|
||||
private struct Column: Sendable {
|
||||
let header: String
|
||||
let width: Int
|
||||
let getValue: @Sendable (String) -> String
|
||||
}
|
||||
|
||||
private static let columns: [Column] = [
|
||||
Column(header: "name", width: 28) { $0.split(separator: ":").first.map(String.init) ?? $0 },
|
||||
Column(header: "tag", width: 16) { $0.split(separator: ":").last.map(String.init) ?? "-" }
|
||||
]
|
||||
|
||||
static func print(images: [String]) {
|
||||
if images.isEmpty {
|
||||
Swift.print("No images found")
|
||||
return
|
||||
}
|
||||
|
||||
printHeader()
|
||||
images.sorted().forEach(printImage)
|
||||
}
|
||||
|
||||
private static func printHeader() {
|
||||
let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) }
|
||||
Swift.print(paddedHeaders.joined())
|
||||
}
|
||||
|
||||
private static func printImage(_ image: String) {
|
||||
let paddedColumns = columns.map { column in
|
||||
column.getValue(image).paddedToWidth(column.width)
|
||||
}
|
||||
Swift.print(paddedColumns.joined())
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func paddedToWidth(_ width: Int) -> String {
|
||||
padding(toLength: width, withPad: " ", startingAt: 0)
|
||||
}
|
||||
}
|
||||
157
src/Errors/Errors.swift
Normal file
157
src/Errors/Errors.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
enum HomeError: Error, LocalizedError {
|
||||
case directoryCreationFailed(path: String)
|
||||
case directoryAccessDenied(path: String)
|
||||
case invalidHomeDirectory
|
||||
case directoryAlreadyExists(path: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .directoryCreationFailed(let path):
|
||||
return "Failed to create directory at path: \(path)"
|
||||
case .directoryAccessDenied(let path):
|
||||
return "Access denied to directory at path: \(path)"
|
||||
case .invalidHomeDirectory:
|
||||
return "Invalid home directory configuration"
|
||||
case .directoryAlreadyExists(let path):
|
||||
return "Directory already exists at path: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PullError: Error, LocalizedError {
|
||||
case invalidImageFormat
|
||||
case tokenFetchFailed
|
||||
case manifestFetchFailed
|
||||
case layerDownloadFailed(String)
|
||||
case missingPart(Int)
|
||||
case decompressionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImageFormat:
|
||||
return "Invalid image format. Expected format: name:tag"
|
||||
case .tokenFetchFailed:
|
||||
return "Failed to obtain authentication token"
|
||||
case .manifestFetchFailed:
|
||||
return "Failed to fetch manifest"
|
||||
case .layerDownloadFailed(let digest):
|
||||
return "Failed to download layer: \(digest)"
|
||||
case .missingPart(let number):
|
||||
return "Missing disk image part \(number)"
|
||||
case .decompressionFailed(let filename):
|
||||
return "Failed to decompress file: \(filename)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum VMConfigError: CustomNSError, LocalizedError {
|
||||
case invalidDisplayResolution(String)
|
||||
case invalidMachineIdentifier
|
||||
case emptyMachineIdentifier
|
||||
case emptyHardwareModel
|
||||
case invalidHardwareModel
|
||||
case invalidDiskSize
|
||||
case malformedSizeInput(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidDisplayResolution(let resolution):
|
||||
return "Invalid display resolution: \(resolution)"
|
||||
case .emptyMachineIdentifier:
|
||||
return "Empty machine identifier"
|
||||
case .invalidMachineIdentifier:
|
||||
return "Invalid machine identifier"
|
||||
case .emptyHardwareModel:
|
||||
return "Empty hardware model"
|
||||
case .invalidHardwareModel:
|
||||
return "Invalid hardware model: the host does not support the hardware model"
|
||||
case .invalidDiskSize:
|
||||
return "Invalid disk size"
|
||||
case .malformedSizeInput(let input):
|
||||
return "Malformed size input: \(input)"
|
||||
}
|
||||
}
|
||||
|
||||
static var errorDomain: String { "VMConfigError" }
|
||||
|
||||
var errorCode: Int {
|
||||
switch self {
|
||||
case .invalidDisplayResolution: return 1
|
||||
case .emptyMachineIdentifier: return 2
|
||||
case .invalidMachineIdentifier: return 3
|
||||
case .emptyHardwareModel: return 4
|
||||
case .invalidHardwareModel: return 5
|
||||
case .invalidDiskSize: return 6
|
||||
case .malformedSizeInput: return 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum VMDirectoryError: Error, LocalizedError {
|
||||
case configNotFound
|
||||
case invalidConfigData
|
||||
case diskOperationFailed(String)
|
||||
case fileCreationFailed(String)
|
||||
case sessionNotFound
|
||||
case invalidSessionData
|
||||
|
||||
var errorDescription: String {
|
||||
switch self {
|
||||
case .configNotFound:
|
||||
return "VM configuration file not found"
|
||||
case .invalidConfigData:
|
||||
return "Invalid VM configuration data"
|
||||
case .diskOperationFailed(let reason):
|
||||
return "Disk operation failed: \(reason)"
|
||||
case .fileCreationFailed(let path):
|
||||
return "Failed to create file at path: \(path)"
|
||||
case .sessionNotFound:
|
||||
return "VNC session file not found"
|
||||
case .invalidSessionData:
|
||||
return "Invalid VNC session data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum VMError: Error, LocalizedError {
|
||||
case alreadyExists(String)
|
||||
case notFound(String)
|
||||
case notInitialized(String)
|
||||
case notRunning(String)
|
||||
case alreadyRunning(String)
|
||||
case installNotStarted(String)
|
||||
case stopTimeout(String)
|
||||
case resizeTooSmall(current: UInt64, requested: UInt64)
|
||||
case vncNotConfigured
|
||||
case internalError(String)
|
||||
case unsupportedOS(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .alreadyExists(let name):
|
||||
return "Virtual machine already exists with name: \(name)"
|
||||
case .notFound(let name):
|
||||
return "Virtual machine not found: \(name)"
|
||||
case .notInitialized(let name):
|
||||
return "Virtual machine not initialized: \(name)"
|
||||
case .notRunning(let name):
|
||||
return "Virtual machine not running: \(name)"
|
||||
case .alreadyRunning(let name):
|
||||
return "Virtual machine already running: \(name)"
|
||||
case .installNotStarted(let name):
|
||||
return "Virtual machine install not started: \(name)"
|
||||
case .stopTimeout(let name):
|
||||
return "Timeout while stopping virtual machine: \(name)"
|
||||
case .resizeTooSmall(let current, let requested):
|
||||
return "Cannot resize disk to \(requested) bytes, current size is \(current) bytes"
|
||||
case .vncNotConfigured:
|
||||
return "VNC is not configured for this virtual machine"
|
||||
case .internalError(let message):
|
||||
return "Internal error: \(message)"
|
||||
case .unsupportedOS(let os):
|
||||
return "Unsupported operating system: \(os)"
|
||||
}
|
||||
}
|
||||
}
|
||||
146
src/FileSystem/Home.swift
Normal file
146
src/FileSystem/Home.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
/// Manages the application's home directory and virtual machine directories.
|
||||
/// 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 let fileManager: FileManager
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(fileManager: FileManager = .default) {
|
||||
self.fileManager = fileManager
|
||||
self.homeDir = Path(Constants.homeDirPath)
|
||||
}
|
||||
|
||||
// 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)
|
||||
} catch {
|
||||
throw HomeError.directoryCreationFailed(path: tempDir.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a VMDirectory instance for the given name
|
||||
/// - Parameter name: Name of the VM directory
|
||||
/// - 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// - Throws: HomeError if the copy operation fails
|
||||
func copyVMDirectory(from sourceName: String, to destName: String) throws {
|
||||
let sourceDir = getVMDirectory(sourceName)
|
||||
let destDir = getVMDirectory(destName)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
guard isValidDirectory(at: homeDir.path) else {
|
||||
throw HomeError.invalidHomeDirectory
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func createHomeDirectory() throws {
|
||||
do {
|
||||
try createDirectory(at: homeDir.url)
|
||||
} catch {
|
||||
throw HomeError.directoryCreationFailed(path: homeDir.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
|
||||
&& Path(path).writable()
|
||||
}
|
||||
|
||||
private func sanitizeFileName(_ name: String) -> String {
|
||||
// Only decode percent encoding (e.g., %20 for spaces)
|
||||
return name.removingPercentEncoding ?? name
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home + CustomStringConvertible
|
||||
|
||||
extension Home: CustomStringConvertible {
|
||||
var description: String {
|
||||
"Home(path: \(homeDir.path))"
|
||||
}
|
||||
}
|
||||
141
src/FileSystem/VMConfig.swift
Normal file
141
src/FileSystem/VMConfig.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Represents a shared directory configuration
|
||||
struct SharedDirectory {
|
||||
let hostPath: String
|
||||
let tag: String
|
||||
let readOnly: Bool
|
||||
|
||||
var string: String {
|
||||
return "\(hostPath):\(tag):\(readOnly ? "ro" : "rw")"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VMConfig
|
||||
struct VMConfig: Codable {
|
||||
|
||||
// MARK: - Properties
|
||||
let os: String
|
||||
private var _cpuCount: Int?
|
||||
private var _memorySize: UInt64?
|
||||
private var _diskSize: UInt64?
|
||||
private var _macAddress: String?
|
||||
let display: VMDisplayResolution
|
||||
private var _hardwareModel: Data?
|
||||
private var _machineIdentifier: Data?
|
||||
|
||||
// MARK: - Initialization
|
||||
init(
|
||||
os: String,
|
||||
cpuCount: Int? = nil,
|
||||
memorySize: UInt64? = nil,
|
||||
diskSize: UInt64? = nil,
|
||||
macAddress: String? = nil,
|
||||
display: String,
|
||||
hardwareModel: Data? = nil,
|
||||
machineIdentifier: Data? = nil
|
||||
) throws {
|
||||
self.os = os
|
||||
self._cpuCount = cpuCount
|
||||
self._memorySize = memorySize
|
||||
self._diskSize = diskSize
|
||||
self._macAddress = macAddress
|
||||
self.display = VMDisplayResolution(string: display) ?? VMDisplayResolution(string: "1024x768")!
|
||||
self._hardwareModel = hardwareModel
|
||||
self._machineIdentifier = machineIdentifier
|
||||
}
|
||||
|
||||
var cpuCount: Int? {
|
||||
get { _cpuCount }
|
||||
set { _cpuCount = newValue }
|
||||
}
|
||||
|
||||
var memorySize: UInt64? {
|
||||
get { _memorySize }
|
||||
set { _memorySize = newValue }
|
||||
}
|
||||
|
||||
var diskSize: UInt64? {
|
||||
get { _diskSize }
|
||||
set { _diskSize = newValue }
|
||||
}
|
||||
|
||||
var hardwareModel: Data? {
|
||||
get { _hardwareModel }
|
||||
set { _hardwareModel = newValue }
|
||||
}
|
||||
|
||||
var machineIdentifier: Data? {
|
||||
get { _machineIdentifier }
|
||||
set { _machineIdentifier = newValue }
|
||||
}
|
||||
|
||||
var macAddress: String? {
|
||||
get { _macAddress }
|
||||
set { _macAddress = newValue }
|
||||
}
|
||||
|
||||
mutating func setCpuCount(_ count: Int) {
|
||||
_cpuCount = count
|
||||
}
|
||||
|
||||
mutating func setMemorySize(_ size: UInt64) {
|
||||
_memorySize = size
|
||||
}
|
||||
|
||||
mutating func setDiskSize(_ size: UInt64) {
|
||||
_diskSize = size
|
||||
}
|
||||
|
||||
mutating func setHardwareModel(_ hardwareModel: Data) {
|
||||
_hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
mutating func setMachineIdentifier(_ machineIdentifier: Data) {
|
||||
_machineIdentifier = machineIdentifier
|
||||
}
|
||||
|
||||
mutating func setMacAddress(_ macAddress: String) {
|
||||
_macAddress = macAddress
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case _cpuCount = "cpuCount"
|
||||
case _memorySize = "memorySize"
|
||||
case _diskSize = "diskSize"
|
||||
case macAddress
|
||||
case display
|
||||
case _hardwareModel = "hardwareModel"
|
||||
case _machineIdentifier = "machineIdentifier"
|
||||
case os
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
os = try container.decode(String.self, forKey: .os)
|
||||
_cpuCount = try container.decodeIfPresent(Int.self, forKey: ._cpuCount)
|
||||
_memorySize = try container.decodeIfPresent(UInt64.self, forKey: ._memorySize)
|
||||
_diskSize = try container.decodeIfPresent(UInt64.self, forKey: ._diskSize)
|
||||
_macAddress = try container.decodeIfPresent(String.self, forKey: .macAddress)
|
||||
display = VMDisplayResolution(string: try container.decode(String.self, forKey: .display))!
|
||||
_hardwareModel = try container.decodeIfPresent(Data.self, forKey: ._hardwareModel)
|
||||
_machineIdentifier = try container.decodeIfPresent(Data.self, forKey: ._machineIdentifier)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encodeIfPresent(os, forKey: .os)
|
||||
try container.encodeIfPresent(_cpuCount, forKey: ._cpuCount)
|
||||
try container.encodeIfPresent(_memorySize, forKey: ._memorySize)
|
||||
try container.encodeIfPresent(_diskSize, forKey: ._diskSize)
|
||||
try container.encodeIfPresent(_macAddress, forKey: .macAddress)
|
||||
try container.encode(display.string, forKey: .display)
|
||||
try container.encodeIfPresent(_hardwareModel, forKey: ._hardwareModel)
|
||||
try container.encodeIfPresent(_machineIdentifier, forKey: ._machineIdentifier)
|
||||
}
|
||||
}
|
||||
181
src/FileSystem/VMDirectory.swift
Normal file
181
src/FileSystem/VMDirectory.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - VMDirectory
|
||||
|
||||
/// Manages a virtual machine's directory structure and files
|
||||
/// Responsible for:
|
||||
/// - Managing VM configuration files
|
||||
/// - Handling disk operations
|
||||
/// - Managing VM state and locking
|
||||
/// - Providing access to VM-related paths
|
||||
struct VMDirectory {
|
||||
// MARK: - Constants
|
||||
|
||||
private enum FileNames {
|
||||
static let nvram = "nvram.bin"
|
||||
static let disk = "disk.img"
|
||||
static let config = "config.json"
|
||||
static let sessions = "sessions.json"
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let dir: Path
|
||||
let nvramPath: Path
|
||||
let diskPath: Path
|
||||
let configPath: Path
|
||||
let sessionsPath: Path
|
||||
|
||||
private let fileManager: FileManager
|
||||
|
||||
/// The name of the VM directory
|
||||
var name: String { dir.name }
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Creates a new VMDirectory instance
|
||||
/// - Parameters:
|
||||
/// - dir: The base directory path for the VM
|
||||
/// - fileManager: FileManager instance to use for file operations
|
||||
init(_ dir: Path, fileManager: FileManager = .default) {
|
||||
self.dir = dir
|
||||
self.fileManager = fileManager
|
||||
self.nvramPath = dir.file(FileNames.nvram)
|
||||
self.diskPath = dir.file(FileNames.disk)
|
||||
self.configPath = dir.file(FileNames.config)
|
||||
self.sessionsPath = dir.file(FileNames.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VM State Management
|
||||
|
||||
extension VMDirectory {
|
||||
/// Checks if the VM directory is fully initialized with all required files
|
||||
func initialized() -> Bool {
|
||||
configPath.exists() && diskPath.exists() && nvramPath.exists()
|
||||
}
|
||||
|
||||
/// Checks if the VM directory exists
|
||||
func exists() -> Bool {
|
||||
dir.exists()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Disk Management
|
||||
|
||||
extension VMDirectory {
|
||||
/// Resizes the VM's disk to the specified size
|
||||
/// - Parameter size: The new size in bytes
|
||||
/// - Throws: VMDirectoryError if the disk operation fails
|
||||
func setDisk(_ size: UInt64) throws {
|
||||
do {
|
||||
if !diskPath.exists() {
|
||||
guard fileManager.createFile(atPath: diskPath.path, contents: nil) else {
|
||||
throw VMDirectoryError.fileCreationFailed(diskPath.path)
|
||||
}
|
||||
}
|
||||
|
||||
let handle = try FileHandle(forWritingTo: diskPath.url)
|
||||
defer { try? handle.close() }
|
||||
|
||||
try handle.truncate(atOffset: size)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration Management
|
||||
|
||||
extension VMDirectory {
|
||||
/// Saves the VM configuration to disk
|
||||
/// - Parameter config: The configuration to save
|
||||
/// - Throws: VMDirectoryError if the save operation fails
|
||||
func saveConfig(_ config: VMConfig) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
|
||||
do {
|
||||
let data = try encoder.encode(config)
|
||||
guard fileManager.createFile(atPath: configPath.path, contents: data) else {
|
||||
throw VMDirectoryError.fileCreationFailed(configPath.path)
|
||||
}
|
||||
} catch {
|
||||
throw VMDirectoryError.invalidConfigData
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the VM configuration from disk
|
||||
/// - Returns: The loaded configuration
|
||||
/// - Throws: VMDirectoryError if the load operation fails
|
||||
func loadConfig() throws -> VMConfig {
|
||||
guard let data = fileManager.contents(atPath: configPath.path) else {
|
||||
throw VMDirectoryError.configNotFound
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(VMConfig.self, from: data)
|
||||
} catch {
|
||||
throw VMDirectoryError.invalidConfigData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VNC Session Management
|
||||
|
||||
struct VNCSession: Codable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
/// Saves VNC session information to disk
|
||||
/// - Parameter session: The VNC session to save
|
||||
/// - Throws: VMDirectoryError if the save operation fails
|
||||
func saveSession(_ session: VNCSession) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
|
||||
do {
|
||||
let data = try encoder.encode(session)
|
||||
guard fileManager.createFile(atPath: sessionsPath.path, contents: data) else {
|
||||
throw VMDirectoryError.fileCreationFailed(sessionsPath.path)
|
||||
}
|
||||
} catch {
|
||||
throw VMDirectoryError.invalidSessionData
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the VNC session information from disk
|
||||
/// - Returns: The loaded VNC session
|
||||
/// - Throws: VMDirectoryError if the load operation fails
|
||||
func loadSession() throws -> VNCSession {
|
||||
guard let data = fileManager.contents(atPath: sessionsPath.path) else {
|
||||
throw VMDirectoryError.sessionNotFound
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(VNCSession.self, from: data)
|
||||
} catch {
|
||||
throw VMDirectoryError.invalidSessionData
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the VNC session information from disk
|
||||
func clearSession() {
|
||||
try? fileManager.removeItem(atPath: sessionsPath.path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CustomStringConvertible
|
||||
extension VMDirectory: CustomStringConvertible {
|
||||
var description: String {
|
||||
"VMDirectory(path: \(dir.path))"
|
||||
}
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
func delete() throws {
|
||||
try fileManager.removeItem(atPath: dir.path)
|
||||
}
|
||||
}
|
||||
514
src/LumeController.swift
Normal file
514
src/LumeController.swift
Normal file
@@ -0,0 +1,514 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
// MARK: - Shared VM Manager
|
||||
|
||||
@MainActor
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrypoint for Commands and API server
|
||||
final class LumeController {
|
||||
// MARK: - Properties
|
||||
|
||||
let home: Home
|
||||
private let imageLoaderFactory: ImageLoaderFactory
|
||||
private let vmFactory: VMFactory
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
home: Home = Home(),
|
||||
imageLoaderFactory: ImageLoaderFactory = DefaultImageLoaderFactory(),
|
||||
vmFactory: VMFactory = DefaultVMFactory()
|
||||
) {
|
||||
self.home = home
|
||||
self.imageLoaderFactory = imageLoaderFactory
|
||||
self.vmFactory = vmFactory
|
||||
}
|
||||
|
||||
// MARK: - Public VM Management Methods
|
||||
|
||||
/// Lists all virtual machines in the system
|
||||
@MainActor
|
||||
public func list() throws -> [VMDetails] {
|
||||
do {
|
||||
let statuses = try home.getAllVMDirectories().map { directory in
|
||||
let vm = try self.get(name: directory.name)
|
||||
return vm.details
|
||||
}
|
||||
return statuses
|
||||
} catch {
|
||||
Logger.error("Failed to list VMs", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func clone(name: String, newName: String) throws {
|
||||
Logger.info("Cloning VM", metadata: ["source": name, "destination": newName])
|
||||
|
||||
do {
|
||||
try self.validateVMExists(name)
|
||||
|
||||
// Copy the VM directory
|
||||
try home.copyVMDirectory(from: name, to: newName)
|
||||
|
||||
// Update MAC address in the cloned VM to ensure uniqueness
|
||||
let clonedVM = try get(name: newName)
|
||||
try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
|
||||
|
||||
Logger.info("VM cloned successfully", metadata: ["source": name, "destination": newName])
|
||||
} catch {
|
||||
Logger.error("Failed to clone VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func get(name: String) throws -> VM {
|
||||
do {
|
||||
try self.validateVMExists(name)
|
||||
|
||||
let vm = try self.loadVM(name: name)
|
||||
return vm
|
||||
} catch {
|
||||
Logger.error("Failed to get VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for creating the appropriate VM type based on the OS
|
||||
@MainActor
|
||||
public func create(
|
||||
name: String,
|
||||
os: String,
|
||||
diskSize: UInt64,
|
||||
cpuCount: Int,
|
||||
memorySize: UInt64,
|
||||
display: String,
|
||||
ipsw: String?
|
||||
) async throws {
|
||||
Logger.info(
|
||||
"Creating VM",
|
||||
metadata: [
|
||||
"name": name,
|
||||
"os": os,
|
||||
"disk_size": "\(diskSize / 1024 / 1024)MB",
|
||||
"cpu_count": "\(cpuCount)",
|
||||
"memory_size": "\(memorySize / 1024 / 1024)MB",
|
||||
"display": display,
|
||||
"ipsw": ipsw ?? "none",
|
||||
])
|
||||
|
||||
do {
|
||||
try validateCreateParameters(name: name, os: os, ipsw: ipsw)
|
||||
|
||||
let vm = try await createTempVMConfig(
|
||||
os: os,
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
diskSize: diskSize,
|
||||
display: display
|
||||
)
|
||||
|
||||
try await vm.setup(
|
||||
ipswPath: ipsw ?? "none",
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
diskSize: diskSize,
|
||||
display: display
|
||||
)
|
||||
|
||||
try vm.finalize(to: name, home: home)
|
||||
|
||||
Logger.info("VM created successfully", metadata: ["name": name])
|
||||
} catch {
|
||||
Logger.error("Failed to create VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func delete(name: String) async throws {
|
||||
Logger.info("Deleting VM", metadata: ["name": name])
|
||||
|
||||
do {
|
||||
try self.validateVMExists(name)
|
||||
|
||||
// Stop VM if it's running
|
||||
if SharedVM.shared.getVM(name: name) != nil {
|
||||
try await stopVM(name: name)
|
||||
}
|
||||
|
||||
let vmDir = home.getVMDirectory(name)
|
||||
try vmDir.delete()
|
||||
Logger.info("VM deleted successfully", metadata: ["name": name])
|
||||
|
||||
} catch {
|
||||
Logger.error("Failed to delete VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VM Operations
|
||||
|
||||
@MainActor
|
||||
public func updateSettings(
|
||||
name: String,
|
||||
cpu: Int? = nil,
|
||||
memory: UInt64? = nil,
|
||||
diskSize: UInt64? = nil
|
||||
) throws {
|
||||
Logger.info(
|
||||
"Updating VM settings",
|
||||
metadata: [
|
||||
"name": name,
|
||||
"cpu": cpu.map { "\($0)" } ?? "unchanged",
|
||||
"memory": memory.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
|
||||
"disk_size": diskSize.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
|
||||
])
|
||||
do {
|
||||
try self.validateVMExists(name)
|
||||
|
||||
let vm = try get(name: name)
|
||||
|
||||
// Apply settings in order
|
||||
if let cpu = cpu {
|
||||
try vm.setCpuCount(cpu)
|
||||
}
|
||||
if let memory = memory {
|
||||
try vm.setMemorySize(memory)
|
||||
}
|
||||
if let diskSize = diskSize {
|
||||
try vm.setDiskSize(diskSize)
|
||||
}
|
||||
|
||||
Logger.info("VM settings updated successfully", metadata: ["name": name])
|
||||
} catch {
|
||||
Logger.error(
|
||||
"Failed to update VM settings", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func stopVM(name: String) async throws {
|
||||
Logger.info("Stopping VM", metadata: ["name": name])
|
||||
|
||||
do {
|
||||
try self.validateVMExists(name)
|
||||
|
||||
// Try to get VM from cache first
|
||||
let vm: VM
|
||||
if let cachedVM = SharedVM.shared.getVM(name: name) {
|
||||
vm = cachedVM
|
||||
} else {
|
||||
vm = try get(name: name)
|
||||
}
|
||||
|
||||
try await vm.stop()
|
||||
// Remove VM from cache after stopping
|
||||
SharedVM.shared.removeVM(name: name)
|
||||
Logger.info("VM stopped successfully", metadata: ["name": name])
|
||||
} catch {
|
||||
// Clean up cache even if stop fails
|
||||
SharedVM.shared.removeVM(name: name)
|
||||
Logger.error("Failed to stop VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func runVM(
|
||||
name: String,
|
||||
noDisplay: Bool = false,
|
||||
sharedDirectories: [SharedDirectory] = [],
|
||||
mount: Path? = nil
|
||||
) async throws {
|
||||
Logger.info(
|
||||
"Running VM",
|
||||
metadata: [
|
||||
"name": name,
|
||||
"no_display": "\(noDisplay)",
|
||||
"shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
|
||||
"mount": mount?.path ?? "none",
|
||||
])
|
||||
|
||||
do {
|
||||
try validateRunParameters(
|
||||
name: name, sharedDirectories: sharedDirectories, mount: mount)
|
||||
|
||||
let vm = try get(name: name)
|
||||
SharedVM.shared.setVM(name: name, vm: vm)
|
||||
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
|
||||
Logger.info("VM started successfully", metadata: ["name": name])
|
||||
} catch {
|
||||
SharedVM.shared.removeVM(name: name)
|
||||
Logger.error("Failed to run VM", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Management
|
||||
|
||||
@MainActor
|
||||
public func getLatestIPSWURL() async throws -> URL {
|
||||
Logger.info("Fetching latest supported IPSW URL")
|
||||
|
||||
do {
|
||||
let imageLoader = DarwinImageLoader()
|
||||
let url = try await imageLoader.fetchLatestSupportedURL()
|
||||
Logger.info("Found latest IPSW URL", metadata: ["url": url.absoluteString])
|
||||
return url
|
||||
} catch {
|
||||
Logger.error(
|
||||
"Failed to fetch IPSW URL", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func pullImage(image: String, name: String?, registry: String, organization: String)
|
||||
async throws
|
||||
{
|
||||
do {
|
||||
let vmName: String = name ?? image.split(separator: ":").first.map(String.init) ?? image
|
||||
|
||||
Logger.info(
|
||||
"Pulling image",
|
||||
metadata: [
|
||||
"image": image,
|
||||
"name": name ?? "default",
|
||||
"registry": registry,
|
||||
"organization": organization,
|
||||
])
|
||||
|
||||
try self.validatePullParameters(
|
||||
image: image, name: vmName, registry: registry, organization: organization)
|
||||
|
||||
let imageContainerRegistry = ImageContainerRegistry(
|
||||
registry: registry, organization: organization)
|
||||
try await imageContainerRegistry.pull(image: image, name: vmName)
|
||||
|
||||
Logger.info("Setting new VM mac address")
|
||||
|
||||
// Update MAC address in the cloned VM to ensure uniqueness
|
||||
let vm = try get(name: vmName)
|
||||
try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
|
||||
|
||||
Logger.info(
|
||||
"Image pulled successfully",
|
||||
metadata: [
|
||||
"image": image,
|
||||
"name": vmName,
|
||||
"registry": registry,
|
||||
"organization": organization,
|
||||
])
|
||||
} catch {
|
||||
Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
Logger.info("Successfully removed cached images")
|
||||
} else {
|
||||
Logger.info("No cached images found")
|
||||
}
|
||||
} catch {
|
||||
Logger.error("Failed to prune images", metadata: ["error": error.localizedDescription])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public struct ImageInfo: Codable {
|
||||
public let repository: String
|
||||
public let tag: String
|
||||
public let manifestId: String
|
||||
|
||||
public var fullName: String {
|
||||
return "\(repository):\(tag)"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ImageList: Codable {
|
||||
public let local: [ImageInfo]
|
||||
public let remote: [String]
|
||||
}
|
||||
|
||||
@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 cachedImages = try await imageContainerRegistry.getImages()
|
||||
|
||||
let imageInfos = cachedImages.map { image in
|
||||
ImageInfo(repository: image.repository, tag: image.tag, manifestId: image.manifestId)
|
||||
}
|
||||
|
||||
ImagesPrinter.print(images: imageInfos.map { $0.fullName })
|
||||
return ImageList(local: imageInfos, remote: [])
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
|
||||
@MainActor
|
||||
private func createTempVMConfig(
|
||||
os: String,
|
||||
cpuCount: Int,
|
||||
memorySize: UInt64,
|
||||
diskSize: UInt64,
|
||||
display: String
|
||||
) async throws -> VM {
|
||||
let config = try VMConfig(
|
||||
os: os,
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
diskSize: diskSize,
|
||||
macAddress: VZMACAddress.randomLocallyAdministered().string,
|
||||
display: display
|
||||
)
|
||||
|
||||
let vmDirContext = VMDirContext(
|
||||
dir: try home.createTempVMDirectory(),
|
||||
config: config,
|
||||
home: home
|
||||
)
|
||||
|
||||
let imageLoader = os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
|
||||
return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadVM(name: String) throws -> VM {
|
||||
let vmDir = home.getVMDirectory(name)
|
||||
guard vmDir.initialized() else {
|
||||
throw VMError.notInitialized(name)
|
||||
}
|
||||
|
||||
let config: VMConfig = try vmDir.loadConfig()
|
||||
let vmDirContext = VMDirContext(dir: vmDir, config: config, home: home)
|
||||
|
||||
let imageLoader =
|
||||
config.os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
|
||||
return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
|
||||
}
|
||||
|
||||
// MARK: - Validation Methods
|
||||
|
||||
private func validateCreateParameters(name: String, os: String, ipsw: String?) throws {
|
||||
if os.lowercased() == "macos" {
|
||||
guard let ipsw = ipsw else {
|
||||
throw ValidationError("IPSW path required for macOS VM")
|
||||
}
|
||||
if ipsw != "latest" && !FileManager.default.fileExists(atPath: ipsw) {
|
||||
throw ValidationError("IPSW file not found")
|
||||
}
|
||||
} else if os.lowercased() == "linux" {
|
||||
if ipsw != nil {
|
||||
throw ValidationError("IPSW path not supported for Linux VM")
|
||||
}
|
||||
} else {
|
||||
throw ValidationError("Unsupported OS type: \(os)")
|
||||
}
|
||||
|
||||
let vmDir = home.getVMDirectory(name)
|
||||
if vmDir.exists() {
|
||||
throw VMError.alreadyExists(name)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSharedDirectories(_ directories: [SharedDirectory]) throws {
|
||||
for dir in directories {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func validateVMExists(_ name: String) throws {
|
||||
let vmDir = home.getVMDirectory(name)
|
||||
guard vmDir.initialized() else {
|
||||
throw VMError.notFound(name)
|
||||
}
|
||||
}
|
||||
|
||||
private func validatePullParameters(
|
||||
image: String, name: String, registry: String, organization: String
|
||||
) throws {
|
||||
guard !image.isEmpty else {
|
||||
throw ValidationError("Image name cannot be empty")
|
||||
}
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("VM name cannot be empty")
|
||||
}
|
||||
guard !registry.isEmpty else {
|
||||
throw ValidationError("Registry cannot be empty")
|
||||
}
|
||||
guard !organization.isEmpty else {
|
||||
throw ValidationError("Organization cannot be empty")
|
||||
}
|
||||
|
||||
let vmDir = home.getVMDirectory(name)
|
||||
if vmDir.exists() {
|
||||
throw VMError.alreadyExists(name)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateRunParameters(
|
||||
name: String, sharedDirectories: [SharedDirectory]?, mount: Path?
|
||||
) throws {
|
||||
try self.validateVMExists(name)
|
||||
if let dirs: [SharedDirectory] = sharedDirectories {
|
||||
try self.validateSharedDirectories(dirs)
|
||||
}
|
||||
let vmConfig = try home.getVMDirectory(name).loadConfig()
|
||||
switch vmConfig.os.lowercased() {
|
||||
case "macos":
|
||||
if mount != nil {
|
||||
throw ValidationError(
|
||||
"Mounting disk images is not supported for macOS VMs. If you are looking to mount a IPSW, please use the --ipsw option in the create command."
|
||||
)
|
||||
}
|
||||
case "linux":
|
||||
if let mount = mount, !FileManager.default.fileExists(atPath: mount.path) {
|
||||
throw ValidationError("Mount file not found: \(mount.path)")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Main.swift
Normal file
43
src/Main.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct Lume: AsyncParsableCommand {
|
||||
static var configuration: CommandConfiguration {
|
||||
CommandConfiguration(
|
||||
commandName: "lume",
|
||||
abstract: "A lightweight CLI and local API server to build, run and manage macOS VMs.",
|
||||
version: Version.current,
|
||||
subcommands: CommandRegistry.allCommands,
|
||||
helpNames: .long
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Version Management
|
||||
extension Lume {
|
||||
enum Version {
|
||||
static let current = "0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Execution
|
||||
extension Lume {
|
||||
public static func main() async {
|
||||
do {
|
||||
try await executeCommand()
|
||||
} catch {
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
private static func executeCommand() async throws {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/Server/HTTP.swift
Normal file
113
src/Server/HTTP.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum HTTPError: Error {
|
||||
case internalError
|
||||
}
|
||||
|
||||
struct HTTPRequest {
|
||||
let method: String
|
||||
let path: String
|
||||
let headers: [String: String]
|
||||
let body: Data?
|
||||
|
||||
init?(data: Data) {
|
||||
guard let requestString = String(data: data, encoding: .utf8) else { return nil }
|
||||
let components = requestString.components(separatedBy: "\r\n\r\n")
|
||||
guard components.count >= 1 else { return nil }
|
||||
|
||||
let headerLines = components[0].components(separatedBy: "\r\n")
|
||||
guard !headerLines.isEmpty else { return nil }
|
||||
|
||||
// Parse request line
|
||||
let requestLine = headerLines[0].components(separatedBy: " ")
|
||||
guard requestLine.count >= 2 else { return nil }
|
||||
|
||||
self.method = requestLine[0]
|
||||
self.path = requestLine[1]
|
||||
|
||||
// Parse headers
|
||||
var headers: [String: String] = [:]
|
||||
for line in headerLines.dropFirst() {
|
||||
let headerComponents = line.split(separator: ":", maxSplits: 1).map(String.init)
|
||||
if headerComponents.count == 2 {
|
||||
headers[headerComponents[0].trimmingCharacters(in: .whitespaces)] =
|
||||
headerComponents[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
self.headers = headers
|
||||
|
||||
// Parse body if present
|
||||
if components.count > 1 {
|
||||
self.body = components[1].data(using: .utf8)
|
||||
} else {
|
||||
self.body = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPResponse {
|
||||
enum StatusCode: Int {
|
||||
case ok = 200
|
||||
case accepted = 202
|
||||
case badRequest = 400
|
||||
case notFound = 404
|
||||
case internalServerError = 500
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ok: return "OK"
|
||||
case .accepted: return "Accepted"
|
||||
case .badRequest: return "Bad Request"
|
||||
case .notFound: return "Not Found"
|
||||
case .internalServerError: return "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let statusCode: StatusCode
|
||||
let headers: [String: String]
|
||||
let body: Data?
|
||||
|
||||
init(statusCode: StatusCode, headers: [String: String] = [:], body: Data? = nil) {
|
||||
self.statusCode = statusCode
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
}
|
||||
|
||||
init(statusCode: StatusCode, body: String) {
|
||||
self.statusCode = statusCode
|
||||
self.headers = ["Content-Type": "text/plain"]
|
||||
self.body = body.data(using: .utf8)
|
||||
}
|
||||
|
||||
func serialize() -> Data {
|
||||
var response = "HTTP/1.1 \(statusCode.rawValue) \(statusCode.description)\r\n"
|
||||
|
||||
var headers = self.headers
|
||||
if let body = body {
|
||||
headers["Content-Length"] = "\(body.count)"
|
||||
}
|
||||
|
||||
for (key, value) in headers {
|
||||
response += "\(key): \(value)\r\n"
|
||||
}
|
||||
|
||||
response += "\r\n"
|
||||
|
||||
var responseData = response.data(using: .utf8) ?? Data()
|
||||
if let body = body {
|
||||
responseData.append(body)
|
||||
}
|
||||
|
||||
return responseData
|
||||
}
|
||||
}
|
||||
|
||||
final class HTTPServer {
|
||||
let port: UInt16
|
||||
|
||||
init(port: UInt16) {
|
||||
self.port = port
|
||||
}
|
||||
}
|
||||
316
src/Server/Handlers.swift
Normal file
316
src/Server/Handlers.swift
Normal file
@@ -0,0 +1,316 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import ArgumentParser
|
||||
|
||||
@MainActor
|
||||
extension Server {
|
||||
// MARK: - VM Management Handlers
|
||||
|
||||
func handleListVMs() async throws -> HTTPResponse {
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
let vms = try vmController.list()
|
||||
return try .json(vms)
|
||||
} catch {
|
||||
return .badRequest(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetVM(name: String) async throws -> HTTPResponse {
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
let vm = try vmController.get(name: name)
|
||||
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 {
|
||||
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()
|
||||
try await vmController.create(
|
||||
name: request.name,
|
||||
os: request.os,
|
||||
diskSize: sizes.diskSize,
|
||||
cpuCount: request.cpu,
|
||||
memorySize: sizes.memory,
|
||||
display: request.display,
|
||||
ipsw: request.ipsw
|
||||
)
|
||||
|
||||
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
|
||||
])
|
||||
)
|
||||
} 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()
|
||||
try vmController.updateSettings(
|
||||
name: name,
|
||||
cpu: request.cpu,
|
||||
memory: sizes.memory,
|
||||
diskSize: sizes.diskSize
|
||||
)
|
||||
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(["message": "VM settings updated successfully"])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleStopVM(name: String) async throws -> HTTPResponse {
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
try await vmController.stopVM(name: name)
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(["message": "VM stopped successfully"])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
do {
|
||||
let dirs = try request.parse()
|
||||
|
||||
// Start VM in background
|
||||
startVM(
|
||||
name: name,
|
||||
noDisplay: request.noDisplay ?? false,
|
||||
sharedDirectories: dirs
|
||||
)
|
||||
|
||||
// Return response immediately
|
||||
return HTTPResponse(
|
||||
statusCode: .accepted,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode([
|
||||
"message": "VM start initiated",
|
||||
"name": name,
|
||||
"status": "pending"
|
||||
])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Management Handlers
|
||||
|
||||
func handleIPSW() async throws -> HTTPResponse {
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
let url = try await vmController.getLatestIPSWURL()
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(["url": url.absoluteString])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handlePull(_ body: Data?) async throws -> HTTPResponse {
|
||||
guard let body = body,
|
||||
let request = try? JSONDecoder().decode(PullRequest.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 await vmController.pullImage(image: request.image, name: request.name, registry: request.registry, organization: request.organization)
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(["message": "Image pulled successfully"])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handlePruneImages() async throws -> HTTPResponse {
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
try await vmController.pruneImages()
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(["message": "Successfully removed cached images"])
|
||||
)
|
||||
} catch {
|
||||
return HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetImages(_ request: HTTPRequest) async throws -> HTTPResponse {
|
||||
// Parse query parameters from URL path and query string
|
||||
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 organization = queryParams["organization"] ?? "trycua"
|
||||
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
let images = try await vmController.getImages(organization: organization)
|
||||
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try JSONEncoder().encode(images)
|
||||
)
|
||||
} 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] = []
|
||||
) {
|
||||
Task.detached { @MainActor @Sendable in
|
||||
Logger.info("Starting VM in background", metadata: ["name": name])
|
||||
do {
|
||||
let vmController = LumeController()
|
||||
try await vmController.runVM(
|
||||
name: name,
|
||||
noDisplay: noDisplay,
|
||||
sharedDirectories: sharedDirectories
|
||||
)
|
||||
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
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/Server/Requests.swift
Normal file
86
src/Server/Requests.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import Virtualization
|
||||
|
||||
struct RunVMRequest: Codable {
|
||||
let noDisplay: Bool?
|
||||
let sharedDirectories: [SharedDirectoryRequest]?
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
return SharedDirectory(
|
||||
hostPath: dir.hostPath,
|
||||
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
|
||||
readOnly: dir.readOnly ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PullRequest: Codable {
|
||||
let image: String
|
||||
let name: String?
|
||||
var registry: String
|
||||
var organization: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case image, name, registry, organization
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateVMRequest: Codable {
|
||||
let name: String
|
||||
let os: String
|
||||
let cpu: Int
|
||||
let memory: String
|
||||
let diskSize: String
|
||||
let display: String
|
||||
let ipsw: String?
|
||||
|
||||
func parse() throws -> (memory: UInt64, diskSize: UInt64) {
|
||||
return (
|
||||
memory: try parseSize(memory),
|
||||
diskSize: try parseSize(diskSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SetVMRequest: Codable {
|
||||
let cpu: Int?
|
||||
let memory: String?
|
||||
let diskSize: String?
|
||||
|
||||
func parse() throws -> (memory: UInt64?, diskSize: UInt64?) {
|
||||
return (
|
||||
memory: try memory.map { try parseSize($0) },
|
||||
diskSize: try diskSize.map { try parseSize($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct CloneRequest: Codable {
|
||||
let name: String
|
||||
let newName: String
|
||||
}
|
||||
25
src/Server/Responses.swift
Normal file
25
src/Server/Responses.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
struct APIError: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
extension HTTPResponse {
|
||||
static func json<T: Encodable>(_ value: T) throws -> HTTPResponse {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return HTTPResponse(
|
||||
statusCode: .ok,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: data
|
||||
)
|
||||
}
|
||||
|
||||
static func badRequest(message: String) -> HTTPResponse {
|
||||
let error = APIError(message: message)
|
||||
return try! HTTPResponse(
|
||||
statusCode: .badRequest,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: JSONEncoder().encode(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
281
src/Server/Server.swift
Normal file
281
src/Server/Server.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// 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)
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Server Lifecycle
|
||||
func start() async throws {
|
||||
let parameters = NWParameters.tcp
|
||||
listener = try NWListener(using: parameters, on: port)
|
||||
|
||||
listener?.newConnectionHandler = { [weak self] connection in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleConnection(connection)
|
||||
}
|
||||
}
|
||||
|
||||
listener?.start(queue: .main)
|
||||
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
|
||||
switch state {
|
||||
case .ready:
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.receiveData(connection)
|
||||
}
|
||||
case .failed(let error):
|
||||
Logger.error("Connection failed", metadata: ["error": error.localizedDescription])
|
||||
connection.cancel()
|
||||
case .cancelled:
|
||||
// Connection is already cancelled, no need to cancel again
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .main)
|
||||
}
|
||||
|
||||
private func receiveData(_ connection: NWConnection) {
|
||||
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 {
|
||||
let response = try await self.handleRequest(data)
|
||||
self.send(response, on: connection)
|
||||
} catch {
|
||||
let errorResponse = self.errorResponse(error)
|
||||
self.send(errorResponse, on: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Request Handling
|
||||
private func handleRequest(_ data: Data) async throws -> HTTPResponse {
|
||||
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) ?? ""
|
||||
])
|
||||
|
||||
// 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) ?? ""
|
||||
])
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private func errorResponse(_ error: Error) -> HTTPResponse {
|
||||
HTTPResponse(
|
||||
statusCode: .internalServerError,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
body: try! JSONEncoder().encode(APIError(message: error.localizedDescription))
|
||||
)
|
||||
}
|
||||
}
|
||||
21
src/Utils/CommandRegistry.swift
Normal file
21
src/Utils/CommandRegistry.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import ArgumentParser
|
||||
|
||||
enum CommandRegistry {
|
||||
static var allCommands: [ParsableCommand.Type] {
|
||||
[
|
||||
Create.self,
|
||||
Pull.self,
|
||||
Images.self,
|
||||
Clone.self,
|
||||
Get.self,
|
||||
Set.self,
|
||||
List.self,
|
||||
Run.self,
|
||||
Stop.self,
|
||||
IPSW.self,
|
||||
Serve.self,
|
||||
Delete.self,
|
||||
Prune.self
|
||||
]
|
||||
}
|
||||
}
|
||||
6
src/Utils/CommandUtils.swift
Normal file
6
src/Utils/CommandUtils.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
func completeVMName(_ arguments: [String]) -> [String] {
|
||||
(try? Home().getAllVMDirectories().map(\.name)) ?? []
|
||||
}
|
||||
29
src/Utils/Logger.swift
Normal file
29
src/Utils/Logger.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
struct Logger {
|
||||
typealias Metadata = [String: String]
|
||||
|
||||
enum Level: String {
|
||||
case info
|
||||
case error
|
||||
case debug
|
||||
}
|
||||
|
||||
static func info(_ message: String, metadata: Metadata = [:]) {
|
||||
log(.info, message, metadata)
|
||||
}
|
||||
|
||||
static func error(_ message: String, metadata: Metadata = [:]) {
|
||||
log(.error, message, metadata)
|
||||
}
|
||||
|
||||
static func debug(_ message: String, metadata: Metadata = [:]) {
|
||||
log(.debug, message, metadata)
|
||||
}
|
||||
|
||||
private static func log(_ level: Level, _ message: String, _ metadata: Metadata) {
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
let metadataString = metadata.isEmpty ? "" : " " + metadata.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
|
||||
print("[\(timestamp)] \(level.rawValue.uppercased()): \(message)\(metadataString)")
|
||||
}
|
||||
}
|
||||
24
src/Utils/NetworkUtils.swift
Normal file
24
src/Utils/NetworkUtils.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
enum NetworkUtils {
|
||||
/// Checks if an IP address is reachable by sending a ping
|
||||
/// - Parameter ipAddress: The IP address to check
|
||||
/// - Returns: true if the IP is reachable, false otherwise
|
||||
static func isReachable(ipAddress: String) -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/sbin/ping")
|
||||
process.arguments = ["-c", "1", "-t", "1", ipAddress]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Utils/Path.swift
Normal file
46
src/Utils/Path.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Path: CustomStringConvertible, ExpressibleByArgument {
|
||||
let url: URL
|
||||
|
||||
init(_ path: String) {
|
||||
url = URL(filePath: NSString(string: path).expandingTildeInPath).standardizedFileURL
|
||||
}
|
||||
|
||||
init(_ url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
init(argument: String) {
|
||||
self.init(argument)
|
||||
}
|
||||
|
||||
func file(_ path: String) -> Path {
|
||||
return Path(url.appendingPathComponent(path, isDirectory: false))
|
||||
}
|
||||
|
||||
func directory(_ path: String) -> Path {
|
||||
return Path(url.appendingPathComponent(path, isDirectory: true))
|
||||
}
|
||||
|
||||
func exists() -> Bool {
|
||||
return FileManager.default.fileExists(atPath: url.standardizedFileURL.path(percentEncoded: false))
|
||||
}
|
||||
|
||||
func writable() -> Bool {
|
||||
return FileManager.default.isWritableFile(atPath: url.standardizedFileURL.path(percentEncoded: false))
|
||||
}
|
||||
|
||||
var name: String {
|
||||
return url.lastPathComponent
|
||||
}
|
||||
|
||||
var path: String {
|
||||
return url.standardizedFileURL.path(percentEncoded: false)
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return url.path()
|
||||
}
|
||||
}
|
||||
15
src/Utils/ProcessRunner.swift
Normal file
15
src/Utils/ProcessRunner.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol for process execution
|
||||
protocol ProcessRunner {
|
||||
func run(executable: String, arguments: [String]) throws
|
||||
}
|
||||
|
||||
class DefaultProcessRunner: ProcessRunner {
|
||||
func run(executable: String, arguments: [String]) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = arguments
|
||||
try process.run()
|
||||
}
|
||||
}
|
||||
18
src/Utils/ProgressLogger.swift
Normal file
18
src/Utils/ProgressLogger.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct ProgressLogger {
|
||||
private var lastLoggedProgress: Double = 0.0
|
||||
private let threshold: Double
|
||||
|
||||
init(threshold: Double = 0.05) {
|
||||
self.threshold = threshold
|
||||
}
|
||||
|
||||
mutating func logProgress(current: Double, context: String) {
|
||||
if current - lastLoggedProgress >= threshold {
|
||||
lastLoggedProgress = current
|
||||
let percentage = Int(current * 100)
|
||||
Logger.info("\(context) Progress: \(percentage)%")
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/Utils/String.swift
Normal file
7
src/Utils/String.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func padding(_ toLength: Int) -> String {
|
||||
return self.padding(toLength: toLength, withPad: " ", startingAt: 0)
|
||||
}
|
||||
}
|
||||
57
src/Utils/Utils.swift
Normal file
57
src/Utils/Utils.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
|
||||
extension Collection {
|
||||
subscript (safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBinaryPath(_ name: String) -> URL? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to parse size strings
|
||||
func parseSize(_ input: String) throws -> UInt64 {
|
||||
let lowercased = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let multiplier: UInt64
|
||||
let valueString: String
|
||||
|
||||
if lowercased.hasSuffix("tb") {
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
valueString = String(lowercased.dropLast(2))
|
||||
} else if lowercased.hasSuffix("gb") {
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
valueString = String(lowercased.dropLast(2))
|
||||
} else if lowercased.hasSuffix("mb") {
|
||||
multiplier = 1024 * 1024
|
||||
valueString = String(lowercased.dropLast(2))
|
||||
} else if lowercased.hasSuffix("kb") {
|
||||
multiplier = 1024
|
||||
valueString = String(lowercased.dropLast(2))
|
||||
} else {
|
||||
multiplier = 1024 * 1024 // Default to MB
|
||||
valueString = lowercased
|
||||
}
|
||||
|
||||
guard let value = UInt64(valueString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
throw ValidationError("Malformed size input: \(input)") // Throw ad-hoc error for invalid input
|
||||
}
|
||||
|
||||
let val = value * multiplier
|
||||
|
||||
return val
|
||||
}
|
||||
84
src/VM/DarwinVM.swift
Normal file
84
src/VM/DarwinVM.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
/// macOS-specific virtual machine implementation
|
||||
@MainActor
|
||||
final class DarwinVM: VM {
|
||||
private let imageLoader: ImageLoader
|
||||
|
||||
init(
|
||||
vmDirContext: VMDirContext,
|
||||
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) },
|
||||
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) },
|
||||
imageLoader: ImageLoader
|
||||
) {
|
||||
self.imageLoader = imageLoader
|
||||
super.init(
|
||||
vmDirContext: vmDirContext,
|
||||
virtualizationServiceFactory: virtualizationServiceFactory,
|
||||
vncServiceFactory: vncServiceFactory
|
||||
)
|
||||
}
|
||||
|
||||
override func getOSType() -> String {
|
||||
return "macOS"
|
||||
}
|
||||
|
||||
// MARK: - Installation and Configuration
|
||||
|
||||
override func setup(ipswPath: String, cpuCount: Int, memorySize: UInt64, diskSize: UInt64, display: String) async throws {
|
||||
let imagePath: Path
|
||||
if ipswPath == "latest" {
|
||||
Logger.info("Downloading latest supported Image...")
|
||||
let downloadedPath = try await self.imageLoader.downloadLatestImage()
|
||||
imagePath = Path(downloadedPath.path)
|
||||
} else {
|
||||
imagePath = Path(ipswPath)
|
||||
}
|
||||
|
||||
let requirements = try await imageLoader.loadImageRequirements(from: imagePath.url)
|
||||
try setDiskSize(diskSize)
|
||||
|
||||
let finalCpuCount = max(cpuCount, requirements.minimumSupportedCPUCount)
|
||||
try setCpuCount(finalCpuCount)
|
||||
if finalCpuCount != cpuCount {
|
||||
Logger.info("CPU count overridden due to minimum image requirements", metadata: ["original": "\(cpuCount)", "final": "\(finalCpuCount)"])
|
||||
}
|
||||
|
||||
let finalMemorySize = max(memorySize, requirements.minimumSupportedMemorySize)
|
||||
try setMemorySize(finalMemorySize)
|
||||
if finalMemorySize != memorySize {
|
||||
Logger.info("Memory size overridden due to minimum image requirements", metadata: ["original": "\(memorySize)", "final": "\(finalMemorySize)"])
|
||||
}
|
||||
|
||||
try updateVMConfig(
|
||||
vmConfig: try VMConfig(
|
||||
os: getOSType(),
|
||||
cpuCount: finalCpuCount,
|
||||
memorySize: finalMemorySize,
|
||||
diskSize: diskSize,
|
||||
macAddress: DarwinVirtualizationService.generateMacAddress(),
|
||||
display: display,
|
||||
hardwareModel: requirements.hardwareModel,
|
||||
machineIdentifier: DarwinVirtualizationService.generateMachineIdentifier()
|
||||
)
|
||||
)
|
||||
|
||||
let service: any VMVirtualizationService = try virtualizationServiceFactory(
|
||||
try createVMVirtualizationServiceContext(
|
||||
cpuCount: finalCpuCount,
|
||||
memorySize: finalMemorySize,
|
||||
display: display
|
||||
)
|
||||
)
|
||||
guard let darwinService = service as? DarwinVirtualizationService else {
|
||||
throw VMError.internalError("Installation requires DarwinVirtualizationService")
|
||||
}
|
||||
|
||||
// Create auxiliary storage with hardware model
|
||||
try darwinService.createAuxiliaryStorage(at: vmDirContext.nvramPath, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
try await darwinService.installMacOS(imagePath: imagePath) { progress in
|
||||
Logger.info("Installing macOS", metadata: ["progress": "\(Int(progress * 100))%"])
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/VM/LinuxVM.swift
Normal file
55
src/VM/LinuxVM.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Linux-specific virtual machine implementation
|
||||
@MainActor
|
||||
final class LinuxVM: VM {
|
||||
override init(
|
||||
vmDirContext: VMDirContext,
|
||||
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try LinuxVirtualizationService(configuration: $0) },
|
||||
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) }
|
||||
) {
|
||||
super.init(
|
||||
vmDirContext: vmDirContext,
|
||||
virtualizationServiceFactory: virtualizationServiceFactory,
|
||||
vncServiceFactory: vncServiceFactory
|
||||
)
|
||||
}
|
||||
|
||||
override func getOSType() -> String {
|
||||
return "linux"
|
||||
}
|
||||
|
||||
override func setup(
|
||||
ipswPath: String,
|
||||
cpuCount: Int,
|
||||
memorySize: UInt64,
|
||||
diskSize: UInt64,
|
||||
display: String
|
||||
) async throws {
|
||||
|
||||
try setDiskSize(diskSize)
|
||||
|
||||
let service = try virtualizationServiceFactory(
|
||||
try createVMVirtualizationServiceContext(
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
display: display
|
||||
)
|
||||
)
|
||||
guard let linuxService = service as? LinuxVirtualizationService else {
|
||||
throw VMError.internalError("Installation requires LinuxVirtualizationService")
|
||||
}
|
||||
|
||||
try updateVMConfig(vmConfig: try VMConfig(
|
||||
os: getOSType(),
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
diskSize: diskSize,
|
||||
macAddress: linuxService.generateMacAddress(),
|
||||
display: display
|
||||
))
|
||||
|
||||
// Create NVRAM store for EFI
|
||||
try linuxService.createNVRAM(at: vmDirContext.nvramPath)
|
||||
}
|
||||
}
|
||||
390
src/VM/VM.swift
Normal file
390
src/VM/VM.swift
Normal file
@@ -0,0 +1,390 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Support Types
|
||||
|
||||
/// Base context for virtual machine directory and configuration
|
||||
struct VMDirContext {
|
||||
let dir: VMDirectory
|
||||
var config: VMConfig
|
||||
let home: Home
|
||||
|
||||
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)
|
||||
try FileManager.default.moveItem(at: dir.dir.url, to: vmDir.dir.url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Base VM Class
|
||||
|
||||
/// Base class for virtual machine implementations
|
||||
@MainActor
|
||||
class VM {
|
||||
// MARK: - Properties
|
||||
|
||||
var vmDirContext: VMDirContext
|
||||
|
||||
@MainActor
|
||||
private var virtualizationService: VMVirtualizationService?
|
||||
private let vncService: VNCService
|
||||
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) }
|
||||
) {
|
||||
self.vmDirContext = vmDirContext
|
||||
self.virtualizationServiceFactory = virtualizationServiceFactory
|
||||
self.vncServiceFactory = vncServiceFactory
|
||||
|
||||
// Initialize VNC service
|
||||
self.vncService = vncServiceFactory(vmDirContext.dir)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Then check if it's reachable
|
||||
return NetworkUtils.isReachable(ipAddress: ipAddress)
|
||||
}
|
||||
|
||||
var details: VMDetails {
|
||||
let isRunning: Bool = self.isRunning
|
||||
let vncUrl = isRunning ? getVNCUrl() : nil
|
||||
|
||||
return VMDetails(
|
||||
name: vmDirContext.name,
|
||||
os: getOSType(),
|
||||
cpuCount: vmDirContext.config.cpuCount ?? 0,
|
||||
memorySize: vmDirContext.config.memorySize ?? 0,
|
||||
diskSize: try! getDiskSize(),
|
||||
status: isRunning ? "running" : "stopped",
|
||||
vncUrl: vncUrl,
|
||||
ipAddress: isRunning ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - VM Lifecycle Management
|
||||
|
||||
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: 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 configuration", metadata: [
|
||||
"cpuCount": "\(cpuCount)",
|
||||
"memorySize": "\(memorySize)",
|
||||
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
|
||||
"sharedDirectories": sharedDirectories.map(
|
||||
{ $0.string }
|
||||
).joined(separator: ", ")
|
||||
])
|
||||
|
||||
// Create and configure the VM
|
||||
do {
|
||||
let config = try createVMVirtualizationServiceContext(
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
display: vmDirContext.config.display.string,
|
||||
sharedDirectories: sharedDirectories,
|
||||
mount: mount
|
||||
)
|
||||
virtualizationService = try virtualizationServiceFactory(config)
|
||||
|
||||
let vncInfo = try await setupVNC(noDisplay: noDisplay)
|
||||
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 {
|
||||
virtualizationService = nil
|
||||
vncService.stop()
|
||||
// Release lock
|
||||
flock(fileHandle.fileDescriptor, LOCK_UN)
|
||||
try? fileHandle.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func stop() async throws {
|
||||
guard vmDirContext.initialized else {
|
||||
throw VMError.notInitialized(vmDirContext.name)
|
||||
}
|
||||
|
||||
Logger.info("Attempting to stop VM", metadata: ["name": vmDirContext.name])
|
||||
|
||||
// If we have a virtualization service, try to stop it cleanly first
|
||||
if let service = virtualizationService {
|
||||
do {
|
||||
try await service.stop()
|
||||
virtualizationService = nil
|
||||
vncService.stop()
|
||||
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)"
|
||||
])
|
||||
// Fall through to process termination
|
||||
}
|
||||
}
|
||||
|
||||
// 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])
|
||||
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 {
|
||||
try? fileHandle.close()
|
||||
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)"])
|
||||
}
|
||||
|
||||
// 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])
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
}
|
||||
|
||||
// If graceful shutdown failed, force kill the process
|
||||
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)"])
|
||||
throw VMError.internalError("Failed to stop VM process")
|
||||
}
|
||||
|
||||
// MARK: - Resource Management
|
||||
|
||||
func updateVMConfig(vmConfig: VMConfig) throws {
|
||||
vmDirContext.config = vmConfig
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
private func getDiskSize() throws -> DiskSize {
|
||||
let resourceValues = try vmDirContext.diskPath.url.resourceValues(forKeys: [
|
||||
.totalFileAllocatedSizeKey,
|
||||
.totalFileSizeKey
|
||||
])
|
||||
|
||||
guard let allocated = resourceValues.totalFileAllocatedSize,
|
||||
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)
|
||||
}
|
||||
|
||||
try setDiskSize(newSize)
|
||||
}
|
||||
|
||||
func setCpuCount(_ newCpuCount: Int) throws {
|
||||
guard !isRunning else {
|
||||
throw VMError.alreadyRunning(vmDirContext.name)
|
||||
}
|
||||
vmDirContext.config.setCpuCount(newCpuCount)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
func setMemorySize(_ newMemorySize: UInt64) throws {
|
||||
guard !isRunning else {
|
||||
throw VMError.alreadyRunning(vmDirContext.name)
|
||||
}
|
||||
vmDirContext.config.setMemorySize(newMemorySize)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
func setDiskSize(_ newDiskSize: UInt64) throws {
|
||||
try vmDirContext.setDisk(newDiskSize)
|
||||
vmDirContext.config.setDiskSize(newDiskSize)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
func setHardwareModel(_ newHardwareModel: Data) throws {
|
||||
guard !isRunning else {
|
||||
throw VMError.alreadyRunning(vmDirContext.name)
|
||||
}
|
||||
vmDirContext.config.setHardwareModel(newHardwareModel)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
func setMachineIdentifier(_ newMachineIdentifier: Data) throws {
|
||||
guard !isRunning else {
|
||||
throw VMError.alreadyRunning(vmDirContext.name)
|
||||
}
|
||||
vmDirContext.config.setMachineIdentifier(newMachineIdentifier)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
func setMacAddress(_ newMacAddress: String) throws {
|
||||
guard !isRunning else {
|
||||
throw VMError.alreadyRunning(vmDirContext.name)
|
||||
}
|
||||
vmDirContext.config.setMacAddress(newMacAddress)
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
// MARK: - VNC Management
|
||||
|
||||
func getVNCUrl() -> String? {
|
||||
return vncService.url
|
||||
}
|
||||
|
||||
private func setupVNC(noDisplay: Bool) async throws -> String {
|
||||
guard let service = virtualizationService else {
|
||||
throw VMError.internalError("Virtualization service not initialized")
|
||||
}
|
||||
|
||||
try await vncService.start(port: 0, virtualMachine: service.getVirtualMachine())
|
||||
|
||||
guard let url = vncService.url else {
|
||||
throw VMError.vncNotConfigured
|
||||
}
|
||||
|
||||
if !noDisplay {
|
||||
Logger.info("Starting VNC session")
|
||||
try await vncService.openClient(url: url)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// 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
|
||||
) throws -> VMVirtualizationServiceContext {
|
||||
return VMVirtualizationServiceContext(
|
||||
cpuCount: cpuCount,
|
||||
memorySize: memorySize,
|
||||
display: display,
|
||||
sharedDirectories: sharedDirectories,
|
||||
mount: mount,
|
||||
hardwareModel: vmDirContext.config.hardwareModel,
|
||||
machineIdentifier: vmDirContext.config.machineIdentifier,
|
||||
macAddress: vmDirContext.config.macAddress!,
|
||||
diskPath: vmDirContext.diskPath,
|
||||
nvramPath: vmDirContext.nvramPath
|
||||
)
|
||||
}
|
||||
|
||||
func setup(
|
||||
ipswPath: String,
|
||||
cpuCount: Int,
|
||||
memorySize: UInt64,
|
||||
diskSize: UInt64,
|
||||
display: String
|
||||
) async throws {
|
||||
fatalError("Must be implemented by subclass")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
41
src/VM/VMDetails.swift
Normal file
41
src/VM/VMDetails.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct DiskSize: Codable {
|
||||
let allocated: UInt64
|
||||
let total: UInt64
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
struct VMDetails: Codable {
|
||||
let name: String
|
||||
let os: String
|
||||
let cpuCount: Int
|
||||
let memorySize: UInt64
|
||||
let diskSize: DiskSize
|
||||
let status: String
|
||||
let vncUrl: String?
|
||||
let ipAddress: String?
|
||||
}
|
||||
61
src/VM/VMDetailsPrinter.swift
Normal file
61
src/VM/VMDetailsPrinter.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Prints VM status information in a formatted table
|
||||
enum VMDetailsPrinter {
|
||||
/// Represents a column in the VM status table
|
||||
private struct Column: Sendable {
|
||||
let header: String
|
||||
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: "status", width: 16, getValue: {
|
||||
$0.status
|
||||
}),
|
||||
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]) {
|
||||
printHeader()
|
||||
vms.forEach(printVM)
|
||||
}
|
||||
|
||||
private static func printHeader() {
|
||||
let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) }
|
||||
print(paddedHeaders.joined())
|
||||
}
|
||||
|
||||
private static func printVM(_ vm: VMDetails) {
|
||||
let paddedColumns = columns.map { column in
|
||||
column.getValue(vm).paddedToWidth(column.width)
|
||||
}
|
||||
print(paddedColumns.joined())
|
||||
}
|
||||
}
|
||||
|
||||
private 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 {
|
||||
padding(toLength: width, withPad: " ", startingAt: 0)
|
||||
}
|
||||
}
|
||||
28
src/VM/VMDisplayResolution.swift
Normal file
28
src/VM/VMDisplayResolution.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
|
||||
struct VMDisplayResolution: Codable, ExpressibleByArgument {
|
||||
let width: Int
|
||||
let height: Int
|
||||
|
||||
init?(string: String) {
|
||||
let components = string.components(separatedBy: "x")
|
||||
guard components.count == 2,
|
||||
let width = Int(components[0]),
|
||||
let height = Int(components[1]),
|
||||
width > 0, height > 0 else {
|
||||
return nil
|
||||
}
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
var string: String {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
|
||||
init?(argument: String) {
|
||||
guard let resolution = VMDisplayResolution(string: argument) else { return nil }
|
||||
self = resolution
|
||||
}
|
||||
}
|
||||
37
src/VM/VMFactory.swift
Normal file
37
src/VM/VMFactory.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
enum VMType: String {
|
||||
case darwin = "macOS"
|
||||
case linux = "linux"
|
||||
}
|
||||
|
||||
protocol VMFactory {
|
||||
@MainActor
|
||||
func createVM(
|
||||
vmDirContext: VMDirContext,
|
||||
imageLoader: ImageLoader?
|
||||
) throws -> VM
|
||||
}
|
||||
|
||||
class DefaultVMFactory: VMFactory {
|
||||
@MainActor
|
||||
func createVM(
|
||||
vmDirContext: VMDirContext,
|
||||
imageLoader: ImageLoader?
|
||||
) throws -> VM {
|
||||
let osType = vmDirContext.config.os.lowercased()
|
||||
|
||||
switch osType {
|
||||
case "macos", "darwin":
|
||||
guard let imageLoader = imageLoader else {
|
||||
throw VMError.internalError("ImageLoader required for macOS VM")
|
||||
}
|
||||
return DarwinVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
|
||||
case "linux":
|
||||
return LinuxVM(vmDirContext: vmDirContext)
|
||||
default:
|
||||
throw VMError.unsupportedOS(osType)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/VNC/PassphraseGenerator.swift
Normal file
19
src/VNC/PassphraseGenerator.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
final class PassphraseGenerator {
|
||||
private let words: [String]
|
||||
|
||||
init(words: [String] = PassphraseGenerator.defaultWords) {
|
||||
self.words = words
|
||||
}
|
||||
|
||||
func prefix(_ count: Int) -> [String] {
|
||||
guard count > 0 else { return [] }
|
||||
return (0..<count).map { _ in words.randomElement() ?? words[0] }
|
||||
}
|
||||
|
||||
private static let defaultWords = [
|
||||
"apple", "banana", "cherry", "date",
|
||||
"elder", "fig", "grape", "honey"
|
||||
]
|
||||
}
|
||||
70
src/VNC/VNCService.swift
Normal file
70
src/VNC/VNCService.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Dynamic
|
||||
import Virtualization
|
||||
|
||||
/// Protocol defining the interface for VNC server operations
|
||||
@MainActor
|
||||
protocol VNCService {
|
||||
var url: String? { get }
|
||||
func start(port: Int, virtualMachine: Any?) async throws
|
||||
func stop()
|
||||
func openClient(url: String) async throws
|
||||
}
|
||||
|
||||
/// Default implementation of VNCService
|
||||
@MainActor
|
||||
final class DefaultVNCService: VNCService {
|
||||
private var vncServer: Any?
|
||||
private let vmDirectory: VMDirectory
|
||||
|
||||
init(vmDirectory: VMDirectory) {
|
||||
self.vmDirectory = vmDirectory
|
||||
}
|
||||
|
||||
var url: String? {
|
||||
get {
|
||||
return try? vmDirectory.loadSession().url
|
||||
}
|
||||
}
|
||||
|
||||
func start(port: Int, virtualMachine: Any?) async throws {
|
||||
let password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||
let server = Dynamic._VZVNCServer(port: port, queue: DispatchQueue.main,
|
||||
securityConfiguration: securityConfiguration)
|
||||
if let vm = virtualMachine as? VZVirtualMachine {
|
||||
server.virtualMachine = vm
|
||||
}
|
||||
server.start()
|
||||
|
||||
vncServer = server
|
||||
|
||||
// Wait for port to be assigned
|
||||
while true {
|
||||
if let port: UInt16 = server.port.asUInt16, port != 0 {
|
||||
let url = "vnc://:\(password)@127.0.0.1:\(port)"
|
||||
|
||||
// Save session information
|
||||
let session = VNCSession(
|
||||
url: url
|
||||
)
|
||||
try vmDirectory.saveSession(session)
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let server = vncServer as? Dynamic {
|
||||
server.stop()
|
||||
}
|
||||
vncServer = nil
|
||||
vmDirectory.clearSession()
|
||||
}
|
||||
|
||||
func openClient(url: String) async throws {
|
||||
let processRunner = DefaultProcessRunner()
|
||||
try processRunner.run(executable: "/usr/bin/open", arguments: [url])
|
||||
}
|
||||
}
|
||||
109
src/Virtualization/DHCPLeaseParser.swift
Normal file
109
src/Virtualization/DHCPLeaseParser.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents a DHCP lease entry from the system's DHCP lease file
|
||||
private struct DHCPLease {
|
||||
let macAddress: String
|
||||
let ipAddress: String
|
||||
let expirationDate: Date
|
||||
|
||||
/// Creates a lease entry from raw DHCP lease file key-value pairs
|
||||
/// - Parameter dict: Dictionary containing the raw lease data
|
||||
/// - Returns: A DHCPLease instance if the data is valid, nil otherwise
|
||||
static func from(_ dict: [String: String]) -> DHCPLease? {
|
||||
guard let hwAddress = dict["hw_address"],
|
||||
let ipAddress = dict["ip_address"],
|
||||
let lease = dict["lease"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse MAC address from hw_address field (format can be "1,xx:xx:xx:xx:xx:xx" or "ff,...")
|
||||
let hwParts = hwAddress.split(separator: ",")
|
||||
guard hwParts.count >= 2 else { return nil }
|
||||
|
||||
// Get the MAC part after the prefix and normalize it
|
||||
let rawMacAddress = String(hwParts[1]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Normalize the MAC address by ensuring each component is two digits
|
||||
let normalizedMacAddress = rawMacAddress.split(separator: ":")
|
||||
.map { component in
|
||||
let hex = String(component)
|
||||
return hex.count == 1 ? "0\(hex)" : hex
|
||||
}
|
||||
.joined(separator: ":")
|
||||
|
||||
// Convert hex timestamp to Date
|
||||
let timestampHex = lease.trimmingCharacters(in: CharacterSet(charactersIn: "0x"))
|
||||
guard let timestamp = UInt64(timestampHex, radix: 16) else { return nil }
|
||||
let expirationDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||
|
||||
return DHCPLease(
|
||||
macAddress: normalizedMacAddress,
|
||||
ipAddress: ipAddress,
|
||||
expirationDate: expirationDate
|
||||
)
|
||||
}
|
||||
|
||||
/// Checks if the lease is currently valid
|
||||
var isValid: Bool {
|
||||
expirationDate > Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses DHCP lease files to retrieve IP addresses for VMs based on their MAC addresses
|
||||
enum DHCPLeaseParser {
|
||||
private static let leasePath = "/var/db/dhcpd_leases"
|
||||
|
||||
/// Retrieves the IP address for a given MAC address from the DHCP lease file
|
||||
/// - Parameter macAddress: The MAC address to look up
|
||||
/// - Returns: The IP address if found, nil otherwise
|
||||
static func getIPAddress(forMAC macAddress: String) -> String? {
|
||||
guard let leaseContents = try? String(contentsOfFile: leasePath, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize the input MAC address to ensure consistent format
|
||||
let normalizedMacAddress = macAddress.split(separator: ":").map { component in
|
||||
let hex = String(component)
|
||||
return hex.count == 1 ? "0\(hex)" : hex
|
||||
}.joined(separator: ":")
|
||||
|
||||
let leases = try? parseDHCPLeases(leaseContents)
|
||||
return leases?.first { lease in
|
||||
lease.macAddress == normalizedMacAddress
|
||||
}?.ipAddress
|
||||
}
|
||||
|
||||
/// Parses the contents of a DHCP lease file into lease entries
|
||||
/// - Parameter contents: The raw contents of the lease file
|
||||
/// - Returns: Array of parsed lease entries
|
||||
private static func parseDHCPLeases(_ contents: String) throws -> [DHCPLease] {
|
||||
var leases: [DHCPLease] = []
|
||||
var currentLease: [String: String] = [:]
|
||||
var inLeaseBlock = false
|
||||
|
||||
let lines = contents.components(separatedBy: .newlines)
|
||||
|
||||
for line in lines {
|
||||
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmedLine == "{" {
|
||||
inLeaseBlock = true
|
||||
currentLease = [:]
|
||||
} else if trimmedLine == "}" {
|
||||
if let lease = DHCPLease.from(currentLease) {
|
||||
leases.append(lease)
|
||||
}
|
||||
inLeaseBlock = false
|
||||
} else if inLeaseBlock {
|
||||
let parts = trimmedLine.split(separator: "=", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
currentLease[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return leases
|
||||
}
|
||||
}
|
||||
113
src/Virtualization/DarwinImageLoader.swift
Normal file
113
src/Virtualization/DarwinImageLoader.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Handles loading and validation of macOS restore images (IPSW files).
|
||||
/// Provides functionality to:
|
||||
/// - Fetch the latest supported macOS restore image URL
|
||||
/// - Load and validate image requirements for VM creation
|
||||
/// - Extract hardware model and auxiliary storage configuration
|
||||
protocol ImageLoader: Sendable {
|
||||
typealias ImageRequirements = DarwinImageLoader.ImageRequirements
|
||||
func fetchLatestSupportedURL() async throws -> URL
|
||||
func loadImageRequirements(from url: URL) async throws -> ImageRequirements
|
||||
func downloadLatestImage() async throws -> Path
|
||||
}
|
||||
|
||||
final class DarwinImageLoader: NSObject, ImageLoader, @unchecked Sendable, URLSessionDownloadDelegate {
|
||||
struct ImageRequirements: Sendable {
|
||||
let hardwareModel: Data
|
||||
let minimumSupportedCPUCount: Int
|
||||
let minimumSupportedMemorySize: UInt64
|
||||
}
|
||||
|
||||
enum ImageError: Error {
|
||||
case invalidImage
|
||||
case unsupportedConfiguration
|
||||
case downloadFailed
|
||||
}
|
||||
|
||||
private var lastLoggedProgress: Double = 0.0
|
||||
private var progressLogger = ProgressLogger()
|
||||
private var completionHandler: ((URL?, Error?) -> Void)?
|
||||
|
||||
func fetchLatestSupportedURL() async throws -> URL {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported { result in
|
||||
switch result {
|
||||
case .success(let image):
|
||||
continuation.resume(returning: image.url)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadImageRequirements(from url: URL) async throws -> ImageRequirements {
|
||||
let image = try await VZMacOSRestoreImage.image(from: url)
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw ImageError.unsupportedConfiguration
|
||||
}
|
||||
|
||||
return ImageRequirements(
|
||||
hardwareModel: requirements.hardwareModel.dataRepresentation,
|
||||
minimumSupportedCPUCount: requirements.minimumSupportedCPUCount,
|
||||
minimumSupportedMemorySize: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
}
|
||||
|
||||
func downloadLatestImage() async throws -> Path {
|
||||
let url = try await fetchLatestSupportedURL()
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let downloadPath = tempDir.appendingPathComponent("latest.ipsw")
|
||||
|
||||
// Reset progress logger state
|
||||
progressLogger = ProgressLogger(threshold: 0.01)
|
||||
|
||||
// Create a continuation to wait for download completion
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||
let task = session.downloadTask(with: url)
|
||||
|
||||
// Use the delegate method to handle completion
|
||||
self.completionHandler = { location, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Remove existing file if it exists
|
||||
if FileManager.default.fileExists(atPath: downloadPath.path) {
|
||||
try FileManager.default.removeItem(at: downloadPath)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: location!, to: downloadPath)
|
||||
Logger.info("Download completed and moved to: \(downloadPath.path)")
|
||||
continuation.resume(returning: Path(downloadPath.path))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
progressLogger.logProgress(current: progress, context: "Downloading IPSW")
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
// Call the stored completion handler
|
||||
completionHandler?(location, nil)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
// Call the stored completion handler with an error if it occurred
|
||||
if let error = error {
|
||||
completionHandler?(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/Virtualization/ImageLoaderFactory.swift
Normal file
17
src/Virtualization/ImageLoaderFactory.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining a factory for creating image loaders based on the image type
|
||||
protocol ImageLoaderFactory {
|
||||
/// Creates an appropriate ImageLoader based on the image path or type
|
||||
func createImageLoader() -> ImageLoader
|
||||
}
|
||||
|
||||
/// Default implementation of ImageLoaderFactory that creates appropriate loaders based on image type
|
||||
final class DefaultImageLoaderFactory: ImageLoaderFactory {
|
||||
func createImageLoader() -> ImageLoader {
|
||||
// For now, we only support Darwin images
|
||||
// In the future, this can be extended to support other OS types
|
||||
// by analyzing the image path or having explicit OS type parameter
|
||||
return DarwinImageLoader()
|
||||
}
|
||||
}
|
||||
329
src/Virtualization/VMVirtualizationService.swift
Normal file
329
src/Virtualization/VMVirtualizationService.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Framework-agnostic VM configuration
|
||||
struct VMVirtualizationServiceContext {
|
||||
let cpuCount: Int
|
||||
let memorySize: UInt64
|
||||
let display: String
|
||||
let sharedDirectories: [SharedDirectory]?
|
||||
let mount: Path?
|
||||
let hardwareModel: Data?
|
||||
let machineIdentifier: Data?
|
||||
let macAddress: String
|
||||
let diskPath: Path
|
||||
let nvramPath: Path
|
||||
}
|
||||
|
||||
/// Protocol defining the interface for virtualization operations
|
||||
@MainActor
|
||||
protocol VMVirtualizationService {
|
||||
var state: VZVirtualMachine.State { get }
|
||||
func start() async throws
|
||||
func stop() async throws
|
||||
func pause() async throws
|
||||
func resume() async throws
|
||||
func getVirtualMachine() -> Any
|
||||
}
|
||||
|
||||
/// Base implementation of VMVirtualizationService using VZVirtualMachine
|
||||
@MainActor
|
||||
class BaseVirtualizationService: VMVirtualizationService {
|
||||
let virtualMachine: VZVirtualMachine
|
||||
|
||||
var state: VZVirtualMachine.State {
|
||||
virtualMachine.state
|
||||
}
|
||||
|
||||
init(virtualMachine: VZVirtualMachine) {
|
||||
self.virtualMachine = virtualMachine
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
Task { @MainActor in
|
||||
virtualMachine.start { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
virtualMachine.stop { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
virtualMachine.start { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resume() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
virtualMachine.start { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getVirtualMachine() -> Any {
|
||||
return virtualMachine
|
||||
}
|
||||
|
||||
// Helper methods for creating common configurations
|
||||
static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws -> VZStorageDeviceConfiguration {
|
||||
return VZVirtioBlockDeviceConfiguration(
|
||||
attachment: try VZDiskImageStorageDeviceAttachment(
|
||||
url: diskPath.url,
|
||||
readOnly: readOnly,
|
||||
cachingMode: VZDiskImageCachingMode.automatic,
|
||||
synchronizationMode: VZDiskImageSynchronizationMode.fsync
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func createNetworkDeviceConfiguration(macAddress: String) throws -> VZNetworkDeviceConfiguration {
|
||||
let network = VZVirtioNetworkDeviceConfiguration()
|
||||
guard let vzMacAddress = VZMACAddress(string: macAddress) else {
|
||||
throw VMConfigError.invalidMachineIdentifier
|
||||
}
|
||||
network.attachment = VZNATNetworkDeviceAttachment()
|
||||
network.macAddress = vzMacAddress
|
||||
return network
|
||||
}
|
||||
|
||||
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))
|
||||
return device
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS-specific virtualization service
|
||||
@MainActor
|
||||
final class DarwinVirtualizationService: BaseVirtualizationService {
|
||||
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
|
||||
let vzConfig = VZVirtualMachineConfiguration()
|
||||
vzConfig.cpuCount = config.cpuCount
|
||||
vzConfig.memorySize = config.memorySize
|
||||
|
||||
// Platform configuration
|
||||
guard let machineIdentifier = config.machineIdentifier else {
|
||||
throw VMConfigError.emptyMachineIdentifier
|
||||
}
|
||||
|
||||
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)")
|
||||
guard let vzHardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModel) else {
|
||||
throw VMConfigError.invalidHardwareModel
|
||||
}
|
||||
platform.hardwareModel = vzHardwareModel
|
||||
guard let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier) else {
|
||||
throw VMConfigError.invalidMachineIdentifier
|
||||
}
|
||||
platform.machineIdentifier = vzMachineIdentifier
|
||||
vzConfig.platform = platform
|
||||
vzConfig.bootLoader = VZMacOSBootLoader()
|
||||
|
||||
// Graphics configuration
|
||||
let display = VMDisplayResolution(string: config.display)!
|
||||
let graphics = VZMacGraphicsDeviceConfiguration()
|
||||
graphics.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: display.width,
|
||||
heightInPixels: display.height,
|
||||
pixelsPerInch: 220 // Retina display density
|
||||
)
|
||||
]
|
||||
vzConfig.graphicsDevices = [graphics]
|
||||
|
||||
// Common configurations
|
||||
vzConfig.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
|
||||
if let mount = config.mount {
|
||||
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
|
||||
}
|
||||
vzConfig.storageDevices = storageDevices
|
||||
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
|
||||
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
|
||||
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
// Directory sharing
|
||||
let directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
|
||||
if !directorySharingDevices.isEmpty {
|
||||
vzConfig.directorySharingDevices = directorySharingDevices
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
Task {
|
||||
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
|
||||
if let newValue = change.newValue {
|
||||
progressHandler(newValue)
|
||||
}
|
||||
}
|
||||
observers.append(observer)
|
||||
}
|
||||
|
||||
installer.install { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
Logger.error("Failed to install, error=\(error))")
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.info("macOS installation finished")
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux-specific virtualization service
|
||||
@MainActor
|
||||
final class LinuxVirtualizationService: BaseVirtualizationService {
|
||||
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
|
||||
let vzConfig = VZVirtualMachineConfiguration()
|
||||
vzConfig.cpuCount = config.cpuCount
|
||||
vzConfig.memorySize = config.memorySize
|
||||
|
||||
// Platform configuration
|
||||
let platform = VZGenericPlatformConfiguration()
|
||||
if #available(macOS 15, *) {
|
||||
platform.isNestedVirtualizationEnabled = VZGenericPlatformConfiguration.isNestedVirtualizationSupported
|
||||
}
|
||||
vzConfig.platform = platform
|
||||
|
||||
let bootLoader = VZEFIBootLoader()
|
||||
bootLoader.variableStore = VZEFIVariableStore(url: config.nvramPath.url)
|
||||
vzConfig.bootLoader = bootLoader
|
||||
|
||||
// Graphics configuration
|
||||
let display = VMDisplayResolution(string: config.display)!
|
||||
let graphics = VZVirtioGraphicsDeviceConfiguration()
|
||||
graphics.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: display.width,
|
||||
heightInPixels: display.height
|
||||
)
|
||||
]
|
||||
vzConfig.graphicsDevices = [graphics]
|
||||
|
||||
// Common configurations
|
||||
vzConfig.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
|
||||
if let mount = config.mount {
|
||||
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
|
||||
}
|
||||
vzConfig.storageDevices = storageDevices
|
||||
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
|
||||
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
|
||||
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
// Directory sharing
|
||||
var directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
|
||||
|
||||
// Add Rosetta support if available
|
||||
if #available(macOS 13.0, *) {
|
||||
if VZLinuxRosettaDirectoryShare.availability == .installed {
|
||||
do {
|
||||
let rosettaShare = try VZLinuxRosettaDirectoryShare()
|
||||
let rosettaDevice = VZVirtioFileSystemDeviceConfiguration(tag: "rosetta")
|
||||
rosettaDevice.share = rosettaShare
|
||||
directorySharingDevices.append(rosettaDevice)
|
||||
Logger.info("Added Rosetta support to Linux VM")
|
||||
} catch {
|
||||
Logger.info("Failed to add Rosetta support: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
Logger.info("Rosetta not installed, skipping Rosetta support")
|
||||
}
|
||||
}
|
||||
|
||||
if !directorySharingDevices.isEmpty {
|
||||
vzConfig.directorySharingDevices = directorySharingDevices
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
30
tests/Mocks/MockVM.swift
Normal file
30
tests/Mocks/MockVM.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
@testable import lume
|
||||
|
||||
@MainActor
|
||||
class MockVM: VM {
|
||||
private var mockIsRunning = false
|
||||
|
||||
override func getOSType() -> String {
|
||||
return "mock-os"
|
||||
}
|
||||
|
||||
override func setup(ipswPath: String, cpuCount: Int, memorySize: UInt64, diskSize: UInt64, display: String) async throws {
|
||||
// Mock setup implementation
|
||||
vmDirContext.config.setCpuCount(cpuCount)
|
||||
vmDirContext.config.setMemorySize(memorySize)
|
||||
vmDirContext.config.setDiskSize(diskSize)
|
||||
vmDirContext.config.setMacAddress("00:11:22:33:44:55")
|
||||
try vmDirContext.saveConfig()
|
||||
}
|
||||
|
||||
override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
|
||||
mockIsRunning = true
|
||||
try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
|
||||
}
|
||||
|
||||
override func stop() async throws {
|
||||
mockIsRunning = false
|
||||
try await super.stop()
|
||||
}
|
||||
}
|
||||
65
tests/Mocks/MockVMVirtualizationService.swift
Normal file
65
tests/Mocks/MockVMVirtualizationService.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
@testable import lume
|
||||
|
||||
@MainActor
|
||||
final class MockVMVirtualizationService: VMVirtualizationService {
|
||||
private(set) var currentState: VZVirtualMachine.State = .stopped
|
||||
private(set) var startCallCount = 0
|
||||
private(set) var stopCallCount = 0
|
||||
private(set) var pauseCallCount = 0
|
||||
private(set) var resumeCallCount = 0
|
||||
|
||||
var state: VZVirtualMachine.State {
|
||||
currentState
|
||||
}
|
||||
|
||||
private var _shouldFailNextOperation = false
|
||||
private var _operationError: Error = VMError.internalError("Mock operation failed")
|
||||
|
||||
nonisolated func configure(shouldFail: Bool, error: Error = VMError.internalError("Mock operation failed")) async {
|
||||
await setConfiguration(shouldFail: shouldFail, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setConfiguration(shouldFail: Bool, error: Error) {
|
||||
_shouldFailNextOperation = shouldFail
|
||||
_operationError = error
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
startCallCount += 1
|
||||
if _shouldFailNextOperation {
|
||||
throw _operationError
|
||||
}
|
||||
currentState = .running
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
stopCallCount += 1
|
||||
if _shouldFailNextOperation {
|
||||
throw _operationError
|
||||
}
|
||||
currentState = .stopped
|
||||
}
|
||||
|
||||
func pause() async throws {
|
||||
pauseCallCount += 1
|
||||
if _shouldFailNextOperation {
|
||||
throw _operationError
|
||||
}
|
||||
currentState = .paused
|
||||
}
|
||||
|
||||
func resume() async throws {
|
||||
resumeCallCount += 1
|
||||
if _shouldFailNextOperation {
|
||||
throw _operationError
|
||||
}
|
||||
currentState = .running
|
||||
}
|
||||
|
||||
func getVirtualMachine() -> Any {
|
||||
return "mock_vm"
|
||||
}
|
||||
}
|
||||
42
tests/Mocks/MockVNCService.swift
Normal file
42
tests/Mocks/MockVNCService.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
@testable import lume
|
||||
|
||||
@MainActor
|
||||
final class MockVNCService: VNCService {
|
||||
private(set) var url: String?
|
||||
private(set) var isRunning = false
|
||||
private(set) var clientOpenCount = 0
|
||||
private var _attachedVM: Any?
|
||||
private let vmDirectory: VMDirectory
|
||||
|
||||
init(vmDirectory: VMDirectory) {
|
||||
self.vmDirectory = vmDirectory
|
||||
}
|
||||
|
||||
nonisolated var attachedVM: String? {
|
||||
get async {
|
||||
await Task { @MainActor in
|
||||
_attachedVM as? String
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
func start(port: Int, virtualMachine: Any?) async throws {
|
||||
isRunning = true
|
||||
url = "vnc://localhost:\(port)"
|
||||
_attachedVM = virtualMachine
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isRunning = false
|
||||
url = nil
|
||||
_attachedVM = nil
|
||||
}
|
||||
|
||||
func openClient(url: String) async throws {
|
||||
guard isRunning else {
|
||||
throw VMError.vncNotConfigured
|
||||
}
|
||||
clientOpenCount += 1
|
||||
}
|
||||
}
|
||||
187
tests/VMTests.swift
Normal file
187
tests/VMTests.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import lume
|
||||
|
||||
class MockProcessRunner: ProcessRunner {
|
||||
var runCalls: [(executable: String, arguments: [String])] = []
|
||||
|
||||
func run(executable: String, arguments: [String]) throws {
|
||||
runCalls.append((executable, arguments))
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVMDirectory(_ tempDir: URL) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(Path(tempDir.path))
|
||||
|
||||
// Create disk image file
|
||||
let diskPath = vmDir.diskPath
|
||||
let diskData = Data(repeating: 0, count: 1024 * 1024) // 1MB mock disk
|
||||
try diskData.write(to: diskPath.url)
|
||||
|
||||
// Create nvram file
|
||||
let nvramPath = vmDir.nvramPath
|
||||
let nvramData = Data(repeating: 0, count: 1024) // 1KB mock nvram
|
||||
try nvramData.write(to: nvramPath.url)
|
||||
|
||||
// Create initial config file
|
||||
var config = try VMConfig(
|
||||
os: "mock-os",
|
||||
cpuCount: 1,
|
||||
memorySize: 1024,
|
||||
diskSize: 1024,
|
||||
display: "1024x768"
|
||||
)
|
||||
config.setMacAddress("00:11:22:33:44:55")
|
||||
try vmDir.saveConfig(config)
|
||||
|
||||
// Create .initialized file to mark VM as initialized
|
||||
let initializedPath = vmDir.dir.file(".initialized")
|
||||
try Data().write(to: initializedPath.url)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("VM initialization and configuration")
|
||||
func testVMInitialization() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = try setupVMDirectory(tempDir)
|
||||
var config = try VMConfig(
|
||||
os: "mock-os",
|
||||
cpuCount: 1,
|
||||
memorySize: 1024,
|
||||
diskSize: 1024,
|
||||
display: "1024x768"
|
||||
)
|
||||
config.setMacAddress("00:11:22:33:44:55") // Set MAC address to avoid nil
|
||||
let home = Home(fileManager: FileManager.default)
|
||||
let context = VMDirContext(dir: vmDir, config: config, home: home)
|
||||
|
||||
let vm = MockVM(
|
||||
vmDirContext: context,
|
||||
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
|
||||
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
|
||||
)
|
||||
|
||||
// Test initial state
|
||||
let details = vm.details
|
||||
#expect(details.name == vmDir.name)
|
||||
#expect(details.os == "mock-os")
|
||||
#expect(details.status == "stopped")
|
||||
#expect(details.vncUrl == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("VM run and stop operations")
|
||||
func testVMRunAndStop() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = try setupVMDirectory(tempDir)
|
||||
var config = try VMConfig(
|
||||
os: "mock-os",
|
||||
cpuCount: 2,
|
||||
memorySize: 2048,
|
||||
diskSize: 1024,
|
||||
display: "1024x768"
|
||||
)
|
||||
config.setMacAddress("00:11:22:33:44:55")
|
||||
let home = Home(fileManager: FileManager.default)
|
||||
let context = VMDirContext(dir: vmDir, config: config, home: home)
|
||||
|
||||
let vm = MockVM(
|
||||
vmDirContext: context,
|
||||
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
|
||||
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
|
||||
)
|
||||
|
||||
// Test running VM
|
||||
let runTask = Task {
|
||||
try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil)
|
||||
}
|
||||
|
||||
// Give the VM time to start
|
||||
try await Task.sleep(nanoseconds: UInt64(1e9))
|
||||
|
||||
// Test stopping VM
|
||||
try await vm.stop()
|
||||
runTask.cancel()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("VM configuration updates")
|
||||
func testVMConfigurationUpdates() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = try setupVMDirectory(tempDir)
|
||||
var config = try VMConfig(
|
||||
os: "mock-os",
|
||||
cpuCount: 1,
|
||||
memorySize: 1024,
|
||||
diskSize: 1024,
|
||||
display: "1024x768"
|
||||
)
|
||||
config.setMacAddress("00:11:22:33:44:55")
|
||||
let home = Home(fileManager: FileManager.default)
|
||||
let context = VMDirContext(dir: vmDir, config: config, home: home)
|
||||
|
||||
let vm = MockVM(
|
||||
vmDirContext: context,
|
||||
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
|
||||
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
|
||||
)
|
||||
|
||||
// Test CPU count update
|
||||
try vm.setCpuCount(4)
|
||||
#expect(vm.vmDirContext.config.cpuCount == 4)
|
||||
|
||||
// Test memory size update
|
||||
try vm.setMemorySize(4096)
|
||||
#expect(vm.vmDirContext.config.memorySize == 4096)
|
||||
|
||||
// Test MAC address update
|
||||
try vm.setMacAddress("00:11:22:33:44:66")
|
||||
#expect(vm.vmDirContext.config.macAddress == "00:11:22:33:44:66")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("VM setup process")
|
||||
func testVMSetup() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = try setupVMDirectory(tempDir)
|
||||
var config = try VMConfig(
|
||||
os: "mock-os",
|
||||
cpuCount: 1,
|
||||
memorySize: 1024,
|
||||
diskSize: 1024,
|
||||
display: "1024x768"
|
||||
)
|
||||
config.setMacAddress("00:11:22:33:44:55")
|
||||
let home = Home(fileManager: FileManager.default)
|
||||
let context = VMDirContext(dir: vmDir, config: config, home: home)
|
||||
|
||||
let vm = MockVM(
|
||||
vmDirContext: context,
|
||||
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
|
||||
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
|
||||
)
|
||||
|
||||
let expectedDiskSize: UInt64 = 64 * 1024 * 1024 * 1024 // 64 GB
|
||||
|
||||
try await vm.setup(
|
||||
ipswPath: "/path/to/mock.ipsw",
|
||||
cpuCount: 2,
|
||||
memorySize: 2048,
|
||||
diskSize: expectedDiskSize,
|
||||
display: "1024x768"
|
||||
)
|
||||
|
||||
#expect(vm.vmDirContext.config.cpuCount == 2)
|
||||
#expect(vm.vmDirContext.config.memorySize == 2048)
|
||||
let actualDiskSize = vm.vmDirContext.config.diskSize ?? 0
|
||||
#expect(actualDiskSize == expectedDiskSize, "Expected disk size \(expectedDiskSize), but got \(actualDiskSize)")
|
||||
#expect(vm.vmDirContext.config.macAddress == "00:11:22:33:44:55")
|
||||
}
|
||||
|
||||
private func createTempDirectory() throws -> URL {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
return tempDir
|
||||
}
|
||||
68
tests/VMVirtualizationServiceTests.swift
Normal file
68
tests/VMVirtualizationServiceTests.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import Virtualization
|
||||
@testable import lume
|
||||
|
||||
@Test("VMVirtualizationService starts correctly")
|
||||
func testVMVirtualizationServiceStart() async throws {
|
||||
let service = MockVMVirtualizationService()
|
||||
|
||||
// Initial state
|
||||
#expect(await service.state == .stopped)
|
||||
#expect(await service.startCallCount == 0)
|
||||
|
||||
// Start service
|
||||
try await service.start()
|
||||
#expect(await service.state == .running)
|
||||
#expect(await service.startCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("VMVirtualizationService stops correctly")
|
||||
func testVMVirtualizationServiceStop() async throws {
|
||||
let service = MockVMVirtualizationService()
|
||||
|
||||
// Start then stop
|
||||
try await service.start()
|
||||
try await service.stop()
|
||||
|
||||
#expect(await service.state == .stopped)
|
||||
#expect(await service.stopCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("VMVirtualizationService handles pause and resume")
|
||||
func testVMVirtualizationServicePauseResume() async throws {
|
||||
let service = MockVMVirtualizationService()
|
||||
|
||||
// Start and pause
|
||||
try await service.start()
|
||||
try await service.pause()
|
||||
#expect(await service.state == .paused)
|
||||
#expect(await service.pauseCallCount == 1)
|
||||
|
||||
// Resume
|
||||
try await service.resume()
|
||||
#expect(await service.state == .running)
|
||||
#expect(await service.resumeCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("VMVirtualizationService handles operation failures")
|
||||
func testVMVirtualizationServiceFailures() async throws {
|
||||
let service = MockVMVirtualizationService()
|
||||
await service.configure(shouldFail: true)
|
||||
|
||||
// Test start failure
|
||||
do {
|
||||
try await service.start()
|
||||
#expect(Bool(false), "Expected start to throw")
|
||||
} catch let error as VMError {
|
||||
switch error {
|
||||
case .internalError(let message):
|
||||
#expect(message == "Mock operation failed")
|
||||
default:
|
||||
#expect(Bool(false), "Unexpected error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
#expect(await service.state == .stopped)
|
||||
#expect(await service.startCallCount == 1)
|
||||
}
|
||||
86
tests/VNCServiceTests.swift
Normal file
86
tests/VNCServiceTests.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import lume
|
||||
|
||||
@Test("VNCService starts correctly")
|
||||
func testVNCServiceStart() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = VMDirectory(Path(tempDir.path))
|
||||
let service = await MockVNCService(vmDirectory: vmDir)
|
||||
|
||||
// Initial state
|
||||
let isRunning = await service.isRunning
|
||||
let url = await service.url
|
||||
#expect(!isRunning)
|
||||
#expect(url == nil)
|
||||
|
||||
// Start service
|
||||
try await service.start(port: 5900, virtualMachine: nil)
|
||||
#expect(await service.isRunning)
|
||||
#expect(await service.url?.contains("5900") ?? false)
|
||||
}
|
||||
|
||||
@Test("VNCService stops correctly")
|
||||
func testVNCServiceStop() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = VMDirectory(Path(tempDir.path))
|
||||
let service = await MockVNCService(vmDirectory: vmDir)
|
||||
try await service.start(port: 5900, virtualMachine: nil)
|
||||
|
||||
await service.stop()
|
||||
let isRunning = await service.isRunning
|
||||
let url = await service.url
|
||||
#expect(!isRunning)
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
@Test("VNCService handles client operations")
|
||||
func testVNCServiceClient() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = VMDirectory(Path(tempDir.path))
|
||||
let service = await MockVNCService(vmDirectory: vmDir)
|
||||
|
||||
// Should fail when not started
|
||||
do {
|
||||
try await service.openClient(url: "vnc://localhost:5900")
|
||||
#expect(Bool(false), "Expected openClient to throw when not started")
|
||||
} catch VMError.vncNotConfigured {
|
||||
// Expected error
|
||||
} catch {
|
||||
#expect(Bool(false), "Expected vncNotConfigured error but got \(error)")
|
||||
}
|
||||
|
||||
// Start and try client operations
|
||||
try await service.start(port: 5900, virtualMachine: nil)
|
||||
try await service.openClient(url: "vnc://localhost:5900")
|
||||
#expect(await service.clientOpenCount == 1)
|
||||
|
||||
// Stop and verify client operations fail
|
||||
await service.stop()
|
||||
do {
|
||||
try await service.openClient(url: "vnc://localhost:5900")
|
||||
#expect(Bool(false), "Expected openClient to throw after stopping")
|
||||
} catch VMError.vncNotConfigured {
|
||||
// Expected error
|
||||
} catch {
|
||||
#expect(Bool(false), "Expected vncNotConfigured error but got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("VNCService handles virtual machine attachment")
|
||||
func testVNCServiceVMAttachment() async throws {
|
||||
let tempDir = try createTempDirectory()
|
||||
let vmDir = VMDirectory(Path(tempDir.path))
|
||||
let service = await MockVNCService(vmDirectory: vmDir)
|
||||
let mockVM = "mock_vm"
|
||||
|
||||
try await service.start(port: 5900, virtualMachine: mockVM)
|
||||
let attachedVM = await service.attachedVM
|
||||
#expect(attachedVM == mockVM)
|
||||
}
|
||||
|
||||
private func createTempDirectory() throws -> URL {
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
return tempDir
|
||||
}
|
||||
Reference in New Issue
Block a user