From 8089caeff23cd0cf601d9e40b165827a089f9b49 Mon Sep 17 00:00:00 2001 From: Kathryn Baldauf Date: Tue, 15 Sep 2026 11:24:59 -0700 Subject: [PATCH 1/2] K8s plugin: support CNI on create Signed-off-by: Kathryn Baldauf --- Sources/ContainerK8s/Commands/K8sCreate.swift | 11 ++-- Sources/ContainerK8s/K8sHelper.swift | 2 + .../Support/K8sHelper+Bootstrap.swift | 20 ++++--- .../K8s/ContainerFixture+K8sHelpers.swift | 34 ++++++++++++ .../K8s/TestK8sCNISerial.swift | 52 +++++++++++++++++++ .../K8s/TestK8sNetworkingSerial.swift | 46 ++++++---------- Tests/K8sPluginTests/K8sCreateCNITests.swift | 5 ++ docs/command-reference.md | 5 +- 8 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 Tests/IntegrationTests/K8s/ContainerFixture+K8sHelpers.swift create mode 100644 Tests/IntegrationTests/K8s/TestK8sCNISerial.swift diff --git a/Sources/ContainerK8s/Commands/K8sCreate.swift b/Sources/ContainerK8s/Commands/K8sCreate.swift index 3432240e1..4ff968abd 100644 --- a/Sources/ContainerK8s/Commands/K8sCreate.swift +++ b/Sources/ContainerK8s/Commands/K8sCreate.swift @@ -51,7 +51,7 @@ public struct K8sCreate: AsyncParsableCommand { @Option(help: "Node image reference (default: \(K8sHelper.nodeImage))") var nodeImage: String = K8sHelper.nodeImage - @Option(name: .long, help: "Optional path to a CNI manifest to apply.") + @Option(name: .long, help: "Optional path to a CNI manifest to apply, or \"none\" to skip installing a CNI.") var cni: String? public func run() async throws { @@ -62,7 +62,8 @@ public struct K8sCreate: AsyncParsableCommand { throw ContainerizationError(.invalidArgument, message: "cluster name \(name) is not a valid container ID") } - if let cni { + let skipReadinessWait = cni == K8sHelper.noCNIName + if let cni, !skipReadinessWait { guard FileManager.default.fileExists(atPath: cni) else { throw ContainerizationError(.invalidArgument, message: "CNI manifest not found at \(cni)") } @@ -115,8 +116,10 @@ public struct K8sCreate: AsyncParsableCommand { cniManifestPath: cni, client: client, log: log) - progress.set(description: "Waiting for cluster to be ready") - try await K8sHelper.waitForReady(containerId: name, client: client, log: log) + if !skipReadinessWait { + progress.set(description: "Waiting for cluster to be ready") + try await K8sHelper.waitForReady(containerId: name, client: client, log: log) + } progress.set(description: "Writing kubeconfig") do { diff --git a/Sources/ContainerK8s/K8sHelper.swift b/Sources/ContainerK8s/K8sHelper.swift index 2bc4c9d7f..e0f2a7689 100644 --- a/Sources/ContainerK8s/K8sHelper.swift +++ b/Sources/ContainerK8s/K8sHelper.swift @@ -44,6 +44,8 @@ public struct K8sHelper { public static let ignorePreflightErrors = "Swap,SystemVerification,FileContent--proc-sys-net-bridge-bridge-nf-call-iptables" static let podSubnet = "10.244.0.0/16" + /// Sentinel value for `--cni` that skips installing a CNI entirely. + public static let noCNIName = "none" // kubeadm default service subnet; must stay in sync if ClusterConfiguration.serviceSubnet is ever set. static let serviceSubnet = "10.96.0.0/12" diff --git a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift index 58e6bec33..2b44ead4f 100644 --- a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift +++ b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift @@ -73,14 +73,18 @@ extension K8sHelper { arguments: ["taint", "nodes", "--all", "node-role.kubernetes.io/control-plane-"]) } - log.info("Applying CNI manifest", metadata: ["node": "\(nodeID)"]) - let manifest = try await loadCNIManifest(path: cniManifestPath, log: log) - let apply = "\(kubeconfigEnv) kubectl apply -f - <<'EOF'\n\(manifest)\nEOF" - r = try await execCapture( - containerId: nodeID, executable: "/bin/sh", - arguments: ["-c", apply], client: client) - guard r.code == 0 else { - throw ContainerizationError(.internalError, message: "apply CNI failed on \(nodeID): \(r.output)") + if cniManifestPath == noCNIName { + log.info("Skipping CNI installation", metadata: ["node": "\(nodeID)"]) + } else { + log.info("Applying CNI manifest", metadata: ["node": "\(nodeID)"]) + let manifest = try await loadCNIManifest(path: cniManifestPath, log: log) + let apply = "\(kubeconfigEnv) kubectl apply -f - <<'EOF'\n\(manifest)\nEOF" + r = try await execCapture( + containerId: nodeID, executable: "/bin/sh", + arguments: ["-c", apply], client: client) + guard r.code == 0 else { + throw ContainerizationError(.internalError, message: "apply CNI failed on \(nodeID): \(r.output)") + } } } diff --git a/Tests/IntegrationTests/K8s/ContainerFixture+K8sHelpers.swift b/Tests/IntegrationTests/K8s/ContainerFixture+K8sHelpers.swift new file mode 100644 index 000000000..35ce88e7a --- /dev/null +++ b/Tests/IntegrationTests/K8s/ContainerFixture+K8sHelpers.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 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 ContainerTestSupport +import Foundation + +extension ContainerFixture { + @discardableResult + func kubectl(node: String, args: [String]) throws -> (output: String, status: Int32) { + print("[k8s] kubectl \(args.joined(separator: " ")) (node: \(node))") + let result = try self.run(["exec", node, "kubectl"] + args) + print("[k8s] kubectl exit=\(result.status) output=\(result.output.prefix(120).trimmingCharacters(in: .whitespacesAndNewlines))") + let filteredStderr = result.error.components(separatedBy: "\n") + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + .joined(separator: "\n") + if !filteredStderr.isEmpty { + print("[k8s] kubectl stderr: \(filteredStderr.prefix(300))") + } + return (result.output, result.status) + } +} diff --git a/Tests/IntegrationTests/K8s/TestK8sCNISerial.swift b/Tests/IntegrationTests/K8s/TestK8sCNISerial.swift new file mode 100644 index 000000000..0d0499524 --- /dev/null +++ b/Tests/IntegrationTests/K8s/TestK8sCNISerial.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// Copyright © 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 ContainerTestSupport +import Foundation +import Testing + +@Suite(.serialized) +struct TestK8sCNISerial { + + @Test func testCreateWithCNINoneSkipsCNIInstallation() async throws { + try await ContainerFixture.with { f in + let name = "k8s-\(f.testID)" + f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + + try f.restoreWarmupImage(.kindestNodeV1_35_5) + print("[k8s-cni] k8s create --name \(name) --cni none") + let result = try f.run(["k8s", "create", "--name", name, "--cni", "none"]) + print("[k8s-cni] k8s create exit=\(result.status)") + if result.status != 0 { + print("[k8s-cni] k8s create stderr: \(result.error)") + f.dumpNodeDiagnostics(node: name) + } + + try result.check() + #expect(result.output.contains(name)) + #expect(try f.getContainerStatus(name) == "running") + + // No CNI manifest was applied, so kube-system has no CNI daemonset and the node never reaches Ready. + let (podsOutput, podsStatus) = try f.kubectl(node: name, args: ["get", "pods", "-n", "kube-system", "--no-headers"]) + #expect(podsStatus == 0) + #expect(!podsOutput.lowercased().contains("kindnet")) + + let (nodesOutput, nodesStatus) = try f.kubectl(node: name, args: ["get", "nodes", "--no-headers"]) + #expect(nodesStatus == 0) + #expect(nodesOutput.contains("NotReady")) + } + } +} diff --git a/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift b/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift index 1325f41e8..34c179096 100644 --- a/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift +++ b/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift @@ -37,31 +37,17 @@ struct TestK8sNetworkingSerial { print("=== END ENV DUMP ===") } - @discardableResult - private func kubectl(_ f: ContainerFixture, node: String, args: [String]) throws -> (output: String, status: Int32) { - print("[k8s-net] kubectl \(args.joined(separator: " ")) (node: \(node))") - let result = try f.run(["exec", node, "kubectl"] + args) - print("[k8s-net] kubectl exit=\(result.status) output=\(result.output.prefix(120).trimmingCharacters(in: .whitespacesAndNewlines))") - let filteredStderr = result.error.components(separatedBy: "\n") - .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } - .joined(separator: "\n") - if !filteredStderr.isEmpty { - print("[k8s-net] kubectl stderr: \(filteredStderr.prefix(300))") - } - return (result.output, result.status) - } - private func waitForPod(_ f: ContainerFixture, node: String, podName: String, timeoutSeconds: Int) throws { print("[k8s-net] waitForPod \(podName) on \(node) (timeout=\(timeoutSeconds)s)") - let (_, status) = try kubectl( - f, node: node, + let (_, status) = try f.kubectl( + node: node, args: [ "wait", "--for=condition=Ready", "pod/\(podName)", "--timeout=\(timeoutSeconds)s", ]) guard status == 0 else { - let (podStatus, _) = try kubectl(f, node: node, args: ["get", "pod", podName, "--no-headers"]) + let (podStatus, _) = try f.kubectl(node: node, args: ["get", "pod", podName, "--no-headers"]) print("[k8s-net] pod \(podName) status: \(podStatus.trimmingCharacters(in: .whitespacesAndNewlines))") - let (podDesc, _) = try kubectl(f, node: node, args: ["describe", "pod", podName]) + let (podDesc, _) = try f.kubectl(node: node, args: ["describe", "pod", podName]) print("[k8s-net] pod \(podName) describe:\n\(podDesc.prefix(1000))") throw CommandError.executionFailed("pod \(podName) did not become ready within \(timeoutSeconds)s") } @@ -94,8 +80,8 @@ struct TestK8sNetworkingSerial { if loadResult.status != 0 { print("[k8s-net] k8s load-image stderr: \(loadResult.error)") } #expect(loadResult.status == 0) - let (_, createStatus) = try kubectl( - f, node: name, + let (_, createStatus) = try f.kubectl( + node: name, args: [ "run", "test-pod", "--image=\(Self.testImage.rawValue)", @@ -107,8 +93,8 @@ struct TestK8sNetworkingSerial { try waitForPod(f, node: name, podName: "test-pod", timeoutSeconds: 300) - let (output, execStatus) = try kubectl( - f, node: name, + let (output, execStatus) = try f.kubectl( + node: name, args: [ "exec", "test-pod", "--", "echo", "hello", ]) @@ -143,8 +129,8 @@ struct TestK8sNetworkingSerial { #expect(loadResult.status == 0) // Server: alpine busybox httpd serving a static response. - let (_, serverStatus) = try kubectl( - f, node: name, + let (_, serverStatus) = try f.kubectl( + node: name, args: [ "run", "server", "--image=\(Self.testImage.rawValue)", @@ -158,16 +144,16 @@ struct TestK8sNetworkingSerial { try waitForPod(f, node: name, podName: "server", timeoutSeconds: 300) // Expose server as a ClusterIP service. - let (_, exposeStatus) = try kubectl( - f, node: name, + let (_, exposeStatus) = try f.kubectl( + node: name, args: [ "expose", "pod", "server", "--port=8080", "--name=server-svc", ]) #expect(exposeStatus == 0) // Client pod that stays alive so we can exec into it. - let (_, clientStatus) = try kubectl( - f, node: name, + let (_, clientStatus) = try f.kubectl( + node: name, args: [ "run", "client", "--image=\(Self.testImage.rawValue)", @@ -180,8 +166,8 @@ struct TestK8sNetworkingSerial { // Reach server via the service DNS name — exercises CoreDNS + kube-proxy. print("[k8s-net] wget from client to server-svc:8080") - let (response, wgetStatus) = try kubectl( - f, node: name, + let (response, wgetStatus) = try f.kubectl( + node: name, args: [ "exec", "client", "--", "sh", "-c", "sleep 2 && wget -qO- http://server-svc:8080", diff --git a/Tests/K8sPluginTests/K8sCreateCNITests.swift b/Tests/K8sPluginTests/K8sCreateCNITests.swift index cafde5673..09cc46fee 100644 --- a/Tests/K8sPluginTests/K8sCreateCNITests.swift +++ b/Tests/K8sPluginTests/K8sCreateCNITests.swift @@ -34,6 +34,11 @@ struct K8sCreateCNIFlagTests { let command = try K8sCreate.parse(["--cni", "/tmp/my-cni.yaml"]) #expect(command.cni == "/tmp/my-cni.yaml") } + + @Test func cniAcceptsNone() throws { + let command = try K8sCreate.parse(["--cni", "none"]) + #expect(command.cni == "none") + } } // MARK: - K8sHelper.loadCNIManifest diff --git a/docs/command-reference.md b/docs/command-reference.md index 9361036aa..bd90ea972 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1617,7 +1617,7 @@ container k8s create [--name ] [--node-image ] [--cni ] [--rm * `--name `: Cluster name (default: `k8s-dev`) * `--node-image `: Node image reference (default: `docker.io/kindest/node:v1.35.5`) -* `--cni `: Optional path to a CNI manifest to apply. If not provided, the bundled kindnet CNI is used. +* `--cni `: Optional path to a CNI manifest to apply, or `none` to skip installing a CNI. If not provided, the bundled kindnet CNI is used. With `none`, the command returns without waiting for nodes to become `Ready`, since that requires a CNI; apply your own afterward with `kubectl apply`. * `--rm`: Remove the cluster container after it stops **Resource Options** @@ -1647,6 +1647,9 @@ container k8s create --name temp-cluster --rm # create a cluster using a custom CNI manifest instead of the bundled kindnet container k8s create --cni ./my-cni.yaml + +# create a cluster with no CNI installed +container k8s create --cni none ``` ### `container k8s start` From 9c6f48a44cc699ebff4c88a780cbac4763025ad4 Mon Sep 17 00:00:00 2001 From: Kathryn Baldauf Date: Tue, 15 Sep 2026 18:36:11 -0700 Subject: [PATCH 2/2] k8sStart does not wait for nodes to be in ready state unless wait option is passed Signed-off-by: Kathryn Baldauf --- Sources/ContainerK8s/Commands/K8sCreate.swift | 16 ++++---- Sources/ContainerK8s/Commands/K8sStart.swift | 8 +++- Sources/ContainerK8s/K8sHelper.swift | 27 ++++++++++++- .../Support/K8sHelper+Bootstrap.swift | 39 ++++++++++--------- Tests/K8sPluginTests/K8sCreateCNITests.swift | 35 ++++++++++------- docs/command-reference.md | 10 +++-- docs/kubernetes.md | 10 +++++ 7 files changed, 100 insertions(+), 45 deletions(-) diff --git a/Sources/ContainerK8s/Commands/K8sCreate.swift b/Sources/ContainerK8s/Commands/K8sCreate.swift index 4ff968abd..13b86dd4f 100644 --- a/Sources/ContainerK8s/Commands/K8sCreate.swift +++ b/Sources/ContainerK8s/Commands/K8sCreate.swift @@ -51,7 +51,10 @@ public struct K8sCreate: AsyncParsableCommand { @Option(help: "Node image reference (default: \(K8sHelper.nodeImage))") var nodeImage: String = K8sHelper.nodeImage - @Option(name: .long, help: "Optional path to a CNI manifest to apply, or \"none\" to skip installing a CNI.") + @Option( + name: .long, + help: "Optional path to a CNI manifest to apply, or \"none\" (case-insensitive) to skip installing a CNI." + ) var cni: String? public func run() async throws { @@ -62,12 +65,7 @@ public struct K8sCreate: AsyncParsableCommand { throw ContainerizationError(.invalidArgument, message: "cluster name \(name) is not a valid container ID") } - let skipReadinessWait = cni == K8sHelper.noCNIName - if let cni, !skipReadinessWait { - guard FileManager.default.fileExists(atPath: cni) else { - throw ContainerizationError(.invalidArgument, message: "CNI manifest not found at \(cni)") - } - } + let cniSelection = try CNISelection.resolve(cni) let isTTY = isatty(FileHandle.standardError.fileDescriptor) == 1 let progressConfig = try ProgressConfig( @@ -113,10 +111,10 @@ public struct K8sCreate: AsyncParsableCommand { try await K8sHelper.bootstrapControlPlane( nodeID: name, apiServerSANs: sans, advertiseAddress: vmIP, schedulable: provisioner.roles.contains(StandardRoles.worker), - cniManifestPath: cni, + cni: cniSelection, client: client, log: log) - if !skipReadinessWait { + if cniSelection != .none { progress.set(description: "Waiting for cluster to be ready") try await K8sHelper.waitForReady(containerId: name, client: client, log: log) } diff --git a/Sources/ContainerK8s/Commands/K8sStart.swift b/Sources/ContainerK8s/Commands/K8sStart.swift index ae792ba4a..1c5276aee 100644 --- a/Sources/ContainerK8s/Commands/K8sStart.swift +++ b/Sources/ContainerK8s/Commands/K8sStart.swift @@ -33,6 +33,9 @@ public struct K8sStart: AsyncParsableCommand { @Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))") var name: String = K8sHelper.defaultName + @Flag(name: .long, help: "Wait for the node to report Ready before returning") + var wait: Bool = false + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) @@ -57,7 +60,10 @@ public struct K8sStart: AsyncParsableCommand { try io.closeAfterStart() try await K8sHelper.waitForNodeBooted(containerId: name, client: client, log: log) - try await K8sHelper.waitForReady(containerId: name, client: client, log: log) + + if wait { + try await K8sHelper.waitForReady(containerId: name, client: client, log: log) + } do { let fqdn = await K8sHelper.detectFQDN(name: name) diff --git a/Sources/ContainerK8s/K8sHelper.swift b/Sources/ContainerK8s/K8sHelper.swift index e0f2a7689..99d33f30d 100644 --- a/Sources/ContainerK8s/K8sHelper.swift +++ b/Sources/ContainerK8s/K8sHelper.swift @@ -21,6 +21,31 @@ import ContainerizationOS import Foundation import Logging +// MARK: - CNISelection + +/// Resolved interpretation of the `--cni` create flag: use the bundled kindnet +/// CNI, skip CNI installation entirely, or apply a manifest at a given path. +enum CNISelection: Equatable { + case kindnet + case none + case manifest(URL) + + /// Parses a raw `--cni` flag value. Case-insensitively matches + /// `K8sHelper.noCNIName` ("none") as the sentinel for skipping CNI + /// installation; anything else is treated as a manifest path, which must + /// exist. `nil` resolves to `.kindnet`. + static func resolve(_ raw: String?) throws -> CNISelection { + guard let raw else { return .kindnet } + if raw.caseInsensitiveCompare(K8sHelper.noCNIName) == .orderedSame { + return .none + } + guard FileManager.default.fileExists(atPath: raw) else { + throw ContainerizationError(.invalidArgument, message: "CNI manifest not found at \(raw)") + } + return .manifest(URL(fileURLWithPath: raw)) + } +} + // MARK: - K8sHelper public struct K8sHelper { @@ -45,7 +70,7 @@ public struct K8sHelper { "Swap,SystemVerification,FileContent--proc-sys-net-bridge-bridge-nf-call-iptables" static let podSubnet = "10.244.0.0/16" /// Sentinel value for `--cni` that skips installing a CNI entirely. - public static let noCNIName = "none" + static let noCNIName = "none" // kubeadm default service subnet; must stay in sync if ClusterConfiguration.serviceSubnet is ever set. static let serviceSubnet = "10.96.0.0/12" diff --git a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift index 2b44ead4f..b1e3d52d7 100644 --- a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift +++ b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift @@ -35,7 +35,7 @@ extension K8sHelper { static func bootstrapControlPlane( nodeID: String, apiServerSANs: [String], advertiseAddress: String, - schedulable: Bool, cniManifestPath: String? = nil, client: ContainerClient, log: Logger + schedulable: Bool, cni: CNISelection = .kindnet, client: ContainerClient, log: Logger ) async throws { let configYAML = initConfigYAML(advertiseAddress: advertiseAddress, certSANs: apiServerSANs) var r = try await execCapture( @@ -73,18 +73,22 @@ extension K8sHelper { arguments: ["taint", "nodes", "--all", "node-role.kubernetes.io/control-plane-"]) } - if cniManifestPath == noCNIName { + switch cni { + case .none: log.info("Skipping CNI installation", metadata: ["node": "\(nodeID)"]) - } else { + case .kindnet: log.info("Applying CNI manifest", metadata: ["node": "\(nodeID)"]) - let manifest = try await loadCNIManifest(path: cniManifestPath, log: log) - let apply = "\(kubeconfigEnv) kubectl apply -f - <<'EOF'\n\(manifest)\nEOF" - r = try await execCapture( - containerId: nodeID, executable: "/bin/sh", - arguments: ["-c", apply], client: client) - guard r.code == 0 else { - throw ContainerizationError(.internalError, message: "apply CNI failed on \(nodeID): \(r.output)") + let manifest = try await loadKindnetManifest(log: log) + try await applyCNIManifest(manifest, nodeID: nodeID, client: client) + case .manifest(let url): + log.info("Applying CNI manifest", metadata: ["node": "\(nodeID)"]) + let manifest: String + do { + manifest = try String(contentsOf: url, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidArgument, message: "failed to read CNI manifest at \(url.path): \(error)") } + try await applyCNIManifest(manifest, nodeID: nodeID, client: client) } } @@ -105,15 +109,14 @@ extension K8sHelper { return (token: parts[tokenIdx + 1], caCertHash: parts[hashIdx + 1]) } - static func loadCNIManifest(path: String?, log: Logger) async throws -> String { - if let path { - do { - return try String(contentsOfFile: path, encoding: .utf8) - } catch { - throw ContainerizationError(.invalidArgument, message: "failed to read CNI manifest at \(path): \(error)") - } + private static func applyCNIManifest(_ manifest: String, nodeID: String, client: ContainerClient) async throws { + let apply = "\(kubeconfigEnv) kubectl apply -f - <<'EOF'\n\(manifest)\nEOF" + let r = try await execCapture( + containerId: nodeID, executable: "/bin/sh", + arguments: ["-c", apply], client: client) + guard r.code == 0 else { + throw ContainerizationError(.internalError, message: "apply CNI failed on \(nodeID): \(r.output)") } - return try await loadKindnetManifest(log: log) } private static func loadKindnetManifest(log: Logger) async throws -> String { diff --git a/Tests/K8sPluginTests/K8sCreateCNITests.swift b/Tests/K8sPluginTests/K8sCreateCNITests.swift index 09cc46fee..340075c1d 100644 --- a/Tests/K8sPluginTests/K8sCreateCNITests.swift +++ b/Tests/K8sPluginTests/K8sCreateCNITests.swift @@ -16,7 +16,6 @@ import ContainerizationError import Foundation -import Logging import Testing @testable import ContainerK8s @@ -41,29 +40,39 @@ struct K8sCreateCNIFlagTests { } } -// MARK: - K8sHelper.loadCNIManifest +// MARK: - CNISelection.resolve -@Suite("K8sHelper.loadCNIManifest") -struct LoadCNIManifestTests { - private let log = Logger(label: "test") +@Suite("CNISelection.resolve") +struct CNISelectionResolveTests { + @Test func nilResolvesToKindnet() throws { + #expect(try CNISelection.resolve(nil) == .kindnet) + } + + @Test func noneResolvesToNone() throws { + #expect(try CNISelection.resolve("none") == .none) + } + + @Test func noneIsCaseInsensitive() throws { + #expect(try CNISelection.resolve("None") == .none) + #expect(try CNISelection.resolve("NONE") == .none) + #expect(try CNISelection.resolve("nOnE") == .none) + } - @Test func customPathReturnsItsContents() async throws { - let contents = "kind: DaemonSet\nmetadata:\n name: my-custom-cni\n" + @Test func existingPathResolvesToManifest() throws { let dir = FileManager.default.temporaryDirectory let url = dir.appendingPathComponent(UUID().uuidString + ".yaml") - try contents.write(to: url, atomically: true, encoding: .utf8) + try "kind: DaemonSet".write(to: url, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: url) } - let result = try await K8sHelper.loadCNIManifest(path: url.path, log: log) - #expect(result == contents) + #expect(try CNISelection.resolve(url.path) == .manifest(URL(fileURLWithPath: url.path))) } - @Test func missingPathThrowsInvalidArgument() async throws { + @Test func missingPathThrowsInvalidArgument() throws { let missingPath = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString + "-does-not-exist.yaml").path - await #expect(throws: ContainerizationError.self) { - _ = try await K8sHelper.loadCNIManifest(path: missingPath, log: log) + #expect(throws: ContainerizationError.self) { + _ = try CNISelection.resolve(missingPath) } } } diff --git a/docs/command-reference.md b/docs/command-reference.md index bd90ea972..df8a9dd4e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1617,7 +1617,7 @@ container k8s create [--name ] [--node-image ] [--cni ] [--rm * `--name `: Cluster name (default: `k8s-dev`) * `--node-image `: Node image reference (default: `docker.io/kindest/node:v1.35.5`) -* `--cni `: Optional path to a CNI manifest to apply, or `none` to skip installing a CNI. If not provided, the bundled kindnet CNI is used. With `none`, the command returns without waiting for nodes to become `Ready`, since that requires a CNI; apply your own afterward with `kubectl apply`. +* `--cni `: Optional path to a CNI manifest to apply, or `none` (case-insensitive) to skip installing a CNI. If not provided, the bundled kindnet CNI is used. * `--rm`: Remove the cluster container after it stops **Resource Options** @@ -1654,17 +1654,18 @@ container k8s create --cni none ### `container k8s start` -Starts a stopped Kubernetes cluster and refreshes its entry in `~/.kube/config` (the container IP can change between starts). +Starts a stopped Kubernetes cluster and refreshes its entry in `~/.kube/config` (the container IP can change between starts). By default this returns as soon as the node has booted, without waiting for it to report `Ready`; pass `--wait` to block until it does. **Usage** ```bash -container k8s start [--name ] [--debug] +container k8s start [--name ] [--wait] [--debug] ``` **Options** * `--name `: Cluster name (default: `k8s-dev`) +* `--wait`: Wait for the node to report `Ready` before returning **Examples** @@ -1674,6 +1675,9 @@ container k8s start # start a named cluster container k8s start --name my-cluster + +# start and wait for the node to become Ready +container k8s start --wait ``` ### `container k8s delete (rm)` diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 9e3e73a88..d7ae8c207 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -160,6 +160,16 @@ container k8s create --name my-cluster --cni ./my-cni.yaml The manifest must be a plain Kubernetes YAML file (the same shape `kubectl apply -f` expects), not a Helm chart. +### No CNI + +Pass `none` (case-insensitive) to skip installing a CNI entirely, so you can apply your own afterward: + +```bash +container k8s create --name my-cluster --cni none +``` + +With `none`, the command returns as soon as the control plane is up, without waiting for nodes to reach `Ready`, which requires a working CNI. Nodes stay `NotReady` until you `kubectl apply` one yourself. + ### Example: Cilium Cilium is distributed as a Helm chart, so render a plain manifest from it first: