mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4f00fa78 | ||
|
|
1380ece108 | ||
|
|
9cdb7087f2 | ||
|
|
c3e37854c3 | ||
|
|
828351a8fb | ||
|
|
f9ac994e18 | ||
|
|
fdeaf979c2 | ||
|
|
d9f1c37cdd | ||
|
|
81253417a4 | ||
|
|
c9caeab098 | ||
|
|
6d7dc40c57 | ||
|
|
15b26f78ae | ||
|
|
d9f4f9e954 |
+17
-1
@@ -61,6 +61,9 @@ task:
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
@@ -71,10 +74,23 @@ task:
|
||||
- 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 go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
upload_sentry_debug_files_script:
|
||||
- cd .build/arm64-apple-macosx/debug/
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
|
||||
- sentry-cli releases finalize $SENTRY_RELEASE
|
||||
|
||||
+12
-2
@@ -27,6 +27,15 @@
|
||||
"version" : "0.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "c3c19e29f775ee95b77aa3168f3e2fd6c20deba6",
|
||||
"version" : "7.31.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -102,9 +111,10 @@
|
||||
{
|
||||
"identity" : "swiftformat",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edigaryev/SwiftFormat",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "46f34946546a29eeb6cb23ec44936b74fd379d63"
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+4
-1
@@ -18,7 +18,8 @@ let package = Package(
|
||||
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/edigaryev/SwiftFormat", branch: "46f34946546a29eeb6cb23ec44936b74fd379d63"),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -30,6 +31,8 @@ let package = Package(
|
||||
.product(name: "Puppy", package: "Puppy"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) 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).
|
||||
@@ -40,7 +40,7 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/tart
|
||||
@@ -133,7 +133,7 @@ task:
|
||||
|
||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||
|
||||
```bash
|
||||
```shell
|
||||
cirrus run --artifacts-dir artifacts
|
||||
```
|
||||
|
||||
@@ -167,7 +167,9 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
|
||||
|
||||
```shell
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
@@ -278,7 +280,7 @@ This invocation calls the `tart pull` implicitly (if the image is not being pres
|
||||
|
||||
To mount a directory, run the VM with the `--dir` argument:
|
||||
|
||||
```sh
|
||||
```shell
|
||||
tart run --dir=project:~/src/project vm
|
||||
```
|
||||
|
||||
@@ -286,13 +288,13 @@ Here, the `project` specifies a mount name, whereas the `~/src/project` is a pat
|
||||
|
||||
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
|
||||
|
||||
```sh
|
||||
```shell
|
||||
tart run --dir=project:~/src/project:ro vm
|
||||
```
|
||||
|
||||
To mount multiple directories, repeat the `--dir` argument for each directory:
|
||||
|
||||
```sh
|
||||
```shell
|
||||
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
|
||||
```
|
||||
|
||||
@@ -312,7 +314,7 @@ Note: to use the directory mounting feature, the guest VM needs to run macOS 13.
|
||||
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
```sh
|
||||
```shell
|
||||
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
|
||||
```
|
||||
|
||||
@@ -356,7 +358,7 @@ The directory we've mounted above will be accessible from the `/mnt/shared/proje
|
||||
<summary>VM location on disk</summary>
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
||||
Remote images are pulled into `~/.tart/cache/OCIs/`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -371,7 +373,7 @@ The directory we've mounted above will be accessible from the `/mnt/shared/proje
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
@@ -379,7 +381,17 @@ The directory we've mounted above will be accessible from the `/mnt/shared/proje
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```
|
||||
```shell
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How to connect to a VM over SSH?</summary>
|
||||
|
||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||
|
||||
```shell
|
||||
ssh admin@$(tart ip macos-monterey-base)
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -4,6 +4,10 @@ struct CI {
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
}
|
||||
|
||||
static var release: String? {
|
||||
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
|
||||
@@ -21,43 +21,35 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,46 +25,38 @@ struct Create: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, 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)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,8 @@ struct Delete: AsyncParsableCommand {
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,49 @@ import Foundation
|
||||
struct Get: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name.")
|
||||
var name: String
|
||||
|
||||
@Flag(help: "Number of VM CPUs.")
|
||||
var cpu: Bool = false
|
||||
|
||||
@Flag(help: "VM memory size in megabytes.")
|
||||
var memory: Bool = false
|
||||
|
||||
@Flag(help: "Disk size in gigabytes.")
|
||||
var diskSize: Bool = false
|
||||
|
||||
@Flag(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800.")
|
||||
var display: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if [cpu, memory, diskSize, display].filter({$0}).count > 1 {
|
||||
throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
print("CPU\tMemory\tDisk\tDisplay")
|
||||
|
||||
var s = "\(vmConfig.cpuCount)\t"
|
||||
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
|
||||
s += "\(diskSize) GB\t"
|
||||
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
|
||||
print(s)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if cpu {
|
||||
print(vmConfig.cpuCount)
|
||||
} else if memory {
|
||||
print(memorySizeInMb)
|
||||
} else if diskSize {
|
||||
print(diskSizeInGb)
|
||||
} else if display {
|
||||
print("\(vmConfig.display.width)x\(vmConfig.display.height)")
|
||||
} else {
|
||||
print(
|
||||
"CPU\tMemory\tDisk\tDisplay\n" +
|
||||
"\(vmConfig.cpuCount)\t" +
|
||||
"\(memorySizeInMb) MB\t" +
|
||||
"\(diskSizeInGb) GB\t" +
|
||||
"\(vmConfig.display)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
@@ -13,31 +14,37 @@ struct IP: AsyncParsableCommand {
|
||||
var wait: UInt16 = 0
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
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 {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
||||
}
|
||||
|
||||
let arpCache = try ARPCache()
|
||||
|
||||
if let ipViaARP = try arpCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
// Capture the warning into Sentry
|
||||
SentrySDK.capture(message: "DHCP lease and ARP cache entries for a single MAC address differ") { scope in
|
||||
scope.setLevel(.warning)
|
||||
|
||||
scope.setContext(value: [
|
||||
"MAC address": vmMACAddress,
|
||||
"IP via ARP": ipViaARP,
|
||||
"IP via DHCP": ipViaDHCP,
|
||||
], key: "Address conflict details")
|
||||
|
||||
scope.add(Attachment(path: "/var/db/dhcpd_leases", filename: "dhcpd_leases.txt", contentType: "text/plain"))
|
||||
scope.add(Attachment(data: arpCache.arpCommandOutput, filename: "arp-an-output.txt", contentType: "text/plain"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
|
||||
@@ -5,23 +5,33 @@ import SwiftUI
|
||||
struct List: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
|
||||
var quiet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
|
||||
var source: String?
|
||||
|
||||
func validate() throws {
|
||||
guard let source = source else {
|
||||
return
|
||||
}
|
||||
|
||||
if !["local", "oci"].contains(source) {
|
||||
throw ValidationError("'\(source)' is not a valid <source>")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if !quiet {
|
||||
print("Source\tName")
|
||||
}
|
||||
if !quiet {
|
||||
print("Source\tName")
|
||||
}
|
||||
|
||||
if source == nil || source == "local" {
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +40,7 @@ struct List: AsyncParsableCommand {
|
||||
if quiet {
|
||||
print(name)
|
||||
} else {
|
||||
let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0)
|
||||
print("\(source)\t\(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,40 +27,30 @@ struct Login: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
var user: String
|
||||
var password: String
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
if let username = username {
|
||||
user = username
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
}
|
||||
|
||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -26,29 +27,21 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
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
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,5 +104,7 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,27 +12,19 @@ struct Pull: AsyncParsableCommand {
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,54 +28,46 @@ struct Push: AsyncParsableCommand {
|
||||
var populateCache: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||
for remoteName in remoteNamesForRegistry {
|
||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||
}
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,24 +17,16 @@ struct Rename: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localStorage = VMStorageLocal()
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
|
||||
var vm: VM?
|
||||
|
||||
@@ -116,9 +117,8 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
||||
print("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
||||
+ "unmount it first in Finder")
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,7 @@ struct Run: AsyncParsableCommand {
|
||||
// [1]: https://man.openbsd.org/fcntl
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
if try !lock.trylock() {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
Foundation.exit(2)
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
@@ -179,7 +178,12 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,38 +20,30 @@ struct Set: AsyncParsableCommand {
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
if let cpu = cpu {
|
||||
try vmConfig.setCPU(cpuCount: Int(cpu))
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
|
||||
if let memory = memory {
|
||||
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
|
||||
if let display = display {
|
||||
if (display.width > 0) {
|
||||
vmConfig.display.width = display.width
|
||||
}
|
||||
if (display.height > 0) {
|
||||
vmConfig.display.height = display.height
|
||||
}
|
||||
}
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,60 +13,48 @@ struct Stop: AsyncParsableCommand {
|
||||
var timeout: UInt64 = 30
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
//
|
||||
// Note that we don't check the return code here
|
||||
// to provide a clean exit from "tart stop" in cases
|
||||
// when the VM is already shutting down and we hit
|
||||
// a race condition.
|
||||
//
|
||||
// We check the return code in the kill(2) below, though,
|
||||
// because it's a less common scenario and it would be
|
||||
// nice to know for the user that we've tried all methods
|
||||
// and failed to shutdown the VM.
|
||||
kill(pid, SIGINT)
|
||||
|
||||
// Ensure that the VM has terminated
|
||||
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||
|
||||
while gracefulWaitDuration.value > 0 {
|
||||
pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
print("VM \(name) is not running")
|
||||
|
||||
Foundation.exit(2)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
//
|
||||
// Note that we don't check the return code here
|
||||
// to provide a clean exit from "tart stop" in cases
|
||||
// when the VM is already shutting down and we hit
|
||||
// a race condition.
|
||||
//
|
||||
// We check the return code in the kill(2) below, though,
|
||||
// because it's a less common scenario and it would be
|
||||
// nice to know for the user that we've tried all methods
|
||||
// and failed to shutdown the VM.
|
||||
kill(pid, SIGINT)
|
||||
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||
}
|
||||
|
||||
// Ensure that the VM has terminated
|
||||
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||
// Seems that VM is still running, proceed with forceful termination
|
||||
let ret = kill(pid, SIGKILL)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
while gracefulWaitDuration.value > 0 {
|
||||
pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||
}
|
||||
|
||||
// Seems that VM is still running, proceed with forceful termination
|
||||
let ret = kill(pid, SIGKILL)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
print("failed to forcefully terminate the VM \(name): \(details)")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let arpCommandOutput: Data
|
||||
|
||||
init() throws {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
@@ -58,10 +60,15 @@ struct ARPCache {
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
|
||||
@@ -69,15 +69,19 @@ class Softnet: Network {
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
var option_value = sizeBytes
|
||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
||||
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
|
||||
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
|
||||
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
|
||||
var receiveBufferSize = 4 * sizeBytes
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||
var sendBufferSize = sizeBytes
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ class Registry {
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ class Registry {
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
@@ -364,7 +364,13 @@ class Registry {
|
||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
||||
}
|
||||
|
||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
var host = baseURL.host!
|
||||
|
||||
if let port = baseURL.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
|
||||
@@ -106,7 +106,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||
|
||||
if let error = errorCollector.error {
|
||||
throw RuntimeError("failed to parse remote name: \(error)")
|
||||
throw RuntimeError.FailedToParseRemoteName("\(error)")
|
||||
}
|
||||
|
||||
host = referenceCollector.host!
|
||||
@@ -120,7 +120,7 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
} else if reference.starts(with: ":") {
|
||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||
} else {
|
||||
throw RuntimeError("failed to parse remote name: unknown reference format")
|
||||
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
|
||||
}
|
||||
} else {
|
||||
self.reference = Reference(tag: "latest")
|
||||
|
||||
@@ -44,7 +44,7 @@ class PIDLock {
|
||||
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError("\(message): \(details)")
|
||||
throw RuntimeError.PIDLockFailed("\(message): \(details)")
|
||||
}
|
||||
|
||||
return (true, result)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Puppy
|
||||
import Sentry
|
||||
|
||||
var puppy = Puppy.default
|
||||
|
||||
@@ -33,6 +34,35 @@ struct Root: AsyncParsableCommand {
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Initialize Sentry
|
||||
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = CI.release
|
||||
options.tracesSampleRate = Float(
|
||||
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
|
||||
) as NSNumber?
|
||||
|
||||
// By default only 5XX are captured
|
||||
// Let's capture everything but 401 (unauthorized)
|
||||
options.enableCaptureFailedRequests = true
|
||||
options.failedRequestStatusCodes = [
|
||||
HttpStatusCodeRange(min: 400, max: 400),
|
||||
HttpStatusCodeRange(min: 402, max: 599)
|
||||
]
|
||||
}
|
||||
}
|
||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||
|
||||
// Enrich future events with Cirrus CI-specific tags
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
|
||||
scope.setTag(value: value, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the default SIGINT handled is disabled,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
@@ -66,7 +96,28 @@ struct Root: AsyncParsableCommand {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(errorWithExitCode.exitCode)
|
||||
}
|
||||
|
||||
// Handle any other exception, including ArgumentParser's ones
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
|
||||
let splits = tag.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (String(splits[0]), String(splits[1]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ extension URL {
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ struct VMDisplayConfig: Codable {
|
||||
var height: Int = 768
|
||||
}
|
||||
|
||||
extension VMDisplayConfig: CustomStringConvertible {
|
||||
var description: String {
|
||||
"\(width)x\(height)"
|
||||
}
|
||||
}
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var os: OS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import Sentry
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -77,6 +78,7 @@ extension VMDirectory {
|
||||
}
|
||||
try filter.finalize()
|
||||
try disk.close()
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
@@ -41,7 +41,7 @@ struct VMDirectory: Prunable {
|
||||
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
if !overwrite && initialized {
|
||||
throw RuntimeError("VM directory is already initialized, preventing overwrite")
|
||||
throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -53,11 +53,11 @@ struct VMDirectory: Prunable {
|
||||
|
||||
func validate() throws {
|
||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||
throw RuntimeError("the specified VM does not exist")
|
||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class VMStorageHelper {
|
||||
return try closure()
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
}
|
||||
|
||||
throw error
|
||||
@@ -40,14 +40,76 @@ extension Error {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
enum RuntimeError : Error {
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
case VMTerminationFailed(_ message: String)
|
||||
case InvalidCredentials(_ message: String)
|
||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||
}
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
protocol HasExitCode {
|
||||
var exitCode: Int32 { get }
|
||||
}
|
||||
|
||||
var description: String {
|
||||
message
|
||||
extension RuntimeError : CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .VMDoesNotExist(let name):
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
return message
|
||||
case .FailedToParseRemoteName(let cause):
|
||||
return "failed to parse remote name: \(cause)"
|
||||
case .VMTerminationFailed(let message):
|
||||
return message
|
||||
case .InvalidCredentials(let message):
|
||||
return message
|
||||
case .VMDirectoryAlreadyInitialized(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RuntimeError : HasExitCode {
|
||||
var exitCode: Int32 {
|
||||
switch self {
|
||||
case .VMNotRunning:
|
||||
return 2
|
||||
case .VMAlreadyRunning:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customize error description for Sentry[1]
|
||||
//
|
||||
// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
|
||||
extension RuntimeError : CustomNSError {
|
||||
var errorUserInfo: [String : Any] {
|
||||
[
|
||||
NSDebugDescriptionErrorKey: description,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -148,6 +149,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -182,7 +184,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
@@ -191,7 +195,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
|
||||
if name != digestName {
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
try link(from: name, to: digestName)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
@@ -200,11 +204,11 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func link(from: RemoteName, to: RemoteName) throws {
|
||||
if FileManager.default.fileExists(atPath: vmURL(to).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(to))
|
||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(from))
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||
|
||||
try gc()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user