mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c54b140750 | ||
|
|
048a5506df | ||
|
|
553b36349b | ||
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 | ||
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc |
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
@@ -84,6 +84,11 @@ For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/bina
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
#### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
@@ -100,6 +105,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
@@ -165,10 +191,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
|
||||
|
||||
#### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```shell
|
||||
tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
...or instantiate a VM from a remote image:
|
||||
|
||||
```shell
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
|
||||
@@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
throw ValidationError("Please specify a --from-ipsw option!")
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if fromIPSW! == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
|
||||
if let fromIPSW = fromIPSW {
|
||||
if fromIPSW == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
||||
}
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError()
|
||||
}
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
print(ip)
|
||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -33,12 +40,11 @@ struct IP: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProvider: credentialsProvider)
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
@@ -16,14 +16,21 @@ struct Prune: AsyncParsableCommand {
|
||||
valueName: "n"))
|
||||
var cacheBudget: UInt?
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var gc: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if olderThan == nil && cacheBudget == nil {
|
||||
throw ValidationError("at least one criteria must be specified")
|
||||
if olderThan == nil && cacheBudget == nil && !gc {
|
||||
throw ValidationError("at least one pruning criteria must be specified")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
class HelperProgramCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||
return nil
|
||||
}
|
||||
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
|
||||
|
||||
if let helperProgram = config.credHelpers[host] {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process.init()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["get"]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = outPipe
|
||||
process.standardInput = inPipe
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerConfig: Codable {
|
||||
var credHelpers: Dictionary<String, String> = Dictionary()
|
||||
}
|
||||
|
||||
struct DockerGetOutput: Codable {
|
||||
var Username: String
|
||||
var Secret: String
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
|
||||
@@ -88,29 +88,29 @@ class Registry {
|
||||
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
self.credentialsProvider = credentialsProvider
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
convenience init(
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
credentialsProviders: [CredentialsProvider] = [HelperProgramCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
@@ -303,7 +303,7 @@ class Registry {
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme == "Basic" {
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ class Registry {
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
@@ -356,6 +356,15 @@ class Registry {
|
||||
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
|
||||
}
|
||||
|
||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
||||
var request = request
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
|
||||
func CurrentArchitecture() -> Architecture {
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported starting with macOS Ventura
|
||||
// macOS Monterey will continue using a USB device == .darwin
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Virtualization
|
||||
|
||||
@available(macOS 13, *)
|
||||
struct Linux: Platform {
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Virtualization
|
||||
|
||||
enum OS: String, Codable {
|
||||
case darwin
|
||||
case linux
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class Softnet {
|
||||
init(vmMACAddress: String) throws {
|
||||
let binaryName = "softnet"
|
||||
|
||||
guard let executableURL = Self.resolveBinaryPath(binaryName) else {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
@@ -43,23 +43,6 @@ class Softnet {
|
||||
process.waitUntilExit()
|
||||
}
|
||||
|
||||
private static 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
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
var option_value = sizeBytes
|
||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||
|
||||
@@ -20,6 +20,6 @@ extension URL {
|
||||
|
||||
extension Date {
|
||||
func asTimeval() -> timeval {
|
||||
timeval(tv_sec: timeIntervalSince1970.toUnit(.second)!, tv_usec: 0)
|
||||
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,20 @@ extension Collection {
|
||||
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
|
||||
}
|
||||
|
||||
+38
-37
@@ -10,6 +10,13 @@ struct NoMainScreenFoundError: Error {
|
||||
struct DownloadFailed: Error {
|
||||
}
|
||||
|
||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||
private(set) var description: String = "error: Linux VMs are only supported on macOS 13.0 (Ventura) or newer"
|
||||
}
|
||||
|
||||
struct UnsupportedArchitectureError: Error {
|
||||
}
|
||||
|
||||
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
@@ -29,17 +36,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
withSoftnet: Bool = false,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
) throws {
|
||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
if config.arch != CurrentArchitecture() {
|
||||
throw UnsupportedArchitectureError()
|
||||
}
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -116,7 +126,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
@@ -124,7 +134,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
hardwareModel: requirements.hardwareModel,
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
@@ -137,8 +147,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, softnet: softnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -159,6 +170,21 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
// Create NVRAM
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
// Create config
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
if let softnet = softnet {
|
||||
try softnet.run()
|
||||
@@ -199,7 +225,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
auxStorage: VZMacAuxiliaryStorage,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
|
||||
@@ -207,42 +233,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
// Boot loader
|
||||
configuration.bootLoader = VZMacOSBootLoader()
|
||||
configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
|
||||
|
||||
// CPU and memory
|
||||
configuration.cpuCount = vmConfig.cpuCount
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
let platform = VZMacPlatformConfiguration()
|
||||
|
||||
platform.machineIdentifier = vmConfig.ecid
|
||||
platform.auxiliaryStorage = auxStorage
|
||||
platform.hardwareModel = vmConfig.hardwareModel
|
||||
|
||||
configuration.platform = platform
|
||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
|
||||
// Display
|
||||
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(
|
||||
width: vmConfig.display.width,
|
||||
height: vmConfig.display.height
|
||||
)
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
} else {
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
}
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
@@ -255,7 +256,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
|
||||
+30
-36
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case ecid
|
||||
case hardwareModel
|
||||
case os
|
||||
case arch
|
||||
case cpuCountMin
|
||||
case cpuCount
|
||||
case memorySizeMin
|
||||
case memorySize
|
||||
case macAddress
|
||||
case display
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable {
|
||||
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
var os: OS
|
||||
var arch: Architecture
|
||||
var platform: Platform
|
||||
var cpuCountMin: Int
|
||||
private(set) var cpuCount: Int
|
||||
var memorySizeMin: UInt64
|
||||
private(set) var memorySize: UInt64
|
||||
var macAddress: VZMACAddress
|
||||
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
|
||||
init(
|
||||
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
|
||||
hardwareModel: VZMacHardwareModel,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
self.platform = platform
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
version = try container.decode(Int.self, forKey: .version)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
case .linux:
|
||||
if #available(macOS 13, *) {
|
||||
platform = try Linux(from: decoder)
|
||||
} else {
|
||||
throw UnsupportedOSError()
|
||||
}
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
|
||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
|
||||
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(arch, forKey: .arch)
|
||||
try platform.encode(to: encoder)
|
||||
try container.encode(cpuCountMin, forKey: .cpuCountMin)
|
||||
try container.encode(cpuCount, forKey: .cpuCount)
|
||||
try container.encode(memorySizeMin, forKey: .memorySizeMin)
|
||||
|
||||
@@ -117,13 +117,15 @@ extension VMDirectory {
|
||||
|
||||
// Read VM's compressed disk as chunks
|
||||
// and sequentially upload them as blobs
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
var diskReadBytes: UInt64 = 0
|
||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
||||
diskReadBytes += UInt64(data?.count ?? 0)
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDiskSize = mappedDisk.count
|
||||
var mappedDiskReadOffset = 0
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -140,12 +142,12 @@ extension VMDirectory {
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
let ociConfigJSON = try OCIConfig().toJSON()
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: diskReadBytes
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
)
|
||||
|
||||
// Manifest
|
||||
|
||||
@@ -20,6 +20,10 @@ struct VMDirectory: Prunable {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
}
|
||||
|
||||
var name: String {
|
||||
baseURL.lastPathComponent
|
||||
}
|
||||
@@ -89,4 +93,12 @@ struct VMDirectory: Prunable {
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
func isExplicitlyPulled() -> Bool {
|
||||
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,44 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try gc()
|
||||
}
|
||||
|
||||
func gc() throws {
|
||||
var refCounts = Dictionary<URL, UInt>()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let foundURL as URL in enumerator {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
|
||||
// Perform garbage collection for tag-based images
|
||||
// with broken outgoing references
|
||||
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
|
||||
try FileManager.default.removeItem(at: foundURL)
|
||||
continue
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
|
||||
}
|
||||
|
||||
// Perform garbage collection for digest-based images
|
||||
// with no incoming references
|
||||
for (baseURL, incRefCount) in refCounts {
|
||||
let vmDir = VMDirectory(baseURL: baseURL)
|
||||
|
||||
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory, Bool)] {
|
||||
@@ -82,10 +120,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
|
||||
var digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: try manifest.digest()))
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
if !exists(digestName) {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
@@ -113,8 +151,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if name != digestName {
|
||||
// Overwrite the old symbolic link
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +166,8 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
|
||||
try gc()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC {
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
Reference in New Issue
Block a user