Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
buildAndTest:
name: Build and test the project
if: github.repository == 'apple/container'
timeout-minutes: 75
timeout-minutes: 90
runs-on: [self-hosted, macos, tahoe, ARM64]
permissions:
contents: read
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ $(STAGING_DIR):
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin)"

@install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)"
@install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)"
Expand All @@ -146,6 +147,8 @@ $(STAGING_DIR):
@install Sources/Plugins/MachineAPIServer/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/config.toml)"
@install Sources/Plugins/MachineAPIServer/Resources/init "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/init)"
@install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)"
@install "$(BUILD_BIN_DIR)/k8s" "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)"
@install Sources/Plugins/K8s/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/config.toml)"

@echo Install update script
@install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)"
Expand All @@ -161,6 +164,7 @@ installer-pkg: $(STAGING_DIR)
@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)"
@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)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)"

@echo Creating application installer
@pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH)
Expand Down
28 changes: 28 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,34 @@ let package = Package(
"ContainerResource",
]
),
.testTarget(
name: "K8sTests",
dependencies: [
"k8s",
"ContainerResource",
"Yams",
],
path: "Tests/K8sPluginTests"
),
.executableTarget(
name: "k8s",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
"ContainerAPIClient",
"ContainerLog",
"ContainerPersistence",
"ContainerResource",
"ContainerVersion",
"TerminalProgress",
"Yams",
],
path: "Sources/Plugins/K8s",
exclude: ["config.toml"],
resources: [.process("Resources/kindnet.yaml")]
),
.executableTarget(
name: "container-apiserver",
dependencies: [
Expand Down
59 changes: 59 additions & 0 deletions Sources/Plugins/K8s/K8sCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import ContainerVersion

@main
struct K8sCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "k8s",
abstract: "Manage local Kubernetes development clusters (EXPERIMENTAL)",
discussion: """
EXAMPLES:
Create a cluster by name and list clusters:
$ container k8s create --name my-cluster
$ container k8s list

Switch between clusters:
$ container k8s create --name second-cluster
$ kubectl config use-context second-cluster
$ kubectl config use-context my-cluster

Write the cluster context to an alternate configuration file:
$ container k8s write-config --name my-cluster --kubeconfig ~/.kube/my-cluster.kubeconfig
$ KUBECONFIG=~/.kube/my-cluster.kubeconfig kubectl cluster-info

Load a local image into the cluster and run it:
$ container image pull docker.io/library/hello-world:latest
$ container image tag docker.io/library/hello-world:latest my-hello-world:latest
$ container k8s load-image --name my-cluster my-hello-world:latest
$ kubectl run hello-job --image=my-hello-world:latest --restart=Never --attach --rm -i

Stop and delete the cluster:
$ container k8s delete --name my-cluster
""",
version: ReleaseVersion.singleLine(appName: "k8s"),
subcommands: [
K8sCreate.self,
K8sDelete.self,
K8sList.self,
K8sLoadImage.self,
K8sStart.self,
K8sWriteConfig.self,
]
)
}
187 changes: 187 additions & 0 deletions Sources/Plugins/K8s/K8sCreate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import ContainerAPIClient
import ContainerLog
import ContainerPersistence
import ContainerResource
import ContainerizationError
import Darwin
import Foundation
import Logging
import TerminalProgress

struct K8sCreate: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create and start a local Kubernetes cluster"
)

@Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))")
var name: String = K8sHelper.defaultName

@Flag(name: [.customLong("rm"), .long], help: "Remove the cluster container after it stops")
var remove: Bool = false

@OptionGroup(title: "Resource options")
var resourceFlags: Flags.Resource

@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry

@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch

@Option(help: "Node image reference (default: \(K8sHelper.nodeImage))")
var nodeImage: String = K8sHelper.nodeImage

