mirror of
https://github.com/apple/container.git
synced 2026-10-02 12:27:52 +00:00
Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user