Files
container/Sources/ContainerPlugin/PluginLoader.swift
T
J Logan 3ad0914fd2 Fix CI integration test coldstart issues. (#1230)
- Closes #1206.
- Closes #1185.
- Closes #507.
- Addresses existing log messages for #642.
- Nondeterministic CI errors are resulting from very slow launch times
for the first runtime helper, which causes ContainersService to be
locked for longer than our 20 sec timeout. Bumping the timeout to 60
seconds addresses this case for now.
- Since many log messages needed to be changed to troubleshoot the
issue, updated all log messages to use structured logging, and
implemented consistent entry/exit logging for all service operations.
- Added logging for ContainerService lock acquisition to help with
finding root cause for the slow service startup.
- Plumbed the `--debug` flag on both `container system start` and
`container system logs` so that the flag is actually useful.
- Updated the `install-init.sh` script so that can install in a custom
app root directory.
2026-02-19 09:58:09 -08:00

264 lines
9.4 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ContainerizationOS
import Foundation
import Logging
public struct PluginLoader: Sendable {
private let appRoot: URL
private let installRoot: URL
private let pluginDirectories: [URL]
private let pluginFactories: [PluginFactory]
private let log: Logger?
public typealias PluginQualifier = ((Plugin) -> Bool)
// 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 pluginResourceRoot: URL
public init(
appRoot: URL,
installRoot: URL,
pluginDirectories: [URL],
pluginFactories: [PluginFactory],
log: Logger? = nil
) throws {
let pluginResourceRoot = appRoot.appendingPathComponent("plugin-state")
try FileManager.default.createDirectory(at: pluginResourceRoot, withIntermediateDirectories: true)
self.pluginResourceRoot = pluginResourceRoot
self.appRoot = appRoot
self.installRoot = installRoot
self.pluginDirectories = pluginDirectories
self.pluginFactories = pluginFactories
self.log = log
}
static public func userPluginsDir(installRoot: URL) -> URL {
installRoot
.appending(path: "libexec")
.appending(path: "container-plugins")
.resolvingSymlinksInPath()
}
}
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 sectionHeader = "PLUGINS:"
lines.append(sectionHeader)
for plugin in plugins {
let helpText = plugin.helpText(padding: 24)
lines.append(helpText)
}
return lines.joined(separator: "\n")
}
/// Scan all plugin directories and detect plugins.
public func findPlugins() -> [Plugin] {
let fm = FileManager.default
// Maintain a set for tracking shadowed plugins
var pluginNames = Set<String>()
var plugins: [Plugin] = []
for pluginDir in pluginDirectories {
// Skip nonexistent plugin parent directories
if !fm.fileExists(atPath: pluginDir.path) {
continue
}
// Get all entries under the parent directory
let resolvedPluginDir = pluginDir.resolvingSymlinksInPath()
guard
let urls = try? fm.contentsOfDirectory(
at: resolvedPluginDir,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: .skipsHiddenFiles
)
else {
continue
}
// Filter out all but plugin installation directories
let installURLs = urls.filter { url in
if url.isDirectory {
return true
}
if url.isSymlink {
var isDirectory: ObjCBool = false
_ = fm.fileExists(atPath: url.resolvingSymlinksInPath().path(percentEncoded: false), isDirectory: &isDirectory)
return isDirectory.boolValue
}
return false
}
for installURL in installURLs {
do {
// Create a plugin with the first factory that can grok the layout under the install URL
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
}
// Warn and skip if this plugin name has been encountered already
guard !pluginNames.contains(plugin.name) else {
log?.warning(
"not installing shadowed plugin",
metadata: [
"path": "\(installURL.path)",
"name": "\(plugin.name)",
])
continue
}
// Add the plugin to the list
plugins.append(plugin)
pluginNames.insert(plugin.name)
} catch {
log?.warning(
"not installing plugin with invalid configuration",
metadata: [
"path": "\(installURL.path)",
"error": "\(error)",
]
)
}
}
}
return plugins
}
/// Locate a plugin with a specific name.
public func findPlugin(name: String, log: Logger? = nil) -> Plugin? {
do {
for pluginDirectory in pluginDirectories {
for PluginFactory in pluginFactories {
// throw means that the factory is correct but the plugin is broken
if let plugin = try PluginFactory.create(parentURL: pluginDirectory, name: name) {
return plugin
}
}
}
} catch {
log?.warning(
"not installing plugin with invalid configuration",
metadata: [
"name": "\(name)",
"error": "\(error)",
]
)
}
return nil
}
}
extension PluginLoader {
public static let proxyKeys = Set([
"http_proxy", "HTTP_PROXY",
"https_proxy", "HTTPS_PROXY",
"no_proxy", "NO_PROXY",
])
public func registerWithLaunchd(
plugin: Plugin,
pluginStateRoot: URL? = nil,
args: [String]? = nil,
instanceId: String? = nil,
debug: Bool = false,
) 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 = pluginStateRoot ?? self.pluginResourceRoot.appending(path: plugin.name)
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
var env = Self.filterEnvironment()
env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false)
env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false)
let processedArgs = (args ?? ["start"]) + (debug ? ["--debug"] : [])
let plist = LaunchPlist(
label: id,
arguments: [plugin.binaryURL.path] + processedArgs + serviceConfig.defaultArguments,
environment: env,
limitLoadToSessionType: [.Aqua, .Background, .System],
runAtLoad: serviceConfig.runAtLoad,
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)
}
public static func filterEnvironment(
env: [String: String] = ProcessInfo.processInfo.environment,
additionalAllowKeys: Set<String> = Self.proxyKeys
) -> [String: String] {
env.filter { key, _ in
key.hasPrefix("CONTAINER_") || additionalAllowKeys.contains(key)
}
}
}