Skip to content

Commit 123afcd

Browse files
committed
feat: k8s plugin implementation
1 parent dcadeef commit 123afcd

11 files changed

Lines changed: 1549 additions & 0 deletions
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: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
mounts: [],
98+
name: name,
99+
networks: [],
100+
os: "linux",
101+
platform: nil,
102+
publishPorts: fqdn == nil ? [try await K8sHelper.clusterPort()] : [],
103+
publishSockets: [],
104+
readOnly: false,
105+
remove: remove,
106+
rosetta: true,
107+
runtime: nil,
108+
ssh: false,
109+
shmSize: nil,
110+
tmpFs: [],
111+
useInit: false,
112+
virtualization: false,
113+
volumes: []
114+
)
115+
116+
let updatedResource = K8sHelper.defaultedResourceFlags(resourceFlags)
117+
let processFlags = Flags.Process(cwd: nil, env: K8sHelper.proxyEnvVars, envFile: [], gid: nil, interactive: false, tty: false, uid: nil, ulimits: [], user: nil)
118+
119+
var (config, kernel, initfs) = try await Utility.containerConfigFromFlags(
120+
id: name,
121+
image: nodeImage,
122+
arguments: [],
123+
process: processFlags,
124+
management: management,
125+
resource: updatedResource,
126+
registry: registryFlags,
127+
imageFetch: imageFetchFlags,
128+
containerSystemConfig: containerSystemConfig,
129+
progressUpdate: progress.handler,
130+
log: log
131+
)
132+
133+
// Allow the node to modify /proc/sys (e.g. net.ipv4.ip_forward) during setup.
134+
config.maskedPaths = []
135+
config.readonlyPaths = []
136+
137+
let client = ContainerClient()
138+
let options = ContainerCreateOptions(autoRemove: remove)
139+
try await client.create(
140+
configuration: config,
141+
options: options,
142+
kernel: kernel,
143+
initImage: initfs
144+
)
145+
146+
progress.set(description: "Starting cluster")
147+
let io = try ProcessIO.create(tty: false, interactive: false, detach: true)
148+
defer { try? io.close() }
149+
let process = try await client.bootstrap(id: name, stdio: io.stdio)
150+
try await process.start()
151+
try io.closeAfterStart()
152+
153+
progress.set(description: "Waiting for node to boot")
154+
try await K8sHelper.waitForNodeBooted(containerId: name, client: client, log: log)
155+
156+
let snapshot = try await client.get(id: name)
157+
guard let vmIP = snapshot.networks.first?.ipv4Address.address.description else {
158+
throw ContainerizationError(.internalError, message: "no VM IP for control plane \(name)")
159+
}
160+
var sans = ["127.0.0.1"]
161+
if let fqdn { sans.append(contentsOf: [vmIP, fqdn]) }
162+
163+
progress.set(description: "Running kubeadm init")
164+
try await K8sHelper.prepareNode(nodeID: name, client: client, log: log)
165+
try await K8sHelper.bootstrapControlPlane(
166+
nodeID: name, apiServerSANs: sans, advertiseAddress: vmIP,
167+
client: client, log: log)
168+
169+
progress.set(description: "Waiting for cluster to be ready")
170+
try await K8sHelper.waitForReady(containerId: name, client: client, log: log)
171+
172+
progress.set(description: "Writing kubeconfig")
173+
do {
174+
let rawConfig = try await K8sHelper.fetchConfig(containerId: name, client: client, log: log)
175+
let kubeConfig = try await K8sHelper.transformConfig(rawConfig, containerId: name, fqdn: fqdn, client: client)
176+
try K8sHelper.mergeConfig(kubeConfig, containerId: name, setCurrentContext: true, log: log)
177+
} catch {
178+
log.warning("failed to write kubeconfig", metadata: ["name": "\(name)", "error": "\(error)"])
179+
log.info("cluster is running; use 'container k8s write-config --name \(name)' to write the kubeconfig")
180+
}
181+
182+
progress.finish()
183+
print(name)
184+
}
185+
}
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)