From 6bcc8c8af79109f3b382d40e1af30729f8824b54 Mon Sep 17 00:00:00 2001 From: ori0o0p Date: Mon, 16 Feb 2026 20:35:20 +0900 Subject: [PATCH 1/2] feat(diagnostics): add socket permission and network reachability checks --- .../Zero/Services/DiagnosticsService.swift | 111 +++++++++++++++++- Sources/Zero/Views/DiagnosticsView.swift | 28 +++++ Tests/ZeroTests/DiagnosticsServiceTests.swift | 67 ++++++++++- 3 files changed, 201 insertions(+), 5 deletions(-) diff --git a/Sources/Zero/Services/DiagnosticsService.swift b/Sources/Zero/Services/DiagnosticsService.swift index 161a179..4a803a1 100644 --- a/Sources/Zero/Services/DiagnosticsService.swift +++ b/Sources/Zero/Services/DiagnosticsService.swift @@ -1,11 +1,20 @@ 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 } @@ -13,31 +22,40 @@ struct DiagnosticsSnapshot { 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))" ) @@ -55,6 +73,7 @@ struct DiagnosticsService { arguments: ["ps", "--format", "{{.Names}}"] ) let runningContainers = parseLines(runningContainersOutput) + let socketStatus = dockerSocketStatus(isDockerInstalled: true, isDockerDaemonRunning: true, daemonErrorMessage: nil) return DiagnosticsSnapshot( checkedAt: checkedAt, @@ -62,22 +81,58 @@ struct DiagnosticsService { 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) @@ -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 + } } diff --git a/Sources/Zero/Views/DiagnosticsView.swift b/Sources/Zero/Views/DiagnosticsView.swift index 2e54ab6..71242b1 100644 --- a/Sources/Zero/Views/DiagnosticsView.swift +++ b/Sources/Zero/Views/DiagnosticsView.swift @@ -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) @@ -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)) diff --git a/Tests/ZeroTests/DiagnosticsServiceTests.swift b/Tests/ZeroTests/DiagnosticsServiceTests.swift index 37e668f..2d6624f 100644 --- a/Tests/ZeroTests/DiagnosticsServiceTests.swift +++ b/Tests/ZeroTests/DiagnosticsServiceTests.swift @@ -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 @@ -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, [ @@ -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() @@ -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"]]) @@ -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() @@ -73,6 +96,10 @@ 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, [ @@ -80,6 +107,40 @@ final class DiagnosticsServiceTests: XCTestCase { ["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 { From 0f67abef64b58517061eae70bcde18bf0d03d32f Mon Sep 17 00:00:00 2001 From: ori0o0p Date: Mon, 16 Feb 2026 20:35:29 +0900 Subject: [PATCH 2/2] feat(logs): include diagnostics network and socket details in export --- Sources/Zero/Services/LogExportService.swift | 4 ++++ Tests/ZeroTests/LogExportServiceTests.swift | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/Sources/Zero/Services/LogExportService.swift b/Sources/Zero/Services/LogExportService.swift index 3816d08..5860f3c 100644 --- a/Sources/Zero/Services/LogExportService.swift +++ b/Sources/Zero/Services/LogExportService.swift @@ -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: ", ") diff --git a/Tests/ZeroTests/LogExportServiceTests.swift b/Tests/ZeroTests/LogExportServiceTests.swift index 133edea..809c05f 100644 --- a/Tests/ZeroTests/LogExportServiceTests.swift +++ b/Tests/ZeroTests/LogExportServiceTests.swift @@ -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" ) @@ -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"))