Initial commit

This commit is contained in:
Kathryn Baldauf
2025-06-03 15:23:07 -07:00
commit d5f30b8e3e
225 changed files with 27705 additions and 0 deletions
@@ -0,0 +1,26 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
extension CommandLine {
public static var executableDirectoryUrl: URL {
let executablePath = Self.arguments[0]
let executableUrl = URL(filePath: executablePath)
let executableDirectoryUrl = executableUrl.deletingLastPathComponent()
return executableDirectoryUrl.standardized
}
}
+116
View File
@@ -0,0 +1,116 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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.
//===----------------------------------------------------------------------===//
#if os(macOS)
import Foundation
public struct LaunchPlist: Encodable {
public enum Domain: String, Codable {
case Aqua
case Background
case System
}
public let label: String
public let arguments: [String]
public let environment: [String: String]?
public let cwd: String?
public let username: String?
public let groupname: String?
public let limitLoadToSessionType: [Domain]?
public let runAtLoad: Bool?
public let stdin: String?
public let stdout: String?
public let stderr: String?
public let disabled: Bool?
public let program: String?
public let keepAlive: Bool?
public let machServices: [String: Bool]?
public let waitForDebugger: Bool?
enum CodingKeys: String, CodingKey {
case label = "Label"
case arguments = "ProgramArguments"
case environment = "EnvironmentVariables"
case cwd = "WorkingDirectory"
case username = "UserName"
case groupname = "GroupName"
case limitLoadToSessionType = "LimitLoadToSessionType"
case runAtLoad = "RunAtLoad"
case stdin = "StandardInPath"
case stdout = "StandardOutPath"
case stderr = "StandardErrorPath"
case disabled = "Disabled"
case program = "Program"
case keepAlive = "KeepAlive"
case machServices = "MachServices"
case waitForDebugger = "WaitForDebugger"
}
public init(
label: String,
arguments: [String],
environment: [String: String]? = nil,
cwd: String? = nil,
username: String? = nil,
groupname: String? = nil,
limitLoadToSessionType: [Domain]? = nil,
runAtLoad: Bool? = nil,
stdin: String? = nil,
stdout: String? = nil,
stderr: String? = nil,
disabled: Bool? = nil,
program: String? = nil,
keepAlive: Bool? = nil,
machServices: [String]? = nil,
waitForDebugger: Bool? = nil
) {
self.label = label
self.arguments = arguments
self.environment = environment
self.cwd = cwd
self.username = username
self.groupname = groupname
self.limitLoadToSessionType = limitLoadToSessionType
self.runAtLoad = runAtLoad
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
self.disabled = disabled
self.program = program
self.keepAlive = keepAlive
self.waitForDebugger = waitForDebugger
if let services = machServices {
var machServices: [String: Bool] = [:]
for service in services {
machServices[service] = true
}
self.machServices = machServices
} else {
self.machServices = nil
}
}
}
extension LaunchPlist {
public func encode() throws -> Data {
let enc = PropertyListEncoder()
enc.outputFormat = .xml
return try enc.encode(self)
}
}
#endif
+118
View File
@@ -0,0 +1,118 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
/// Value type that contains the plugin configuration, the parsed name of the
/// plugin and whether a CLI surface for the plugin was found.
public struct Plugin: Sendable, Codable {
private static let machServicePrefix = "com.apple.container."
/// Pathname to installation directory for plugins.
public let binaryURL: URL
/// Configuration for the plugin.
public let config: PluginConfig
public init(binaryURL: URL, config: PluginConfig) {
self.binaryURL = binaryURL
self.config = config
}
}
extension Plugin {
public var name: String { binaryURL.lastPathComponent }
public var shouldBoot: Bool {
guard let config = self.config.servicesConfig else {
return false
}
return config.loadAtBoot
}
public func getLaunchdLabel(instanceId: String? = nil) -> String {
// Use the plugin name for the launchd label.
guard let instanceId else {
return "\(Self.machServicePrefix)\(self.name)"
}
return "\(Self.machServicePrefix)\(self.name).\(instanceId)"
}
public func getMachServices(instanceId: String? = nil) -> [String] {
// Use the service type for the mach service.
guard let config = self.config.servicesConfig else {
return []
}
var services = [String]()
for service in config.services {
let serviceName: String
if let instanceId {
serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name).\(instanceId)"
} else {
serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name)"
}
services.append(serviceName)
}
return services
}
public func getMachService(instanceId: String? = nil, type: PluginConfig.DaemonPluginType) -> String? {
guard hasType(type) else {
return nil
}
guard let instanceId else {
return "\(Self.machServicePrefix)\(type.rawValue).\(name)"
}
return "\(Self.machServicePrefix)\(type.rawValue).\(name).\(instanceId)"
}
public func hasType(_ type: PluginConfig.DaemonPluginType) -> Bool {
guard let config = self.config.servicesConfig else {
return false
}
guard !(config.services.filter { $0.type == type }.isEmpty) else {
return false
}
return true
}
}
extension Plugin {
public func exec(args: [String]) throws {
var args = args
let executable = self.binaryURL.path
args[0] = executable
let argv = args.map { strdup($0) } + [nil]
guard execvp(executable, argv) != -1 else {
throw POSIXError.fromErrno()
}
fatalError("unreachable")
}
func helpText(padding: Int) -> String {
guard !self.name.isEmpty else {
return ""
}
let namePadded = name.padding(toLength: padding, withPad: " ", startingAt: 0)
return " " + namePadded + self.config.abstract
}
}
+117
View File
@@ -0,0 +1,117 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
/// PluginConfig details all of the fields to describe and register a plugin.
/// A plugin is registered by creating a subdirectory `<application-root>/user-plugins`,
/// where the name of the subdirectory is the name of the plugin, and then placing a
/// file named `config.json` inside with the schema below.
/// If `services` is filled in then there MUST be a binary named matching the plugin name
/// in a `bin` subdirectory inside the same directory as the `config.json`.
/// An example of a valid plugin directory structure would be
/// $ tree foobar
/// foobar
/// ├── bin
/// │ └── foobar
/// └── config.json
public struct PluginConfig: Sendable, Codable {
/// Categories of services that can be offered through plugins.
public enum DaemonPluginType: String, Sendable, Codable {
/// A runtime plugin provides an XPC API through which the lifecycle
/// of a **single** container can be managed.
/// A runtime daemon plugin would typically also have a counterpart
/// CLI plugin which knows how to talk to the API exposed by the runtime plugin.
/// The API server ensures that a single instance of the plugin is configured
/// for a given container such that the client can communicate with it given an instance id.
case runtime
/// A network plugin provides an XPC API through which IP address allocations on a given
/// network can be managed. The API server ensures that a single instance
/// of this plugin is configured for a given network. Similar to the runtime plugin, it typically
/// would be accompanied by a CLI plugin that knows how to communicate with the XPC API
/// given an instance id.
case network
/// A core plugin provides an XPC API to manage a given type of resource.
/// The API server ensures that there exist only a single running instance
/// of this plugin type. A core plugin can be thought of a singleton whose lifecycle
/// is tied to that of the API server. Core plugins can be used to expand the base functionality
/// provided by the API server. As with the other plugin types, it maybe associated with a client
/// side plugin that communicates with the XPC service exposed by the daemon plugin.
case core
/// Reserved for future use. Currently there is no difference between a core and auxiliary daemon plugin.
case auxiliary
}
// An XPC service that the plugin publishes.
public struct Service: Sendable, Codable {
/// The type of the service the daemon is exposing.
/// One plugin can expose multiple services of different types.
///
/// The plugin MUST expose a MachService at
/// `com.apple.container.{type}.{name}.[{id}]` for
/// each service that it exposes.
public let type: DaemonPluginType
/// Optional description of this service.
public let description: String?
}
/// Descriptor for the services that the plugin offers.
public struct ServicesConfig: Sendable, Codable {
/// Load the plugin into launchd when the API server starts.
public let loadAtBoot: Bool
/// Launch the plugin binary as soon as it loads into launchd.
public let runAtLoad: Bool
/// The service types that the plugin provides.
public let services: [Service]
/// An optional parameter that include any command line arguments
/// that must be passed to the plugin binary when it is loaded.
/// This parameter is used only when `servicesConfig.loadAtBoot` is `true`
public let defaultArguments: [String]
}
/// Short description of the plugin surface. This will be displayed as the
/// help-text for CLI plugins, and will be returned in API calls to view loaded
/// plugins from the daemon.
public let abstract: String
/// Author of the plugin. This is solely metadata.
public let author: String?
/// Services configuration. Specify nil for a CLI plugin, and an empty array for
/// that does not publish any XPC services.
public let servicesConfig: ServicesConfig?
}
extension PluginConfig {
public var isCLI: Bool { self.servicesConfig == nil }
}
extension PluginConfig {
public init?(configURL: URL) throws {
let fm = FileManager.default
if !fm.fileExists(atPath: configURL.path) {
return nil
}
guard let data = fm.contents(atPath: configURL.path) else {
return nil
}
let decoder: JSONDecoder = JSONDecoder()
self = try decoder.decode(PluginConfig.self, from: data)
}
}
@@ -0,0 +1,93 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
private let configFilename: String = "config.json"
/// Describes the configuration and binary file locations for a plugin.
public protocol PluginFactory: Sendable {
/// Create a plugin conforming to the layout, if possible.
func create(installURL: URL) throws -> Plugin?
}
/// Default layout which uses a Unix-like structure.
public struct DefaultPluginFactory: PluginFactory {
public init() {}
public func create(installURL: URL) throws -> Plugin? {
let fm = FileManager.default
let configURL = installURL.appending(path: configFilename)
guard fm.fileExists(atPath: configURL.path) else {
return nil
}
guard let config = try PluginConfig(configURL: configURL) else {
return nil
}
let name = installURL.lastPathComponent
let binaryURL = installURL.appending(path: "bin").appending(path: name)
guard fm.fileExists(atPath: binaryURL.path) else {
return nil
}
return Plugin(binaryURL: binaryURL, config: config)
}
}
/// Layout which uses a macOS application bundle structure.
public struct AppBundlePluginFactory: PluginFactory {
private static let appSuffix = ".app"
public init() {}
public func create(installURL: URL) throws -> Plugin? {
let fm = FileManager.default
let configURL =
installURL
.appending(path: "Contents")
.appending(path: "Resources")
.appending(path: configFilename)
guard fm.fileExists(atPath: configURL.path) else {
return nil
}
guard let config = try PluginConfig(configURL: configURL) else {
return nil
}
let appName = installURL.lastPathComponent
guard appName.hasSuffix(Self.appSuffix) else {
return nil
}
let name = String(appName.dropLast(Self.appSuffix.count))
let binaryURL =
installURL
.appending(path: "Contents")
.appending(path: "MacOS")
.appending(path: name)
guard fm.fileExists(atPath: binaryURL.path) else {
return nil
}
return Plugin(binaryURL: binaryURL, config: config)
}
}
+213
View File
@@ -0,0 +1,213 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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 ContainerizationOS
import Foundation
import Logging
public struct PluginLoader: Sendable {
// A path on disk managed by the PluginLoader, where it stores
// runtime data for loaded plugins. This includes the launchd plists
// and logs files.
private let defaultPluginResourcePath: URL
private let pluginDirectories: [URL]
private let pluginFactories: [PluginFactory]
private let log: Logger?
public typealias PluginQualifier = ((Plugin) -> Bool)
public init(pluginDirectories: [URL], pluginFactories: [PluginFactory], defaultResourcePath: URL, log: Logger? = nil) {
self.pluginDirectories = pluginDirectories
self.pluginFactories = pluginFactories
self.log = log
self.defaultPluginResourcePath = defaultResourcePath
}
static public func defaultPluginResourcePath(root: URL) -> URL {
root.appending(path: "plugin-state")
}
static public func userPluginsDir(root: URL) -> URL {
root.appending(path: "user-plugins")
}
}
extension PluginLoader {
public func alterCLIHelpText(original: String) -> String {
var plugins = findPlugins()
plugins = plugins.filter { $0.config.isCLI }
guard !plugins.isEmpty else {
return original
}
var lines = original.split(separator: "\n").map { String($0) }
let footer = String(lines.removeLast())
let sectionHeader = "PLUGINS:"
lines.append(sectionHeader)
for plugin in plugins {
let helpText = plugin.helpText(padding: 24)
lines.append(helpText)
}
lines.append("")
lines.append(footer)
return lines.joined(separator: "\n")
}
public func findPlugins() -> [Plugin] {
let fm = FileManager.default
var pluginNames = Set<String>()
var plugins: [Plugin] = []
for pluginDir in pluginDirectories {
if !fm.fileExists(atPath: pluginDir.path) {
continue
}
guard
var dirs = try? fm.contentsOfDirectory(
at: pluginDir,
includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
)
else {
continue
}
dirs = dirs.filter {
$0.isDirectory
}
for installURL in dirs {
do {
guard
let plugin = try
(pluginFactories.compactMap {
try $0.create(installURL: installURL)
}.first)
else {
log?.warning(
"Not installing plugin with missing configuration",
metadata: [
"path": "\(installURL.path)"
]
)
continue
}
guard !pluginNames.contains(plugin.name) else {
log?.warning(
"Not installing shadowed plugin",
metadata: [
"path": "\(installURL.path)",
"name": "\(plugin.name)",
])
continue
}
plugins.append(plugin)
pluginNames.insert(plugin.name)
} catch {
log?.warning(
"Not installing plugin with invalid configuration",
metadata: [
"path": "\(installURL.path)",
"error": "\(error)",
]
)
}
}
}
return plugins
}
public func findPlugin(name: String, log: Logger? = nil) -> Plugin? {
do {
return
try pluginDirectories
.compactMap { installURL in
try pluginFactories.compactMap { try $0.create(installURL: installURL.appending(path: name)) }.first
}
.first
} catch {
log?.warning(
"Not installing plugin with invalid configuration",
metadata: [
"name": "\(name)",
"error": "\(error)",
]
)
return nil
}
}
}
extension PluginLoader {
public func registerWithLaunchd(
plugin: Plugin,
rootURL: URL? = nil,
args: [String]? = nil,
instanceId: String? = nil
) throws {
// We only care about loading plugins that have a service
// to expose, otherwise they may just be CLI commands.
guard let serviceConfig = plugin.config.servicesConfig else {
return
}
let id = plugin.getLaunchdLabel(instanceId: instanceId)
log?.info("Registering plugin", metadata: ["id": "\(id)"])
let rootURL = rootURL ?? self.defaultPluginResourcePath.appending(path: plugin.name)
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
let env = ProcessInfo.processInfo.environment.filter { key, _ in
key.hasPrefix("CONTAINER_")
}
let logUrl = rootURL.appendingPathComponent("service.log")
let plist = LaunchPlist(
label: id,
arguments: [plugin.binaryURL.path] + (args ?? serviceConfig.defaultArguments),
environment: env,
limitLoadToSessionType: [.Aqua, .Background, .System],
runAtLoad: serviceConfig.runAtLoad,
stdout: logUrl.path,
stderr: logUrl.path,
machServices: plugin.getMachServices(instanceId: instanceId)
)
let plistUrl = rootURL.appendingPathComponent("service.plist")
let data = try plist.encode()
try data.write(to: plistUrl)
try ServiceManager.register(plistPath: plistUrl.path)
}
public func deregisterWithLaunchd(plugin: Plugin, instanceId: String? = nil) throws {
// We only care about loading plugins that have a service
// to expose, otherwise they may just be CLI commands.
guard plugin.config.servicesConfig != nil else {
return
}
let domain = try ServiceManager.getDomainString()
let label = "\(domain)/\(plugin.getLaunchdLabel(instanceId: instanceId))"
log?.info("Deregistering plugin", metadata: ["id": "\(plugin.getLaunchdLabel())"])
try ServiceManager.deregister(fullServiceLabel: label)
}
}
@@ -0,0 +1,131 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// 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
public struct ServiceManager {
private static func runLaunchctlCommand(args: [String]) throws -> Int32 {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = args
let null = FileHandle.nullDevice
launchctl.standardOutput = null
launchctl.standardError = null
try launchctl.run()
launchctl.waitUntilExit()
return launchctl.terminationStatus
}
/// Register a service by providing the path to a plist.
public static func register(plistPath: String) throws {
let domain = try Self.getDomainString()
_ = try runLaunchctlCommand(args: ["bootstrap", domain, plistPath])
}
/// Deregister a service by a launchd label.
public static func deregister(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["bootout", label])
}
/// Restart a service by a launchd label.
public static func kickstart(fullServiceLabel label: String) throws {
_ = try runLaunchctlCommand(args: ["kickstart", "-k", label])
}
/// Send a signal to a service by a launchd label.
public static func kill(fullServiceLabel label: String, signal: Int32 = 15) throws {
_ = try runLaunchctlCommand(args: ["kill", "\(signal)", label])
}
/// Retrieve labels for all loaded launch units.
public static func enumerate() throws -> [String] {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["list"]
let null = FileHandle.nullDevice
let stdoutPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = null
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
// TODO: review error handling
return []
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
// TODO: review error handling
return []
}
// The third field of each line of launchctl list output is the label
return outputText.split { $0.isNewline }
.map { String($0).split { $0.isWhitespace } }
.filter { $0.count >= 3 }
.map { String($0[2]) }
}
/// Check if a service has been registered or not.
public static func isRegistered(fullServiceLabel label: String) throws -> Bool {
let exitStatus = try runLaunchctlCommand(args: ["list", label])
return exitStatus == 0
}
private static func getLaunchdSessionType() throws -> String {
let launchctl = Foundation.Process()
launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl")
launchctl.arguments = ["managername"]
let null = FileHandle.nullDevice
let stdoutPipe = Pipe()
launchctl.standardOutput = stdoutPipe
launchctl.standardError = null
try launchctl.run()
let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
launchctl.waitUntilExit()
let status = launchctl.terminationStatus
guard status == 0 else {
throw ContainerizationError(.internalError, message: "Command `launchctl managername` failed with status \(status)")
}
guard let outputText = String(data: outputData, encoding: .utf8) else {
throw ContainerizationError(.internalError, message: "Could not decode output of command `launchctl managername`")
}
return outputText.trimmingCharacters(in: .whitespacesAndNewlines)
}
public static func getDomainString() throws -> String {
let currentSessionType = try getLaunchdSessionType()
switch currentSessionType {
case LaunchPlist.Domain.System.rawValue:
return LaunchPlist.Domain.System.rawValue.lowercased()
case LaunchPlist.Domain.Background.rawValue:
return "user/\(getuid())"
case LaunchPlist.Domain.Aqua.rawValue:
return "gui/\(getuid())"
default:
throw ContainerizationError(.internalError, message: "Unsupported session type \(currentSessionType)")
}
}
}