K8s plugin: support no CNI on create (#2277)

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
This commit is contained in:
Kathryn Baldauf authored and GitHub committed 2026-09-30 15:57:41 -07:00
1 parent dbd5ca0840
commit 2ef8aef92a
9 files changed
+170 -46

No files matched your search

+12 -6
View File
@@ -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 skipCNI = cni?.uppercased() == K8sHelper.noCNIName
if let cni, !skipCNI {
guard FileManager.default.fileExists(atPath: cni) else {
throw ContainerizationError(.invalidArgument, message: "CNI manifest not found at \(cni)")
}
@@ -118,13 +119,18 @@ 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 skipCNI {
progress.set(description: "Waiting for API server")
try await K8sHelper.waitForAPIServer(containerId: name, client: client, log: log)
} else {
progress.set(description: "Waiting for cluster to be ready")
try await K8sHelper.waitForReady(containerId: name, client: client, log: log)
}
progress.set(description: "Writing kubeconfig")
let rawConfig = try await K8sHelper.fetchConfig(containerId: name, client: client, log: log)
let kubeConfig = try await K8sHelper.transformConfig(rawConfig, containerId: name, fqdn: fqdn, client: client)
do {
let rawConfig = try await K8sHelper.fetchConfig(containerId: name, client: client, log: log)
let kubeConfig = try await K8sHelper.transformConfig(rawConfig, containerId: name, fqdn: fqdn, client: client)
try K8sHelper.mergeConfig(kubeConfig, containerId: name, setCurrentContext: true, log: log)
} catch {
log.warning("failed to write kubeconfig", metadata: ["name": "\(name)", "error": "\(error)"])
+2
View File
@@ -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` (compared case-insensitively) that skips installing a CNI entirely.
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"
@@ -68,19 +68,26 @@ extension K8sHelper {
if schedulable {
log.info("Removing control-plane taint for single-node scheduling", metadata: ["node": "\(nodeID)"])
_ = try await runProbe(
let taintCode = try await runProbe(
client: client, containerId: nodeID,
arguments: ["taint", "nodes", "--all", "node-role.kubernetes.io/control-plane-"])
guard taintCode == 0 else {
throw ContainerizationError(.internalError, message: "failed to remove control-plane taint on \(nodeID)")
}
}
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?.uppercased() == 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)")
}
}
}
@@ -60,7 +60,35 @@ extension K8sHelper {
}
}
/// Wait until the API server is serving. This is the one readiness signal
/// that does not depend on a CNI being installed.
static func waitForAPIServer(containerId: String, client: ContainerClient, log: Logger) async throws {
let timeout = 60
log.info("Waiting for API server to become available")
for attempt in 1...timeout {
let code: Int32
do {
code = try await runProbe(
client: client, containerId: containerId,
arguments: ["get", "--raw", "/readyz", "--request-timeout=2s"])
} catch {
throw ContainerizationError(
.internalError, message: "k8s cluster \(containerId) stopped unexpectedly during startup: \(error)")
}
if code == 0 { return }
if attempt == timeout {
log.info("check control-plane logs with 'container logs \(containerId)'")
throw ContainerizationError(
.timeout,
message: "k8s cluster \(containerId) API server did not become available within \(timeout * 2)s")
}
try await Task.sleep(for: .seconds(2))
}
}
static func waitForReady(containerId: String, client: ContainerClient, log: Logger) async throws {
try await waitForAPIServer(containerId: containerId, client: client, log: log)
let nodeReadyTimeout = 180
let podReadyTimeout = 300
@@ -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)
}
}
@@ -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"))
}
}
}
@@ -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",
@@ -34,6 +34,12 @@ struct K8sCreateCNIFlagTests {
let command = try K8sCreate.parse(["--cni", "/tmp/my-cni.yaml"])
#expect(command.cni == "/tmp/my-cni.yaml")
}
@Test(arguments: ["NONE", "none", "None"])
func cniAcceptsNone(value: String) throws {
let command = try K8sCreate.parse(["--cni", value])
#expect(command.cni == value)
}
}
// MARK: - K8sHelper.loadCNIManifest
+4 -1
View File
@@ -1633,7 +1633,7 @@ container k8s create [--name <name>] [--node-image <image>] [--cni <path>] [--rm
* `--name <name>`: Cluster name (default: `k8s-dev`)
* `--node-image <image>`: Node image reference (default: `docker.io/kindest/node:v1.35.5`)
* `--cni <path>`: Optional path to a CNI manifest to apply. If not provided, the bundled kindnet CNI is used.
* `--cni <path>`: 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. 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**
@@ -1663,6 +1663,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 delete (rm)`