Skip to content

Commit 3bb93d9

Browse files
committed
Refactor container k8s to use plugin resource management.
- Closes #2096. - Storing resources in a macOS resource bundle won't work for unix-layout plugins. - Rework the kindnet resource fetch to create a plugin loader, find the plugin based on the plugin executable path, and use the plugin's resourceURL to locate the resource. - Add a findPlugin function to locate the plugin that contains a pathname. - Fix help text to insure example runs regardless of the default registry configuration.
1 parent cec124f commit 3bb93d9

7 files changed

Lines changed: 233 additions & 11 deletions

File tree

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ $(STAGING_DIR):
134134
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)"
135135
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)"
136136
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin)"
137+
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources)"
137138

138139
@install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)"
139140
@install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)"
@@ -149,6 +150,7 @@ $(STAGING_DIR):
149150
@install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)"
150151
@install "$(BUILD_BIN_DIR)/k8s" "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)"
151152
@install Sources/Plugins/K8s/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/config.toml)"
153+
@install Sources/Plugins/K8s/Resources/kindnet.yaml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources/kindnet.yaml)"
152154

153155
@echo Install update script
154156
@install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)"

Package.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,18 +186,18 @@ let package = Package(
186186
"ContainerAPIClient",
187187
"ContainerLog",
188188
"ContainerPersistence",
189+
"ContainerPlugin",
189190
"ContainerResource",
190191
"ContainerVersion",
191192
"TerminalProgress",
192193
"Yams",
193-
],
194-
resources: [.process("Resources/kindnet.yaml")]
194+
]
195195
),
196196
.executableTarget(
197197
name: "k8s",
198198
dependencies: ["ContainerK8s"],
199199
path: "Sources/Plugins/K8s",
200-
exclude: ["config.toml"]
200+
exclude: ["config.toml", "Resources"]
201201
),
202202
.executableTarget(
203203
name: "container-apiserver",

Sources/ContainerK8s/K8sHelper.swift

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import ContainerAPIClient
1818
import ContainerPersistence
19+
import ContainerPlugin
1920
import ContainerResource
2021
import ContainerVersion
2122
import ContainerizationError
@@ -257,7 +258,7 @@ struct K8sHelper {
257258
arguments: ["taint", "nodes", "--all", "node-role.kubernetes.io/control-plane-"])
258259

259260
log.info("Applying kindnet CNI", metadata: ["node": "\(nodeID)"])
260-
let manifest = try loadKindnetManifest()
261+
let manifest = try await loadKindnetManifest(log: log)
261262
let apply =
262263
"cat > /tmp/kindnet.yaml <<'EOF'\n\(manifest)\nEOF\n"
263264
+ "\(kubeconfigEnv) kubectl apply -f /tmp/kindnet.yaml"
@@ -269,15 +270,60 @@ struct K8sHelper {
269270
}
270271
}
271272

