Skip to content

Commit 86735d0

Browse files
committed
Fix Kubernetes control-plane restart after IP rotation
1 parent b8ffd38 commit 86735d0

5 files changed

Lines changed: 106 additions & 4 deletions

File tree

‎Sources/ContainerK8s/Commands/K8sCreate.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ public struct K8sCreate: AsyncParsableCommand {
102102
try await K8sHelper.prepareNode(nodeID: name, client: client, log: log)
103103
try await K8sHelper.bootstrapControlPlane(
104104
nodeID: name, apiServerSANs: sans, advertiseAddress: vmIP,
105+
// All in-VM Kubernetes clients (including host-networked kube-proxy)
106+
// can reach the single-node API through loopback. This endpoint is
107+
// independent of the container's rotating vmnet address.
108+
controlPlaneEndpoint: K8sHelper.nodeLocalControlPlaneEndpoint,
105109
schedulable: provisioner.roles.contains(StandardRoles.worker),
106110
client: client, log: log)
107111

‎Sources/ContainerK8s/K8sHelper.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public struct K8sHelper {
5151
static let proxyEnvVars = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"]
5252

5353
public static let clusterContainerPort: UInt16 = 6443
54+
/// Endpoint used by clients inside the single-node cluster container.
55+
static let nodeLocalControlPlaneEndpoint = "127.0.0.1:\(clusterContainerPort)"
5456

5557
// MARK: - Resource defaults
5658

‎Sources/ContainerK8s/Support/K8sHelper+Bootstrap.swift‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ extension K8sHelper {
3535

3636
static func bootstrapControlPlane(
3737
nodeID: String, apiServerSANs: [String], advertiseAddress: String,
38+
controlPlaneEndpoint: String,
3839
schedulable: Bool, client: ContainerClient, log: Logger
3940
) async throws {
40-
let configYAML = initConfigYAML(advertiseAddress: advertiseAddress, certSANs: apiServerSANs)
41+
let configYAML = initConfigYAML(
42+
advertiseAddress: advertiseAddress, certSANs: apiServerSANs,
43+
controlPlaneEndpoint: controlPlaneEndpoint)
4144
var r = try await execCapture(
4245
containerId: nodeID, executable: "/bin/sh",
43-
arguments: ["-c", "cat > /etc/kubernetes/kubeadm-config.yaml <<'EOF'\n\(configYAML)\nEOF"],
46+
arguments: ["-c", "mkdir -p /kind && cat > /kind/kubeadm.conf <<'EOF'\n\(configYAML)\nEOF"],
4447
client: client)
4548
guard r.code == 0 else {
4649
throw ContainerizationError(.internalError, message: "write kubeadm config failed on \(nodeID): \(r.output)")
@@ -50,7 +53,7 @@ extension K8sHelper {
5053
r = try await execCapture(
5154
containerId: nodeID, executable: kubeadmPath,
5255
arguments: [
53-
"init", "--config", "/etc/kubernetes/kubeadm-config.yaml",
56+
"init", "--config", "/kind/kubeadm.conf",
5457
"--ignore-preflight-errors", ignorePreflightErrors,
5558
],
5659
client: client)
@@ -135,7 +138,9 @@ extension K8sHelper {
135138
"""
136139
}
137140

138-
private static func initConfigYAML(advertiseAddress: String, certSANs: [String]) -> String {
141+
static func initConfigYAML(
142+
advertiseAddress: String, certSANs: [String], controlPlaneEndpoint: String
143+
) -> String {
139144
let sans = certSANs.map { " - \($0)" }.joined(separator: "\n")
140145
return """
141146
apiVersion: kubeadm.k8s.io/v1beta4
@@ -148,6 +153,7 @@ extension K8sHelper {
148153
---
149154
apiVersion: kubeadm.k8s.io/v1beta4
150155
kind: ClusterConfiguration
156+
controlPlaneEndpoint: \(controlPlaneEndpoint)
151157
kubernetesVersion: \(kubernetesVersion())
152158
networking:
153159
podSubnet: \(podSubnet)

‎Tests/IntegrationTests/K8s/TestK8sRunSerial.swift‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,53 @@ struct TestK8sRunSerial {
115115
#expect(server1 != server2)
116116
}
117117
}
118+
119+
@Test func testRestartAfterAddressRotation() async throws {
120+
try await ContainerFixture.with { f in
121+
let name = "k8s-\(f.testID)"
122+
let bumper = "\(name)-bumper"
123+
f.addCleanup { try f.doRemoveIfExists(bumper, force: true, ignoreFailure: true) }
124+
f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) }
125+
126+
try f.restoreWarmupImage(.kindestNodeV1_35_5)
127+
try f.run(["k8s", "create", "--name", name]).check()
128+
129+
let originalAddress = try f.run(["exec", name, "cat", "/kind/old-ipv4"])
130+
try originalAddress.check()
131+
#expect(try f.run(["exec", name, "test", "-f", "/kind/kubeadm.conf"]).status == 0)
132+
try f.run([
133+
"exec", name, "grep", "-F", "controlPlaneEndpoint: 127.0.0.1:6443", "/kind/kubeadm.conf",
134+
]).check()
135+
136+
try f.run(["stop", name]).check()
137+
// Advance the rotating allocator while the node is stopped so its
138+
// restart cannot accidentally reuse the same address.
139+
try f.restoreWarmupImage(.alpine320)
140+
try f.run([
141+
"run", "--name", bumper, "-d", WarmupImage.alpine320.rawValue,
142+
"sleep", "infinity",
143+
]).check()
144+
145+
let restart = try f.run(["k8s", "start", "--name", name])
146+
if restart.status != 0 {
147+
print("[k8s-run] restart stderr: \(restart.error)")
148+
f.dumpNodeDiagnostics(node: name)
149+
}
150+
try restart.check()
151+
152+
let rotatedAddress = try f.run(["exec", name, "cat", "/kind/old-ipv4"])
153+
try rotatedAddress.check()
154+
let originalIP = originalAddress.output.trimmingCharacters(in: .whitespacesAndNewlines)
155+
let rotatedIP = rotatedAddress.output.trimmingCharacters(in: .whitespacesAndNewlines)
156+
print("[k8s-run] restart rotated address \(originalIP) -> \(rotatedIP)")
157+
#expect(originalIP != rotatedIP)
158+
#expect(try f.getContainerStatus(name) == "running")
159+
try f.run([
160+
"exec", name, "grep", "-F", "advertiseAddress: \(rotatedIP)", "/kind/kubeadm.conf",
161+
]).check()
162+
try f.run([
163+
"exec", name, "kubectl", "wait", "--for=condition=Ready", "node", "--all", "--timeout=30s",
164+
]).check()
165+
}
166+
}
118167
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Testing
18+
import Yams
19+
20+
@testable import ContainerK8s
21+
22+
@Suite("K8s bootstrap configuration")
23+
struct K8sBootstrapTests {
24+
@Test func nodeLocalEndpointUsesClusterPort() {
25+
#expect(K8sHelper.nodeLocalControlPlaneEndpoint == "127.0.0.1:6443")
26+
}
27+
28+
@Test func nodeLocalControlPlaneEndpointIsRendered() {
29+
let yaml = K8sHelper.initConfigYAML(
30+
advertiseAddress: "192.168.64.2",
31+
certSANs: ["127.0.0.1"],
32+
controlPlaneEndpoint: K8sHelper.nodeLocalControlPlaneEndpoint)
33+
34+
#expect(yaml.contains("controlPlaneEndpoint: 127.0.0.1:6443"))
35+
#expect(yaml.contains("advertiseAddress: 192.168.64.2"))
36+
#expect(yaml.contains(" - 127.0.0.1"))
37+
for document in yaml.components(separatedBy: "\n---\n") {
38+
#expect((try? Yams.load(yaml: document)) != nil)
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)