Skip to content

Commit 0d111be

Browse files
authored
K8s plugin (#2044)
- closes #2043 - introduces the k8s plugin, allowing users to make single node clusters with the kind base image - other functionality is included as well such as creation, deletion, and loading custom images
1 parent 60612ee commit 0d111be

21 files changed

Lines changed: 3176 additions & 1 deletion

.github/workflows/common.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
buildAndTest:
2424
name: Build and test the project
2525
if: github.repository == 'apple/container'
26-
timeout-minutes: 75
26+
timeout-minutes: 90
2727
runs-on: [self-hosted, macos, tahoe, ARM64]
2828
permissions:
2929
contents: read

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ $(STAGING_DIR):
133133
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)"
134134
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)"
135135
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)"
136+
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin)"
136137

137138
@install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)"
138139
@install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)"
@@ -146,6 +147,8 @@ $(STAGING_DIR):
146147
@install Sources/Plugins/MachineAPIServer/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/config.toml)"
147148
@install Sources/Plugins/MachineAPIServer/Resources/init "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/init)"
148149
@install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)"
150+
@install "$(BUILD_BIN_DIR)/k8s" "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)"
151+
@install Sources/Plugins/K8s/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/config.toml)"
149152

150153
@echo Install update script
151154
@install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)"
@@ -161,6 +164,7 @@ installer-pkg: $(STAGING_DIR)
161164
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-runtime-linux.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)"
162165
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)"
163166
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)"
167+
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)"
164168

165169
@echo Creating application installer
166170
@pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH)

Package.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,34 @@ let package = Package(
164164
"ContainerResource",
165165
]
166166
),
167+
.testTarget(
168+
name: "K8sTests",
169+
dependencies: [
170+
"k8s",
171+
"ContainerResource",
172+
"Yams",
173+
],
174+
path: "Tests/K8sPluginTests"
175+
),
176+
.executableTarget(
177+
name: "k8s",
178+
dependencies: [
179+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
180+
.product(name: "Logging", package: "swift-log"),
181+
.product(name: "Containerization", package: "containerization"),
182+
.product(name: "ContainerizationOCI", package: "containerization"),
183+
"ContainerAPIClient",
184+
"ContainerLog",
185+
"ContainerPersistence",
186+
"ContainerResource",
187+
"ContainerVersion",
188+
"TerminalProgress",
189+
"Yams",
190+
],
191+
path: "Sources/Plugins/K8s",
192+
exclude: ["config.toml"],
193+
resources: [.process("Resources/kindnet.yaml")]
194+
),
167195
.executableTarget(
168196
name: "container-apiserver",
169197
dependencies: [
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 ArgumentParser
18+
import ContainerVersion
19+
20+
@main
21+
struct K8sCommand: AsyncParsableCommand {
22+
static let configuration = CommandConfiguration(
23+
commandName: "k8s",
24+
abstract: "Manage local Kubernetes development clusters (EXPERIMENTAL)",
25+
discussion: """
26+
EXAMPLES:
27+
Create a cluster by name and list clusters:
28+
$ container k8s create --name my-cluster
29+
$ container k8s list
30+
31+
Switch between clusters:
32+
$ container k8s create --name second-cluster
33+
$ kubectl config use-context second-cluster
34+
$ kubectl config use-context my-cluster
35+
36+
Write the cluster context to an alternate configuration file:
37+
$ container k8s write-config --name my-cluster --kubeconfig ~/.kube/my-cluster.kubeconfig
38+
$ KUBECONFIG=~/.kube/my-cluster.kubeconfig kubectl cluster-info
39+
40+
Load a local image into the cluster and run it:
41+
$ 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
45+
46+
Stop and delete the cluster:
47+
$ container k8s delete --name my-cluster
48+
""",
49+
version: ReleaseVersion.singleLine(appName: "k8s"),
50+
subcommands: [
51+
K8sCreate.self,
52+
K8sDelete.self,
53+
K8sList.self,
54+
K8sLoadImage.self,
55+
K8sStart.self,
56+
K8sWriteConfig.self,
57+
]
58+
)
59+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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 ArgumentParser
18+
import ContainerAPIClient
19+
import ContainerLog
20+
import ContainerPersistence
21+
import ContainerResource
22+
import ContainerizationError
23+
import Darwin
24+
import Foundation
25+
import Logging
26+
import TerminalProgress
27+
28+
struct K8sCreate: AsyncParsableCommand {
29+
static let configuration = CommandConfiguration(
30+
commandName: "create",
31+
abstract: "Create and start a local Kubernetes cluster"
32+
)
33+
34+
@Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))")
35+
var name: String = K8sHelper.defaultName
36+
37+
@Flag(name: [.customLong("rm"), .long], help: "Remove the cluster container after it stops")
38+
var remove: Bool = false
39+
40+
@OptionGroup(title: "Resource options")
41+
var resourceFlags: Flags.Resource
42+
43+
@OptionGroup(title: "Registry options")
44+
var registryFlags: Flags.Registry
45+
46+
@OptionGroup(title: "Image fetch options")
47+
var imageFetchFlags: Flags.ImageFetch
48+
49+
@Option(help: "Node image reference (default: \(K8sHelper.nodeImage))")
50+
var nodeImage: String = K8sHelper.nodeImage
51+
52+
func run() async throws {
53+
LoggingSystem.bootstrap { _ in StderrLogHandler() }
54+
let log = Logger(label: K8sHelper.pluginName)
55+
56+
guard ManagedContainer.nameValid(name) else {
57+
throw ContainerizationError(.invalidArgument, message: "cluster name \(name) is not a valid container ID")
58+
}
59+
60+
let isTTY = isatty(FileHandle.standardError.fileDescriptor) == 1
61+
let progressConfig = try ProgressConfig(
62+
showSpinner: isTTY,
63+
showTasks: true,
64+
showItems: true,
65+
ignoreSmallSize: true,
66+
totalTasks: 2, // fetch image, unpack image
67+
clearOnFinish: isTTY,
68+
outputMode: isTTY ? .ansi : .plain
69+
)
70+
71+
let progress = ProgressBar(config: progressConfig)
72+
defer { progress.finish() }
73+
progress.start()
74+
75+
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
76+
try await K8sHelper.ensureImage(nodeImage: nodeImage, log: log, containerSystemConfig: containerSystemConfig)
77+
78+
let fqdn = K8sHelper.fqdn(for: name, domain: containerSystemConfig.dns.domain)
79+
let dns = Flags.DNS(domain: nil, nameservers: [], options: [], searchDomains: [])
80+
81+
let management = Flags.Management(
82+
arch: Arch.hostArchitecture().rawValue,
83+
capAdd: ["ALL"],
84+
capDrop: [],
85+
cidfile: "",
86+
detach: true,
87+
dns: dns,
88+
dnsDisabled: false,
89+
entrypoint: nil,
90+
initImage: nil,
91+
kernel: nil,
92+
kernelArgs: [],
93+
labels: [
94+
"\(ResourceLabelKeys.plugin)=\(K8sHelper.pluginName)",
95+
"\(ResourceLabelKeys.role)=\(K8sHelper.controlPlaneRoleName)",
96+
],
97+
maskedPaths: [],
98+
mounts: [],
99+
name: name,
100+
networks: [],
101+
os: "linux",
102+
platform: nil,
103+
publishPorts: fqdn == nil ? [try await K8sHelper.clusterPort()] : [],
104+
publishSockets: [],
105+
readOnly: false,
106+
readonlyPaths: [],
107+
remove: remove,
108+
rosetta: true,
109+
runtime: nil,
110+
ssh: false,
111+
shmSize: nil,
112+
tmpFs: [],
113+
useInit: false,
114+
virtualization: false,
115+
volumes: []
116+
)
117+
118+
let updatedResource = K8sHelper.defaultedResourceFlags(resourceFlags)
119+
let processFlags = Flags.Process(cwd: nil, env: K8sHelper.nodeProxyEnv(), envFile: [], gid: nil, interactive: false, tty: false, uid: nil, ulimits: [], user: nil)
120+
121+
var (config, kernel, initfs) = try await Utility.containerConfigFromFlags(
122+
id: name,
123+
image: nodeImage,
124+
arguments: [],
125+
process: processFlags,
126+
management: management,
127+
resource: updatedResource,
128+
registry: registryFlags,
129+
imageFetch: imageFetchFlags,
130+
containerSystemConfig: containerSystemConfig,
131+
progressUpdate: progress.handler,
132+
log: log
133+
)
134+
135+
// Allow the node to modify /proc/sys (e.g. net.ipv4.ip_forward) during setup.
136+
config.maskedPaths = []
137+
config.readonlyPaths = []
138+
139+
let client = ContainerClient()
140+
let options = ContainerCreateOptions(autoRemove: remove)
141+
try await client.create(
142+
configuration: config,
143+
options: options,
144+
kernel: kernel,
145+
initImage: initfs
146+
)
147+
148+
progress.set(description: "Starting cluster")
149+
let io = try ProcessIO.create(tty: false, interactive: false, detach: true)
150+
defer { try? io.close() }
151+
let process = try await client.bootstrap(id: name, stdio: io.stdio)
152+
try await process.start()
153+
try io.closeAfterStart()
154+
155+
progress.set(description: "Waiting for node to boot")
156+
try await K8sHelper.waitForNodeBooted(containerId: name, client: client, log: log)
157+
158+
let snapshot = try await client.get(id: name)
159+
guard let vmIP = snapshot.networks.first?.ipv4Address.address.description else {
160+
throw ContainerizationError(.internalError, message: "no VM IP for control plane \(name)")
161+
}
162+
var sans = ["127.0.0.1"]
163+
if let fqdn { sans.append(contentsOf: [vmIP, fqdn]) }
164+
165+
progress.set(description: "Running kubeadm init")
166+
try await K8sHelper.prepareNode(nodeID: name, client: client, log: log)
167+
try await K8sHelper.bootstrapControlPlane(
168+
nodeID: name, apiServerSANs: sans, advertiseAddress: vmIP,
169+
client: client, log: log)
170+
171+
progress.set(description: "Waiting for cluster to be ready")
172+
try await K8sHelper.waitForReady(containerId: name, client: client, log: log)
173+
174+
progress.set(description: "Writing kubeconfig")
175+
do {
176+
let rawConfig = try await K8sHelper.fetchConfig(containerId: name, client: client, log: log)
177+
let kubeConfig = try await K8sHelper.transformConfig(rawConfig, containerId: name, fqdn: fqdn, client: client)
178+
try K8sHelper.mergeConfig(kubeConfig, containerId: name, setCurrentContext: true, log: log)
179+
} catch {
180+
log.warning("failed to write kubeconfig", metadata: ["name": "\(name)", "error": "\(error)"])
181+
log.info("cluster is running; use 'container k8s write-config --name \(name)' to write the kubeconfig")
182+
}
183+
184+
progress.finish()
185+
print(name)
186+
}
187+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 ArgumentParser
18+
import ContainerAPIClient
19+
import ContainerLog
20+
import ContainerResource
21+
import ContainerizationError
22+
import Logging
23+
24+
struct K8sDelete: AsyncParsableCommand {
25+
static let configuration = CommandConfiguration(
26+
commandName: "delete",
27+
abstract: "Delete a Kubernetes cluster",
28+
aliases: ["rm"]
29+
)
30+
31+
@Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))")
32+
var name: String = K8sHelper.defaultName
33+
34+
func run() async throws {
35+
LoggingSystem.bootstrap { _ in StderrLogHandler() }
36+
let log = Logger(label: K8sHelper.pluginName)
37+
38+
let client = ContainerClient()
39+
40+
if let container = try? await client.get(id: name) {
41+
guard container.configuration.labels[ResourceLabelKeys.plugin] == K8sHelper.pluginName else {
42+
log.error("container is not a k8s cluster, refusing delete", metadata: ["name": "\(name)"])
43+
throw ContainerizationError(.invalidArgument, message: "\(name) is not a k8s cluster")
44+
}
45+
}
46+
47+
do {
48+
try? await client.stop(id: name)
49+
try await client.delete(id: name)
50+
} catch let error as ContainerizationError where error.code == .notFound {
51+
log.debug("cluster container not found, skipping delete", metadata: ["name": "\(name)"])
52+
}
53+
54+
try K8sHelper.removeConfig(containerId: name, log: log)
55+
print(name)
56+
}
57+
}

0 commit comments

Comments
 (0)