From d9f1c37cdda524fa1eb7cc72eb4039a58115993d Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 15 Dec 2022 17:50:21 +0400 Subject: [PATCH] Sentry integration (#352) * Ditch Foundation.exit()'s where feasible * Sentry integration * SwiftFormat * Upload symbols and sources to Sentry * Use Sentry Releases * Do not use ExitCode exceptions * Clarify why we need CustomNSError extension --- .cirrus.yml | 16 ++++ Package.resolved | 9 ++ Package.swift | 3 + Sources/tart/CI/CI.swift | 4 + Sources/tart/Commands/Clone.swift | 62 ++++++-------- Sources/tart/Commands/Create.swift | 60 ++++++------- Sources/tart/Commands/Delete.swift | 12 +-- Sources/tart/Commands/Get.swift | 26 ++---- Sources/tart/Commands/IP.swift | 53 +++++++----- Sources/tart/Commands/List.swift | 24 ++---- Sources/tart/Commands/Login.swift | 52 +++++------- Sources/tart/Commands/Prune.swift | 32 +++---- Sources/tart/Commands/Pull.swift | 32 +++---- Sources/tart/Commands/Push.swift | 70 +++++++--------- Sources/tart/Commands/Rename.swift | 26 ++---- Sources/tart/Commands/Run.swift | 12 ++- Sources/tart/Commands/Set.swift | 46 +++++----- Sources/tart/Commands/Stop.swift | 84 ++++++++----------- .../tart/MACAddressResolver/ARPCache.swift | 13 ++- Sources/tart/Root.swift | 40 ++++++++- Sources/tart/VMStorageHelper.swift | 15 +++- 21 files changed, 345 insertions(+), 346 deletions(-) diff --git a/.cirrus.yml b/.cirrus.yml index c79351a..f8c8813 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -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 @@ -78,3 +81,16 @@ task: - 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 diff --git a/Package.resolved b/Package.resolved index d92ce04..5729a11 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", diff --git a/Package.swift b/Package.swift index ff8f12c..7b729e5 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let package = Package( .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/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", diff --git a/Sources/tart/CI/CI.swift b/Sources/tart/CI/CI.swift index 53abb0a..f0cf9f5 100644 --- a/Sources/tart/CI/CI.swift +++ b/Sources/tart/CI/CI.swift @@ -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 { diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index 46bb6ba..1b6d66c 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -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) + }) } } diff --git a/Sources/tart/Commands/Create.swift b/Sources/tart/Commands/Create.swift index 0511627..ab1c487 100644 --- a/Sources/tart/Commands/Create.swift +++ b/Sources/tart/Commands/Create.swift @@ -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) + }) } } diff --git a/Sources/tart/Commands/Delete.swift b/Sources/tart/Commands/Delete.swift index 8f1e00c..4f8308f 100644 --- a/Sources/tart/Commands/Delete.swift +++ b/Sources/tart/Commands/Delete.swift @@ -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) } } } diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 3f9aafc..dbbb7bb 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -8,24 +8,16 @@ struct Get: AsyncParsableCommand { var name: String 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 diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000 - print("CPU\tMemory\tDisk\tDisplay") + 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) - } + 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) } } diff --git a/Sources/tart/Commands/IP.swift b/Sources/tart/Commands/IP.swift index 7e5f234..beca08b 100644 --- a/Sources/tart/Commands/IP.swift +++ b/Sources/tart/Commands/IP.swift @@ -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("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? { diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index bbccf4a..0c7f8b1 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -22,24 +22,16 @@ struct List: AsyncParsableCommand { } 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 == "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) + if source == nil || source == "oci" { + displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) }) } } diff --git a/Sources/tart/Commands/Login.swift b/Sources/tart/Commands/Login.swift index 0bd4992..f68777b 100644 --- a/Sources/tart/Commands/Login.swift +++ b/Sources/tart/Commands/Login.swift @@ -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("invalid credentials: \(error)") + } + + try KeychainCredentialsProvider().store(host: host, user: user, password: password) } } diff --git a/Sources/tart/Commands/Prune.swift b/Sources/tart/Commands/Prune.swift index feab283..ad4f7f1 100644 --- a/Sources/tart/Commands/Prune.swift +++ b/Sources/tart/Commands/Prune.swift @@ -26,29 +26,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) } } diff --git a/Sources/tart/Commands/Pull.swift b/Sources/tart/Commands/Pull.swift index af30de1..6ffd6a9 100644 --- a/Sources/tart/Commands/Pull.swift +++ b/Sources/tart/Commands/Pull.swift @@ -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) } } diff --git a/Sources/tart/Commands/Push.swift b/Sources/tart/Commands/Push.swift index b7ef60c..8a6571b 100644 --- a/Sources/tart/Commands/Push.swift +++ b/Sources/tart/Commands/Push.swift @@ -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) } } } diff --git a/Sources/tart/Commands/Rename.swift b/Sources/tart/Commands/Rename.swift index bc9be3d..d8f9340 100644 --- a/Sources/tart/Commands/Rename.swift +++ b/Sources/tart/Commands/Rename.swift @@ -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) } } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index b222e92..6a80ca8 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -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("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("Virtual machine \"\(name)\" is already running!", exitCode: 2) } 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) } } diff --git a/Sources/tart/Commands/Set.swift b/Sources/tart/Commands/Set.swift index 698a7d7..8fd46bb 100644 --- a/Sources/tart/Commands/Set.swift +++ b/Sources/tart/Commands/Set.swift @@ -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!) } } } diff --git a/Sources/tart/Commands/Stop.swift b/Sources/tart/Commands/Stop.swift index 5f5b8b3..7e7306f 100644 --- a/Sources/tart/Commands/Stop.swift +++ b/Sources/tart/Commands/Stop.swift @@ -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("VM \(name) is not running", exitCode: 2) + } + + // 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("failed to forcefully terminate the VM \(name): \(details)") } } } diff --git a/Sources/tart/MACAddressResolver/ARPCache.swift b/Sources/tart/MACAddressResolver/ARPCache.swift index e96e402..4dc0f65 100644 --- a/Sources/tart/MACAddressResolver/ARPCache.swift +++ b/Sources/tart/MACAddressResolver/ARPCache.swift @@ -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") diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 9df041b..c5af631 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import Puppy +import Sentry var puppy = Puppy.default @@ -33,6 +34,24 @@ 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 + } + } + 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 +85,26 @@ struct Root: AsyncParsableCommand { try command.run() } } catch { - exit(withError: error) + // Capture the error into Sentry + SentrySDK.capture(error: error) + SentrySDK.flush(timeout: 2.seconds.timeInterval) + + print(error) + + if let runtimeError = error as? RuntimeError { + Foundation.exit(runtimeError.exitCode) + } + + Foundation.exit(1) } } + + 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])) + } } diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 664a47a..a214c69 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -42,12 +42,25 @@ extension Error { class RuntimeError: Error, CustomStringConvertible { let message: String + let exitCode: Int32 - init(_ message: String) { + init(_ message: String, exitCode: Int32 = 1) { self.message = message + self.exitCode = exitCode } var description: String { message } } + +// 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: message, + ] + } +}