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)

Reply via email to