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`.
This commit is contained in:
J Logan
2026-02-19 17:47:41 -08:00
committed by GitHub
parent 3425f04206
commit d29947121f
31 changed files with 628 additions and 117 deletions
+24 -2
View File
@@ -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:
+3
View File
@@ -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)
+21 -13
View File
@@ -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",
]
),
@@ -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
@@ -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)
@@ -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
}
}
@@ -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)",
])
}
}
@@ -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,
@@ -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
}
}
+101
View File
@@ -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
}
}
+74
View File
@@ -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
}
}
+39
View File
@@ -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
}
@@ -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(
+12 -2
View File
@@ -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()
@@ -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
}
-16
View File
@@ -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
}
}
@@ -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)",
])
}
}
+10 -16
View File
@@ -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
}
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
@@ -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
}
}
@@ -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,
@@ -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
@@ -61,6 +61,7 @@ public enum XPCKeys: String {
case ping
case appRoot
case installRoot
case logRoot
case apiServerVersion
case apiServerCommit
case apiServerBuild
@@ -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)
@@ -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
@@ -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
}
@@ -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)
}
}
@@ -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]
)
+2 -1
View File
@@ -945,13 +945,14 @@ Starts the container services and (optionally) installs a default kernel. It wil
**Usage**
```bash
container system start [--app-root <app-root>] [--install-root <install-root>] [--enable-kernel-install] [--disable-kernel-install] [--debug]
container system start [--app-root <app-root>] [--install-root <install-root>] [--log-root <log-root>] [--enable-kernel-install] [--disable-kernel-install] [--debug]
```
**Options**
* `-a, --app-root <app-root>`: Path to the root directory for application data
* `--install-root <install-root>`: Path to the root directory for application executables and plugins
* `--log-root <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`