func run() async throws {
LoggingSystem.bootstrap { _ in StderrLogHandler() }
let log = Logger(label: K8sHelper.pluginName)

guard ManagedContainer.nameValid(name) else {
throw ContainerizationError(.invalidArgument, message: "cluster name \(name) is not a valid container ID")
}

let isTTY = isatty(FileHandle.standardError.fileDescriptor) == 1
let progressConfig = try ProgressConfig(
showSpinner: isTTY,
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 2, // fetch image, unpack image
clearOnFinish: isTTY,
outputMode: isTTY ? .ansi : .plain
)

let progress = ProgressBar(config: progressConfig)
defer { progress.finish() }
progress.start()

let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
try await K8sHelper.ensureImage(nodeImage: nodeImage, log: log, containerSystemConfig: containerSystemConfig)

let fqdn = K8sHelper.fqdn(for: name, domain: containerSystemConfig.dns.domain)
let dns = Flags.DNS(domain: nil, nameservers: [], options: [], searchDomains: [])

let management = Flags.Management(
arch: Arch.hostArchitecture().rawValue,
capAdd: ["ALL"],
capDrop: [],
cidfile: "",
detach: true,
dns: dns,
dnsDisabled: false,
entrypoint: nil,
initImage: nil,
kernel: nil,
kernelArgs: [],
labels: [
"\(ResourceLabelKeys.plugin)=\(K8sHelper.pluginName)",
"\(ResourceLabelKeys.role)=\(K8sHelper.controlPlaneRoleName)",
],
maskedPaths: [],
mounts: [],
name: name,
networks: [],
os: "linux",
platform: nil,
publishPorts: fqdn == nil ? [try await K8sHelper.clusterPort()] : [],
publishSockets: [],
readOnly: false,
readonlyPaths: [],
remove: remove,
rosetta: true,
runtime: nil,
ssh: false,
shmSize: nil,
tmpFs: [],
useInit: false,
virtualization: false,
volumes: []
)

let updatedResource = K8sHelper.defaultedResourceFlags(resourceFlags)
let processFlags = Flags.Process(cwd: nil, env: K8sHelper.nodeProxyEnv(), envFile: [], gid: nil, interactive: false, tty: false, uid: nil, ulimits: [], user: nil)

var (config, kernel, initfs) = try await Utility.containerConfigFromFlags(
id: name,
image: nodeImage,
arguments: [],
process: processFlags,
management: management,
resource: updatedResource,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler,
log: log
)

// Allow the node to modify /proc/sys (e.g. net.ipv4.ip_forward) during setup.
config.maskedPaths = []
config.readonlyPaths = []

let client = ContainerClient()
let options = ContainerCreateOptions(autoRemove: remove)
try await client.create(
configuration: config,
options: options,
kernel: kernel,
initImage: initfs
)

progress.set(description: "Starting cluster")
let io = try ProcessIO.create(tty: false, interactive: false, detach: true)
defer { try? io.close() }
let process = try await client.bootstrap(id: name, stdio: io.stdio)
try await process.start()
try io.closeAfterStart()

progress.set(description: "Waiting for node to boot")
try await K8sHelper.waitForNodeBooted(containerId: name, client: client, log: log)

let snapshot = try await client.get(id: name)
guard let vmIP = snapshot.networks.first?.ipv4Address.address.description else {
throw ContainerizationError(.internalError, message: "no VM IP for control plane \(name)")
}
var sans = ["127.0.0.1"]
if let fqdn { sans.append(contentsOf: [vmIP, fqdn]) }

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,
client: client, log: log)

progress.set(description: "Waiting for cluster to be ready")
try await K8sHelper.waitForReady(containerId: name, client: client, log: log)

progress.set(description: "Writing kubeconfig")
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)"])
log.info("cluster is running; use 'container k8s write-config --name \(name)' to write the kubeconfig")
}

progress.finish()
print(name)
}
}
57 changes: 57 additions & 0 deletions Sources/Plugins/K8s/K8sDelete.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import ContainerAPIClient
import ContainerLog
import ContainerResource
import ContainerizationError
import Logging

struct K8sDelete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a Kubernetes cluster",
aliases: ["rm"]
)

@Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))")
var name: String = K8sHelper.defaultName

func run() async throws {
LoggingSystem.bootstrap { _ in StderrLogHandler() }
let log = Logger(label: K8sHelper.pluginName)

let client = ContainerClient()

if let container = try? await client.get(id: name) {
guard container.configuration.labels[ResourceLabelKeys.plugin] == K8sHelper.pluginName else {
log.error("container is not a k8s cluster, refusing delete", metadata: ["name": "\(name)"])
throw ContainerizationError(.invalidArgument, message: "\(name) is not a k8s cluster")
}
}

do {
try? await client.stop(id: name)
try await client.delete(id: name)
} catch let error as ContainerizationError where error.code == .notFound {
log.debug("cluster container not found, skipping delete", metadata: ["name": "\(name)"])
}

try K8sHelper.removeConfig(containerId: name, log: log)
print(name)
}
}
Loading