diff --git a/Package.swift b/Package.swift index bb28a31f..16b2d219 100644 --- a/Package.swift +++ b/Package.swift @@ -52,6 +52,7 @@ let package = Package( .library(name: "TerminalProgress", targets: ["TerminalProgress"]), .library(name: "MachineAPIClient", targets: ["MachineAPIClient"]), .library(name: "MachineAPIService", targets: ["MachineAPIService"]), + .library(name: "ContainerK8s", targets: ["ContainerK8s"]), ], dependencies: [ .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), @@ -167,19 +168,21 @@ let package = Package( .testTarget( name: "K8sTests", dependencies: [ - "k8s", + "ContainerK8s", "ContainerResource", "Yams", ], path: "Tests/K8sPluginTests" ), - .executableTarget( - name: "k8s", + .target( + name: "ContainerK8s", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "SystemPackage", package: "swift-system"), "ContainerAPIClient", "ContainerLog", "ContainerPersistence", @@ -188,10 +191,14 @@ let package = Package( "TerminalProgress", "Yams", ], - path: "Sources/Plugins/K8s", - exclude: ["config.toml"], resources: [.process("Resources/kindnet.yaml")] ), + .executableTarget( + name: "k8s", + dependencies: ["ContainerK8s"], + path: "Sources/Plugins/K8s", + exclude: ["config.toml"] + ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/Plugins/K8s/K8sCreate.swift b/Sources/ContainerK8s/Commands/K8sCreate.swift similarity index 97% rename from Sources/Plugins/K8s/K8sCreate.swift rename to Sources/ContainerK8s/Commands/K8sCreate.swift index cdce6cde..c19bc20f 100644 --- a/Sources/Plugins/K8s/K8sCreate.swift +++ b/Sources/ContainerK8s/Commands/K8sCreate.swift @@ -25,8 +25,10 @@ import Foundation import Logging import TerminalProgress -struct K8sCreate: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sCreate: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "create", abstract: "Create and start a local Kubernetes cluster" ) @@ -49,7 +51,7 @@ struct K8sCreate: AsyncParsableCommand { @Option(help: "Node image reference (default: \(K8sHelper.nodeImage))") var nodeImage: String = K8sHelper.nodeImage - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) diff --git a/Sources/Plugins/K8s/K8sDelete.swift b/Sources/ContainerK8s/Commands/K8sDelete.swift similarity index 92% rename from Sources/Plugins/K8s/K8sDelete.swift rename to Sources/ContainerK8s/Commands/K8sDelete.swift index 532a8044..bb1d28bb 100644 --- a/Sources/Plugins/K8s/K8sDelete.swift +++ b/Sources/ContainerK8s/Commands/K8sDelete.swift @@ -21,8 +21,10 @@ import ContainerResource import ContainerizationError import Logging -struct K8sDelete: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sDelete: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "delete", abstract: "Delete a Kubernetes cluster", aliases: ["rm"] @@ -31,7 +33,7 @@ struct K8sDelete: AsyncParsableCommand { @Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))") var name: String = K8sHelper.defaultName - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) diff --git a/Sources/Plugins/K8s/K8sList.swift b/Sources/ContainerK8s/Commands/K8sList.swift similarity index 89% rename from Sources/Plugins/K8s/K8sList.swift rename to Sources/ContainerK8s/Commands/K8sList.swift index d24aec04..3e01bfba 100644 --- a/Sources/Plugins/K8s/K8sList.swift +++ b/Sources/ContainerK8s/Commands/K8sList.swift @@ -20,14 +20,16 @@ import ContainerLog import ContainerResource import Logging -struct K8sList: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sList: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "list", abstract: "List clusters and their nodes", aliases: ["ls"] ) - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let snapshots = try await ContainerClient().list( diff --git a/Sources/Plugins/K8s/K8sLoadImage.swift b/Sources/ContainerK8s/Commands/K8sLoadImage.swift similarity index 96% rename from Sources/Plugins/K8s/K8sLoadImage.swift rename to Sources/ContainerK8s/Commands/K8sLoadImage.swift index 8072f702..0cbcb89c 100644 --- a/Sources/Plugins/K8s/K8sLoadImage.swift +++ b/Sources/ContainerK8s/Commands/K8sLoadImage.swift @@ -25,10 +25,12 @@ import Foundation import Logging import SystemPackage -struct K8sLoadImage: AsyncParsableCommand { +public struct K8sLoadImage: AsyncParsableCommand { + public init() {} + private static let ctrPath = "/usr/local/bin/ctr" - static let configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "load-image", abstract: "Load a container image into a cluster's containerd" ) @@ -44,7 +46,7 @@ struct K8sLoadImage: AsyncParsableCommand { ) var platform: String? - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) diff --git a/Sources/Plugins/K8s/K8sStart.swift b/Sources/ContainerK8s/Commands/K8sStart.swift similarity index 94% rename from Sources/Plugins/K8s/K8sStart.swift rename to Sources/ContainerK8s/Commands/K8sStart.swift index adc98edf..ae792ba4 100644 --- a/Sources/Plugins/K8s/K8sStart.swift +++ b/Sources/ContainerK8s/Commands/K8sStart.swift @@ -22,8 +22,10 @@ import ContainerizationError import Foundation import Logging -struct K8sStart: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sStart: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "start", abstract: "Start a stopped Kubernetes cluster" ) @@ -31,7 +33,7 @@ struct K8sStart: AsyncParsableCommand { @Option(name: .long, help: "Cluster name (default: \(K8sHelper.defaultName))") var name: String = K8sHelper.defaultName - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) diff --git a/Sources/Plugins/K8s/K8sWriteConfig.swift b/Sources/ContainerK8s/Commands/K8sWriteConfig.swift similarity index 91% rename from Sources/Plugins/K8s/K8sWriteConfig.swift rename to Sources/ContainerK8s/Commands/K8sWriteConfig.swift index f7501feb..9a7e687c 100644 --- a/Sources/Plugins/K8s/K8sWriteConfig.swift +++ b/Sources/ContainerK8s/Commands/K8sWriteConfig.swift @@ -21,8 +21,10 @@ import Foundation import Logging import SystemPackage -struct K8sWriteConfig: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sWriteConfig: AsyncParsableCommand { + public init() {} + + public static let configuration = CommandConfiguration( commandName: "write-config", abstract: "Write the cluster context to a Kubernetes configuration file" ) @@ -33,7 +35,7 @@ struct K8sWriteConfig: AsyncParsableCommand { @Option(name: .long, help: "Path to the kubeconfig file to write or append to (default: ~/.kube/config)") var kubeconfig: String? - func run() async throws { + public func run() async throws { LoggingSystem.bootstrap { _ in StderrLogHandler() } let log = Logger(label: K8sHelper.pluginName) diff --git a/Sources/Plugins/K8s/K8sHelper.swift b/Sources/ContainerK8s/K8sHelper.swift similarity index 98% rename from Sources/Plugins/K8s/K8sHelper.swift rename to Sources/ContainerK8s/K8sHelper.swift index 31409840..68043bb9 100644 --- a/Sources/Plugins/K8s/K8sHelper.swift +++ b/Sources/ContainerK8s/K8sHelper.swift @@ -199,7 +199,7 @@ struct K8sHelper { executable: executable, arguments: arguments, environment: [], terminal: false) let proc = try await client.createProcess( containerId: containerId, processId: UUID().uuidString.lowercased(), - configuration: config, stdio: [nil, pipe.fileHandleForWriting, nil]) + configuration: config, stdio: [nil, pipe.fileHandleForWriting, pipe.fileHandleForWriting]) try await proc.start() pipe.fileHandleForWriting.closeFile() let data = pipe.fileHandleForReading.readDataToEndOfFile() @@ -291,8 +291,8 @@ struct K8sHelper { sysctl -w net.bridge.bridge-nf-call-ip6tables=1 2>/dev/null || true systemctl restart containerd ctr -n k8s.io images tag registry.k8s.io/pause:3.10 registry.k8s.io/pause:3.10.1 2>/dev/null || true - iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220 - iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220 + /usr/sbin/iptables-nft -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220 + /usr/sbin/iptables-nft -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220 """ }() @@ -538,7 +538,7 @@ struct K8sHelper { existing.clusters.append(contentsOf: config.clusters) existing.contexts.append(contentsOf: config.contexts) existing.users.append(contentsOf: config.users) - if setCurrentContext && existing.currentContext == nil { + if setCurrentContext { existing.currentContext = containerId } diff --git a/Sources/Plugins/K8s/KubeConfig.swift b/Sources/ContainerK8s/KubeConfig.swift similarity index 100% rename from Sources/Plugins/K8s/KubeConfig.swift rename to Sources/ContainerK8s/KubeConfig.swift diff --git a/Sources/Plugins/K8s/Resources/kindnet.yaml b/Sources/ContainerK8s/Resources/kindnet.yaml similarity index 100% rename from Sources/Plugins/K8s/Resources/kindnet.yaml rename to Sources/ContainerK8s/Resources/kindnet.yaml diff --git a/Sources/ContainerTestSupport/WarmupImage.swift b/Sources/ContainerTestSupport/WarmupImage.swift index 069ed256..f839525c 100644 --- a/Sources/ContainerTestSupport/WarmupImage.swift +++ b/Sources/ContainerTestSupport/WarmupImage.swift @@ -23,6 +23,7 @@ public enum WarmupImage: String, CaseIterable, Sendable { case alpine320 = "ghcr.io/linuxcontainers/alpine:3.20" case alpine318 = "ghcr.io/linuxcontainers/alpine:3.18" case busybox136 = "ghcr.io/containerd/busybox:1.36" + case kindestNodeV1_35_5 = "docker.io/kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" /// Directory under app-root holding OCI tar archives of each warmup image. /// diff --git a/Sources/Plugins/K8s/K8sCommand.swift b/Sources/Plugins/K8s/K8sCommand.swift index 2076fa70..257c28a5 100644 --- a/Sources/Plugins/K8s/K8sCommand.swift +++ b/Sources/Plugins/K8s/K8sCommand.swift @@ -15,11 +15,11 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ContainerK8s import ContainerVersion -@main -struct K8sCommand: AsyncParsableCommand { - static let configuration = CommandConfiguration( +public struct K8sCommand: AsyncParsableCommand { + public static let configuration = CommandConfiguration( commandName: "k8s", abstract: "Manage local Kubernetes development clusters (EXPERIMENTAL)", discussion: """ @@ -56,4 +56,6 @@ struct K8sCommand: AsyncParsableCommand { K8sWriteConfig.self, ] ) + + public init() {} } diff --git a/Sources/Plugins/K8s/K8sMain.swift b/Sources/Plugins/K8s/K8sMain.swift new file mode 100644 index 00000000..c56fc907 --- /dev/null +++ b/Sources/Plugins/K8s/K8sMain.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerK8s + +@main struct K8sPlugin { + static func main() async { + await K8sCommand.main() + } +} diff --git a/Tests/IntegrationTests/K8s/TestK8sLoadImageSerial.swift b/Tests/IntegrationTests/K8s/TestK8sLoadImageSerial.swift index 59fd6723..3e7c5bff 100644 --- a/Tests/IntegrationTests/K8s/TestK8sLoadImageSerial.swift +++ b/Tests/IntegrationTests/K8s/TestK8sLoadImageSerial.swift @@ -74,6 +74,7 @@ struct TestK8sLoadImageSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-load] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-load] k8s create exit=\(result.status)") @@ -85,7 +86,7 @@ struct TestK8sLoadImageSerial { try result.check() print("[k8s-load] pulling \(Self.testImage)") - try f.doPull(Self.testImage) + try f.restoreWarmupImage(.alpine320) print("[k8s-load] k8s load-image --name \(name) \(Self.testImage)") let loadResult = try f.run(["k8s", "load-image", "--name", name, Self.testImage]) diff --git a/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift b/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift index eeabaa6f..160c90bd 100644 --- a/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift +++ b/Tests/IntegrationTests/K8s/TestK8sNetworkingSerial.swift @@ -95,6 +95,7 @@ struct TestK8sNetworkingSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-net] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-net] k8s create exit=\(result.status)") @@ -106,7 +107,7 @@ struct TestK8sNetworkingSerial { try result.check() print("[k8s-net] pulling \(Self.testImage)") - try f.doPull(Self.testImage) + try f.restoreWarmupImage(.alpine320) print("[k8s-net] k8s load-image --name \(name) \(Self.testImage)") let loadResult = try f.run(["k8s", "load-image", "--name", name, Self.testImage]) @@ -143,6 +144,7 @@ struct TestK8sNetworkingSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-net] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-net] k8s create exit=\(result.status)") @@ -153,7 +155,7 @@ struct TestK8sNetworkingSerial { try result.check() print("[k8s-net] pulling \(Self.testImage)") - try f.doPull(Self.testImage) + try f.restoreWarmupImage(.alpine320) print("[k8s-net] k8s load-image --name \(name) \(Self.testImage)") let loadResult = try f.run(["k8s", "load-image", "--name", name, Self.testImage]) diff --git a/Tests/IntegrationTests/K8s/TestK8sRunSerial.swift b/Tests/IntegrationTests/K8s/TestK8sRunSerial.swift index 78dac348..71ae4574 100644 --- a/Tests/IntegrationTests/K8s/TestK8sRunSerial.swift +++ b/Tests/IntegrationTests/K8s/TestK8sRunSerial.swift @@ -60,6 +60,7 @@ struct TestK8sRunSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-run] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-run] k8s create exit=\(result.status)") @@ -94,6 +95,7 @@ struct TestK8sRunSerial { f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name1]) } f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name2]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-run] k8s create --name \(name1)") let result1 = try f.run(["k8s", "create", "--name", name1]) print("[k8s-run] k8s create exit=\(result1.status)") diff --git a/Tests/IntegrationTests/K8s/TestK8sWriteConfigSerial.swift b/Tests/IntegrationTests/K8s/TestK8sWriteConfigSerial.swift index 03fcba76..63a40afc 100644 --- a/Tests/IntegrationTests/K8s/TestK8sWriteConfigSerial.swift +++ b/Tests/IntegrationTests/K8s/TestK8sWriteConfigSerial.swift @@ -99,6 +99,7 @@ struct TestK8sWriteConfigSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-cfg] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-cfg] k8s create exit=\(result.status)") @@ -136,6 +137,7 @@ struct TestK8sWriteConfigSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-cfg] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-cfg] k8s create exit=\(result.status)") @@ -165,6 +167,7 @@ struct TestK8sWriteConfigSerial { let name = "k8s-\(f.testID)" f.addCleanup { _ = try? f.run(["k8s", "delete", "--name", name]) } + try f.restoreWarmupImage(.kindestNodeV1_35_5) print("[k8s-cfg] k8s create --name \(name)") let result = try f.run(["k8s", "create", "--name", name]) print("[k8s-cfg] k8s create exit=\(result.status)") diff --git a/Tests/K8sPluginTests/K8sListTests.swift b/Tests/K8sPluginTests/K8sListTests.swift index 44b87a8a..f8d04b42 100644 --- a/Tests/K8sPluginTests/K8sListTests.swift +++ b/Tests/K8sPluginTests/K8sListTests.swift @@ -19,7 +19,7 @@ import Foundation import Testing import Yams -@testable import k8s +@testable import ContainerK8s // MARK: - Fixtures diff --git a/Tests/K8sPluginTests/KubeconfigMergeTests.swift b/Tests/K8sPluginTests/KubeconfigMergeTests.swift index 58e91c80..2ccb1aea 100644 --- a/Tests/K8sPluginTests/KubeconfigMergeTests.swift +++ b/Tests/K8sPluginTests/KubeconfigMergeTests.swift @@ -21,7 +21,7 @@ import SystemPackage import Testing import Yams -@testable import k8s +@testable import ContainerK8s // MARK: - Helpers @@ -330,17 +330,17 @@ struct MergeConfigTests { } } - @Test func mergeSetsCurrentContextOnFirstClusterOnly() throws { + @Test func mergeAlwaysSetsCurrentContext() throws { let (path, cleanup) = try makeTempFile() defer { cleanup() } - // First create: sets current-context because kubeconfig is empty + // First create: sets current-context try K8sHelper.mergeConfig(makeConfig(clusterName: "first"), containerId: "first", targetPath: path, setCurrentContext: true, log: log) - // Second create: does NOT overwrite current-context because it's already set + // Second create: overwrites current-context to point to the new cluster try K8sHelper.mergeConfig(makeConfig(clusterName: "second"), containerId: "second", targetPath: path, setCurrentContext: true, log: log) let result = try decode(String(contentsOfFile: path.string, encoding: .utf8)) - #expect(result.currentContext == "first") // first-use semantics: not overwritten by second cluster + #expect(result.currentContext == "second") // always switches to the most recently created cluster #expect(result.clusters.count == 2) } }