Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 109 additions & 2 deletions Sources/Zero/Services/DiagnosticsService.swift
Original file line number Diff line number Diff line change
@@ -1,43 +1,61 @@
import Foundation

struct DiagnosticsNetworkStatus {
let isReachable: Bool
let message: String
}

struct DiagnosticsSnapshot {
let checkedAt: Date
let dockerPath: String
let isDockerInstalled: Bool
let dockerVersion: String?
let isDockerDaemonRunning: Bool
let isDockerSocketAccessible: Bool
let dockerSocketStatusMessage: String
let isNetworkReachable: Bool
let networkStatusMessage: String
let runningContainers: [String]
let dockerStatusMessage: String
}

struct DiagnosticsService {
private let runner: CommandRunning
private let now: () -> Date
private let networkStatusProvider: () -> DiagnosticsNetworkStatus

let dockerPath: String

init(
runner: CommandRunning = CommandRunner(),
dockerPath: String? = nil,
now: @escaping () -> Date = Date.init
now: @escaping () -> Date = Date.init,
networkStatusProvider: @escaping () -> DiagnosticsNetworkStatus = Self.defaultNetworkStatusProvider
) {
self.runner = runner
self.dockerPath = dockerPath ?? Self.resolveDockerPath()
self.now = now
self.networkStatusProvider = networkStatusProvider
}

func collectSnapshot() -> DiagnosticsSnapshot {
let checkedAt = now()
let networkStatus = networkStatusProvider()

do {
_ = try runner.execute(command: dockerPath, arguments: ["--version"])
} catch {
let socketStatus = dockerSocketStatus(isDockerInstalled: false, isDockerDaemonRunning: false, daemonErrorMessage: nil)
return DiagnosticsSnapshot(
checkedAt: checkedAt,
dockerPath: dockerPath,
isDockerInstalled: false,
dockerVersion: nil,
isDockerDaemonRunning: false,
isDockerSocketAccessible: socketStatus.isAccessible,
dockerSocketStatusMessage: socketStatus.message,
isNetworkReachable: networkStatus.isReachable,
networkStatusMessage: networkStatus.message,
runningContainers: [],
dockerStatusMessage: "Docker CLI not found: \(errorMessage(error))"
)
Expand All @@ -55,29 +73,66 @@ struct DiagnosticsService {
arguments: ["ps", "--format", "{{.Names}}"]
)
let runningContainers = parseLines(runningContainersOutput)
let socketStatus = dockerSocketStatus(isDockerInstalled: true, isDockerDaemonRunning: true, daemonErrorMessage: nil)

return DiagnosticsSnapshot(
checkedAt: checkedAt,
dockerPath: dockerPath,
isDockerInstalled: true,
dockerVersion: serverVersion.isEmpty ? nil : serverVersion,
isDockerDaemonRunning: true,
isDockerSocketAccessible: socketStatus.isAccessible,
dockerSocketStatusMessage: socketStatus.message,
isNetworkReachable: networkStatus.isReachable,
networkStatusMessage: networkStatus.message,
runningContainers: runningContainers,
dockerStatusMessage: "Docker is ready"
)
} catch {
let daemonErrorMessage = errorMessage(error)
let socketStatus = dockerSocketStatus(
isDockerInstalled: true,
isDockerDaemonRunning: false,
daemonErrorMessage: daemonErrorMessage
)

return DiagnosticsSnapshot(
checkedAt: checkedAt,
dockerPath: dockerPath,
isDockerInstalled: true,
dockerVersion: nil,
isDockerDaemonRunning: false,
isDockerSocketAccessible: socketStatus.isAccessible,
dockerSocketStatusMessage: socketStatus.message,
isNetworkReachable: networkStatus.isReachable,
networkStatusMessage: networkStatus.message,
runningContainers: [],
dockerStatusMessage: "Docker daemon is not reachable: \(errorMessage(error))"
dockerStatusMessage: "Docker daemon is not reachable: \(daemonErrorMessage)"
)
}
}

private func dockerSocketStatus(
isDockerInstalled: Bool,
isDockerDaemonRunning: Bool,
daemonErrorMessage: String?
) -> (isAccessible: Bool, message: String) {
guard isDockerInstalled else {
return (false, "Docker CLI unavailable; socket access not checked")
}

if isDockerDaemonRunning {
return (true, "Docker socket access is available")
}

if let daemonErrorMessage,
daemonErrorMessage.lowercased().contains("permission denied") {
return (false, "Docker socket permission denied")
}

return (false, "Docker socket access could not be verified")
}

private func parseLines(_ output: String) -> [String] {
output
.split(whereSeparator: \.isNewline)
Expand All @@ -104,4 +159,56 @@ struct DiagnosticsService {
return possiblePaths.first(where: { FileManager.default.fileExists(atPath: $0) })
?? "/usr/local/bin/docker"
}

private static func defaultNetworkStatusProvider() -> DiagnosticsNetworkStatus {
guard let url = URL(string: "https://api.github.com/meta") else {
return DiagnosticsNetworkStatus(isReachable: false, message: "Network check failed: invalid URL")
}

var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 3

let semaphore = DispatchSemaphore(value: 0)
let lock = NSLock()
var result = DiagnosticsNetworkStatus(isReachable: false, message: "Network check timed out")

let task = URLSession.shared.dataTask(with: request) { _, response, error in
defer { semaphore.signal() }

let nextResult: DiagnosticsNetworkStatus
if let error {
nextResult = DiagnosticsNetworkStatus(
isReachable: false,
message: "Network unreachable: \(error.localizedDescription)"
)
} else if let httpResponse = response as? HTTPURLResponse,
(200..<500).contains(httpResponse.statusCode) {
nextResult = DiagnosticsNetworkStatus(
isReachable: true,
message: "Network reachable (HTTP \(httpResponse.statusCode))"
)
} else {
nextResult = DiagnosticsNetworkStatus(
isReachable: false,
message: "Network unreachable: unexpected response"
)
}

lock.lock()
result = nextResult
lock.unlock()
}

task.resume()

if semaphore.wait(timeout: .now() + 4) == .timedOut {
task.cancel()
return DiagnosticsNetworkStatus(isReachable: false, message: "Network check timed out")
}

lock.lock()
defer { lock.unlock() }
return result
}
}
4 changes: 4 additions & 0 deletions Sources/Zero/Services/LogExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ final class LogExportService {
sections.append("Docker Path: \(snapshot.dockerPath)")
sections.append("Docker Installed: \(snapshot.isDockerInstalled ? "yes" : "no")")
sections.append("Docker Daemon Running: \(snapshot.isDockerDaemonRunning ? "yes" : "no")")
sections.append("Docker Socket Access: \(snapshot.isDockerSocketAccessible ? "yes" : "no")")
if let dockerVersion = snapshot.dockerVersion {
sections.append("Docker Version: \(dockerVersion)")
}
sections.append("Docker Socket Message: \(snapshot.dockerSocketStatusMessage)")
sections.append("Network Reachable: \(snapshot.isNetworkReachable ? "yes" : "no")")
sections.append("Network Message: \(snapshot.networkStatusMessage)")
let containers = snapshot.runningContainers.isEmpty
? "none"
: snapshot.runningContainers.joined(separator: ", ")
Expand Down
28 changes: 28 additions & 0 deletions Sources/Zero/Views/DiagnosticsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ struct DiagnosticsView: View {
isHealthy: snapshot.isDockerDaemonRunning
)

DiagnosticsStatusRow(
title: "Socket Permission",
value: snapshot.isDockerSocketAccessible ? "Granted" : "Unavailable",
isHealthy: snapshot.isDockerSocketAccessible
)

Text("Docker Path: \(snapshot.dockerPath)")
.font(.caption)
.foregroundStyle(.secondary)
Expand All @@ -109,6 +115,28 @@ struct DiagnosticsView: View {
Text(snapshot.dockerStatusMessage)
.font(.caption)
.foregroundStyle(snapshot.isDockerDaemonRunning ? Color.secondary : Color.red)

Text(snapshot.dockerSocketStatusMessage)
.font(.caption)
.foregroundStyle(snapshot.isDockerSocketAccessible ? Color.secondary : Color.red)
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)

VStack(alignment: .leading, spacing: 10) {
Text("Network")
.font(.headline)

DiagnosticsStatusRow(
title: "Reachability",
value: snapshot.isNetworkReachable ? "Reachable" : "Unavailable",
isHealthy: snapshot.isNetworkReachable
)

Text(snapshot.networkStatusMessage)
.font(.caption)
.foregroundStyle(snapshot.isNetworkReachable ? Color.secondary : Color.red)
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
Expand Down
67 changes: 64 additions & 3 deletions Tests/ZeroTests/DiagnosticsServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ final class DiagnosticsServiceTests: XCTestCase {
let service = DiagnosticsService(
runner: mockRunner,
dockerPath: "/opt/homebrew/bin/docker",
now: { fixedDate }
now: { fixedDate },
networkStatusProvider: {
DiagnosticsNetworkStatus(isReachable: true, message: "Network reachable")
}
)

// When
Expand All @@ -25,6 +28,10 @@ final class DiagnosticsServiceTests: XCTestCase {
XCTAssertTrue(snapshot.isDockerInstalled)
XCTAssertEqual(snapshot.dockerVersion, "27.0.1")
XCTAssertTrue(snapshot.isDockerDaemonRunning)
XCTAssertTrue(snapshot.isDockerSocketAccessible)
XCTAssertEqual(snapshot.dockerSocketStatusMessage, "Docker socket access is available")
XCTAssertTrue(snapshot.isNetworkReachable)
XCTAssertEqual(snapshot.networkStatusMessage, "Network reachable")
XCTAssertEqual(snapshot.runningContainers, ["zero-dev", "zero-lsp-java"])
XCTAssertEqual(snapshot.dockerStatusMessage, "Docker is ready")
XCTAssertEqual(mockRunner.executedCommands, [
Expand All @@ -44,7 +51,13 @@ final class DiagnosticsServiceTests: XCTestCase {
let mockRunner = DiagnosticsMockCommandRunner(responses: [
.failure(NSError(domain: "Test", code: 127, userInfo: [NSLocalizedDescriptionKey: "No such file or directory"]))
])
let service = DiagnosticsService(runner: mockRunner, dockerPath: "/missing/docker")
let service = DiagnosticsService(
runner: mockRunner,
dockerPath: "/missing/docker",
networkStatusProvider: {
DiagnosticsNetworkStatus(isReachable: false, message: "Network unreachable")
}
)

// When
let snapshot = service.collectSnapshot()
Expand All @@ -53,6 +66,10 @@ final class DiagnosticsServiceTests: XCTestCase {
XCTAssertFalse(snapshot.isDockerInstalled)
XCTAssertNil(snapshot.dockerVersion)
XCTAssertFalse(snapshot.isDockerDaemonRunning)
XCTAssertFalse(snapshot.isDockerSocketAccessible)
XCTAssertEqual(snapshot.dockerSocketStatusMessage, "Docker CLI unavailable; socket access not checked")
XCTAssertFalse(snapshot.isNetworkReachable)
XCTAssertEqual(snapshot.networkStatusMessage, "Network unreachable")
XCTAssertTrue(snapshot.runningContainers.isEmpty)
XCTAssertEqual(snapshot.dockerStatusMessage, "Docker CLI not found: No such file or directory")
XCTAssertEqual(mockRunner.executedArguments, [["--version"]])
Expand All @@ -64,7 +81,13 @@ final class DiagnosticsServiceTests: XCTestCase {
.success("Docker version 27.0.1, build deadbeef"),
.failure(NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot connect to the Docker daemon"]))
])
let service = DiagnosticsService(runner: mockRunner, dockerPath: "/usr/local/bin/docker")
let service = DiagnosticsService(
runner: mockRunner,
dockerPath: "/usr/local/bin/docker",
networkStatusProvider: {
DiagnosticsNetworkStatus(isReachable: true, message: "Network reachable")
}
)

// When
let snapshot = service.collectSnapshot()
Expand All @@ -73,13 +96,51 @@ final class DiagnosticsServiceTests: XCTestCase {
XCTAssertTrue(snapshot.isDockerInstalled)
XCTAssertNil(snapshot.dockerVersion)
XCTAssertFalse(snapshot.isDockerDaemonRunning)
XCTAssertFalse(snapshot.isDockerSocketAccessible)
XCTAssertEqual(snapshot.dockerSocketStatusMessage, "Docker socket access could not be verified")
XCTAssertTrue(snapshot.isNetworkReachable)
XCTAssertEqual(snapshot.networkStatusMessage, "Network reachable")
XCTAssertTrue(snapshot.runningContainers.isEmpty)
XCTAssertEqual(snapshot.dockerStatusMessage, "Docker daemon is not reachable: Cannot connect to the Docker daemon")
XCTAssertEqual(mockRunner.executedArguments, [
["--version"],
["info", "--format", "{{.ServerVersion}}"]
])
}

func testCollectSnapshotReportsDockerSocketPermissionDenied() {
// Given
let mockRunner = DiagnosticsMockCommandRunner(responses: [
.success("Docker version 27.0.1, build deadbeef"),
.failure(NSError(
domain: "Test",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"
]
))
])
let service = DiagnosticsService(
runner: mockRunner,
dockerPath: "/usr/local/bin/docker",
networkStatusProvider: {
DiagnosticsNetworkStatus(isReachable: true, message: "Network reachable")
}
)

// When
let snapshot = service.collectSnapshot()

// Then
XCTAssertTrue(snapshot.isDockerInstalled)
XCTAssertFalse(snapshot.isDockerDaemonRunning)
XCTAssertFalse(snapshot.isDockerSocketAccessible)
XCTAssertEqual(snapshot.dockerSocketStatusMessage, "Docker socket permission denied")
XCTAssertEqual(
snapshot.dockerStatusMessage,
"Docker daemon is not reachable: Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"
)
}
}

private final class DiagnosticsMockCommandRunner: CommandRunning {
Expand Down
6 changes: 6 additions & 0 deletions Tests/ZeroTests/LogExportServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ final class LogExportServiceTests: XCTestCase {
isDockerInstalled: true,
dockerVersion: "27.0.1",
isDockerDaemonRunning: true,
isDockerSocketAccessible: true,
dockerSocketStatusMessage: "Docker socket access is available",
isNetworkReachable: true,
networkStatusMessage: "Network reachable",
runningContainers: ["zero-dev", "zero-lsp-java"],
dockerStatusMessage: "Docker is ready"
)
Expand All @@ -30,6 +34,8 @@ final class LogExportServiceTests: XCTestCase {
XCTAssertTrue(bundle.contains("## Diagnostics Snapshot"))
XCTAssertTrue(bundle.contains("Docker Path: /opt/homebrew/bin/docker"))
XCTAssertTrue(bundle.contains("Docker Version: 27.0.1"))
XCTAssertTrue(bundle.contains("Docker Socket Access: yes"))
XCTAssertTrue(bundle.contains("Network Reachable: yes"))
XCTAssertTrue(bundle.contains("Running Containers: zero-dev, zero-lsp-java"))
XCTAssertTrue(bundle.contains("## Execution Output"))
XCTAssertTrue(bundle.contains("swift test"))
Expand Down