diff --git a/Sources/ContainerK8s/Commands/K8sCreate.swift b/Sources/ContainerK8s/Commands/K8sCreate.swift index 3432240e..80f537a8 100644 --- a/Sources/ContainerK8s/Commands/K8sCreate.swift +++ b/Sources/ContainerK8s/Commands/K8sCreate.swift @@ -68,6 +68,9 @@ public struct K8sCreate: AsyncParsableCommand { } } + // Fail before provisioning the node VM. + _ = try K8sHelper.kubernetesVersion(nodeImage: nodeImage) + let isTTY = isatty(FileHandle.standardError.fileDescriptor) == 1 let progressConfig = try ProgressConfig( showSpinner: isTTY, @@ -110,7 +113,7 @@ public struct K8sCreate: AsyncParsableCommand { progress.set(description: "Running kubeadm init") try await K8sHelper.prepareNode(nodeID: name, client: client, log: log) try await K8sHelper.bootstrapControlPlane( - nodeID: name, apiServerSANs: sans, advertiseAddress: vmIP, + nodeID: name, nodeImage: nodeImage, apiServerSANs: sans, advertiseAddress: vmIP, schedulable: provisioner.roles.contains(StandardRoles.worker), cniManifestPath: cni, client: client, log: log) diff --git a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift index 58e6bec3..41f08ff9 100644 --- a/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift +++ b/Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift @@ -34,10 +34,10 @@ extension K8sHelper { } static func bootstrapControlPlane( - nodeID: String, apiServerSANs: [String], advertiseAddress: String, + nodeID: String, nodeImage: String, apiServerSANs: [String], advertiseAddress: String, schedulable: Bool, cniManifestPath: String? = nil, client: ContainerClient, log: Logger ) async throws { - let configYAML = initConfigYAML(advertiseAddress: advertiseAddress, certSANs: apiServerSANs) + let configYAML = try initConfigYAML(nodeImage: nodeImage, advertiseAddress: advertiseAddress, certSANs: apiServerSANs) var r = try await execCapture( containerId: nodeID, executable: "/bin/sh", arguments: ["-c", "cat > /etc/kubernetes/kubeadm-config.yaml <<'EOF'\n\(configYAML)\nEOF"], @@ -144,7 +144,7 @@ extension K8sHelper { """ } - private static func initConfigYAML(advertiseAddress: String, certSANs: [String]) -> String { + private static func initConfigYAML(nodeImage: String, advertiseAddress: String, certSANs: [String]) throws -> String { let sans = certSANs.map { " - \($0)" }.joined(separator: "\n") return """ apiVersion: kubeadm.k8s.io/v1beta4 @@ -157,7 +157,7 @@ extension K8sHelper { --- apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration - kubernetesVersion: \(kubernetesVersion()) + kubernetesVersion: \(try kubernetesVersion(nodeImage: nodeImage)) networking: podSubnet: \(podSubnet) apiServer: @@ -171,9 +171,14 @@ extension K8sHelper { """ } - private static func kubernetesVersion() -> String { + /// kubeadm needs the exact version, and only the tag carries it. + static func kubernetesVersion(nodeImage: String) throws -> String { let nameAndTag = nodeImage.split(separator: "@").first.map(String.init) ?? nodeImage - guard let ref = try? Reference.parse(nameAndTag), let tag = ref.tag else { return "v1.35" } + guard let ref = try? Reference.parse(nameAndTag), let tag = ref.tag else { + throw ContainerizationError( + .invalidArgument, + message: "node image \(nodeImage) has no tag; use a tagged image such as docker.io/kindest/node:v1.34.11") + } return tag } } diff --git a/Tests/K8sPluginTests/K8sNodeImageVersionTests.swift b/Tests/K8sPluginTests/K8sNodeImageVersionTests.swift new file mode 100644 index 00000000..a0cb79b5 --- /dev/null +++ b/Tests/K8sPluginTests/K8sNodeImageVersionTests.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 Testing + +@testable import ContainerK8s + +struct K8sNodeImageVersionTests { + @Test + func versionComesFromTheGivenImage() throws { + #expect(try K8sHelper.kubernetesVersion(nodeImage: "docker.io/kindest/node:v1.34.11") == "v1.34.11") + } + + @Test + func versionComesFromTagWhenDigestIsPresent() throws { + let image = "docker.io/kindest/node:v1.34.11@sha256:0000000000000000000000000000000000000000000000000000000000000000" + #expect(try K8sHelper.kubernetesVersion(nodeImage: image) == "v1.34.11") + } + + @Test + func defaultImageStillResolves() throws { + #expect(try K8sHelper.kubernetesVersion(nodeImage: K8sHelper.nodeImage) == "v1.35.5") + } + + @Test + func untaggedImageThrows() { + #expect(throws: (any Error).self) { + try K8sHelper.kubernetesVersion(nodeImage: "docker.io/kindest/node") + } + } + + @Test + func digestOnlyImageThrows() { + let image = "docker.io/kindest/node@sha256:0000000000000000000000000000000000000000000000000000000000000000" + #expect(throws: (any Error).self) { + try K8sHelper.kubernetesVersion(nodeImage: image) + } + } +}