diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift index ff6349579..6e83c0bab 100644 --- a/Sources/ContainerCommands/System/SystemStatus.swift +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -42,7 +42,8 @@ extension Application { public init() {} public func run() async throws { - let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") + let domain = try ServiceManager.getDomainString() + let isRegistered = (try? ServiceManager.isRegistered(fullServiceLabel: "\(domain)/\(prefix)apiserver")) ?? true if !isRegistered { try Output.render(payload: StatusPayload(status: "unregistered"), format: format) { "apiserver is not running and not registered with launchd" diff --git a/Sources/ContainerPlugin/ServiceManager.swift b/Sources/ContainerPlugin/ServiceManager.swift index 4d2c36246..8abc16304 100644 --- a/Sources/ContainerPlugin/ServiceManager.swift +++ b/Sources/ContainerPlugin/ServiceManager.swift @@ -18,6 +18,10 @@ import ContainerizationError import Foundation public struct ServiceManager { + enum LaunchctlStatus { + static let noSuchService: Int32 = 113 + } + private static func runLaunchctlCommand(args: [String]) throws -> Int32 { let launchctl = Foundation.Process() launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") @@ -94,8 +98,40 @@ public struct ServiceManager { /// Check if a service has been registered or not. public static func isRegistered(fullServiceLabel label: String) throws -> Bool { - let exitStatus = try runLaunchctlCommand(args: ["list", label]) - return exitStatus == 0 + let result = try runLaunchctlPrint(target: label) + return try Self.interpretPrintStatus(result.status, target: label, standardError: result.standardError) + } + + private static func runLaunchctlPrint(target: String) throws -> (status: Int32, standardError: String) { + let launchctl = Foundation.Process() + launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") + launchctl.arguments = ["print", target] + + let stderrPipe = Pipe() + launchctl.standardOutput = FileHandle.nullDevice + launchctl.standardError = stderrPipe + + try launchctl.run() + let errorData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + launchctl.waitUntilExit() + + return (launchctl.terminationStatus, String(decoding: errorData, as: UTF8.self)) + } + + static func interpretPrintStatus(_ status: Int32, target: String = "", standardError: String = "") throws -> Bool { + switch status { + case 0: + return true + case LaunchctlStatus.noSuchService: + return false + default: + var message = "command `launchctl print \(target)` failed with status \(status)" + let details = standardError.trimmingCharacters(in: .whitespacesAndNewlines) + if !details.isEmpty { + message += ", message: \(details)" + } + throw ContainerizationError(.internalError, message: message) + } } private static func getLaunchdSessionType() throws -> String { diff --git a/Tests/ContainerPluginTests/ServiceManagerTests.swift b/Tests/ContainerPluginTests/ServiceManagerTests.swift new file mode 100644 index 000000000..890aa9379 --- /dev/null +++ b/Tests/ContainerPluginTests/ServiceManagerTests.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerPlugin + +struct ServiceManagerTests { + @Test + func testInterpretPrintStatusSuccessMeansRegistered() throws { + #expect(try ServiceManager.interpretPrintStatus(0)) + } + + @Test + func testInterpretPrintStatusNoSuchServiceMeansUnregistered() throws { + #expect(try !ServiceManager.interpretPrintStatus(ServiceManager.LaunchctlStatus.noSuchService)) + } + + @Test + func testInterpretPrintStatusUnexpectedStatusThrows() throws { + let statuses: [Int32] = [1, 3, 64, 112] + for status in statuses { + #expect { + _ = try ServiceManager.interpretPrintStatus( + status, + target: "gui/501/com.apple.container.apiserver", + standardError: "marker" + ) + } throws: { error in + "\(error)".contains("status \(status), message: marker") + } + } + } + + @Test + func testIsRegisteredUnknownLabel() throws { + let domain = try ServiceManager.getDomainString() + let label = "\(domain)/com.apple.container.bogus-\(UUID().uuidString)" + #expect(try !ServiceManager.isRegistered(fullServiceLabel: label)) + } +}