Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ let package = Package(
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerPersistence",
"ContainerTestSupport",
]
),
.executableTarget(
Expand Down
4 changes: 1 addition & 3 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ extension APIServer {
var logRoot = LogRoot.path

func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let commandName = APIServer._commandName
let logPath = logRoot.map { $0.appending("\(commandName).log") }
let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath)
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ extension Application {
var pull: Bool = false

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
do {
let timeout: Duration = .seconds(300)
let progressConfig = try ProgressConfig(
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Builder/BuilderStart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ extension Application {
public init() {}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Container/ContainerCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ extension Application {
var arguments: [String] = []

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ extension Application {
var arguments: [String] = []

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var exitCode: Int32 = 127
let id = Utility.createContainerID(name: self.managementFlags.name)

Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImageDelete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ extension Application {
}

public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
}
}
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImageInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ extension Application {
}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var printable: [ImageDetail] = []
var succeededImages: [String] = []
var allErrors: [(String, Error)] = []
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ extension Application {
public var logOptions: Flags.Logging

public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
try Self.validate(format: format, quiet: quiet, verbose: verbose)

var images = try await ClientImage.list().filter { img in
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImagePull.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ extension Application {
}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)

let scheme = try RequestScheme(registry.scheme)
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImagePush.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ extension Application {
public init() {}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)

let scheme = try RequestScheme(registry.scheme)
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImageSave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@ extension Application {
@Argument var references: [String]

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)

let progressConfig = try ProgressConfig(
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Image/ImageTag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ extension Application {
public var logOptions: Flags.Logging

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig)
let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig)
try await existing.tag(new: targetReference)
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/Registry/RegistryLogin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ extension Application {
var server: String

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var username = self.username
var password = ""
if passwordStdin {
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/System/Kernel/KernelSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ extension Application {
public init() {}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
if recommended {
let url = containerSystemConfig.kernel.url
let path: String = containerSystemConfig.kernel.binaryPath
Expand Down
4 changes: 1 addition & 3 deletions Sources/ContainerCommands/System/Property/PropertyList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ extension Application {
public init() {}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let output =
switch format {
case .json: try Output.renderJSON(containerSystemConfig)
Expand Down
10 changes: 4 additions & 6 deletions Sources/ContainerCommands/System/SystemStart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,10 @@ extension Application {
public init() {}

public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
// Copy the user's config into appRoot/config/ so that all plugins
// and the apiserver subsequently read the same snapshot.
SystemRuntimeOptions.copyConfigToAppRoot(appRoot: appRoot)
let appRootPath = FilePath(appRoot.path(percentEncoded: false))
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRootPath)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit for the next commit: we can use type inference here and shorten the line

configurationFile: ConfigurationLoader.configurationFile(in: appRootPath))

// Without the true path to the binary in the plist, `container-apiserver` won't launch properly.
// Resolve the symlink to get the true binary path before writing the launchd plist.
Expand Down
111 changes: 111 additions & 0 deletions Sources/ContainerPersistence/ConfigurationLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import Foundation
import SystemPackage
import TOML

public protocol Initable {
init()
}

public typealias LoadableConfiguration = Codable & Sendable & Initable

public enum ConfigurationLoader {
private static let configFilename = "runtime-config.toml"
private static let configDirectory = "config"
private static let READ_ONLY: Int = 0o444
private static let READ_AND_WRITE: Int = 0o644

/// Returns the canonical configuration file path under an appRoot base directory:
/// `<base>/config/runtime-config.toml`.
public static func configurationFile(in base: FilePath) -> FilePath {
base.appending(configDirectory).appending(configFilename)
}

/// Loads and decodes a TOML configuration file as type `T`.
///
/// - Parameter configurationFile: Absolute path to the configuration file.
/// When `nil`, falls back to
/// `configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath())`.
/// - Returns: A decoded value of type `T`, or a default-initialized `T` if the
/// configuration file does not exist.
public static func load<T: LoadableConfiguration>(configurationFile: FilePath? = nil) throws -> T {
let path = configurationFile ?? Self.configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath())
guard FileManager.default.fileExists(atPath: path.string) else {
return T()
}
do {
let data = try Data(contentsOf: URL(filePath: path.string))
return try TOMLDecoder().decode(T.self, from: data)
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to load configuration from '\(path)': \(error)"
)
}
}

/// Copies a TOML configuration file into a read-only destination under an appRoot base.
///
/// - Parameters:
/// - source: The file to copy. When `nil`, defaults to
/// `<home>/container/runtime-config.toml`. If the source does not exist,
/// this is a no-op.
/// - destination: Base directory under which the file is written at
/// `<destination>/config/runtime-config.toml`. When `nil`, falls back to
/// `PathUtils.BaseConfigPath.appRoot.basePath()`. The destination file is written
/// with `READ_ONLY` (`0o444`) permissions.
public static func copyConfigurationToReadOnly(
from source: FilePath? = nil,
to destination: FilePath? = nil
) throws {
let source =
source
?? PathUtils.BaseConfigPath.home.basePath()
.appending(configFilename)
let destinationFile = Self.configurationFile(in: destination ?? PathUtils.BaseConfigPath.appRoot.basePath())
do {
let fm = FileManager.default
guard fm.fileExists(atPath: source.string) else { return }

let destDir = destinationFile.removingLastComponent()
try fm.createDirectory(
atPath: destDir.string,
withIntermediateDirectories: true
)
if fm.fileExists(atPath: destinationFile.string) {
try fm.setAttributes(
[.posixPermissions: READ_AND_WRITE],
ofItemAtPath: destinationFile.string
)
try fm.removeItem(at: URL(filePath: destinationFile.string))
}
try fm.copyItem(
at: URL(filePath: source.string),
to: URL(filePath: destinationFile.string)
)
try fm.setAttributes(
[.posixPermissions: READ_ONLY],
ofItemAtPath: destinationFile.string
)
} catch {
throw ContainerizationError(
.invalidState, message: "Failed to copy user TOML to AppRoot `\(error)`")
}
}
}
12 changes: 11 additions & 1 deletion Sources/ContainerPersistence/ContainerSystemConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import Foundation
///
/// Each section maps to a nested struct. Missing keys fall back to
/// hardcoded defaults via custom `init(from:)` implementations.
final public class ContainerSystemConfig: Codable, Sendable {
public final class ContainerSystemConfig: Codable, Sendable, Initable {
public let build: BuildConfig
public let container: ContainerConfig
public let dns: DNSConfig
Expand All @@ -50,6 +50,16 @@ final public class ContainerSystemConfig: Codable, Sendable {
self.vminit = vminit
}

public init() {
self.build = .init()
self.container = .init()
self.dns = .init()
self.kernel = .init()
self.network = .init()
self.registry = .init()
self.vminit = .init()
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.build = try container.decodeIfPresent(BuildConfig.self, forKey: .build) ?? .init()
Expand Down
47 changes: 47 additions & 0 deletions Sources/ContainerPersistence/PathUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// 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

public enum PathUtils {
public enum BaseConfigPath {
case home
case appRoot

public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath {
switch self {
case .home:
let configHome: String
if let xdg = env["XDG_CONFIG_HOME"], !xdg.isEmpty {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a follow up PR to scrub ContainerPersistence for string and URL path manipulation. I took care of this for the entity store type.

e.g. we could start here with something like:

    let configHome = env["XDG_CONFIG_HOME"]
        .map { FilePath($0) }
        ?? FilePath(NSHomeDirectory()).appending("/.config")

configHome = xdg
} else {
configHome = NSHomeDirectory() + "/.config"
}
return FilePath(configHome).appending("container")
case .appRoot:
if let envPath = env["CONTAINER_APP_ROOT"], !envPath.isEmpty {
return FilePath(envPath)
}
let appSupportURL = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.appendingPathComponent("com.apple.container")
return FilePath(appSupportURL.path(percentEncoded: false))
}
}
}
}
Loading