diff --git a/Makefile b/Makefile index 9ad62067d..a4f188cfa 100644 --- a/Makefile +++ b/Makefile @@ -134,6 +134,7 @@ $(STAGING_DIR): @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources)" @install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)" @install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)" @@ -149,6 +150,7 @@ $(STAGING_DIR): @install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)" @install "$(BUILD_BIN_DIR)/k8s" "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)" @install Sources/Plugins/K8s/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/config.toml)" + @install Sources/Plugins/K8s/Resources/kindnet.yaml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources/kindnet.yaml)" @echo Install update script @install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)" diff --git a/Package.swift b/Package.swift index 16b2d2190..2491f939a 100644 --- a/Package.swift +++ b/Package.swift @@ -186,18 +186,18 @@ let package = Package( "ContainerAPIClient", "ContainerLog", "ContainerPersistence", + "ContainerPlugin", "ContainerResource", "ContainerVersion", "TerminalProgress", "Yams", - ], - resources: [.process("Resources/kindnet.yaml")] + ] ), .executableTarget( name: "k8s", dependencies: ["ContainerK8s"], path: "Sources/Plugins/K8s", - exclude: ["config.toml"] + exclude: ["config.toml", "Resources"] ), .executableTarget( name: "container-apiserver", diff --git a/Sources/ContainerK8s/K8sHelper.swift b/Sources/ContainerK8s/K8sHelper.swift index 68043bb97..d0dd68638 100644 --- a/Sources/ContainerK8s/K8sHelper.swift +++ b/Sources/ContainerK8s/K8sHelper.swift @@ -16,6 +16,7 @@ import ContainerAPIClient import ContainerPersistence +import ContainerPlugin import ContainerResource import ContainerVersion import ContainerizationError @@ -257,7 +258,7 @@ struct K8sHelper { arguments: ["taint", "nodes", "--all", "node-role.kubernetes.io/control-plane-"]) log.info("Applying kindnet CNI", metadata: ["node": "\(nodeID)"]) - let manifest = try loadKindnetManifest() + let manifest = try await loadKindnetManifest(log: log) let apply = "cat > /tmp/kindnet.yaml <<'EOF'\n\(manifest)\nEOF\n" + "\(kubeconfigEnv) kubectl apply -f /tmp/kindnet.yaml" @@ -269,15 +270,60 @@ struct K8sHelper { } } - private static func loadKindnetManifest() throws -> String { - guard let url = Bundle.module.url(forResource: "kindnet", withExtension: "yaml"), - let contents = try? String(contentsOf: url, encoding: .utf8) + private static func loadKindnetManifest(log: Logger) async throws -> String { + let pluginLoader = try await makePluginLoader(log: log) + guard let plugin = pluginLoader.findPlugin(forExecutable: CommandLine.executablePath), + let resourceURL = plugin.resourceURL else { - throw ContainerizationError(.internalError, message: "kindnet manifest resource missing") + throw ContainerizationError(.internalError, message: "unable to locate k8s plugin installation or resources") + } + let url = resourceURL.appendingPathComponent("kindnet.yaml") + guard let contents = try? String(contentsOf: url, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "kindnet manifest resource missing at \(url.path)") } return contents } + /// NOTE: This duplicates `Application.createPluginLoader()` in + /// Sources/ContainerCommands/Application.swift. `ContainerK8s` cannot depend on + /// `ContainerCommands`, so the plugin directory/factory list here is kept in sync + /// by hand — if that logic changes, update this copy too (or factor a shared + /// constructor into `ContainerPlugin`). + private static func makePluginLoader(log: Logger) async throws -> PluginLoader { + let health = try await ClientHealthCheck.ping(timeout: .seconds(10)) + + let installRootPath = FilePath(health.installRoot.path(percentEncoded: false)) + let userPluginsURL = PluginLoader.userPluginsDir(installRoot: health.installRoot) + var directoryExists: ObjCBool = false + _ = FileManager.default.fileExists(atPath: userPluginsURL.path, isDirectory: &directoryExists) + + let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins") + let installRootPluginsPath = + installRootPath + .appending(FilePath.Component("libexec")) + .appending(FilePath.Component("container")) + .appending(FilePath.Component("plugins")) + let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string) + + let pluginDirectories = [ + directoryExists.boolValue ? userPluginsURL : nil, + appBundlePluginsURL, + installRootPluginsURL, + ].compactMap { $0 } + + return try PluginLoader( + appRoot: health.appRoot, + installRoot: health.installRoot, + logRoot: health.logRoot, + pluginDirectories: pluginDirectories, + pluginFactories: [ + DefaultPluginFactory(logger: log), + AppBundlePluginFactory(logger: log), + ], + log: log + ) + } + private static let nodePrepScript: String = { """ set -e diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift index d685d39d1..7285efa80 100644 --- a/Sources/ContainerPlugin/PluginLoader.swift +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationOS +import Darwin import Foundation import Logging import SystemPackage @@ -198,6 +199,32 @@ extension PluginLoader { return nil } + + /// Locate the plugin whose executable resolves to `path`, e.g. to let a + /// running plugin process identify its own `Plugin` (and thus its + /// `resourceURL`) from `CommandLine.executablePath`. + public func findPlugin(forExecutable path: FilePath) -> Plugin? { + guard let resolvedPath = Self.resolveSymlinks(path.string) else { + return nil + } + for plugin in findPlugins() { + guard let binaryPath = Self.resolveSymlinks(plugin.binaryURL.path(percentEncoded: false)) else { + continue + } + if binaryPath == resolvedPath { + return plugin + } + } + return nil + } + + private static func resolveSymlinks(_ path: String) -> String? { + guard let resolved = Darwin.realpath(path, nil) else { + return nil + } + defer { free(resolved) } + return String(cString: resolved) + } } extension PluginLoader { diff --git a/Sources/Plugins/K8s/K8sCommand.swift b/Sources/Plugins/K8s/K8sCommand.swift index 257c28a5f..af2e184f0 100644 --- a/Sources/Plugins/K8s/K8sCommand.swift +++ b/Sources/Plugins/K8s/K8sCommand.swift @@ -39,9 +39,9 @@ public struct K8sCommand: AsyncParsableCommand { Load a local image into the cluster and run it: $ container image pull docker.io/library/hello-world:latest - $ container image tag docker.io/library/hello-world:latest my-hello-world:latest - $ container k8s load-image --name my-cluster my-hello-world:latest - $ kubectl run hello-job --image=my-hello-world:latest --restart=Never --attach --rm -i + $ container image tag docker.io/library/hello-world:latest registry.example.com/max_mustermann/my-hello-world:latest + $ container k8s load-image --name my-cluster registry.example.com/max_mustermann/my-hello-world:latest + $ kubectl run hello-job --image=registry.example.com/max_mustermann/my-hello-world:latest --image-pull-policy=Never --restart=Never --attach --rm -i Stop and delete the cluster: $ container k8s delete --name my-cluster diff --git a/Sources/ContainerK8s/Resources/kindnet.yaml b/Sources/Plugins/K8s/Resources/kindnet.yaml similarity index 100% rename from Sources/ContainerK8s/Resources/kindnet.yaml rename to Sources/Plugins/K8s/Resources/kindnet.yaml diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift index 20a3b4e47..b113ad229 100644 --- a/Tests/ContainerPluginTests/PluginLoaderTest.swift +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -15,6 +15,8 @@ //===----------------------------------------------------------------------===// import Foundation +import Logging +import SystemPackage import Testing @testable import ContainerPlugin @@ -88,6 +90,179 @@ struct PluginLoaderTest { #expect(loader.findPlugin(name: "throw") == nil) } + @Test + func testFindPluginForExecutable() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let (factory, cliBinaryURL, serviceBinaryURL) = try setupMockWithRealBinaries(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let cliMatch = loader.findPlugin(forExecutable: FilePath(cliBinaryURL.path(percentEncoded: false))) + #expect(cliMatch?.name == "cli") + + let serviceMatch = loader.findPlugin(forExecutable: FilePath(serviceBinaryURL.path(percentEncoded: false))) + #expect(serviceMatch?.name == "service") + } + + @Test + func testFindPluginForExecutableViaSymlinkedInput() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let (factory, cliBinaryURL, _) = try setupMockWithRealBinaries(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + // Simulate CommandLine.executablePath resolving to a symlink that + // ultimately points at the plugin's real binary on disk. + let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: otherTempURL) } + try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true) + let symlinkURL = otherTempURL.appendingPathComponent("cli-symlink") + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: cliBinaryURL) + + let match = loader.findPlugin(forExecutable: FilePath(symlinkURL.path(percentEncoded: false))) + #expect(match?.name == "cli") + } + + @Test + func testFindPluginForExecutableWithSymlinkedPluginBinary() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true) + + // The plugin's registered binaryURL is itself a symlink pointing at a + // binary that lives elsewhere on disk (e.g. a dev-mode install). + let realBinDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: realBinDir) } + try FileManager.default.createDirectory(at: realBinDir, withIntermediateDirectories: true) + let realBinaryURL = realBinDir.appendingPathComponent("cli-real") + try Data().write(to: realBinaryURL) + + let symlinkBinaryURL = tempURL.appendingPathComponent("bin").appendingPathComponent("cli") + try FileManager.default.createDirectory(at: symlinkBinaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: symlinkBinaryURL, withDestinationURL: realBinaryURL) + + let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil) + let cliPlugin = Plugin(binaryURL: symlinkBinaryURL, config: cliConfig) + let factory = try MockPluginFactory(tempURL: tempURL, plugins: ["cli": cliPlugin]) + + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let match = loader.findPlugin(forExecutable: FilePath(realBinaryURL.path(percentEncoded: false))) + #expect(match?.name == "cli") + } + + @Test + func testFindPluginForExecutableNoMatch() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let unrelatedURL = tempURL.appendingPathComponent("unrelated-bin") + try Data().write(to: unrelatedURL) + + let match = loader.findPlugin(forExecutable: FilePath(unrelatedURL.path(percentEncoded: false))) + #expect(match == nil) + } + + @Test + func testFindPluginForExecutableNonexistentPath() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let missingURL = tempURL.appendingPathComponent("does-not-exist") + let match = loader.findPlugin(forExecutable: FilePath(missingURL.path(percentEncoded: false))) + #expect(match == nil) + } + + // Confirms findPlugin(forExecutable:) works against plugins produced by + // the real factories, not just MockPluginFactory's adhoc paths. + @Test + func testFindPluginForExecutableUnixLayout() async throws { + let fm = FileManager.default + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fm.removeItem(at: tempURL) } + let pluginsURL = tempURL.appendingPathComponent("plugins") + let installURL = pluginsURL.appendingPathComponent("cli") + let binaryDirURL = installURL.appendingPathComponent("bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appendingPathComponent("cli") + try Data().write(to: binaryURL) + try "abstract = \"cli\"\nauthor = \"Apple\"".write( + to: installURL.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [pluginsURL], + pluginFactories: [DefaultPluginFactory(logger: Logger(label: "test"))] + ) + + let match = loader.findPlugin(forExecutable: FilePath(binaryURL.path(percentEncoded: false))) + #expect(match?.name == "cli") + } + + @Test + func testFindPluginForExecutableAppBundleLayout() async throws { + let fm = FileManager.default + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? fm.removeItem(at: tempURL) } + let pluginsURL = tempURL.appendingPathComponent("plugins") + let installURL = pluginsURL.appendingPathComponent("cli.app") + let macosURL = installURL.appendingPathComponent("Contents").appendingPathComponent("MacOS") + let resourcesURL = installURL.appendingPathComponent("Contents").appendingPathComponent("Resources") + try fm.createDirectory(at: macosURL, withIntermediateDirectories: true) + try fm.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + let binaryURL = macosURL.appendingPathComponent("cli") + try Data().write(to: binaryURL) + try "abstract = \"cli\"\nauthor = \"Apple\"".write( + to: resourcesURL.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) + + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [pluginsURL], + pluginFactories: [AppBundlePluginFactory(logger: Logger(label: "test"))] + ) + + let match = loader.findPlugin(forExecutable: FilePath(binaryURL.path(percentEncoded: false))) + #expect(match?.name == "cli") + } + @Test func testFilterEnvironmentWithContainerPrefix() async throws { let env = [ @@ -289,4 +464,39 @@ struct PluginLoaderTest { return try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins) } + + // Unlike `setupMock`, the plugins here are backed by real, empty files on + // disk so `findPlugin(forExecutable:)` can resolve their paths with + // `realpath`. + private func setupMockWithRealBinaries(tempURL: URL) throws -> (factory: MockPluginFactory, cliBinaryURL: URL, serviceBinaryURL: URL) { + // Binaries live under a "bin" subdirectory, distinct from the + // per-plugin bookkeeping directories MockPluginFactory creates + // directly under tempURL, and are named to match `Plugin.name` + // (`binaryURL.lastPathComponent`). + let binDir = tempURL.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let cliBinaryURL = binDir.appendingPathComponent("cli") + let serviceBinaryURL = binDir.appendingPathComponent("service") + try Data().write(to: cliBinaryURL) + try Data().write(to: serviceBinaryURL) + + let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil) + let cliPlugin: Plugin = Plugin(binaryURL: cliBinaryURL, config: cliConfig) + let serviceServicesConfig = PluginConfig.ServicesConfig( + loadAtBoot: false, + runAtLoad: false, + services: [PluginConfig.Service(type: .runtime, description: nil)], + defaultArguments: [] + ) + let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig) + let servicePlugin: Plugin = Plugin(binaryURL: serviceBinaryURL, config: serviceConfig) + let mockPlugins = [ + "cli": cliPlugin, + MockPluginFactory.throwSuffix: nil, + "service": servicePlugin, + ] + + let factory = try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins) + return (factory, cliBinaryURL, serviceBinaryURL) + } }