This is an automated email from the ASF dual-hosted git repository. DaanHoogland pushed a commit to branch apple-container-swift-extension in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
commit fe852191541f7df99e45e7c802f9a74c80a37836 Author: Daan Hoogland <[email protected]> AuthorDate: Wed Oct 14 09:54:45 2026 +0200 swift version of apple container extension --- apple-container-swift/.gitignore | 2 + apple-container-swift/Package.swift | 34 ++ apple-container-swift/README.txt | 232 +++++++++++ .../Sources/AppleContainerCore/Config.swift | 337 ++++++++++++++++ .../AppleContainerCore/ContainerManager.swift | 434 +++++++++++++++++++++ .../Sources/apple-container/main.swift | 31 ++ .../Tests/apple-container-tests/main.swift | 222 +++++++++++ 7 files changed, 1292 insertions(+) diff --git a/apple-container-swift/.gitignore b/apple-container-swift/.gitignore new file mode 100644 index 0000000..af13669 --- /dev/null +++ b/apple-container-swift/.gitignore @@ -0,0 +1,2 @@ +/.build/ +/.swiftpm/ diff --git a/apple-container-swift/Package.swift b/apple-container-swift/Package.swift new file mode 100644 index 0000000..ad455d9 --- /dev/null +++ b/apple-container-swift/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version:5.9 +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 +// +// http://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 PackageDescription + +let package = Package( + name: "apple-container", + products: [ + .executable(name: "apple-container", targets: ["apple-container"]), + ], + targets: [ + .target(name: "AppleContainerCore"), + .executableTarget(name: "apple-container", dependencies: ["AppleContainerCore"]), + // A plain executable instead of XCTest or Swift Testing, which the + // Command Line Tools alone do not provide: swift run apple-container-tests + .executableTarget(name: "apple-container-tests", dependencies: ["AppleContainerCore"], + path: "Tests/apple-container-tests"), + ] +) diff --git a/apple-container-swift/README.txt b/apple-container-swift/README.txt new file mode 100644 index 0000000..1a0c4a3 --- /dev/null +++ b/apple-container-swift/README.txt @@ -0,0 +1,232 @@ +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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 + + http://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. + +Apple container Orchestrator Extension +====================================== + +Orchestrator extension that lets Apache CloudStack run Linux instances with +Apple container (https://github.com/apple/container) on Apple silicon Macs. +Apple container runs every container from an OCI image in its own +lightweight virtual machine, so each CloudStack instance gets its own Linux +kernel. Each Mac running the container system service is added to +CloudStack as a host in a cluster mapped to this extension. + +The extension is a Swift program that runs on the management server and +drives the "container" command line tool on the Mac through SSH. Every +CloudStack instance is one container named after the instance's internal +name (e.g. i-2-10-VM) and labelled org.apache.cloudstack.managed=true. When +the management server itself runs on the Mac (e.g. a development setup), +the host url can be "localhost" and no SSH is used. + +Requirements +------------ + +Management server: + - The apple-container binary built for the management server's platform + (see Setup). Built with --static-swift-stdlib it needs no Swift runtime + on the management server. + - ssh client; sshpass only when password authentication is used + +Building: + - Swift 5.9 or later (https://www.swift.org/install): Xcode or the Command + Line Tools on macOS, the Swift toolchain on Linux. The package has no + dependencies besides Foundation. + +Mac: + - Apple silicon, macOS 26 or later + - Apple container 1.5 or later, installed from the signed package on + https://github.com/apple/container/releases, with the system service + started by the SSH user: container system start + - Remote Login (SSH) enabled for that user + +Supported operations +-------------------- + + create Pull the image, create a container with the instance's CPU + count, memory (rounded up to whole MiB), one network + attachment per NIC with the CloudStack MAC address, then + start it. The container is removed again when it fails + to start. See Networks for which network each NIC uses. + start, stop, reboot, delete, status, statuses + stop sends SIGTERM and kills the container after + wait_timeout seconds; statuses reports only containers + carrying the CloudStack label. Containers stop together + with the container system service, so when the service is + not running it is started first (see start_service) and + its containers are reported as stopped, instead of + CloudStack seeing an unknown power state. + getconsole Not supported, Apple container has no VNC endpoint. + Other operations CloudStack passes through, such as its + periodic GetVmIpAddressCommand, are answered with + "Operation not supported". + +Custom actions (register them with addCustomAction): + GetIpAddresses List the IPv4/IPv6 addresses of the instance's network + attachments + GetLogs Show the last lines of the container output. Optional + parameters: lines (default 100), boot ("true" shows the + VM boot log instead) + +Configuration details +--------------------- + +Extension or host details (host details win): + url Mac hostname/IP, or "localhost" to run locally + username SSH user running the container system service; leave + empty for local + password Optional SSH password (needs sshpass); keys preferred + ssh_key Optional private key path on the management server + ssh_port Optional, default 22 + verify_host_key Optional, "true" (default) or "false" + container_path Optional, default /usr/local/bin/container + network Optional, comma separated container networks, one per + NIC in device order; the last one is used for further + NICs. Default: default. Create other networks with + "container network create <name>". Not used for NICs on + vmnet extension networks (see Networks). + wait_timeout Optional, seconds a stop waits before killing the + container, and the limit for other commands, default 60 + pull_timeout Optional, seconds an image pull may take, default 900 + start_service Optional, "true" (default) starts the container system + service when a command finds it not running + +Template, service offering or instance details (instance details win; they +can also be set on the host or extension as defaults): + image OCI image reference, e.g. docker.io/library/alpine:3.22 + command Optional command line replacing the image's default + arguments, split on whitespace, e.g. "sleep infinity" + platform Optional image platform, e.g. linux/amd64 + init Optional, "true" runs an init process in the container + that reaps zombies and forwards signals + rosetta Optional, "true" enables Rosetta, for linux/amd64 images + +Networks +-------- + +A NIC on a CloudStack network of the vmnet network extension (an L2 network +whose broadcast URI looks like vs://cs-net-42?mode=nat, see the vmnet +extension's README.txt) is attached to the container network of that name. +That network gets one vmnet network per CloudStack network, so instances on +different CloudStack networks are isolated from each other: + + - It is created with "container network create", labelled + org.apache.cloudstack.managed=true and with --internal for mode + internal, before an instance using it is created, started or rebooted. + A network of that name not created by CloudStack is not used, and the + instance fails to deploy. + - It is deleted when an instance using it is deleted and no other + container uses it any more. + +Any other NIC uses the network detail: the container network at its +position in the list, the last one for further NICs. + +Setup +----- + +1. On the Mac, install Apple container and start its system service as the + user CloudStack will connect as: + + container system start --enable-kernel-install + + The service does not start again by itself after a reboot or logout. + The extension starts it when it finds it down, but only once CloudStack + asks for a status (about once a minute), and starting it over SSH is not + verified yet. To start it at login instead, create a launchd + agent ~/Library/LaunchAgents/org.apache.cloudstack.container.plist: + + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" + "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>Label</key><string>org.apache.cloudstack.container</string> + <key>ProgramArguments</key> + <array> + <string>/usr/local/bin/container</string> + <string>system</string> + <string>start</string> + <string>--disable-kernel-install</string> + </array> + <key>RunAtLoad</key><true/> + </dict> + </plist> + + and load it with: launchctl load ~/Library/LaunchAgents/org.apache.cloudstack.container.plist + +2. Build the binary on (or for) the management server's platform, e.g. on + a Linux management server: + + swift build -c release --static-swift-stdlib + + and copy it to every management server: + + mkdir -p /usr/share/cloudstack-management/extensions/apple-container + cp .build/release/apple-container /usr/share/cloudstack-management/extensions/apple-container/apple-container + chmod 755 /usr/share/cloudstack-management/extensions/apple-container/apple-container + chown -R cloud:cloud /usr/share/cloudstack-management/extensions/apple-container + + The unit tests run with "swift run apple-container-tests". They are a + plain executable because XCTest and Swift Testing are not part of the + Command Line Tools. + + For SSH key authentication, create a key for the cloud user and add the + public key to ~/.ssh/authorized_keys of the user on the Mac. + +3. Register the extension and its custom actions (CloudMonkey): + + cmk create extension name=apple-container type=Orchestrator path=apple-container + cmk add customaction extensionid=<id> name=GetIpAddresses resourcetype=VirtualMachine + cmk add customaction extensionid=<id> name=GetLogs resourcetype=VirtualMachine + +4. Create a cluster with hypervisor External, register the extension to it, + and add each Mac as a host with url, username and (optionally) ssh_key + and network as details. + +5. Register a template with hypervisor External and extension + apple-container, using a dummy url, and set the external detail image to + the OCI image to run, plus command when the image's default command + exits right away (e.g. image=alpine:3.22, command=sleep infinity). + +6. Deploy instances from that template. + +Limitations +----------- + + - Instances are containers: the image's command is the container's main + process and the instance stops when it exits. Use an image with a long + running command, a command detail such as "sleep infinity", or an image + with an init system. + - Networking is limited to container (vmnet) networks on the Mac: no VLAN + isolation, only networks of the vmnet network extension or ones + created by hand, and CloudStack does not manage the IP addresses. Apple + container assigns them, possibly a different IPv4 address on every + start; GetIpAddresses reports the current ones. The IPv6 address is + derived from the MAC address and stays the same. + - The command detail is split on whitespace, quoting is not supported. + - Changing the service offering is not applied to an existing container. + - No console access, no volumes and no snapshot support. + - The container name equals the CloudStack internal instance name; + renaming or recreating it outside CloudStack breaks the mapping. + - Tested on macOS 26.6 with Apple container 1.5.0 and Swift 6.4, locally + (url localhost), by running the binary directly: every operation with + alpine:3.22 and command "sleep infinity", restarting a stopped container + system service, and a vmnet extension network in mode internal (two + instances reach each other but not the internet, the network is created + with the first instance and deleted with the last). Running it from + CloudStack, building it on Linux, SSH to a remote Mac and multiple NICs + still need verification. The Rust version of this extension was tested + from CloudStack. diff --git a/apple-container-swift/Sources/AppleContainerCore/Config.swift b/apple-container-swift/Sources/AppleContainerCore/Config.swift new file mode 100644 index 0000000..36a2b83 --- /dev/null +++ b/apple-container-swift/Sources/AppleContainerCore/Config.swift @@ -0,0 +1,337 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 +// +// http://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. + +// CloudStack orchestrator extension for Apple container on macOS. +// +// Apple container (https://github.com/apple/container) runs each Linux +// container from an OCI image in its own lightweight virtual machine on Apple +// silicon, through Apple's Virtualization framework. This program drives its +// `container` command line tool: every CloudStack instance is one container, +// named after the instance's internal name and labelled as managed by +// CloudStack, created from the OCI image configured on the template. +// +// The management server reaches the Mac over SSH (or runs locally when the +// management server itself runs on the Mac). Each NIC of the instance is +// attached to a container network with the CloudStack MAC address. A NIC on +// a network of the vmnet network extension carries a broadcast URI such as +// vs://cs-net-42?mode=nat; it is attached to the container network of that +// name, which is created on first use and deleted with its last container. +// Other NICs use the networks of the network detail. +// +// Details (host details override extension details): +// url Mac hostname/IP, or "localhost" to run locally +// username SSH user running the container system service +// password optional, SSH password (requires sshpass on the +// management server; key authentication is preferred) +// ssh_key optional, private key path on the management server +// ssh_port optional, defaults to 22 +// verify_host_key optional, "true" (default) or "false" +// container_path optional, defaults to /usr/local/bin/container +// network optional, comma separated container networks, one +// per NIC in device order, the last one repeating for +// further NICs; defaults to "default". Not used for +// NICs on vmnet extension networks +// wait_timeout optional, seconds a stop waits before the container +// is killed and to wait for other commands (60) +// pull_timeout optional, seconds to wait for an image pull (900) +// start_service optional, "true" (default) starts the container +// system service when it is not running +// +// Template, service offering or instance details (instance wins), which may +// also be set on the host or extension as defaults: +// image OCI image reference, e.g. docker.io/library/alpine:3.22 +// command optional, command line replacing the image's default +// arguments, split on whitespace, e.g. "sleep infinity" +// platform optional, image platform, e.g. linux/amd64 +// init optional, "true" runs an init process that reaps +// zombies and forwards signals +// rosetta optional, "true" enables Rosetta for amd64 images + +import Foundation + +public let defaultContainerPath = "/usr/local/bin/container" +public let defaultNetwork = "default" +public let managedLabel = "org.apache.cloudstack.managed" +let localHosts: Set<String> = ["localhost", "127.0.0.1", "::1"] +let defaultLogLines: UInt64 = 100 +/// Broadcast URI scheme of the networks named by the vmnet network extension. +let vmnetScheme = "vs://" +/// Hint the container tool prints when its system service is not running. +let serviceDownHint = "container system start" +public let operations: Set<String> = [ + "create", "start", "stop", "reboot", "delete", "status", "statuses", "getconsole", "getipaddresses", "getlogs", +] + +public typealias JSONObject = [String: Any] + +/// An error whose message is reported to CloudStack. +public struct ExtensionError: Error, Equatable, CustomStringConvertible { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var description: String { message } +} + +func successMessage(_ message: String) -> JSONObject { + ["status": "success", "message": message] +} + +/// Quote an argument for a POSIX shell, like Python's shlex.quote. +public func shellQuote(_ arg: String) -> String { + let safe = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") + if !arg.isEmpty && arg.allSatisfy({ safe.contains($0) }) { + return arg + } + return "'" + arg.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" +} + +/// Text of a JSON value that may be a string, a number or a boolean. +public func valueText(_ value: Any?) -> String { + switch value { + case nil, is NSNull: + return "" + case let string as String: + return string + case let number as NSNumber: + // JSONSerialization decodes true/false as NSNumber too, typed as char + if String(cString: number.objCType) == "c" { + return number.boolValue ? "true" : "false" + } + return number.stringValue + case let other?: + return "\(other)" + } +} + +public func valueUInt64(_ value: Any?) -> UInt64? { + switch value { + case let number as NSNumber: + return number.int64Value >= 0 ? number.uint64Value : nil + case let string as String: + return UInt64(string.trimmingCharacters(in: .whitespaces)) + default: + return nil + } +} + +/// Memory argument for `container create`, in whole MiB as container has 1 MiB granularity. +public func memoryArgument(_ bytes: UInt64) -> String { + let mib: UInt64 = 1024 * 1024 + return "\(max((bytes + mib - 1) / mib, 1))M" +} + +/// A container network named by the vmnet network extension. +public struct VmnetNetwork: Equatable { + public let name: String + public let isInternal: Bool + + public init(name: String, isInternal: Bool) { + self.name = name + self.isInternal = isInternal + } +} + +/// The vmnet network of a NIC broadcast URI like vs://cs-net-42?mode=internal, +/// or nil for NICs on other networks. +public func vmnetNetwork(_ broadcastURI: String) throws -> VmnetNetwork? { + guard broadcastURI.hasPrefix(vmnetScheme) else { + return nil + } + let rest = broadcastURI.dropFirst(vmnetScheme.count) + let parts = rest.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) + let name = String(parts[0]) + let query = parts.count > 1 ? parts[1] : "" + let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789-") + if name.isEmpty || !name.allSatisfy({ allowed.contains($0) }) { + throw ExtensionError("Invalid vmnet network name in broadcast URI '\(broadcastURI)'") + } + let mode = query.split(separator: "&").first { $0.hasPrefix("mode=") }.map { String($0.dropFirst(5)) } ?? "nat" + switch mode { + case "nat": return VmnetNetwork(name: name, isInternal: false) + case "internal": return VmnetNetwork(name: name, isInternal: true) + default: throw ExtensionError("Invalid vmnet network mode in broadcast URI '\(broadcastURI)'") + } +} + +public struct Nic { + public let mac: String + public let vmnet: VmnetNetwork? +} + +/// The container network for each NIC, the last configured network repeating for further NICs. +public func nicNetworks(_ networks: [String], nicCount: Int) -> [String] { + let last = networks.last ?? defaultNetwork + return (0..<max(nicCount, 1)).map { $0 < networks.count ? networks[$0] : last } +} + +/// Runtime state of a container snapshot. `container list --format json` nests +/// it in status.state, the ContainerSnapshot type serializes a bare status. +public func containerState(_ snapshot: JSONObject) -> String { + if let status = snapshot["status"] as? JSONObject { + return valueText(status["state"]) + } + return valueText(snapshot["status"]) +} + +func containerNetworks(_ snapshot: JSONObject) -> [JSONObject] { + let networks = snapshot["networks"] ?? (snapshot["status"] as? JSONObject)?["networks"] + return networks as? [JSONObject] ?? [] +} + +/// Names of the networks a container is attached to, running or not; only +/// running containers report their network status. +public func networkNames(_ snapshot: JSONObject) -> [String] { + let configured = (snapshot["configuration"] as? JSONObject)?["networks"] as? [JSONObject] ?? [] + var names: [String] = [] + for network in configured + containerNetworks(snapshot) { + let name = valueText(network["network"]) + if !name.isEmpty && !names.contains(name) { + names.append(name) + } + } + return names +} + +public func containerID(_ snapshot: JSONObject) -> String { + valueText((snapshot["configuration"] as? JSONObject)?["id"]) +} + +/// Whether a container or network carries the CloudStack label. +public func isManaged(_ snapshot: JSONObject) -> Bool { + let labels = (snapshot["configuration"] as? JSONObject)?["labels"] as? JSONObject + return valueText(labels?[managedLabel]) == "true" +} + +public func powerState(_ state: String) -> String { + switch state { + case "running", "stopping": return "poweron" + case "stopped": return "poweroff" + default: return "unknown" + } +} + +/// IPv4 and IPv6 addresses of a container, without their prefix lengths. +public func ipAddresses(_ snapshot: JSONObject) -> [String] { + var addresses: [String] = [] + for network in containerNetworks(snapshot) { + for key in ["ipv4Address", "address", "ipv6Address"] { + let address = String(valueText(network[key]).split(separator: "/", omittingEmptySubsequences: false).first ?? "") + if !address.isEmpty && !addresses.contains(address) { + addresses.append(address) + } + } + } + return addresses +} + +public struct Config { + public var url: String + public var username: String + public var password: String + public var sshKey: String + public var sshPort: String + public var verifyHostKey: Bool + public var local: Bool + public var containerPath: String + public var networks: [String] + public var waitTimeout: UInt64 + public var pullTimeout: UInt64 + public var startService: Bool + public var image: String + public var command: [String] + public var platform: String + public var initProcess: Bool + public var rosetta: Bool + public var vmName: String + public var cpus: UInt64? + public var memory: UInt64? + public var nics: [Nic] + public var parameters: JSONObject + + public init(parsing data: JSONObject) throws { + let section = { (value: Any?) in value as? JSONObject ?? [:] } + let external = section(data["externaldetails"]) + let extensionDetails = section(external["extension"]) + let host = section(external["host"]) + let vm = section(external["virtualmachine"]) + + func first(_ sections: [JSONObject], _ name: String, _ fallback: String) -> String { + sections.map { valueText($0[name]) }.first { !$0.isEmpty } ?? fallback + } + func detail(_ name: String, _ fallback: String = "") -> String { + first([host, extensionDetails], name, fallback) + } + func vmDetail(_ name: String, _ fallback: String = "") -> String { + first([vm, host, extensionDetails], name, fallback) + } + func number(_ name: String, _ fallback: String) throws -> UInt64 { + guard let value = UInt64(detail(name, fallback).trimmingCharacters(in: .whitespaces)) else { + throw ExtensionError("Error parsing JSON: invalid \(name)") + } + return value + } + + url = detail("url") + username = detail("username") + if url.isEmpty { + throw ExtensionError("Missing required field in JSON: url") + } + local = localHosts.contains(url.lowercased()) && username.isEmpty + if !local && username.isEmpty { + throw ExtensionError("Missing required field in JSON: username") + } + networks = detail("network", defaultNetwork) + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + let vmDetails = section(data["cloudstack.vm.details"]) + let nics = (vmDetails["nics"] as? [JSONObject] ?? []) + .enumerated() + .sorted { lhs, rhs in + // stable on equal device IDs, like Rust's sort_by_key + let (l, r) = ((lhs.element["deviceId"] as? NSNumber)?.intValue ?? 0, + (rhs.element["deviceId"] as? NSNumber)?.intValue ?? 0) + return l != r ? l < r : lhs.offset < rhs.offset + } + .map(\.element) + self.nics = try nics.map { + Nic(mac: valueText($0["mac"]), vmnet: try vmnetNetwork(valueText($0["broadcastUri"]))) + } + + password = detail("password") + sshKey = detail("ssh_key") + sshPort = detail("ssh_port", "22") + verifyHostKey = detail("verify_host_key", "true").lowercased() == "true" + containerPath = detail("container_path", defaultContainerPath) + waitTimeout = try number("wait_timeout", "60") + pullTimeout = try number("pull_timeout", "900") + startService = detail("start_service", "true").lowercased() == "true" + image = vmDetail("image") + command = vmDetail("command").split(whereSeparator: \.isWhitespace).map(String.init) + platform = vmDetail("platform") + initProcess = vmDetail("init", "false").lowercased() == "true" + rosetta = vmDetail("rosetta", "false").lowercased() == "true" + vmName = valueText(vmDetails["name"]) + cpus = valueUInt64(vmDetails["cpus"]) + memory = valueUInt64(vmDetails["minRam"]) + parameters = section(data["parameters"]) + } +} diff --git a/apple-container-swift/Sources/AppleContainerCore/ContainerManager.swift b/apple-container-swift/Sources/AppleContainerCore/ContainerManager.swift new file mode 100644 index 0000000..d8f7c2b --- /dev/null +++ b/apple-container-swift/Sources/AppleContainerCore/ContainerManager.swift @@ -0,0 +1,434 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 +// +// http://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 Foundation + +/// Output of a child process, filled in by the threads draining its pipes. +private final class ProcessOutput: @unchecked Sendable { + var stdout = Data() + var stderr = Data() +} + +public struct ContainerManager { + public var config: Config + + public init(config: Config) { + self.config = config + } + + func sshCommand(_ remoteArgv: [String]) -> [String] { + let c = config + var cmd = [ + "ssh", + "-o", "ConnectTimeout=15", + "-o", "StrictHostKeyChecking=\(c.verifyHostKey ? "yes" : "no")", + "-p", c.sshPort, + ] + if !c.verifyHostKey { + cmd += ["-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"] + } + if !c.sshKey.isEmpty { + cmd += ["-i", c.sshKey] + } + if !c.password.isEmpty { + cmd.insert(contentsOf: ["sshpass", "-e"], at: 0) + cmd += ["-o", "BatchMode=no"] + } else { + cmd += ["-o", "BatchMode=yes"] + } + cmd.append("\(c.username)@\(c.url)") + cmd.append("--") + cmd.append(remoteArgv.map(shellQuote).joined(separator: " ")) + return cmd + } + + func run(_ argv: [String], timeout: TimeInterval) throws -> String { + let cmd = config.local ? argv : sshCommand(argv) + let process = Process() + if cmd[0].contains("/") { + guard FileManager.default.isExecutableFile(atPath: cmd[0]) else { + throw ExtensionError("Command not found: \(cmd[0])") + } + process.executableURL = URL(fileURLWithPath: cmd[0]) + process.arguments = Array(cmd.dropFirst()) + } else { + // let env search PATH, like a shell would + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = cmd + } + if !config.local && !config.password.isEmpty { + var environment = ProcessInfo.processInfo.environment + environment["SSHPASS"] = config.password + process.environment = environment + } + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + do { + try process.run() + } catch { + throw ExtensionError("Failed to run \(cmd[0]): \(error.localizedDescription)") + } + // drain both pipes while the process runs, a full pipe would block it + let output = ProcessOutput() + let readers = DispatchGroup() + DispatchQueue.global().async(group: readers) { + output.stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + } + DispatchQueue.global().async(group: readers) { + output.stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + } + if finished.wait(timeout: .now() + timeout) == .timedOut { + kill(process.processIdentifier, SIGKILL) + throw ExtensionError("Timed out running: \(argv.joined(separator: " "))") + } + readers.wait() + + let stdout = String(decoding: output.stdout, as: UTF8.self) + let status = process.terminationStatus + if status == 127 && !cmd[0].contains("/") { + throw ExtensionError("Command not found: \(cmd[0])") + } + if status != 0 { + let stderr = String(decoding: output.stderr, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + let message = stderr.isEmpty ? stdout.trimmingCharacters(in: .whitespacesAndNewlines) : stderr + throw ExtensionError(message.isEmpty ? "'\(argv.joined(separator: " "))' exited with \(status)" : message) + } + return stdout + } + + /// Run the container tool with the given arguments, waiting at most the given seconds. + func container(_ args: [String], seconds: UInt64? = nil) throws -> String { + let seconds = seconds ?? config.waitTimeout + return try run([config.containerPath] + args, timeout: TimeInterval(seconds + 30)) + } + + public func requireVMName() throws { + let name = config.vmName + if name.isEmpty { + throw ExtensionError("Missing required field in JSON: cloudstack.vm.details.name") + } + let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") + if !name.allSatisfy({ allowed.contains($0) }) || name.hasPrefix(".") || name.hasPrefix("-") { + throw ExtensionError("Invalid instance name '\(name)'") + } + } + + /// Start the container system service, without installing a kernel as that prompts. + func startService() throws { + do { + _ = try container(["system", "start", "--disable-kernel-install", "--timeout", "\(config.waitTimeout)"]) + } catch let error as ExtensionError { + throw ExtensionError("The container system service is not running and failed to start: \(error.message)") + } + } + + /// All containers, running or not, keyed by container ID. Containers do + /// not survive the system service, so when it is down it is started to + /// report them as stopped instead of failing. + func listContainers() throws -> [String: JSONObject] { + let args = ["list", "--all", "--format", "json"] + let output: String + do { + output = try container(args) + } catch let error as ExtensionError where config.startService && error.message.contains(serviceDownHint) { + try startService() + output = try container(args) + } + let parsed: Any + do { + parsed = try JSONSerialization.jsonObject(with: Data(output.utf8)) + } catch { + throw ExtensionError("Failed to parse the container list: \(error.localizedDescription)") + } + var containers: [String: JSONObject] = [:] + for snapshot in parsed as? [JSONObject] ?? [] { + containers[containerID(snapshot)] = snapshot + } + return containers + } + + func findContainer() throws -> JSONObject? { + try listContainers()[config.vmName] + } + + func requireContainer() throws -> JSONObject { + guard let snapshot = try findContainer() else { + throw ExtensionError("Container '\(config.vmName)' not found") + } + return snapshot + } + + public func createArguments() -> [String] { + let c = config + var args = ["create", "--name", c.vmName, "--label", "\(managedLabel)=true"] + if let cpus = c.cpus { + args += ["--cpus", "\(max(cpus, 1))"] + } + if let memory = c.memory { + args += ["--memory", memoryArgument(memory)] + } + for (i, network) in nicNetworks(c.networks, nicCount: c.nics.count).enumerated() { + let nic = i < c.nics.count ? c.nics[i] : nil + let network = nic?.vmnet?.name ?? network + if let nic, !nic.mac.isEmpty { + args += ["--network", "\(network),mac=\(nic.mac.lowercased())"] + } else { + args += ["--network", network] + } + } + if !c.platform.isEmpty { + args += ["--platform", c.platform] + } + if c.initProcess { + args.append("--init") + } + if c.rosetta { + args.append("--rosetta") + } + args.append(c.image) + args += c.command + return args + } + + /// The distinct vmnet extension networks of the instance's NICs. + public func vmnetNetworks() -> [VmnetNetwork] { + var networks: [VmnetNetwork] = [] + for network in config.nics.compactMap(\.vmnet) where !networks.contains(where: { $0.name == network.name }) { + networks.append(network) + } + return networks + } + + /// All container networks keyed by name. + func listNetworks() throws -> [String: JSONObject] { + let output = try container(["network", "list", "--format", "json"]) + let parsed: Any + do { + parsed = try JSONSerialization.jsonObject(with: Data(output.utf8)) + } catch { + throw ExtensionError("Failed to parse the network list: \(error.localizedDescription)") + } + var networks: [String: JSONObject] = [:] + for network in parsed as? [JSONObject] ?? [] { + networks[valueText(network["id"])] = network + } + return networks + } + + /// Create the vmnet extension networks the instance needs and that do not + /// exist yet. An existing network is only used when CloudStack created it. + func ensureNetworks() throws { + let wanted = vmnetNetworks() + if wanted.isEmpty { + return + } + let existing = try listNetworks() + for network in wanted { + if let found = existing[network.name] { + if isManaged(found) { + continue + } + throw ExtensionError("Container network '\(network.name)' exists but was not created by CloudStack") + } + var args = ["network", "create", "--label", "\(managedLabel)=true"] + if network.isInternal { + args.append("--internal") + } + args.append(network.name) + do { + _ = try container(args) + } catch let error as ExtensionError { + // Another instance may have created it meanwhile. + if !(try listNetworks()[network.name].map(isManaged) ?? false) { + throw ExtensionError("Failed to create container network '\(network.name)': \(error.message)") + } + } + } + } + + /// Delete the given networks when CloudStack created them and no other + /// container uses them; the container tool refuses to delete networks in use. + func deleteUnusedNetworks(_ names: [String]) throws { + if names.isEmpty { + return + } + let existing = try listNetworks() + for name in names where existing[name].map(isManaged) ?? false { + _ = try? container(["network", "delete", name]) + } + } + + func pullImage() throws { + var args = ["image", "pull", "--progress", "none"] + if !config.platform.isEmpty { + args += ["--platform", config.platform] + } + args.append(config.image) + do { + _ = try container(args, seconds: config.pullTimeout) + } catch let error as ExtensionError { + throw ExtensionError("Failed to pull image '\(config.image)': \(error.message)") + } + } + + func startContainer() throws { + _ = try container(["start", config.vmName]) + } + + func stopContainer() throws { + _ = try container(["stop", "--time", "\(config.waitTimeout)", config.vmName]) + } + + func deleteContainer() throws { + _ = try container(["delete", "--force", config.vmName]) + } + + func create() throws -> JSONObject { + try requireVMName() + let name = config.vmName + if config.image.isEmpty { + throw ExtensionError("Missing required field in JSON: image") + } + if config.cpus == nil || config.memory == nil { + throw ExtensionError("Missing CPU or memory in cloudstack.vm.details") + } + if try findContainer() != nil { + throw ExtensionError("A container named '\(name)' already exists") + } + try pullImage() + try ensureNetworks() + _ = try container(createArguments()) + do { + try startContainer() + } catch let error as ExtensionError { + try? deleteContainer() + throw ExtensionError("Failed to start container '\(name)': \(error.message)") + } + return successMessage("Instance created") + } + + func start() throws -> JSONObject { + try requireVMName() + if containerState(try requireContainer()) != "running" { + try ensureNetworks() + try startContainer() + } + return successMessage("Instance started") + } + + func stop() throws -> JSONObject { + try requireVMName() + if let snapshot = try findContainer(), containerState(snapshot) != "stopped" { + try stopContainer() + } + return successMessage("Instance stopped") + } + + func reboot() throws -> JSONObject { + try requireVMName() + if containerState(try requireContainer()) != "stopped" { + try stopContainer() + } + try ensureNetworks() + try startContainer() + return successMessage("Instance rebooted") + } + + func delete() throws -> JSONObject { + try requireVMName() + if let snapshot = try findContainer() { + // The expunge payload has no NICs, so take the networks from the container. + let networks = networkNames(snapshot) + try deleteContainer() + // Best effort: a leftover network is reused or deleted with a later instance. + try? deleteUnusedNetworks(networks) + } + return successMessage("Instance deleted") + } + + func status() throws -> JSONObject { + try requireVMName() + let state = try findContainer().map { powerState(containerState($0)) } ?? "unknown" + return ["status": "success", "power_state": state] + } + + func statuses() throws -> JSONObject { + var powerStates: JSONObject = [:] + for (id, snapshot) in try listContainers() where isManaged(snapshot) { + powerStates[id] = powerState(containerState(snapshot)) + } + return ["status": "success", "power_state": powerStates] + } + + func getIPAddresses() throws -> JSONObject { + try requireVMName() + return ["status": "success", "printmessage": "true", "message": ipAddresses(try requireContainer())] + } + + func getLogs() throws -> JSONObject { + try requireVMName() + let lines = valueUInt64(config.parameters["lines"]) ?? defaultLogLines + var args = ["logs", "-n", "\(lines)"] + if valueText(config.parameters["boot"]).lowercased() == "true" { + args.append("--boot") + } + args.append(config.vmName) + return ["status": "success", "printmessage": "true", "message": try container(args)] + } +} + +public func execute(operation: String, jsonFilePath: String) throws -> JSONObject { + // CloudStack also passes commands it has no extension operation for, such as + // com.cloud.agent.api.GetVmIpAddressCommand, without host details. + guard operations.contains(operation) else { + throw ExtensionError("Operation not supported: \(operation)") + } + guard let content = FileManager.default.contents(atPath: jsonFilePath) else { + let exists = FileManager.default.fileExists(atPath: jsonFilePath) + throw ExtensionError(exists ? "Failed to read \(jsonFilePath)" : "JSON file not found: \(jsonFilePath)") + } + guard let data = (try? JSONSerialization.jsonObject(with: content)) as? JSONObject else { + throw ExtensionError("Invalid JSON in file") + } + let manager = ContainerManager(config: try Config(parsing: data)) + + switch operation { + case "create": return try manager.create() + case "start": return try manager.start() + case "stop": return try manager.stop() + case "reboot": return try manager.reboot() + case "delete": return try manager.delete() + case "status": return try manager.status() + case "statuses": return try manager.statuses() + case "getconsole": throw ExtensionError("Operation not supported") + case "getipaddresses": return try manager.getIPAddresses() + case "getlogs": return try manager.getLogs() + default: preconditionFailure("operation \(operation) is checked above") + } +} + +/// One line of JSON, as CloudStack reads the extension's standard output. +public func jsonLine(_ object: JSONObject) -> String { + let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data() + return String(decoding: data, as: UTF8.self) +} diff --git a/apple-container-swift/Sources/apple-container/main.swift b/apple-container-swift/Sources/apple-container/main.swift new file mode 100644 index 0000000..0cebd25 --- /dev/null +++ b/apple-container-swift/Sources/apple-container/main.swift @@ -0,0 +1,31 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 +// +// http://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 AppleContainerCore +import Foundation + +let args = CommandLine.arguments +do { + guard args.count >= 3 else { + throw ExtensionError("Usage: apple-container <operation> '<json-file-path>'") + } + print(jsonLine(try execute(operation: args[1].lowercased(), jsonFilePath: args[2]))) +} catch { + let message = (error as? ExtensionError)?.message ?? "\(error)" + print(jsonLine(["status": "error", "error": message])) + exit(1) +} diff --git a/apple-container-swift/Tests/apple-container-tests/main.swift b/apple-container-swift/Tests/apple-container-tests/main.swift new file mode 100644 index 0000000..d0cc4e3 --- /dev/null +++ b/apple-container-swift/Tests/apple-container-tests/main.swift @@ -0,0 +1,222 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 +// +// http://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 AppleContainerCore +import Foundation + +var failures = 0 +var current = "" + +func check(_ condition: @autoclosure () throws -> Bool, _ message: String = "", line: Int = #line) { + do { + if try !condition() { + failures += 1 + print(" FAILED \(current) (line \(line)) \(message)") + } + } catch { + failures += 1 + print(" FAILED \(current) (line \(line)) threw \(error)") + } +} + +func test(_ name: String, _ body: () throws -> Void) { + current = name + let before = failures + do { + try body() + } catch { + failures += 1 + print(" FAILED \(name) threw \(error)") + } + print("test \(name) ... \(failures == before ? "ok" : "FAILED")") +} + +func json(_ text: String) -> JSONObject { + (try? JSONSerialization.jsonObject(with: Data(text.utf8))) as? JSONObject ?? [:] +} + +func config(_ data: JSONObject) throws -> Config { + try Config(parsing: data) +} + +/// Base payload with three NICs out of device order, plus the given virtualmachine details. +func base(_ vm: String = "{}") -> JSONObject { + json(""" + {"externaldetails": {"extension": {"url": "localhost"}, + "host": {"network": "default, isolated"}, + "virtualmachine": \(vm)}, + "cloudstack.vm.details": {"name": "i-2-10-VM", "cpus": 2, "minRam": 1610612736, + "nics": [{"deviceId": 1, "mac": "02:00:0A:00:00:02"}, + {"deviceId": 0, "mac": "02:00:0a:00:00:01"}, + {"deviceId": 2, "mac": "02:00:0a:00:00:03"}]}} + """) +} + +func setting(_ data: JSONObject, _ section: String, _ key: String, _ value: Any) -> JSONObject { + var data = data + var external = data["externaldetails"] as? JSONObject ?? [:] + var details = external[section] as? JSONObject ?? [:] + details[key] = value + external[section] = details + data["externaldetails"] = external + return data +} + +test("quotes_shell_arguments") { + check(shellQuote("i-2-10-VM") == "i-2-10-VM") + check(shellQuote("") == "''") + check(shellQuote("a b") == "'a b'") + check(shellQuote("it's") == "'it'\"'\"'s'") +} + +test("rounds_memory_up_to_mib") { + check(memoryArgument(1610612736) == "1536M") + check(memoryArgument(1024 * 1024 + 1) == "2M") + check(memoryArgument(0) == "1M") +} + +test("repeats_last_network_for_extra_nics") { + check(nicNetworks(["a", "b"], nicCount: 3) == ["a", "b", "b"]) + check(nicNetworks(["a", "b"], nicCount: 0) == ["a"]) + check(nicNetworks([], nicCount: 2) == ["default", "default"]) +} + +test("parses_details") { + let c = try config(base(#"{"image": "alpine:3.22", "command": "sleep infinity", "init": "TRUE"}"#)) + check(c.local) + check(c.containerPath == defaultContainerPath) + check(c.networks == ["default", "isolated"]) + check(c.nics.map(\.mac) == ["02:00:0a:00:00:01", "02:00:0A:00:00:02", "02:00:0a:00:00:03"]) + check(c.nics.allSatisfy { $0.vmnet == nil }) + check(c.command == ["sleep", "infinity"]) + check(c.initProcess) + check(!c.rosetta) + check(c.waitTimeout == 60) + check(c.startService) + check(c.memory == 1610612736) + check((try? config(json(#"{"externaldetails": {"extension": {"url": "mac"}}}"#))) == nil, "username required") + check((try? config(json(#"{"externaldetails": {"extension": {"url": "localhost", "wait_timeout": "x"}}}"#))) == nil, + "invalid wait_timeout") + check(try config(setting(base(), "host", "ssh_port", 2222)).sshPort == "2222", "numeric detail") + check(try !config(setting(base(), "host", "start_service", false)).startService, "boolean detail") +} + +test("instance_image_overrides_host_default") { + check(try config(setting(base(#"{"image": "alpine:3.22"}"#), "host", "image", "ubuntu:24.04")).image == "alpine:3.22") + check(try config(setting(base(), "extension", "image", "ubuntu:24.04")).image == "ubuntu:24.04") +} + +test("builds_create_arguments") { + let manager = ContainerManager(config: try config( + base(#"{"image": "alpine:3.22", "command": "sleep infinity", "init": "true"}"#))) + check(manager.createArguments() == [ + "create", "--name", "i-2-10-VM", "--label", "org.apache.cloudstack.managed=true", + "--cpus", "2", "--memory", "1536M", + "--network", "default,mac=02:00:0a:00:00:01", + "--network", "isolated,mac=02:00:0a:00:00:02", + "--network", "isolated,mac=02:00:0a:00:00:03", + "--init", "alpine:3.22", "sleep", "infinity", + ], "\(manager.createArguments())") +} + +test("parses_vmnet_broadcast_uris") { + check(try vmnetNetwork("vs://cs-net-42?mode=nat") == VmnetNetwork(name: "cs-net-42", isInternal: false)) + check(try vmnetNetwork("vs://cs-net-7?mode=internal") == VmnetNetwork(name: "cs-net-7", isInternal: true)) + check(try vmnetNetwork("vs://cs-net-7") == VmnetNetwork(name: "cs-net-7", isInternal: false)) + check(try vmnetNetwork("vlan://300") == nil) + check(try vmnetNetwork("") == nil) + for invalid in ["vs://cs-net-7?mode=bridged", "vs://Bad_Name", "vs://"] { + check((try? vmnetNetwork(invalid)) == nil, invalid) + } +} + +test("attaches_vmnet_nics_to_their_own_networks") { + var data = base(#"{"image": "alpine:3.22"}"#) + var vmDetails = data["cloudstack.vm.details"] as? JSONObject ?? [:] + vmDetails["nics"] = json(#""" + {"nics": [{"deviceId": 0, "mac": "02:00:0a:00:00:01", "broadcastUri": "vs://cs-net-42?mode=internal"}, + {"deviceId": 1, "mac": "02:00:0a:00:00:02", "broadcastUri": "vlan://300"}, + {"deviceId": 2, "mac": "02:00:0a:00:00:03", "broadcastUri": "vs://cs-net-42?mode=internal"}]} + """#)["nics"] + data["cloudstack.vm.details"] = vmDetails + let manager = ContainerManager(config: try config(data)) + check(manager.vmnetNetworks() == [VmnetNetwork(name: "cs-net-42", isInternal: true)]) + let args = manager.createArguments() + let networks = zip(args, args.dropFirst()).filter { $0.0 == "--network" }.map(\.1) + check(networks == [ + "cs-net-42,mac=02:00:0a:00:00:01", "isolated,mac=02:00:0a:00:00:02", "cs-net-42,mac=02:00:0a:00:00:03", + ], "\(networks)") +} + +test("rejects_unsafe_instance_names") { + for name in ["", "-rf", "../x", "a b"] { + var data = base() + var vmDetails = data["cloudstack.vm.details"] as? JSONObject ?? [:] + vmDetails["name"] = name + data["cloudstack.vm.details"] = vmDetails + let manager = ContainerManager(config: try config(data)) + check((try? manager.requireVMName()) == nil, name) + } +} + +test("rejects_unsupported_operations_before_reading_details") { + do { + _ = try execute(operation: "com.cloud.agent.api.getvmipaddresscommand", jsonFilePath: "/nonexistent.json") + check(false, "no error") + } catch let error as ExtensionError { + check(error.message == "Operation not supported: com.cloud.agent.api.getvmipaddresscommand", error.message) + } + do { + _ = try execute(operation: "statuses", jsonFilePath: "/nonexistent.json") + check(false, "no error") + } catch let error as ExtensionError { + check(error.message.hasPrefix("JSON file not found"), error.message) + } +} + +test("reads_container_snapshots") { + let current = json(""" + {"configuration": {"id": "i-2-10-VM", "labels": {"\(managedLabel)": "true"}}, + "status": "running", + "networks": [{"network": "default", "hostname": "i-2-10-VM", + "ipv4Address": "192.168.64.3/24", "ipv4Gateway": "192.168.64.1", + "ipv6Address": "fd00::3/64"}]} + """) + check(containerID(current) == "i-2-10-VM") + check(powerState(containerState(current)) == "poweron") + check(isManaged(current)) + check(ipAddresses(current) == ["192.168.64.3", "fd00::3"]) + check(networkNames(current) == ["default"]) + let stopped = json(""" + {"configuration": {"id": "i-2-11-VM", "networks": [{"network": "cs-net-42", "options": {}}, + {"network": "default", "options": {}}]}, + "status": "stopped"} + """) + check(networkNames(stopped) == ["cs-net-42", "default"]) + + let listed = json(""" + {"configuration": {"id": "buildkit", "labels": {}}, + "status": {"state": "stopped", "networks": [{"address": "192.168.64.2/24"}]}} + """) + check(powerState(containerState(listed)) == "poweroff") + check(!isManaged(listed)) + check(ipAddresses(listed) == ["192.168.64.2"]) + check(powerState("unknown") == "unknown") +} + +print(failures == 0 ? "test result: ok" : "test result: FAILED, \(failures) failed checks") +exit(failures == 0 ? 0 : 1)
