From d29947121fa512a6e6b518df76a00a5f970e8a15 Mon Sep 17 00:00:00 2001 From: J Logan Date: Thu, 19 Feb 2026 17:47:41 -0800 Subject: [PATCH] CI observability enhancements. (#1193) - Adds a a `--log-root` option to `swift system start`, propagating the value as `CONTAINER_LOG_ROOT` to services for logging to files instead of the OS log facility. This is not a "production" capability as it neither merges nor rotates logs. - Currently we don't collect logs on CI builds, and we don't have permission to run the `log` command there. The PR adds `--log-root` to the CI test phase, archives the results, and uploads the archive as an artifact. - Use FilePath from swift-system for the log root. Foundation URL is a bit of a footgun for filesystem paths, so unless we identify a showstopper, we should incrementally transition to this type everywhere except where we really need network URLs. - Output the hostname of the CI runner at the start of the test phase so we can identify runner-specific issues where they exist. - Fix formatting for log messages with multiple metadata items, and fix unstructured messages on instances that weren't found using `grep -r 'log\.' Sources`. - Adds command reference documentation for `--log-root`. --- .github/workflows/common.yml | 26 ++- Makefile | 3 + Package.swift | 34 ++-- Sources/ContainerCommands/Application.swift | 1 + .../Image/ImageInspect.swift | 8 +- .../Network/NetworkDelete.swift | 9 +- .../Network/NetworkPrune.swift | 7 +- .../System/SystemStart.swift | 14 +- .../Volume/VolumeDelete.swift | 9 +- Sources/ContainerLog/FileLogHandler.swift | 101 +++++++++++ Sources/ContainerLog/ServiceLogger.swift | 74 ++++++++ Sources/ContainerPlugin/LogRoot.swift | 39 +++++ Sources/ContainerPlugin/PluginLoader.swift | 11 ++ Sources/ContainerXPC/XPCServer.swift | 14 +- .../Helpers/APIServer/APIServer+Start.swift | 32 +++- Sources/Helpers/APIServer/APIServer.swift | 16 -- .../Helpers/APIServer/DirectoryWatcher.swift | 7 +- Sources/Helpers/Images/ImagesHelper.swift | 26 ++- .../NetworkVmnetHelper+Start.swift | 14 +- .../NetworkVmnet/NetworkVmnetHelper.swift | 17 -- .../RuntimeLinuxHelper+Start.swift | 13 +- .../RuntimeLinux/RuntimeLinuxHelper.swift | 23 --- .../Client/ClientHealthCheck.swift | 3 + .../Client/SystemHealth.swift | 4 + .../ContainerAPIService/Client/XPC+.swift | 1 + .../Server/Containers/ContainersService.swift | 49 +++++- .../HealthCheck/HealthCheckHarness.swift | 8 +- .../Server/Volumes/VolumesService.swift | 8 +- .../Subcommands/System/TestCLIStatus.swift | 165 ++++++++++++++++++ .../PluginLoaderTest.swift | 6 + docs/command-reference.md | 3 +- 31 files changed, 628 insertions(+), 117 deletions(-) create mode 100644 Sources/ContainerLog/FileLogHandler.swift create mode 100644 Sources/ContainerLog/ServiceLogger.swift create mode 100644 Sources/ContainerPlugin/LogRoot.swift create mode 100644 Tests/CLITests/Subcommands/System/TestCLIStatus.swift diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index d5dd808a..84178a51 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -71,16 +71,38 @@ jobs: - name: Test the container project run: | APP_ROOT=$(mktemp -d -p "${RUNNER_TEMP}") + LOG_ROOT="${APP_ROOT}/logs" trap 'rm -rf "${APP_ROOT}"; echo Removing data directory ${APP_ROOT}' EXIT - echo "Created data directory ${APP_ROOT}" + echo "created data directory: ${APP_ROOT}" + echo "hostname: $(hostname)" export NO_PROXY="${NO_PROXY},192.168.0.0/16,fe80::/10" echo NO_PROXY=${NO_PROXY} export no_proxy="${no_proxy},192.168.0.0/16,fe80::/10" echo no_proxy=${no_proxy} - make APP_ROOT="${APP_ROOT}" test install-kernel integration + echo "APP_ROOT=${APP_ROOT}" >> $GITHUB_ENV + echo "LOG_ROOT=${LOG_ROOT}" >> $GITHUB_ENV + make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration || status=$? + if [ -d "${LOG_ROOT}" ] ; then + echo "Collecting logs from ${LOG_ROOT}..." + tar czf container-logs.tar.gz -C "$(dirname "${LOG_ROOT}")" "$(basename "${LOG_ROOT}")" + echo "Log archive created: container-logs.tar.gz" + fi + if [ "${status:-0}" -ne "0" ] ; then + echo "Tests failed with status: ${status}" + exit ${status} + fi env: DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" + - name: Upload logs if present + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: container-test-logs + path: container-logs.tar.gz + retention-days: 14 + if-no-files-found: ignore + - name: Save documentation artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: diff --git a/Makefile b/Makefile index 97685129..0d22945e 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,9 @@ SYSTEM_START_OPTS := ifneq ($(strip $(APP_ROOT)),) SYSTEM_START_OPTS += --app-root "$(strip $(APP_ROOT))" endif +ifneq ($(strip $(LOG_ROOT)),) + SYSTEM_START_OPTS += --log-root "$(strip $(LOG_ROOT))" +endif MACOS_VERSION := $(shell sw_vers -productVersion) MACOS_MAJOR := $(shell echo $(MACOS_VERSION) | cut -d. -f1) diff --git a/Package.swift b/Package.swift index 16f2f3c0..88ca771f 100644 --- a/Package.swift +++ b/Package.swift @@ -46,17 +46,18 @@ let package = Package( .library(name: "TerminalProgress", targets: ["TerminalProgress"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), - .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), - .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), - .package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"), - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), - .package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"), .package(url: "https://github.com/Bouke/DNS.git", from: "1.2.0"), .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"), + .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), + .package(url: "https://github.com/orlandos-nl/DNSClient.git", from: "2.4.1"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"), ], targets: [ .executableTarget( @@ -153,6 +154,7 @@ let package = Package( .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), "CVersion", "ContainerAPIClient", "ContainerNetworkServiceClient", @@ -170,13 +172,14 @@ let package = Package( name: "ContainerAPIClient", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Logging", package: "swift-log"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "SystemPackage", package: "swift-system"), "ContainerImagesServiceClient", "ContainerPersistence", "ContainerPlugin", @@ -200,6 +203,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), + .product(name: "SystemPackage", package: "swift-system"), "ContainerImagesService", "ContainerLog", "ContainerPlugin", @@ -249,6 +253,7 @@ let package = Package( "ContainerLog", "ContainerNetworkService", "ContainerNetworkServiceClient", + "ContainerPlugin", "ContainerResource", "ContainerVersion", "ContainerXPC", @@ -295,6 +300,7 @@ let package = Package( .product(name: "GRPC", package: "grpc-swift"), .product(name: "Containerization", package: "containerization"), "ContainerLog", + "ContainerPlugin", "ContainerResource", "ContainerSandboxService", "ContainerSandboxServiceClient", @@ -350,7 +356,8 @@ let package = Package( .target( name: "ContainerLog", dependencies: [ - .product(name: "Logging", package: "swift-log") + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), ] ), .target( @@ -367,6 +374,7 @@ let package = Package( dependencies: [ .product(name: "Logging", package: "swift-log"), .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "SystemPackage", package: "swift-system"), "ContainerVersion", ] ), diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index ba057fb3..acf311c3 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -172,6 +172,7 @@ public struct Application: AsyncLoggableCommand { return try PluginLoader( appRoot: systemHealth.appRoot, installRoot: systemHealth.installRoot, + logRoot: systemHealth.logRoot, pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, log: bootstrapLogger diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift index 535940d4..dba80a32 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -63,9 +63,13 @@ extension Application { } if !allErrors.isEmpty { - let logger = Logger(label: "ImageInspect", factory: { _ in StderrLogHandler() }) for (name, error) in allErrors { - logger.error("\(name): \(error.localizedDescription)") + log.error( + "image inspect failed", + metadata: [ + "name": "\(name)", + "error": "\(error.localizedDescription)", + ]) } throw InspectError(succeeded: succeededImages, failed: allErrors) diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 97926673..87d710b6 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -89,7 +89,7 @@ extension Application { } var failed = [String]() - let logger = log + let _log = log try await withThrowingTaskGroup(of: NetworkState?.self) { group in for network in networks { group.addTask { @@ -100,7 +100,12 @@ extension Application { print(network.id) return nil } catch { - logger.error("failed to delete network \(network.id): \(error)") + _log.error( + "failed to delete network", + metadata: [ + "id": "\(network.id)", + "error": "\(error)", + ]) return network } } diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift index 38e9e6b7..a044bdb0 100644 --- a/Sources/ContainerCommands/Network/NetworkPrune.swift +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -55,7 +55,12 @@ extension Application.NetworkCommand { // Note: This failure may occur due to a race condition between the network/ // container collection above and a container run command that attaches to a // network listed in the networksToPrune collection. - log.error("failed to prune network", metadata: ["id": "\(network.id)", "error": "\(error)"]) + log.error( + "failed to prune network", + metadata: [ + "id": "\(network.id)", + "error": "\(error)", + ]) } } diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index 004eba4a..2fa7749f 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -21,6 +21,7 @@ import ContainerPlugin import ContainerXPC import ContainerizationError import Foundation +import SystemPackage import TerminalProgress extension Application { @@ -42,6 +43,12 @@ extension Application { transform: { URL(filePath: $0) }) var installRoot = InstallRoot.defaultURL + @Option( + name: .long, + help: "Path to the root directory for log data, using macOS log facility if not set", + transform: { FilePath($0) }) + var logRoot: FilePath? = nil + @Flag( name: .long, inversion: .prefixedEnableDisable, @@ -85,7 +92,12 @@ extension Application { var env = PluginLoader.filterEnvironment() env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false) env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false) - + if let logRoot { + env[LogRoot.environmentName] = + logRoot.isAbsolute + ? logRoot.string + : FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string + } let plist = LaunchPlist( label: "com.apple.container.apiserver", arguments: args, diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift index 3214951d..565fba3a 100644 --- a/Sources/ContainerCommands/Volume/VolumeDelete.swift +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -68,7 +68,7 @@ extension Application.VolumeCommand { } var failed = [String]() - let logger = log + let _log = log try await withThrowingTaskGroup(of: Volume?.self) { group in for volume in volumes { group.addTask { @@ -77,7 +77,12 @@ extension Application.VolumeCommand { print(volume.id) return nil } catch { - logger.error("failed to delete volume \(volume.id): \(error)") + _log.error( + "failed to delete volume", + metadata: [ + "id": "\(volume.id)", + "error": "\(error)", + ]) return volume } } diff --git a/Sources/ContainerLog/FileLogHandler.swift b/Sources/ContainerLog/FileLogHandler.swift new file mode 100644 index 00000000..446fa330 --- /dev/null +++ b/Sources/ContainerLog/FileLogHandler.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Logging +import SystemPackage + +/// Log handler that appends messages to a file, without any +/// rotation or truncation strategy. Use for development purposes only. +public struct FileLogHandler: LogHandler { + public var logLevel: Logger.Level = .info + public var metadata: Logger.Metadata = [:] + + private let label: String + private let category: String + private let fileHandle: FileHandle + + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { + self.metadata[metadataKey] + } + set { + self.metadata[metadataKey] = newValue + } + } + + /// Create a log handler that appends to the specified file. + /// + /// - Parameters: + /// - label: A unique identifier for the application. + /// - category: An identifier for the application subsystem. + /// - path: The log file location. The log handler creates the + /// file and parent directory if needed. + /// - Returns: The log handler. + public init(label: String, category: String, path: FilePath) throws { + self.label = label + self.category = category + let parentPath = path.removingLastComponent() + try FileManager.default.createDirectory(atPath: parentPath.string, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: path.string) { + FileManager.default.createFile(atPath: path.string, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: path.string) else { + throw FileLogFailure.openFailed + } + self.fileHandle = handle + self.fileHandle.seekToEndOfFile() + } + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter + }() + let timestamp = timestampFormatter.string(from: Date()) + + // Merge logger-level metadata with per-message metadata + var effectiveMetadata = self.metadata + if let metadata { + effectiveMetadata.merge(metadata) { _, new in new } + } + + let text: String + if !effectiveMetadata.isEmpty { + text = "\(timestamp) [\(level)] \(label) \(category) \(effectiveMetadata.description): \(message)\n" + } else { + text = "\(timestamp) [\(level)] \(label): \(category) \(message)\n" + } + if let data = text.data(using: .utf8) { + fileHandle.write(data) + } + } + + /// Failures relating to the log handler. + public enum FileLogFailure: Error { + /// The log handler could not open the log file. + case openFailed + } +} diff --git a/Sources/ContainerLog/ServiceLogger.swift b/Sources/ContainerLog/ServiceLogger.swift new file mode 100644 index 00000000..2fd111e2 --- /dev/null +++ b/Sources/ContainerLog/ServiceLogger.swift @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// 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 Logging +import SystemPackage + +/// Common logging setup for application services. +public struct ServiceLogger { + /// Set up the logging system and create a root logger. + /// + /// - Parameters: + /// - label: A unique identifier for the application. + /// - category: An identifier for the application subsystem. + /// - metadata: Metadata to include for all messsages. A message + /// specific value for a duplicate key overrides these values. + /// - debug: Enable debug logging. + /// - logPath: If supplied, create log files under the named + /// directory. Otherwise, log to the OS log facility. + /// - Returns: The root logger. + public static func bootstrap( + label: String = "com.apple.container", + category: String, + metadata: [String: String] = [:], + debug: Bool, + logPath: FilePath? + ) -> Logger { + // Select the log handler and bootstrap logging. + LoggingSystem.bootstrap { label in + if let logPath { + if let handler = try? FileLogHandler( + label: label, + category: category, + path: logPath + ) { + return handler + } + } + return OSLogHandler(label: label, category: category) + } + + // Configure log level and metadata. + var log = Logger(label: label) + if debug { + log.logLevel = .debug + } + for (key, value) in metadata { + log[metadataKey: key] = "\(value)" + } + + // Log an error if for some reason FileLogHandler init failed. + if let logPath, log.handler as? OSLogHandler != nil { + log.error( + "unable to initialize FileLogHandler, using OSLogHandler", + metadata: [ + "logPath": "\(logPath)" + ]) + } + + return log + } +} diff --git a/Sources/ContainerPlugin/LogRoot.swift b/Sources/ContainerPlugin/LogRoot.swift new file mode 100644 index 00000000..2550a887 --- /dev/null +++ b/Sources/ContainerPlugin/LogRoot.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage + +/// Provides the application data root path. +public struct LogRoot { + + private static let envPath = ProcessInfo.processInfo.environment[Self.environmentName].flatMap { + $0.isEmpty ? nil : FilePath($0) + } + + /// The environment variable that if set, determines the root directory for log files. + /// Otherwise, the application uses the macOS log facility. + public static let environmentName = "CONTAINER_LOG_ROOT" + + /// The path object for the log file root directory + public static let path = envPath.map { + guard !$0.isAbsolute else { return $0 } + return FilePath(FileManager.default.currentDirectoryPath).appending($0.components) + } + + /// The pathname to the log file root directory + public static let pathname = path?.string +} diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift index 0cab3f49..ae7af537 100644 --- a/Sources/ContainerPlugin/PluginLoader.swift +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -17,12 +17,15 @@ import ContainerizationOS import Foundation import Logging +import SystemPackage public struct PluginLoader: Sendable { private let appRoot: URL private let installRoot: URL + private let logRoot: FilePath? + private let pluginDirectories: [URL] private let pluginFactories: [PluginFactory] @@ -39,6 +42,7 @@ public struct PluginLoader: Sendable { public init( appRoot: URL, installRoot: URL, + logRoot: FilePath?, pluginDirectories: [URL], pluginFactories: [PluginFactory], log: Logger? = nil @@ -48,6 +52,7 @@ public struct PluginLoader: Sendable { self.pluginResourceRoot = pluginResourceRoot self.appRoot = appRoot self.installRoot = installRoot + self.logRoot = logRoot self.pluginDirectories = pluginDirectories self.pluginFactories = pluginFactories self.log = log @@ -223,6 +228,12 @@ extension PluginLoader { var env = Self.filterEnvironment() env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false) env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false) + if let logRoot { + env[LogRoot.environmentName] = + logRoot.isAbsolute + ? logRoot.string + : FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string + } let processedArgs = (args ?? ["start"]) + (debug ? ["--debug"] : []) let plist = LaunchPlist( diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift index eb8c0805..1a5856bf 100644 --- a/Sources/ContainerXPC/XPCServer.swift +++ b/Sources/ContainerXPC/XPCServer.swift @@ -199,14 +199,24 @@ public struct XPCServer: Sendable { let response = try await handler(message) xpc_connection_send_message(connection, response.underlying) } catch let error as ContainerizationError { - log.error("route handler threw an error", metadata: ["route": "\(route)", "error": "\(error)"]) + log.error( + "route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) Self.replyWithError( connection: connection, object: object, err: error ) } catch { - log.error("route handler threw an error", metadata: ["route": "\(route)", "error": "\(error)"]) + log.error( + "route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) let message = XPCMessage(object: object) let reply = message.reply() diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index f5de5941..62fa0296 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerAPIClient import ContainerAPIService +import ContainerLog import ContainerNetworkService import ContainerPlugin import ContainerResource @@ -24,6 +25,7 @@ import ContainerXPC import DNSServer import Foundation import Logging +import SystemPackage extension APIServer { struct Start: AsyncParsableCommand { @@ -43,9 +45,12 @@ extension APIServer { var installRoot = InstallRoot.url + var logRoot = LogRoot.path + func run() async throws { - let commandName = Self.configuration.commandName ?? "container-apiserver" - let log = APIServer.setupLogger(debug: debug) + let commandName = APIServer._commandName + let logPath = logRoot.map { $0.appending("\(commandName).log") } + let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) @@ -91,6 +96,7 @@ extension APIServer { log.info("starting XPC server") try await server.listen() } + // start up host table DNS group.addTask { let hostsResolver = ContainerDNSHandler(networkService: networkService) @@ -113,7 +119,12 @@ extension APIServer { /* group.addTask { let localhostResolver = LocalhostDNSHandler(log: log) - try localhostResolver.monitorResolvers() + do { + try localhostResolver.monitorResolvers() + } catch { + log.error("could not initialize resolver monitor", metadata: ["error": "\(error)"]) + throw error + } let nxDomainResolver = NxDomainResolver() let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) @@ -131,7 +142,12 @@ extension APIServer { */ } } catch { - log.error("helper failed", metadata: ["name": "\(commandName)", "error": "\(error)"]) + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) APIServer.exit(withError: error) } } @@ -178,6 +194,7 @@ extension APIServer { return try PluginLoader( appRoot: appRoot, installRoot: installRoot, + logRoot: logRoot, pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, log: log @@ -209,7 +226,12 @@ extension APIServer { private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) { log.info("initializing health check service") - let svc = HealthCheckHarness(appRoot: appRoot, installRoot: installRoot, log: log) + let svc = HealthCheckHarness( + appRoot: appRoot, + installRoot: installRoot, + logRoot: logRoot, + log: log + ) routes[XPCRoute.ping] = svc.ping } diff --git a/Sources/Helpers/APIServer/APIServer.swift b/Sources/Helpers/APIServer/APIServer.swift index 2d83370e..fa6a4a64 100644 --- a/Sources/Helpers/APIServer/APIServer.swift +++ b/Sources/Helpers/APIServer/APIServer.swift @@ -15,9 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerLog import ContainerVersion -import Logging @main struct APIServer: AsyncParsableCommand { @@ -27,18 +25,4 @@ struct APIServer: AsyncParsableCommand { version: ReleaseVersion.singleLine(appName: "container-apiserver"), subcommands: [Start.self], ) - - static func setupLogger(debug: Bool) -> Logger { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "APIServer" - ) - } - var log = Logger(label: "com.apple.container") - if debug { - log.logLevel = .debug - } - return log - } } diff --git a/Sources/Helpers/APIServer/DirectoryWatcher.swift b/Sources/Helpers/APIServer/DirectoryWatcher.swift index 78f75ea7..c9d3bdfb 100644 --- a/Sources/Helpers/APIServer/DirectoryWatcher.swift +++ b/Sources/Helpers/APIServer/DirectoryWatcher.swift @@ -66,7 +66,12 @@ public class DirectoryWatcher { let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) try handler(files.map { directoryURL.appending(path: $0) }) } catch { - self.log.error("failed to run DirectoryWatcher handler", metadata: ["error": "\(error)", "path": "\(directoryURL.path)"]) + self.log.error( + "failed to run DirectoryWatcher handler", + metadata: [ + "error": "\(error)", + "path": "\(directoryURL.path)", + ]) } } diff --git a/Sources/Helpers/Images/ImagesHelper.swift b/Sources/Helpers/Images/ImagesHelper.swift index a817d901..0e561c19 100644 --- a/Sources/Helpers/Images/ImagesHelper.swift +++ b/Sources/Helpers/Images/ImagesHelper.swift @@ -54,11 +54,14 @@ extension ImagesHelper { var installRoot = InstallRoot.url + var logRoot = LogRoot.path + private static let unpackStrategy = SnapshotStore.defaultUnpackStrategy func run() async throws { let commandName = ImagesHelper._commandName - let log = setupLogger() + let logPath = logRoot.map { $0.appending("\(commandName).log") } + let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) @@ -77,7 +80,12 @@ extension ImagesHelper { log.info("starting XPC server") try await xpc.listen() } catch { - log.error("helper failed", metadata: ["name": "\(commandName)", "error": "\(error)"]) + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) ImagesHelper.exit(withError: error) } } @@ -114,19 +122,5 @@ extension ImagesHelper { routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = harness.cancelIngestSession routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = harness.completeIngestSession } - - private func setupLogger() -> Logger { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "ImagesHelper" - ) - } - var log = Logger(label: "com.apple.container") - if debug { - log.logLevel = .debug - } - return log - } } } diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift index 71b9e681..a8199689 100644 --- a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -15,8 +15,10 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerLog import ContainerNetworkService import ContainerNetworkServiceClient +import ContainerPlugin import ContainerResource import ContainerXPC import ContainerizationError @@ -64,9 +66,12 @@ extension NetworkVmnetHelper { return .reserved }() + var logRoot = LogRoot.path + func run() async throws { let commandName = NetworkVmnetHelper._commandName - let log = setupLogger(id: id, debug: debug) + let logPath = logRoot.map { $0.appending("\(commandName)-\(id).log") } + let log = ServiceLogger.bootstrap(category: "NetworkVmnetHelper", metadata: ["id": "\(id)"], debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) @@ -110,7 +115,12 @@ extension NetworkVmnetHelper { log.info("starting XPC server") try await xpc.listen() } catch { - log.error("helper failed", metadata: ["name": "\(commandName)", "error": "\(error)"]) + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) NetworkVmnetHelper.exit(withError: error) } } diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift index c924f063..7052ff2f 100644 --- a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift @@ -15,9 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerLog import ContainerVersion -import Logging @main struct NetworkVmnetHelper: AsyncParsableCommand { @@ -29,19 +27,4 @@ struct NetworkVmnetHelper: AsyncParsableCommand { Start.self ] ) - - static func setupLogger(id: String, debug: Bool) -> Logger { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "NetworkVmnetHelper" - ) - } - var log = Logger(label: "com.apple.container") - if debug { - log.logLevel = .debug - } - log[metadataKey: "id"] = "\(id)" - return log - } } diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 1f45e94a..9031359a 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -16,6 +16,7 @@ import ArgumentParser import ContainerLog +import ContainerPlugin import ContainerResource import ContainerSandboxService import ContainerSandboxServiceClient @@ -42,13 +43,16 @@ extension RuntimeLinuxHelper { @Option(name: .shortAndLong, help: "Root directory for the sandbox") var root: String + var logRoot = LogRoot.path + var machServiceLabel: String { "\(Self.label).\(uuid)" } func run() async throws { let commandName = RuntimeLinuxHelper._commandName - let log = RuntimeLinuxHelper.setupLogger(debug: debug, metadata: ["uuid": "\(uuid)"]) + let logPath = logRoot.map { $0.appending("\(commandName)-\(uuid).log") } + let log = ServiceLogger.bootstrap(category: "RuntimeLinuxHelper", metadata: ["uuid": "\(uuid)"], debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) defer { log.info("stopping helper", metadata: ["name": "\(commandName)"]) @@ -117,7 +121,12 @@ extension RuntimeLinuxHelper { _ = try await group.next() } } catch { - log.error("helper failed", metadata: ["name": "\(commandName)", "error": "\(error)"]) + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) try? await eventLoopGroup.shutdownGracefully() RuntimeLinuxHelper.Start.exit(withError: error) } diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift index 3d4e62d8..0f9646f0 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift @@ -15,10 +15,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ContainerLog import ContainerVersion -import Logging -import OSLog @main struct RuntimeLinuxHelper: AsyncParsableCommand { @@ -30,24 +27,4 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { Start.self ] ) - - package static func setupLogger(debug: Bool, metadata: [String: Logging.Logger.Metadata.Value] = [:]) -> Logging.Logger { - LoggingSystem.bootstrap { label in - OSLogHandler( - label: label, - category: "RuntimeLinuxHelper" - ) - } - - var log = Logger(label: "com.apple.container") - if debug { - log.logLevel = .debug - } - - for (key, val) in metadata { - log[metadataKey: key] = val - } - - return log - } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift index ac59d3d9..a6814e8b 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift @@ -17,6 +17,7 @@ import ContainerXPC import ContainerizationError import Foundation +import SystemPackage public enum ClientHealthCheck { static let serviceIdentifier = "com.apple.container.apiserver" @@ -37,6 +38,7 @@ extension ClientHealthCheck { guard let installRootValue = reply.string(key: .installRoot), let installRoot = URL(string: installRootValue) else { throw ContainerizationError(.internalError, message: "failed to decode installRoot in health check") } + let logRoot = reply.string(key: .logRoot).map { FilePath($0) } guard let apiServerVersion = reply.string(key: .apiServerVersion) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerVersion in health check") } @@ -52,6 +54,7 @@ extension ClientHealthCheck { return .init( appRoot: appRoot, installRoot: installRoot, + logRoot: logRoot, apiServerVersion: apiServerVersion, apiServerCommit: apiServerCommit, apiServerBuild: apiServerBuild, diff --git a/Sources/Services/ContainerAPIService/Client/SystemHealth.swift b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift index 11a14526..2c6b7943 100644 --- a/Sources/Services/ContainerAPIService/Client/SystemHealth.swift +++ b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import SystemPackage /// Snapshot of the health of container services and resources public struct SystemHealth: Sendable, Codable { @@ -24,6 +25,9 @@ public struct SystemHealth: Sendable, Codable { /// The full pathname of the application install root. public let installRoot: URL + /// The full pathname of the application install root. + public let logRoot: FilePath? + /// The release version of the container services. public let apiServerVersion: String diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 53f09851..495a227f 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -61,6 +61,7 @@ public enum XPCKeys: String { case ping case appRoot case installRoot + case logRoot case apiServerVersion case apiServerCommit case apiServerBuild diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 0bd9eded..6e99c5e2 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -119,7 +119,12 @@ public actor ContainersService { } } catch { try? FileManager.default.removeItem(at: dir) - log.warning("failed to load container", metadata: ["path": "\(dir.path)", "error": "\(error)"]) + log.warning( + "failed to load container", + metadata: [ + "path": "\(dir.path)", + "error": "\(error)", + ]) } } return results @@ -546,7 +551,12 @@ public actor ContainersService { let waitFunc: ExitMonitor.WaitHandler = { log.info("registering container with exit monitor") let code = try await client.wait(id) - log.info("container finished in exit monitor", metadata: ["id": "\(id)", "rc": "\(code)"]) + log.info( + "container finished in exit monitor", + metadata: [ + "id": "\(id)", + "rc": "\(code)", + ]) return code } @@ -882,7 +892,12 @@ public actor ContainersService { private func handleContainerExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async throws { if let code { - self.log.info("handling container exit", metadata: ["id": "\(id)", "rc": "\(code)"]) + self.log.info( + "handling container exit", + metadata: [ + "id": "\(id)", + "rc": "\(code)", + ]) } var state: ContainerState @@ -916,7 +931,12 @@ public actor ContainersService { do { try await client.shutdown() } catch { - self.log.error("failed to shutdown sandbox service", metadata: ["id": "\(id)", "error": "\(error)"]) + self.log.error( + "failed to shutdown sandbox service", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) } } @@ -927,7 +947,12 @@ public actor ContainersService { try ServiceManager.deregister(fullServiceLabel: label) self.log.info("deregistered sandbox service", metadata: ["id": "\(id)"]) } catch { - self.log.error("failed to deregister sandbox service", metadata: ["id": "\(id)", "error": "\(error)"]) + self.log.error( + "failed to deregister sandbox service", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) } // Best effort deallocate network attachments for the container. Don't throw on @@ -999,7 +1024,12 @@ public actor ContainersService { do { config = try bundle.configuration } catch { - self.log.warning("failed to read bundle configuration during cleanup for container", metadata: ["id": "\(id)", "error": "\(error)"]) + self.log.warning( + "failed to read bundle configuration during cleanup for container", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) } // Only try to deregister service if we have a valid config @@ -1017,7 +1047,12 @@ public actor ContainersService { do { try bundle.delete() } catch { - self.log.warning("failed to delete bundle for container", metadata: ["id": "\(id)", "error": "\(error)"]) + self.log.warning( + "failed to delete bundle for container", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) } self.containers.removeValue(forKey: id) diff --git a/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift index fdc9232a..0e43c2ff 100644 --- a/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift @@ -21,15 +21,18 @@ import ContainerXPC import Containerization import Foundation import Logging +import SystemPackage public actor HealthCheckHarness { private let appRoot: URL private let installRoot: URL + private let logRoot: FilePath? private let log: Logger - public init(appRoot: URL, installRoot: URL, log: Logger) { + public init(appRoot: URL, installRoot: URL, logRoot: FilePath?, log: Logger) { self.appRoot = appRoot self.installRoot = installRoot + self.logRoot = logRoot self.log = log } @@ -38,6 +41,9 @@ public actor HealthCheckHarness { let reply = message.reply() reply.set(key: .appRoot, value: appRoot.absoluteString) reply.set(key: .installRoot, value: installRoot.absoluteString) + if let logRoot { + reply.set(key: .logRoot, value: logRoot.string) + } reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified") // Extra optional fields for richer client display diff --git a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift index 20d5f18d..a54febc1 100644 --- a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift @@ -337,7 +337,13 @@ public actor VolumesService { try await store.create(volume) - log.info("created volume", metadata: ["name": "\(name)", "driver": "\(driver)", "isAnonymous": "\(volume.isAnonymous)"]) + log.info( + "created volume", + metadata: [ + "name": "\(name)", + "driver": "\(driver)", + "isAnonymous": "\(volume.isAnonymous)", + ]) return volume } diff --git a/Tests/CLITests/Subcommands/System/TestCLIStatus.swift b/Tests/CLITests/Subcommands/System/TestCLIStatus.swift new file mode 100644 index 00000000..47ee1767 --- /dev/null +++ b/Tests/CLITests/Subcommands/System/TestCLIStatus.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Tests for `container system status` output formats and content validation. +final class TestCLIStatus: CLITest { + struct StatusJSON: Codable { + let status: String + let appRoot: String + let installRoot: String + let logRoot: String? + let apiServerVersion: String + let apiServerCommit: String + let apiServerBuild: String + let apiServerAppName: String + } + + @Test func defaultDisplaysTable() throws { + let (data, out, _, status) = try run(arguments: ["system", "status"]) // default is table + + // If apiserver is not running, skip this test + guard status == 0 else { + return + } + + #expect(!out.isEmpty) + + // Validate table structure + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) // header + at least one data row + + // Check for header row + #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) + + // Check for key fields in output + let fullOutput = lines.joined(separator: "\n") + #expect(fullOutput.contains("status")) + #expect(fullOutput.contains("running")) + #expect(fullOutput.contains("app-root")) + #expect(fullOutput.contains("install-root")) + #expect(fullOutput.contains("apiserver-version")) + #expect(fullOutput.contains("apiserver-commit")) + + _ = data // silence unused warning if assertions short-circuit + } + + @Test func jsonFormat() throws { + let (data, out, _, status) = try run(arguments: ["system", "status", "--format", "json"]) + + // If apiserver is not running, validate error JSON + if status != 0 { + #expect(!out.isEmpty) + let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) + #expect(decoded.status == "not running" || decoded.status == "unregistered") + return + } + + #expect(!out.isEmpty) + + let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) + #expect(decoded.status == "running") + #expect(!decoded.appRoot.isEmpty) + #expect(!decoded.installRoot.isEmpty) + #expect(!decoded.apiServerVersion.isEmpty) + #expect(!decoded.apiServerCommit.isEmpty) + #expect(!decoded.apiServerBuild.isEmpty) + #expect(!decoded.apiServerAppName.isEmpty) + } + + @Test func explicitTableFormat() throws { + let (_, out, _, status) = try run(arguments: ["system", "status", "--format", "table"]) + + // If apiserver is not running, skip validation + guard status == 0 else { + return + } + + #expect(!out.isEmpty) + + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) + + let fullOutput = lines.joined(separator: "\n") + #expect(fullOutput.contains("status")) + #expect(fullOutput.contains("running")) + } + + @Test func statusFieldsMatch() throws { + // Validate that JSON and table outputs contain the same information + let (jsonData, _, _, jsonStatus) = try run(arguments: ["system", "status", "--format", "json"]) + let (_, tableOut, _, tableStatus) = try run(arguments: ["system", "status", "--format", "table"]) + + #expect(jsonStatus == tableStatus) + + // If apiserver is not running, just verify consistency + guard jsonStatus == 0 else { + return + } + + let decoded = try JSONDecoder().decode(StatusJSON.self, from: jsonData) + + // Verify table output contains key values from JSON + #expect(tableOut.contains(decoded.status)) + #expect(tableOut.contains(decoded.appRoot)) + #expect(tableOut.contains(decoded.installRoot)) + #expect(tableOut.contains(decoded.apiServerVersion)) + #expect(tableOut.contains(decoded.apiServerCommit)) + #expect(tableOut.contains(decoded.apiServerBuild)) + #expect(tableOut.contains(decoded.apiServerAppName)) + } + + @Test func jsonOutputValidStructure() throws { + let (data, _, _, status) = try run(arguments: ["system", "status", "--format", "json"]) + + // Should always produce valid JSON regardless of status + #expect(throws: Never.self) { + _ = try JSONDecoder().decode(StatusJSON.self, from: data) + } + + let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) + + if status == 0 { + // When running, all fields should be populated + #expect(decoded.status == "running") + #expect(!decoded.appRoot.isEmpty) + #expect(!decoded.installRoot.isEmpty) + #expect(!decoded.apiServerVersion.isEmpty) + } else { + // When not running, status should indicate the issue + #expect(decoded.status == "not running" || decoded.status == "unregistered") + } + } + + @Test func prefixOption() throws { + // Test with explicit prefix (should work the same as default) + let (_, out, _, status) = try run(arguments: ["system", "status", "--prefix", "com.apple.container."]) + + guard status == 0 else { + return + } + + #expect(!out.isEmpty) + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + } +} diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift index 5ab5cb5f..6433a36a 100644 --- a/Tests/ContainerPluginTests/PluginLoaderTest.swift +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -28,6 +28,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) @@ -60,6 +61,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) @@ -76,6 +78,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) @@ -181,6 +184,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) @@ -207,6 +211,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) @@ -233,6 +238,7 @@ struct PluginLoaderTest { let loader = try PluginLoader( appRoot: tempURL, installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, pluginDirectories: [tempURL], pluginFactories: [factory] ) diff --git a/docs/command-reference.md b/docs/command-reference.md index d2aa3117..9d7eb7e0 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -945,13 +945,14 @@ Starts the container services and (optionally) installs a default kernel. It wil **Usage** ```bash -container system start [--app-root ] [--install-root ] [--enable-kernel-install] [--disable-kernel-install] [--debug] +container system start [--app-root ] [--install-root ] [--log-root ] [--enable-kernel-install] [--disable-kernel-install] [--debug] ``` **Options** * `-a, --app-root `: Path to the root directory for application data * `--install-root `: Path to the root directory for application executables and plugins +* `--log-root `: Path to the root directory for log data, using macOS log facility if not set * `--enable-kernel-install/--disable-kernel-install`: Specify whether the default kernel should be installed or not (default: prompt user) ### `container system stop`