mirror of
https://github.com/apple/container.git
synced 2026-08-27 02:46:31 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user