Compare commits

..
Author SHA1 Message Date
Fedor Korotkov a65ab5a712 Claude Code Review workflow 2025-07-10 20:23:26 +04:00
Fedor Korotkov 3f7b6d2a61 Claude PR Assistant workflow 2025-07-10 20:23:25 +04:00
16 changed files with 244 additions and 308 deletions
+78
View File
@@ -0,0 +1,78 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
# use_sticky_comment: true
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')
+64
View File
@@ -0,0 +1,64 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
+10 -37
View File
@@ -1,5 +1,5 @@
{
"originHash" : "aa0a5df26b9e35d1908d6876d045af7ce1899086d641507e5faa9d1f9bd29787",
"originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1",
"pins" : [
{
"identity" : "antlr4",
@@ -46,24 +46,6 @@
"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",
@@ -73,6 +55,15 @@
"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",
@@ -136,15 +127,6 @@
"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",
@@ -279,15 +261,6 @@
"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
+2 -7
View File
@@ -17,7 +17,7 @@ let package = Package(
.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/open-telemetry/opentelemetry-swift", from: "1.7.0"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"),
.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,7 +25,6 @@ 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: [
@@ -36,11 +35,7 @@ let package = Package(
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.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: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
.product(name: "Sysctl", package: "swift-sysctl"),
.product(name: "SwiftRadix", package: "SwiftRadix"),
+2 -7
View File
@@ -31,9 +31,6 @@ 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")
@@ -79,10 +76,8 @@ 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()
// 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)
if unallocatedBytes > 0 {
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
}
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+12 -57
View File
@@ -87,13 +87,11 @@ struct Exec: AsyncParsableCommand {
$0.args = Array(command.dropFirst(1))
$0.interactive = interactive
$0.tty = tty
if tty {
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$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)
}
})
})
@@ -102,61 +100,28 @@ struct Exec: AsyncParsableCommand {
try await withThrowingTaskGroup { group in
// Stream host's standard input if interactive mode is enabled
if interactive {
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
let stdinStream = AsyncStream<Data> { continuation in
let handle = FileHandle.standardInput
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
handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
continuation.finish()
} else {
continuation.yield(data)
}
continuation.yield(data)
if data.isEmpty {
continuation.finish()
}
}
}
group.addTask {
for try await stdinData in stdinStream {
for 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()
})
})
}
}
@@ -213,13 +178,3 @@ 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
}
+1
View File
@@ -2,6 +2,7 @@ import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
case dhcp, arp, agent
+17 -20
View File
@@ -1,6 +1,6 @@
import ArgumentParser
import Dispatch
// Telemetry integration is encapsulated in Telemetry.swift
import Sentry
import SwiftUI
import SwiftDate
@@ -109,10 +109,9 @@ struct Prune: AsyncParsableCommand {
return
}
// Record desired reclaim size as an event context
Telemetry.addEvent("Prune.required", attributes: [
"requiredBytes": .int(Int(requiredBytes))
])
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
// Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
@@ -124,17 +123,18 @@ struct Prune: AsyncParsableCommand {
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
)
Telemetry.addEvent("Prune.capacity", attributes: [
"volumeAvailableCapacity": .int(Int(attrs.volumeAvailableCapacity!)),
"volumeAvailableCapacityForImportantUsage": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)),
"volumeAvailableCapacityCalculated": .int(Int(volumeAvailableCapacityCalculated))
])
SentrySDK.configureScope { scope in
scope.setContext(value: [
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
], key: "Prune")
}
if volumeAvailableCapacityCalculated <= 0 {
Telemetry.addEvent("Prune.warning", attributes: [
"message": .string("Zero volume capacity reported"),
"level": .string("warning")
])
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
scope.setLevel(.warning)
}
return
}
@@ -149,7 +149,7 @@ struct Prune: AsyncParsableCommand {
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = Telemetry.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
@@ -177,16 +177,13 @@ struct Prune: AsyncParsableCommand {
continue
}
Telemetry.addEvent("Prune.prunable", attributes: [
"path": .string(prunable.url.path),
"size_bytes": .int(try prunable.allocatedSizeBytes())
])
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
try prunable.delete()
}
Telemetry.setAttribute("gc_disk_reclaimed", .int(cacheReclaimedBytes))
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
}
}
+4 -4
View File
@@ -4,7 +4,7 @@ import Darwin
import Dispatch
import SwiftUI
import Virtualization
// Telemetry integration is encapsulated in Telemetry.swift
import Sentry
import System
var vm: VM?
@@ -498,9 +498,9 @@ struct Run: AsyncParsableCommand {
Foundation.exit(0)
} catch {
// Record the error into OpenTelemetry
Telemetry.recordError(error)
Telemetry.flush()
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
fputs("\(error)\n", stderr)
+1 -1
View File
@@ -21,7 +21,7 @@ enum DiskImageFormat: String, CaseIterable, Codable {
case .raw:
return true
case .asif:
if #available(macOS 26, *) {
if #available(macOS 15, *) {
return true
} else {
return false
+36 -7
View File
@@ -1,7 +1,7 @@
import ArgumentParser
import Darwin
import Foundation
// Telemetry integration is encapsulated in Telemetry.swift
import Sentry
@main
struct Root: AsyncParsableCommand {
@@ -54,9 +54,38 @@ struct Root: AsyncParsableCommand {
// Parse command
var command = try parseAsRoot()
// Initialize OpenTelemetry if configured
Telemetry.bootstrapFromEnv()
defer { Telemetry.flush() }
// 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)
}
}
}
// Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
@@ -79,9 +108,9 @@ struct Root: AsyncParsableCommand {
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Record the error into OpenTelemetry
Telemetry.recordError(error)
Telemetry.flush()
// 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 {
-152
View File
@@ -1,152 +0,0 @@
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
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import Compression
import Foundation
// Telemetry integration is encapsulated in Telemetry.swift
import Sentry
enum OCIError: Error {
case ShouldBeExactlyOneLayer
@@ -43,7 +43,7 @@ extension VMDirectory {
}
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
Telemetry.setAttribute("compressed_disk_size", .int(Int(diskCompressedSize)))
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
+9 -10
View File
@@ -1,5 +1,5 @@
import Foundation
// Telemetry integration is encapsulated in Telemetry.swift
import Sentry
import Retry
class VMStorageOCI: PrunableStorage {
@@ -141,10 +141,9 @@ class VMStorageOCI: PrunableStorage {
}
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
// Record image name for diagnostics
Telemetry.addEvent("OCI.pull.start", attributes: [
"imageName": .string(name.description)
])
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageName": name.description], key: "OCI")
}
defaultLogger.appendNewLine("pulling manifest...")
@@ -178,7 +177,7 @@ class VMStorageOCI: PrunableStorage {
}
if !exists(digestName) {
let transaction = Telemetry.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let transaction = SentrySDK.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,
@@ -191,9 +190,9 @@ class VMStorageOCI: PrunableStorage {
// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
Telemetry.addEvent("OCI.pull.uncompressed_size", attributes: [
"bytes": .int(Int(uncompressedDiskSize))
])
SentrySDK.configureScope { scope in
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
}
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
@@ -228,7 +227,7 @@ class VMStorageOCI: PrunableStorage {
try move(digestName, from: tmpVMDir)
transaction.finish()
}, onCancel: {
transaction.finish(status: .cancelled)
transaction.finish(status: SentrySpanStatus.cancelled)
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
} else {
+6 -2
View File
@@ -2,9 +2,13 @@ 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 26+
if #available(macOS 26, *) {
// ASIF should be supported on macOS 15+
if #available(macOS 15, *) {
XCTAssertTrue(DiskImageFormat.asif.isSupported)
} else {
XCTAssertFalse(DiskImageFormat.asif.isSupported)
-2
View File
@@ -166,8 +166,6 @@ 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