Compare commits

..
19 Commits
Author SHA1 Message Date
Fedor Korotkov 0105280b5d Support plaintext auths from Docker config (#219) 2022-09-01 23:10:52 +04:00
Fedor Korotkov c063c5bdc2 Include version in installer (#218) 2022-09-01 19:42:50 +04:00
Fedor Korotkov 8a2b0151e0 Create Apple Installer in dist folder (#217)
Seems goreleaser reset git state before submitting and therefore removes Tart.pkg
2022-09-01 18:04:43 +04:00
Fedor Korotkov 2eea51d6ec Create Apple Installer (#216)
The installer will install tart binary to `/usr/local/bin/tart`

Fixes #153
2022-08-31 18:36:28 -04:00
Fedor Korotkov 1890909d28 Sign release binaries (#207)
* Sign release binaries

Should fix #184

* Use VMs for building

* Test release

* Fixed password

* Revert testing
2022-08-30 16:26:06 -04:00
Nikolay Edigaryev 0cad2e454c Directory sharing support (#211) 2022-08-30 10:26:16 +04:00
Nikolay Edigaryev 17dcf942c1 Terminate VM on GUI window close (#210) 2022-08-30 02:41:58 +04:00
Nikolay Edigaryev 6296df7c0c Credential helpers: "credHelpers" map is optional in Docker's config (#209) 2022-08-29 16:38:51 -04:00
Fedor Korotkov c54b140750 Support Docker Helpers (#205)
Fixes #167
2022-08-29 20:26:53 +04:00
Fedor Korotkov 048a5506df Support trackpad on macOS and clipboard sharing on Linux (#202)
Fixes #66
Relates to #14 since fixes on Linux
2022-08-27 20:05:45 +04:00
Nikolay Edigaryev 553b36349b README.md: clarify "Pulling a Remote Image" section (#196) 2022-08-25 15:43:30 +04:00
Fedor Korotkov d0bf286aa1 Document Linux VM image creation (#193) 2022-08-24 23:21:50 +04:00
Nikolay Edigaryev 195a4b3a72 Make UnsupportedOSError more user-friendly (#192)
* Make UnsupportedOSError more user-friendly

* tart prune: allow only specifying a --gc flag
2022-08-24 15:03:54 -04:00
Nikolay Edigaryev 59393df2e1 Linux support (#190)
* Linux support

* Support macOS 12

* Improve readability a bit

* Remove useless variable
2022-08-23 22:38:50 +04:00
Nikolay Edigaryev 732c8244a4 Run garbage collection after each symlink and expose --gc in tart prune (#191) 2022-08-23 17:31:02 +04:00
Nikolay Edigaryev 9e69c8c161 testGetAndSetAccessTime: fix flakiness (#187) 2022-08-18 14:42:54 -04:00
Nikolay Edigaryev e4ac2275b9 tart ip: show a warning when DHCP lease and ARP cache entries mismatch (#186) 2022-08-18 22:25:02 +04:00
Nikolay Edigaryev 1a1f19e169 Implement garbage collection when deleting cached OCI VMs (#185)
* Properly calculate remotely-retrieved manifest digests

* Implement garbage collection when deleting cached OCI VMs
2022-08-18 17:13:06 +04:00
Nikolay Edigaryev fea916dacc OCI blob compression: fix Data memory leak when using InputFilter (#183)
* OCI blob compression: fix Data memory leak when using InputFilter

* Rename mappedDiskOffset to mappedDiskReadOffset

* Update progress.completedUnitCount differently
2022-08-17 12:57:39 -04:00
29 changed files with 745 additions and 141 deletions
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
+15 -1
View File
@@ -22,10 +22,24 @@ task:
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env: env:
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!] GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!] GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
install_script: brew install go goreleaser/tap/goreleaser-pro setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
info_script: info_script:
- security find-identity -v
- xcodebuild -version - xcodebuild -version
- swift -version - swift -version
release_script: goreleaser release_script: goreleaser
+7 -2
View File
@@ -7,13 +7,16 @@ builds:
goarch: goarch:
- arm64 - arm64
prebuilt: prebuilt:
path: .build/{{ .Arch }}-apple-macosx/debug/tart path: .build/arm64-apple-macosx/debug/tart
hooks:
post:
- gon gon.hcl
- .ci/create-pkg.sh
before: before:
hooks: hooks:
- .ci/set-version.sh - .ci/set-version.sh
- swift build -c debug --product tart - swift build -c debug --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/debug/tart
archives: archives:
- id: binary - id: binary
@@ -24,6 +27,8 @@ archives:
release: release:
prerelease: auto prerelease: auto
extra_files:
- glob: ./dist/Tart-{{ .Tag }}.pkg
brews: brews:
- name: tart - name: tart
+38 -2
View File
@@ -1,6 +1,6 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png) ![Tart – open source virtualization for your automation needs](Resources/TartSocial.png)
*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: 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). * 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 ### 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 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: 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. 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. 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 ### 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. 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 #### Pulling a Remote Image
You can either pull an image:
```shell ```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 ## FAQ
<details> <details>
+20 -7
View File
@@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String 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? var fromIPSW: String?
@Flag(help: "create a Linux VM")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb")) @Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt16 = 50 var diskSize: UInt16 = 50
func validate() throws { func validate() throws {
if fromIPSW == nil { if fromIPSW == nil && !linux {
throw ValidationError("Please specify a --from-ipsw option!") throw ValidationError("Please specify either a --from-ipsw or --linux option!")
} }
} }
@@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand {
do { do {
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
if fromIPSW! == "latest" { if let fromIPSW = fromIPSW {
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize) if fromIPSW == "latest" {
} else { _ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), 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("Linux VMs", "are")
}
} }
try VMStorageLocal().move(name, from: tmpVMDir) try VMStorageLocal().move(name, from: tmpVMDir)
+11 -5
View File
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
do { do {
let vmDir = try VMStorageLocal().open(name) let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL) 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?") print("no IP address found, is your VM running?")
Foundation.exit(1) 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) Foundation.exit(0)
} catch { } 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 waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
repeat { repeat {
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) { if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
return ip return ip
} }
+1 -1
View File
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
do { do {
let registry = try Registry(host: host, namespace: "", insecure: insecure, let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProvider: credentialsProvider) credentialsProviders: [credentialsProvider])
try await registry.ping() try await registry.ping()
} catch { } catch {
print("invalid credentials: \(error)") print("invalid credentials: \(error)")
+9 -2
View File
@@ -16,14 +16,21 @@ struct Prune: AsyncParsableCommand {
valueName: "n")) valueName: "n"))
var cacheBudget: UInt? var cacheBudget: UInt?
@Flag(help: .hidden)
var gc: Bool = false
func validate() throws { func validate() throws {
if olderThan == nil && cacheBudget == nil { if olderThan == nil && cacheBudget == nil && !gc {
throw ValidationError("at least one criteria must be specified") throw ValidationError("at least one pruning criteria must be specified")
} }
} }
func run() async throws { func run() async throws {
do { do {
if gc {
try VMStorageOCI().gc()
}
// Clean up cache entries based on last accessed date // Clean up cache entries based on last accessed date
if let olderThan = olderThan { if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
+42 -3
View File
@@ -37,13 +37,21 @@ struct Run: AsyncParsableCommand {
@Flag var withSoftnet: Bool = false @Flag var withSoftnet: Bool = false
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\") Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """ """, discussion: """
Learn how to create a disk image using Disk Utility here: Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""")) """, valueName: "path[:ro]"))
var disk: [String] = [] var disk: [String] = []
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
""", valueName: "name:path[:ro]"))
var dir: [String] = []
func validate() throws { func validate() throws {
if vnc && vncExperimental { if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
@@ -56,7 +64,8 @@ struct Run: AsyncParsableCommand {
vm = try VM( vm = try VM(
vmDir: vmDir, vmDir: vmDir,
withSoftnet: withSoftnet, withSoftnet: withSoftnet,
additionalDiskAttachments: additionalDiskAttachments() additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -135,6 +144,34 @@ struct Run: AsyncParsableCommand {
return result return result
} }
func directoryShares() throws -> [DirectoryShare] {
var result: [DirectoryShare] = []
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
if splits.count < 2 {
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
}
var readOnly: Bool = false
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
}
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
}
return result
}
private func runUI() { private func runUI() {
let nsApp = NSApplication.shared let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular) nsApp.setActivationPolicy(.regular)
@@ -148,6 +185,8 @@ struct Run: AsyncParsableCommand {
Group { Group {
VMView(vm: vm!).onAppear { VMView(vm: vm!).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear {
NSApplication.shared.terminate(self)
} }
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height)) }.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
}.commands { }.commands {
@@ -0,0 +1,88 @@
import Foundation
class DockerConfigCredentialsProvider: 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 credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
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 auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
}
struct DockerAuthConfig: Codable {
var auth: String? = nil
func decodeCredentials() -> (String, String)? {
// auth is a base64("username:password")
guard let authBase64 = auth else {
return nil
}
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
}
}
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 { 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])
} }
} }
+2 -2
View File
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
} }
struct OCIConfig: Codable { struct OCIConfig: Codable {
var architecture: String = "arm64" var architecture: Architecture = .arm64
var os: String = "darwin" var os: OS = .darwin
func toJSON() throws -> Data { func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self) try Config.jsonEncoder().encode(self)
+16 -7
View File
@@ -88,29 +88,29 @@ class Registry {
let baseURL: URL let baseURL: URL
let namespace: String let namespace: String
let credentialsProvider: CredentialsProvider let credentialsProviders: [CredentialsProvider]
var currentAuthToken: Authentication? = nil var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents, init(urlComponents: URLComponents,
namespace: String, namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
baseURL = urlComponents.url! baseURL = urlComponents.url!
self.namespace = namespace self.namespace = namespace
self.credentialsProvider = credentialsProvider self.credentialsProviders = credentialsProviders
} }
convenience init( convenience init(
host: String, host: String,
namespace: String, namespace: String,
insecure: Bool = false, insecure: Bool = false,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
let proto = insecure ? "http" : "https" let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")! 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 { func ping() async throws {
@@ -303,7 +303,7 @@ class Registry {
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw) let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" { 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) currentAuthToken = BasicAuthentication(user: user, password: password)
} }
@@ -340,7 +340,7 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary() 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() let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)" headers["Authorization"] = "Basic \(encodedCredentials!)"
} }
@@ -356,6 +356,15 @@ class Registry {
currentAuthToken = try TokenResponse.parse(fromData: bodyData) 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 { private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
var request = request var request = request
+14
View File
@@ -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
}
+97
View File
@@ -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()]
}
}
}
+37
View File
@@ -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()]
}
}
+6
View File
@@ -0,0 +1,6 @@
import Virtualization
enum OS: String, Codable {
case darwin
case linux
}
+9
View File
@@ -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]
}
+1 -18
View File
@@ -12,7 +12,7 @@ class Softnet {
init(vmMACAddress: String) throws { init(vmMACAddress: String) throws {
let binaryName = "softnet" 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") throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
} }
@@ -43,23 +43,6 @@ class Softnet {
process.waitUntilExit() 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 { private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size) let option_len = socklen_t(MemoryLayout<Int>.size)
+1 -1
View File
@@ -20,6 +20,6 @@ extension URL {
extension Date { extension Date {
func asTimeval() -> timeval { func asTimeval() -> timeval {
timeval(tv_sec: timeIntervalSince1970.toUnit(.second)!, tv_usec: 0) timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
} }
} }
+17
View File
@@ -5,3 +5,20 @@ extension Collection {
indices.contains(index) ? self[index] : nil 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
}
+69 -40
View File
@@ -10,6 +10,17 @@ struct NoMainScreenFoundError: Error {
struct DownloadFailed: Error { struct DownloadFailed: Error {
} }
struct UnsupportedOSError: Error, CustomStringConvertible {
let description: String
init(_ what: String, _ plural: String) {
description = "error: \(what) \(plural) only supported on macOS 13.0 (Ventura) or newer"
}
}
struct UnsupportedArchitectureError: Error {
}
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine // Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine @Published var virtualMachine: VZVirtualMachine
@@ -27,20 +38,25 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
init(vmDir: VMDirectory, init(vmDir: VMDirectory,
withSoftnet: Bool = false, withSoftnet: Bool = false,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
) throws { ) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
if config.arch != CurrentArchitecture() {
throw UnsupportedArchitectureError()
}
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
if withSoftnet { if withSoftnet {
softnet = try Softnet(vmMACAddress: config.macAddress.string) 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,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) nvramURL: vmDir.nvramURL, vmConfig: config,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -116,7 +132,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
// Create NVRAM // Create NVRAM
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk // Create disk
try vmDir.resizeDisk(diskSizeGB) try vmDir.resizeDisk(diskSizeGB)
@@ -124,7 +140,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
name = vmDir.name name = vmDir.name
// Create config // Create config
config = VMConfig( config = VMConfig(
hardwareModel: requirements.hardwareModel, platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount, cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize memorySizeMin: requirements.minimumSupportedMemorySize
) )
@@ -137,8 +153,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
softnet = try Softnet(vmMACAddress: config.macAddress.string) 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,
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) vmConfig: config, softnet: softnet,
additionalDiskAttachments: additionalDiskAttachments,
directoryShares: [])
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -159,6 +177,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 { func run(_ recovery: Bool) async throws {
if let softnet = softnet { if let softnet = softnet {
try softnet.run() try softnet.run()
@@ -199,50 +232,26 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func craftConfiguration( static func craftConfiguration(
diskURL: URL, diskURL: URL,
auxStorage: VZMacAuxiliaryStorage, nvramURL: URL,
vmConfig: VMConfig, vmConfig: VMConfig,
softnet: Softnet? = nil, softnet: Softnet? = nil,
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
// Boot loader // Boot loader
configuration.bootLoader = VZMacOSBootLoader() configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
// CPU and memory // CPU and memory
configuration.cpuCount = vmConfig.cpuCount configuration.cpuCount = vmConfig.cpuCount
configuration.memorySize = vmConfig.memorySize configuration.memorySize = vmConfig.memorySize
// Platform // Platform
let platform = VZMacPlatformConfiguration() configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
platform.machineIdentifier = vmConfig.ecid
platform.auxiliaryStorage = auxStorage
platform.hardwareModel = vmConfig.hardwareModel
configuration.platform = platform
// Display // Display
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration() configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
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]
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
@@ -255,7 +264,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Keyboard and mouse // Keyboard and mouse
configuration.keyboards = [VZUSBKeyboardConfiguration()] configuration.keyboards = [VZUSBKeyboardConfiguration()]
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] configuration.pointingDevices = vmConfig.platform.pointingDevices()
// Networking // Networking
let vio = VZVirtioNetworkDeviceConfiguration() let vio = VZVirtioNetworkDeviceConfiguration()
@@ -277,6 +286,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Entropy // Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory share
if #available(macOS 13, *) {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
configuration.directorySharingDevices = [sharingDevice]
} else if !directoryShares.isEmpty {
throw UnsupportedOSError("directory sharing", "is")
}
try configuration.validate() try configuration.validate()
return configuration return configuration
@@ -297,3 +320,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal() sema.signal()
} }
} }
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+30 -36
View File
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case version case version
case ecid case os
case hardwareModel case arch
case cpuCountMin case cpuCountMin
case cpuCount case cpuCount
case memorySizeMin case memorySizeMin
case memorySize case memorySize
case macAddress case macAddress
case display case display
// macOS-specific keys
case ecid
case hardwareModel
} }
struct VMDisplayConfig: Codable { struct VMDisplayConfig: Codable {
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
struct VMConfig: Codable { struct VMConfig: Codable {
var version: Int = 1 var version: Int = 1
var ecid: VZMacMachineIdentifier var os: OS
var hardwareModel: VZMacHardwareModel var arch: Architecture
var platform: Platform
var cpuCountMin: Int var cpuCountMin: Int
private(set) var cpuCount: Int private(set) var cpuCount: Int
var memorySizeMin: UInt64 var memorySizeMin: UInt64
private(set) var memorySize: UInt64 private(set) var memorySize: UInt64
var macAddress: VZMACAddress var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig() var display: VMDisplayConfig = VMDisplayConfig()
init( init(
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), platform: Platform,
hardwareModel: VZMacHardwareModel, cpuCountMin: Int,
cpuCountMin: Int, memorySizeMin: UInt64,
memorySizeMin: UInt64, macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) { ) {
self.ecid = ecid self.os = platform.os()
self.hardwareModel = hardwareModel self.arch = CurrentArchitecture()
self.platform = platform
self.macAddress = macAddress self.macAddress = macAddress
self.cpuCountMin = cpuCountMin self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin self.memorySizeMin = memorySizeMin
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(Int.self, forKey: .version) version = try container.decode(Int.self, forKey: .version)
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
let encodedECID = try container.decode(String.self, forKey: .ecid) arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
guard let data = Data.init(base64Encoded: encodedECID) else { switch os {
throw DecodingError.dataCorruptedError(forKey: .ecid, case .darwin:
in: container, platform = try Darwin(from: decoder)
debugDescription: "failed to initialize Data using the provided value") case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
} }
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) cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount) cpuCount = try container.decode(Int.self, forKey: .cpuCount)
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version) try container.encode(version, forKey: .version)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) try container.encode(os, forKey: .os)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) try container.encode(arch, forKey: .arch)
try platform.encode(to: encoder)
try container.encode(cpuCountMin, forKey: .cpuCountMin) try container.encode(cpuCountMin, forKey: .cpuCountMin)
try container.encode(cpuCount, forKey: .cpuCount) try container.encode(cpuCount, forKey: .cpuCount)
try container.encode(memorySizeMin, forKey: .memorySizeMin) try container.encode(memorySizeMin, forKey: .memorySizeMin)
+10 -8
View File
@@ -117,13 +117,15 @@ extension VMDirectory {
// Read VM's compressed disk as chunks // Read VM's compressed disk as chunks
// and sequentially upload them as blobs // and sequentially upload them as blobs
let disk = try FileHandle(forReadingFrom: diskURL) let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
var diskReadBytes: UInt64 = 0 let mappedDiskSize = mappedDisk.count
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in var mappedDiskReadOffset = 0
let data = try disk.read(upToCount: Self.bufferSizeBytes) let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
diskReadBytes += UInt64(data?.count ?? 0) 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 return data
} }
@@ -140,12 +142,12 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility // 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 ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers, layers: layers,
uncompressedDiskSize: diskReadBytes uncompressedDiskSize: UInt64(mappedDiskReadOffset)
) )
// Manifest // Manifest
+12
View File
@@ -20,6 +20,10 @@ struct VMDirectory: Prunable {
baseURL.appendingPathComponent("nvram.bin") baseURL.appendingPathComponent("nvram.bin")
} }
var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled")
}
var name: String { var name: String {
baseURL.lastPathComponent baseURL.lastPathComponent
} }
@@ -89,4 +93,12 @@ struct VMDirectory: Prunable {
func sizeBytes() throws -> Int { func sizeBytes() throws -> Int {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes() 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)
}
} }
+48 -4
View File
@@ -42,6 +42,44 @@ class VMStorageOCI: PrunableStorage {
func delete(_ name: RemoteName) throws { func delete(_ name: RemoteName) throws {
try FileManager.default.removeItem(at: vmURL(name)) 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)] { func list() throws -> [(String, VMDirectory, Bool)] {
@@ -82,10 +120,10 @@ class VMStorageOCI: PrunableStorage {
func pull(_ name: RemoteName, registry: Registry) async throws { func pull(_ name: RemoteName, registry: Registry) async throws {
defaultLogger.appendNewLine("pulling manifest...") 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, let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: try manifest.digest())) reference: Reference(digest: Digest.hash(manifestData)))
if !exists(digestName) { if !exists(digestName) {
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
@@ -113,8 +151,12 @@ class VMStorageOCI: PrunableStorage {
} }
if name != digestName { if name != digestName {
// Overwrite the old symbolic link // Create new or overwrite the old symbolic link
try link(from: digestName, to: name) 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 FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
try gc()
} }
} }
+2 -1
View File
@@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC {
} }
func waitForURL() async throws -> URL { 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 { if let ip = ip {
return URL(string: "vnc://\(ip)")! return URL(string: "vnc://\(ip)")!
+12
View File
@@ -0,0 +1,12 @@
source = [".build/arm64-apple-macosx/debug/tart"]
bundle_id = "com.github.cirruslabs.tart"
apple_id {
username = "hello@cirruslabs.org"
password = "@env:AC_PASSWORD"
}
sign {
application_identity = "Developer ID Application: Fedor Korotkov"
entitlements_file = "Resources/tart.entitlements"
}