272-
private static func loadKindnetManifest() throws -> String {
273-
guard let url = Bundle.module.url(forResource: "kindnet", withExtension: "yaml"),
274-
let contents = try? String(contentsOf: url, encoding: .utf8)
273+
private static func loadKindnetManifest(log: Logger) async throws -> String {
274+
let pluginLoader = try await makePluginLoader(log: log)
275+
guard let plugin = pluginLoader.findPlugin(forExecutable: CommandLine.executablePath),
276+
let resourceURL = plugin.resourceURL
275277
else {
276-
throw ContainerizationError(.internalError, message: "kindnet manifest resource missing")
278+
throw ContainerizationError(.internalError, message: "unable to locate k8s plugin installation or resources")
279+
}
280+
let url = resourceURL.appendingPathComponent("kindnet.yaml")
281+
guard let contents = try? String(contentsOf: url, encoding: .utf8) else {
282+
throw ContainerizationError(.internalError, message: "kindnet manifest resource missing at \(url.path)")
277283
}
278284
return contents
279285
}
280286

287+
/// NOTE: This duplicates `Application.createPluginLoader()` in
288+
/// Sources/ContainerCommands/Application.swift. `ContainerK8s` cannot depend on
289+
/// `ContainerCommands`, so the plugin directory/factory list here is kept in sync
290+
/// by hand — if that logic changes, update this copy too (or factor a shared
291+
/// constructor into `ContainerPlugin`).
292+
private static func makePluginLoader(log: Logger) async throws -> PluginLoader {
293+
let health = try await ClientHealthCheck.ping(timeout: .seconds(10))
294+
295+
let installRootPath = FilePath(health.installRoot.path(percentEncoded: false))
296+
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: health.installRoot)
297+
var directoryExists: ObjCBool = false
298+
_ = FileManager.default.fileExists(atPath: userPluginsURL.path, isDirectory: &directoryExists)
299+
300+
let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins")
301+
let installRootPluginsPath =
302+
installRootPath
303+
.appending(FilePath.Component("libexec"))
304+
.appending(FilePath.Component("container"))
305+
.appending(FilePath.Component("plugins"))
306+
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
307+
308+
let pluginDirectories = [
309+
directoryExists.boolValue ? userPluginsURL : nil,
310+
appBundlePluginsURL,
311+
installRootPluginsURL,
312+
].compactMap { $0 }
313+
314+
return try PluginLoader(
315+
appRoot: health.appRoot,
316+
installRoot: health.installRoot,
317+
logRoot: health.logRoot,
318+
pluginDirectories: pluginDirectories,
319+
pluginFactories: [
320+
DefaultPluginFactory(logger: log),
321+
AppBundlePluginFactory(logger: log),
322+
],
323+
log: log
324+
)
325+
}
326+
281327
private static let nodePrepScript: String = {
282328
"""
283329
set -e

Sources/ContainerPlugin/PluginLoader.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//===----------------------------------------------------------------------===//
1616

1717
import ContainerizationOS
18+
import Darwin
1819
import Foundation
1920
import Logging
2021
import SystemPackage
@@ -198,6 +199,32 @@ extension PluginLoader {
198199

199200
return nil
200201
}
202+
203+
/// Locate the plugin whose executable resolves to `path`, e.g. to let a
204+
/// running plugin process identify its own `Plugin` (and thus its
205+
/// `resourceURL`) from `CommandLine.executablePath`.
206+
public func findPlugin(forExecutable path: FilePath) -> Plugin? {
207+
guard let resolvedPath = Self.resolveSymlinks(path.string) else {
208+
return nil
209+
}
210+
for plugin in findPlugins() {
211+
guard let binaryPath = Self.resolveSymlinks(plugin.binaryURL.path(percentEncoded: false)) else {
212+
continue
213+
}
214+
if binaryPath == resolvedPath {
215+
return plugin
216+
}
217+
}
218+
return nil
219+
}
220+
221+
private static func resolveSymlinks(_ path: String) -> String? {
222+
guard let resolved = Darwin.realpath(path, nil) else {
223+
return nil
224+
}
225+
defer { free(resolved) }
226+
return String(cString: resolved)
227+
}
201228
}
202229

203230
extension PluginLoader {

Sources/Plugins/K8s/K8sCommand.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ public struct K8sCommand: AsyncParsableCommand {
3939
4040
Load a local image into the cluster and run it:
4141
$ container image pull docker.io/library/hello-world:latest
42-
$ container image tag docker.io/library/hello-world:latest my-hello-world:latest
43-
$ container k8s load-image --name my-cluster my-hello-world:latest
44-
$ kubectl run hello-job --image=my-hello-world:latest --restart=Never --attach --rm -i
42+
$ container image tag docker.io/library/hello-world:latest registry.example.com/max_mustermann/my-hello-world:latest
43+
$ container k8s load-image --name my-cluster registry.example.com/max_mustermann/my-hello-world:latest
44+
$ kubectl run hello-job --image=registry.example.com/max_mustermann/my-hello-world:latest --image-pull-policy=Never --restart=Never --attach --rm -i
4545
4646
Stop and delete the cluster:
4747
$ container k8s delete --name my-cluster
File renamed without changes.

Tests/ContainerPluginTests/PluginLoaderTest.swift

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//===----------------------------------------------------------------------===//
1616

1717
import Foundation
18+
import SystemPackage
1819
import Testing
1920

2021
@testable import ContainerPlugin
@@ -88,6 +89,122 @@ struct PluginLoaderTest {
8889
#expect(loader.findPlugin(name: "throw") == nil)
8990
}
9091

92+
@Test
93+
func testFindPluginForExecutable() async throws {
94+
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
95+
defer { try? FileManager.default.removeItem(at: tempURL) }
96+
let (factory, cliBinaryURL, serviceBinaryURL) = try setupMockWithRealBinaries(tempURL: tempURL)
97+
let loader = try PluginLoader(
98+
appRoot: tempURL,
99+
installRoot: URL(filePath: "/usr/local/"),
100+
logRoot: nil,
101+
pluginDirectories: [tempURL],
102+
pluginFactories: [factory]
103+
)
104+
105+
let cliMatch = loader.findPlugin(forExecutable: FilePath(cliBinaryURL.path(percentEncoded: false)))
106+
#expect(cliMatch?.name == "cli")
107+
108+
let serviceMatch = loader.findPlugin(forExecutable: FilePath(serviceBinaryURL.path(percentEncoded: false)))
109+
#expect(serviceMatch?.name == "service")
110+
}
111+
112+
@Test
113+
func testFindPluginForExecutableViaSymlinkedInput() async throws {
114+
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
115+
defer { try? FileManager.default.removeItem(at: tempURL) }
116+
let (factory, cliBinaryURL, _) = try setupMockWithRealBinaries(tempURL: tempURL)
117+
let loader = try PluginLoader(
118+
appRoot: tempURL,
119+
installRoot: URL(filePath: "/usr/local/"),
120+
logRoot: nil,
121+
pluginDirectories: [tempURL],
122+
pluginFactories: [factory]
123+
)
124+
125+
// Simulate CommandLine.executablePath resolving to a symlink that
126+
// ultimately points at the plugin's real binary on disk.
127+
let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
128+
defer { try? FileManager.default.removeItem(at: otherTempURL) }
129+
try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true)
130+
let symlinkURL = otherTempURL.appendingPathComponent("cli-symlink")
131+
try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: cliBinaryURL)
132+
133+
let match = loader.findPlugin(forExecutable: FilePath(symlinkURL.path(percentEncoded: false)))
134+
#expect(match?.name == "cli")
135+
}
136+
137+
@Test
138+
func testFindPluginForExecutableWithSymlinkedPluginBinary() async throws {
139+
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
140+
defer { try? FileManager.default.removeItem(at: tempURL) }
141+
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
142+
143+
// The plugin's registered binaryURL is itself a symlink pointing at a
144+
// binary that lives elsewhere on disk (e.g. a dev-mode install).
145+
let realBinDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
146+
defer { try? FileManager.default.removeItem(at: realBinDir) }
147+
try FileManager.default.createDirectory(at: realBinDir, withIntermediateDirectories: true)
148+
let realBinaryURL = realBinDir.appendingPathComponent("cli-real")
149+
try Data().write(to: realBinaryURL)
150+
151+
let symlinkBinaryURL = tempURL.appendingPathComponent("cli-bin")
152+
try FileManager.default.createSymbolicLink(at: symlinkBinaryURL, withDestinationURL: realBinaryURL)
153+
154+
let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil)
155+
let cliPlugin = Plugin(binaryURL: symlinkBinaryURL, config: cliConfig)
156+
let factory = try MockPluginFactory(tempURL: tempURL, plugins: ["cli": cliPlugin])
157+
158+
let loader = try PluginLoader(
159+
appRoot: tempURL,
160+
installRoot: URL(filePath: "/usr/local/"),
161+
logRoot: nil,
162+
pluginDirectories: [tempURL],
163+
pluginFactories: [factory]
164+
)
165+
166+
let match = loader.findPlugin(forExecutable: FilePath(realBinaryURL.path(percentEncoded: false)))
167+
#expect(match?.name == "cli")
168+
}
169+
170+
@Test
171+
func testFindPluginForExecutableNoMatch() async throws {
172+
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
173+
defer { try? FileManager.default.removeItem(at: tempURL) }
174+
let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL)
175+
let loader = try PluginLoader(
176+
appRoot: tempURL,
177+
installRoot: URL(filePath: "/usr/local/"),
178+
logRoot: nil,
179+
pluginDirectories: [tempURL],
180+
pluginFactories: [factory]
181+
)
182+
183+
let unrelatedURL = tempURL.appendingPathComponent("unrelated-bin")
184+
try Data().write(to: unrelatedURL)
185+
186+
let match = loader.findPlugin(forExecutable: FilePath(unrelatedURL.path(percentEncoded: false)))
187+
#expect(match == nil)
188+
}
189+
190+
@Test
191+
func testFindPluginForExecutableNonexistentPath() async throws {
192+
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
193+
defer { try? FileManager.default.removeItem(at: tempURL) }
194+
let (factory, _, _) = try setupMockWithRealBinaries(tempURL: tempURL)
195+
let loader = try PluginLoader(
196+
appRoot: tempURL,
197+
installRoot: URL(filePath: "/usr/local/"),
198+
logRoot: nil,
199+
pluginDirectories: [tempURL],
200+
pluginFactories: [factory]
201+
)
202+
203+
let missingURL = tempURL.appendingPathComponent("does-not-exist")
204+
let match = loader.findPlugin(forExecutable: FilePath(missingURL.path(percentEncoded: false)))
205+
#expect(match == nil)
206+
}
207+
91208
@Test
92209
func testFilterEnvironmentWithContainerPrefix() async throws {
93210
let env = [
@@ -289,4 +406,34 @@ struct PluginLoaderTest {
289406

290407
return try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins)
291408
}
409+
410+
// Unlike `setupMock`, the plugins here are backed by real, empty files on
411+
// disk so `findPlugin(forExecutable:)` can resolve their paths with
412+
// `realpath`.
413+
private func setupMockWithRealBinaries(tempURL: URL) throws -> (factory: MockPluginFactory, cliBinaryURL: URL, serviceBinaryURL: URL) {
414+
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
415+
let cliBinaryURL = tempURL.appendingPathComponent("cli-bin")
416+
let serviceBinaryURL = tempURL.appendingPathComponent("service-bin")
417+
try Data().write(to: cliBinaryURL)
418+
try Data().write(to: serviceBinaryURL)
419+
420+
let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil)
421+
let cliPlugin: Plugin = Plugin(binaryURL: cliBinaryURL, config: cliConfig)
422+
let serviceServicesConfig = PluginConfig.ServicesConfig(
423+
loadAtBoot: false,
424+
runAtLoad: false,
425+
services: [PluginConfig.Service(type: .runtime, description: nil)],
426+
defaultArguments: []
427+
)
428+
let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig)
429+
let servicePlugin: Plugin = Plugin(binaryURL: serviceBinaryURL, config: serviceConfig)
430+
let mockPlugins = [
431+
"cli": cliPlugin,
432+
MockPluginFactory.throwSuffix: nil,
433+
"service": servicePlugin,
434+
]
435+
436+
let factory = try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins)
437+
return (factory, cliBinaryURL, serviceBinaryURL)
438+
}
292439
}

0 commit comments

Comments
 (0)