mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-12 08:55:34 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48f443a66f | ||
|
|
c60d08bf43 | ||
|
|
02bf5651e7 | ||
|
|
96c89ad76e | ||
|
|
b78fa6ba1c | ||
|
|
e443cfa9a2 | ||
|
|
e35c13425e | ||
|
|
0debec1266 | ||
|
|
294c5fc5e5 | ||
|
|
99777b6740 | ||
|
|
a2972aa4d9 |
@@ -29,6 +29,7 @@ Table of Contents
|
||||
|
||||
1. Code should follow camel case
|
||||
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||
|
||||
```bash
|
||||
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||
```
|
||||
|
||||
+39
-12
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "c5371137580239f6928cf64425e754e77680bf24430b50f02dad5558c23f68b0",
|
||||
"originHash" : "aa0a5df26b9e35d1908d6876d045af7ce1899086d641507e5faa9d1f9bd29787",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -46,6 +46,24 @@
|
||||
"version" : "1.24.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentelemetry-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/open-telemetry/opentelemetry-swift",
|
||||
"state" : {
|
||||
"revision" : "6a2c29d53ff0b543b551b2221538bd3d0206c6d6",
|
||||
"version" : "1.15.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "opentracing-objc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/undefinedlabs/opentracing-objc",
|
||||
"state" : {
|
||||
"revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1",
|
||||
"version" : "0.5.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semaphore",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -55,15 +73,6 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "65b3d2a7608685e8d4a37c68fa2c64f28d0b537e",
|
||||
"version" : "8.51.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -78,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -127,6 +136,15 @@
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-metrics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-metrics.git",
|
||||
"state" : {
|
||||
"revision" : "4c83e1cdf4ba538ef6e43a9bbd0bcc33a0ca46e3",
|
||||
"version" : "2.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -261,6 +279,15 @@
|
||||
"branch" : "master",
|
||||
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "thrift-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/undefinedlabs/Thrift-Swift",
|
||||
"state" : {
|
||||
"revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e",
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
+8
-3
@@ -10,14 +10,14 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"),
|
||||
.package(url: "https://github.com/open-telemetry/opentelemetry-swift", from: "1.7.0"),
|
||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||
@@ -25,6 +25,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
|
||||
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.24.2")),
|
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.83.0"),
|
||||
.package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-17d7dedafb88.1"),
|
||||
],
|
||||
targets: [
|
||||
@@ -35,7 +36,11 @@ let package = Package(
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift"),
|
||||
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift"),
|
||||
.product(name: "OpenTelemetryProtocolExporter", package: "opentelemetry-swift"),
|
||||
.product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"),
|
||||
.product(name: "NIO", package: "swift-nio"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
|
||||
@@ -66,8 +66,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-base
|
||||
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||
tart run tahoe-base
|
||||
```
|
||||
|
||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||
|
||||
@@ -31,6 +31,9 @@ struct Clone: AsyncParsableCommand {
|
||||
@Flag(help: .hidden)
|
||||
var deduplicate: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
||||
var pruneLimit: UInt = 100
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
@@ -76,8 +79,10 @@ struct Clone: AsyncParsableCommand {
|
||||
//
|
||||
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
||||
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||
if unallocatedBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
|
||||
// Avoid reclaiming an excessive amount of disk space.
|
||||
let reclaimBytes = min(unallocatedBytes, Int(pruneLimit) * 1024 * 1024 * 1024)
|
||||
if reclaimBytes > 0 {
|
||||
try Prune.reclaimIfNeeded(UInt64(reclaimBytes), sourceVM)
|
||||
}
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
|
||||
@@ -87,11 +87,13 @@ struct Exec: AsyncParsableCommand {
|
||||
$0.args = Array(command.dropFirst(1))
|
||||
$0.interactive = interactive
|
||||
$0.tty = tty
|
||||
$0.terminalSize = .with {
|
||||
let (width, height) = try! Term.GetSize()
|
||||
if tty {
|
||||
$0.terminalSize = .with {
|
||||
let (width, height) = try! Term.GetSize()
|
||||
|
||||
$0.cols = UInt32(width)
|
||||
$0.rows = UInt32(height)
|
||||
$0.cols = UInt32(width)
|
||||
$0.rows = UInt32(height)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -100,28 +102,61 @@ struct Exec: AsyncParsableCommand {
|
||||
try await withThrowingTaskGroup { group in
|
||||
// Stream host's standard input if interactive mode is enabled
|
||||
if interactive {
|
||||
let stdinStream = AsyncStream<Data> { continuation in
|
||||
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let handle = FileHandle.standardInput
|
||||
|
||||
handle.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if isRegularFile(handle.fileDescriptor) {
|
||||
// Standard input can be a regular file when input redirection (<) is used,
|
||||
// in which case the handle won't receive any new readability events, so we
|
||||
// just read the file normally here in chunks and consider done with it
|
||||
//
|
||||
// Ideally this is best handled by using non-blocking I/O, but Swift's
|
||||
// standard library only offers inefficient bytes[1] property and SwiftNIO's
|
||||
// NIOFileSystem doesn't seem to support opening raw file descriptors.
|
||||
//
|
||||
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
|
||||
while true {
|
||||
do {
|
||||
let data = try handle.read(upToCount: 64 * 1024)
|
||||
if let data = data {
|
||||
continuation.yield(data)
|
||||
} else {
|
||||
continuation.finish()
|
||||
break
|
||||
}
|
||||
} catch (let error) {
|
||||
continuation.finish(throwing: error)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handle.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
|
||||
continuation.yield(data)
|
||||
|
||||
if data.isEmpty {
|
||||
continuation.finish()
|
||||
if data.isEmpty {
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
for await stdinData in stdinStream {
|
||||
for try await stdinData in stdinStream {
|
||||
try await execCall.requestStream.send(.with {
|
||||
$0.type = .standardInput(.with {
|
||||
$0.data = stdinData
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Signal EOF as we're done reading standard input
|
||||
try await execCall.requestStream.send(.with {
|
||||
$0.type = .standardInput(.with {
|
||||
$0.data = Data()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,3 +213,13 @@ struct Exec: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
|
||||
var stat = stat()
|
||||
|
||||
if fstat(fileDescriptor, &stat) != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return (stat.st_mode & S_IFMT) == S_IFREG
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
case dhcp, arp, agent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Sentry
|
||||
// Telemetry integration is encapsulated in Telemetry.swift
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
@@ -109,9 +109,10 @@ struct Prune: AsyncParsableCommand {
|
||||
return
|
||||
}
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||
}
|
||||
// Record desired reclaim size as an event context
|
||||
Telemetry.addEvent("Prune.required", attributes: [
|
||||
"requiredBytes": .int(Int(requiredBytes))
|
||||
])
|
||||
|
||||
// Figure out how much disk space is available
|
||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||
@@ -123,18 +124,17 @@ struct Prune: AsyncParsableCommand {
|
||||
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||
)
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: [
|
||||
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||
], key: "Prune")
|
||||
}
|
||||
Telemetry.addEvent("Prune.capacity", attributes: [
|
||||
"volumeAvailableCapacity": .int(Int(attrs.volumeAvailableCapacity!)),
|
||||
"volumeAvailableCapacityForImportantUsage": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
|
||||
"volumeAvailableCapacityCalculated": .int(Int(volumeAvailableCapacityCalculated))
|
||||
])
|
||||
|
||||
if volumeAvailableCapacityCalculated <= 0 {
|
||||
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||
scope.setLevel(.warning)
|
||||
}
|
||||
Telemetry.addEvent("Prune.warning", attributes: [
|
||||
"message": .string("Zero volume capacity reported"),
|
||||
"level": .string("warning")
|
||||
])
|
||||
|
||||
return
|
||||
}
|
||||
@@ -149,7 +149,7 @@ struct Prune: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||
let transaction = Telemetry.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||
defer { transaction.finish() }
|
||||
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
@@ -177,13 +177,16 @@ struct Prune: AsyncParsableCommand {
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||
Telemetry.addEvent("Prune.prunable", attributes: [
|
||||
"path": .string(prunable.url.path),
|
||||
"size_bytes": .int(try prunable.allocatedSizeBytes())
|
||||
])
|
||||
|
||||
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||
|
||||
try prunable.delete()
|
||||
}
|
||||
|
||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||
Telemetry.setAttribute("gc_disk_reclaimed", .int(cacheReclaimedBytes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Darwin
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
import Sentry
|
||||
// Telemetry integration is encapsulated in Telemetry.swift
|
||||
import System
|
||||
|
||||
var vm: VM?
|
||||
@@ -116,10 +116,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
// Note that the valueName here should really be "path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only and synchronization options in the form of path[:options] (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
|
||||
""", discussion: """
|
||||
@@ -143,7 +139,7 @@ struct Run: AsyncParsableCommand {
|
||||
To work around this pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
|
||||
""", valueName: "path"), completion: .file())
|
||||
""", valueName: "path[:options]"), completion: .file())
|
||||
var disk: [String] = []
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -162,10 +158,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
var rosettaTag: String?
|
||||
|
||||
// Note that the valueName here should really be "[name:]path[:options]" instead of just "path",
|
||||
// see ArgumentParser issue[1] for more details.
|
||||
//
|
||||
// [1]: https://github.com/apple/swift-argument-parser/issues/761
|
||||
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options in the form of [name:]path[:options] (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
|
||||
|
||||
@@ -178,7 +170,7 @@ struct Run: AsyncParsableCommand {
|
||||
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
|
||||
|
||||
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||
""", valueName: "path"), completion: .directory)
|
||||
""", valueName: "[name:]path[:options]"), completion: .directory)
|
||||
var dir: [String] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||
@@ -506,9 +498,9 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
// Record the error into OpenTelemetry
|
||||
Telemetry.recordError(error)
|
||||
Telemetry.flush()
|
||||
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable {
|
||||
case .raw:
|
||||
return true
|
||||
case .asif:
|
||||
if #available(macOS 15, *) {
|
||||
if #available(macOS 26, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
|
||||
+7
-36
@@ -1,7 +1,7 @@
|
||||
import ArgumentParser
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Sentry
|
||||
// Telemetry integration is encapsulated in Telemetry.swift
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -54,38 +54,9 @@ struct Root: AsyncParsableCommand {
|
||||
// Parse command
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// 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) }
|
||||
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Initialize OpenTelemetry if configured
|
||||
Telemetry.bootstrapFromEnv()
|
||||
defer { Telemetry.flush() }
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||
@@ -108,9 +79,9 @@ struct Root: AsyncParsableCommand {
|
||||
Foundation.exit(execCustomExitCodeError.exitCode)
|
||||
}
|
||||
|
||||
// Capture the error into Sentry
|
||||
SentrySDK.capture(error: error)
|
||||
SentrySDK.flush(timeout: 2.seconds.timeInterval)
|
||||
// Record the error into OpenTelemetry
|
||||
Telemetry.recordError(error)
|
||||
Telemetry.flush()
|
||||
|
||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||
if let errorWithExitCode = error as? HasExitCode {
|
||||
|
||||
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
|
||||
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||
}
|
||||
|
||||
func completeMachines(_ arguments: [String]) -> [String] {
|
||||
func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
|
||||
return (localVMs + ociVMs)
|
||||
}
|
||||
|
||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs.map { name, _ in normalizeName(name) }
|
||||
}
|
||||
|
||||
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||
func completeRunningMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
return localVMs
|
||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import OpenTelemetryApi
|
||||
import OpenTelemetrySdk
|
||||
import OpenTelemetryProtocolExporterCommon
|
||||
import OpenTelemetryProtocolExporterGrpc
|
||||
import OpenTelemetryProtocolExporterHttp
|
||||
import GRPC
|
||||
import NIO
|
||||
|
||||
enum TelemetrySpanStatus { case cancelled }
|
||||
|
||||
final class TelemetrySpan {
|
||||
private let span: Span
|
||||
|
||||
init(_ span: Span) { self.span = span }
|
||||
|
||||
func finish(status: TelemetrySpanStatus? = nil) {
|
||||
if let status = status {
|
||||
switch status {
|
||||
case .cancelled:
|
||||
span.status = .error(description: "cancelled")
|
||||
}
|
||||
}
|
||||
span.end()
|
||||
if Telemetry.currentSpan === span {
|
||||
Telemetry.currentSpan = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Telemetry {
|
||||
static var tracer: Tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version)
|
||||
static var currentSpan: Span?
|
||||
private static var eventLoopGroup: EventLoopGroup?
|
||||
private static var providerSdk: TracerProviderSdk?
|
||||
|
||||
// Configure OpenTelemetry when OTEL_EXPORTER_OTLP_ENDPOINT is set.
|
||||
static func bootstrapFromEnv() {
|
||||
guard let endpoint = ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_ENDPOINT"], !endpoint.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let resource = buildResource()
|
||||
|
||||
// Build exporter configuration
|
||||
let headerList = parseHeaders(ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_HEADERS"]) // [(k,v)]
|
||||
|
||||
// Build exporter based on endpoint scheme
|
||||
var exporter: SpanExporter
|
||||
if endpoint.lowercased().hasPrefix("http://") || endpoint.lowercased().hasPrefix("https://") {
|
||||
let url = URL(string: endpoint)!
|
||||
let config = OtlpConfiguration(timeout: 10, headers: headerList, exportAsJson: false)
|
||||
exporter = OtlpHttpTraceExporter(endpoint: url, config: config, envVarHeaders: nil)
|
||||
} else {
|
||||
// gRPC: parse host[:port]
|
||||
let parts = endpoint.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
let host = String(parts.first!)
|
||||
let port = parts.count > 1 ? Int(parts[1]) ?? 4317 : 4317
|
||||
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
eventLoopGroup = group
|
||||
let channel = ClientConnection.insecure(group: group).connect(host: host, port: port)
|
||||
let config = OtlpConfiguration(timeout: 10, headers: headerList, exportAsJson: false)
|
||||
exporter = OtlpTraceExporter(channel: channel, config: config, envVarHeaders: nil)
|
||||
}
|
||||
|
||||
let spanProcessor = BatchSpanProcessor(spanExporter: exporter)
|
||||
let provider = TracerProviderBuilder()
|
||||
.add(spanProcessor: spanProcessor)
|
||||
.with(resource: resource)
|
||||
.build()
|
||||
|
||||
providerSdk = provider
|
||||
OpenTelemetry.registerTracerProvider(tracerProvider: provider)
|
||||
tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version)
|
||||
}
|
||||
|
||||
// Flush spans quickly on shutdown
|
||||
static func flush() {
|
||||
providerSdk?.forceFlush(timeout: 5)
|
||||
if let group = eventLoopGroup {
|
||||
try? group.syncShutdownGracefully()
|
||||
eventLoopGroup = nil
|
||||
}
|
||||
}
|
||||
|
||||
static func startTransaction(name: String, operation: String? = nil, bindToScope: Bool = false) -> TelemetrySpan {
|
||||
let builder = tracer.spanBuilder(spanName: name)
|
||||
if let op = operation {
|
||||
builder.setSpanKind(spanKind: .internal)
|
||||
builder.setAttribute(key: "operation", value: op)
|
||||
}
|
||||
let span = builder.startSpan()
|
||||
if bindToScope {
|
||||
currentSpan = span
|
||||
}
|
||||
return TelemetrySpan(span)
|
||||
}
|
||||
|
||||
static func recordError(_ error: Error) {
|
||||
let span = currentSpan ?? tracer.spanBuilder(spanName: "error").startSpan()
|
||||
span.recordException(error)
|
||||
span.status = .error(description: String(describing: error))
|
||||
if currentSpan == nil {
|
||||
span.end()
|
||||
}
|
||||
}
|
||||
|
||||
static func addEvent(_ name: String, attributes: [String: AttributeValue] = [:]) {
|
||||
currentSpan?.addEvent(name: name, attributes: attributes)
|
||||
}
|
||||
|
||||
static func setAttribute(_ key: String, _ value: AttributeValue) {
|
||||
currentSpan?.setAttribute(key: key, value: value)
|
||||
}
|
||||
|
||||
// Build a Resource with service + environment tags
|
||||
private static func buildResource() -> Resource {
|
||||
var attributes: [String: AttributeValue] = [
|
||||
"service.name": .string("tart"),
|
||||
"service.version": .string(CI.version),
|
||||
"process.command_args": AttributeValue(ProcessInfo.processInfo.arguments)
|
||||
]
|
||||
|
||||
// Migrate Sentry tags to resource attributes if present
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
for (k, v) in parseTags(tags) {
|
||||
attributes[k] = .string(v)
|
||||
}
|
||||
}
|
||||
|
||||
return Resource(attributes: attributes)
|
||||
}
|
||||
|
||||
private static func parseTags(_ raw: String) -> [(String, String)] {
|
||||
raw.split(separator: ",").compactMap { pair in
|
||||
let parts = pair.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else { return nil }
|
||||
return (String(parts[0]), String(parts[1]))
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseHeaders(_ raw: String?) -> [(String, String)] {
|
||||
guard let raw = raw else { return [] }
|
||||
var result: [(String, String)] = []
|
||||
for part in raw.split(separator: ",") {
|
||||
let kv = part.split(separator: "=", maxSplits: 1)
|
||||
guard kv.count == 2 else { continue }
|
||||
result.append((String(kv[0]), String(kv[1])))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
// Telemetry integration is encapsulated in Telemetry.swift
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
@@ -43,7 +43,7 @@ extension VMDirectory {
|
||||
}
|
||||
|
||||
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||
Telemetry.setAttribute("compressed_disk_size", .int(Int(diskCompressedSize)))
|
||||
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
// Telemetry integration is encapsulated in Telemetry.swift
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
@@ -27,12 +27,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
return digest
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
try vmDir.baseURL.updateAccessDate(accessDate)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
@@ -141,9 +141,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
// Record image name for diagnostics
|
||||
Telemetry.addEvent("OCI.pull.start", attributes: [
|
||||
"imageName": .string(name.description)
|
||||
])
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
@@ -177,18 +178,22 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let transaction = Telemetry.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||
|
||||
// Open an existing VM directory corresponding to this name, if any,
|
||||
// marking it as outdated to speed up the garbage collection process
|
||||
_ = try? open(name, Date(timeIntervalSince1970: 0))
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||
}
|
||||
Telemetry.addEvent("OCI.pull.uncompressed_size", attributes: [
|
||||
"bytes": .int(Int(uncompressedDiskSize))
|
||||
])
|
||||
|
||||
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||
|
||||
@@ -223,7 +228,7 @@ class VMStorageOCI: PrunableStorage {
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
transaction.finish(status: SentrySpanStatus.cancelled)
|
||||
transaction.finish(status: .cancelled)
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -2,13 +2,9 @@ import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DiskImageFormatTests: XCTestCase {
|
||||
func testRawFormatIsAlwaysSupported() throws {
|
||||
XCTAssertTrue(DiskImageFormat.raw.isSupported)
|
||||
}
|
||||
|
||||
func testASIFFormatSupport() throws {
|
||||
// ASIF should be supported on macOS 15+
|
||||
if #available(macOS 15, *) {
|
||||
// ASIF should be supported on macOS 26+
|
||||
if #available(macOS 26, *) {
|
||||
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||
} else {
|
||||
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||
|
||||
@@ -166,6 +166,8 @@ Tart does have an analogue of Anka Controller for managing VMs across a cluster
|
||||
|
||||
In case there's not enough space to fit the newly pulled or cloned VM image, Tart will remove the least recently accessed VMs from OCI cache and `.ipsw` files from IPSW cache until enough free space is available.
|
||||
|
||||
The `tart clone` command limits this automatic pruning to 100 GB by default to avoid removing too many cached items. You can change this limit with the `--prune-limit` option (in gigabytes).
|
||||
|
||||
To disable this functionality, set the `TART_NO_AUTO_PRUNE` environment variable either globally:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -46,6 +46,7 @@ plugins:
|
||||
match_path: blog/posts/.*
|
||||
date_from_meta:
|
||||
as_creation: date
|
||||
abstract_chars_count: -1
|
||||
- social:
|
||||
cards_layout_dir: docs/layouts
|
||||
cards_layout: custom
|
||||
|
||||
Reference in New Issue
Block a user