|
| 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 | +} |
0 commit comments