This is an automated email from the ASF dual-hosted git repository.

DaanHoogland pushed a commit to branch apple-container-extension
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git

commit 01ea6cac13f64b711540bf07f0e5be08407203e6
Author: Daan Hoogland <[email protected]>
AuthorDate: Wed Oct 7 15:30:32 2026 +0200

    apple MacOs container AI generated PoC
---
 apple-container/.gitignore  |   1 +
 apple-container/Cargo.lock  | 128 +++++++++
 apple-container/Cargo.toml  |  29 ++
 apple-container/README.txt  | 167 +++++++++++
 apple-container/src/main.rs | 685 ++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 1010 insertions(+)

diff --git a/apple-container/.gitignore b/apple-container/.gitignore
new file mode 100644
index 0000000..b83d222
--- /dev/null
+++ b/apple-container/.gitignore
@@ -0,0 +1 @@
+/target/
diff --git a/apple-container/Cargo.lock b/apple-container/Cargo.lock
new file mode 100644
index 0000000..1bcc9c1
--- /dev/null
+++ b/apple-container/Cargo.lock
@@ -0,0 +1,128 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "apple-container"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/apple-container/Cargo.toml b/apple-container/Cargo.toml
new file mode 100644
index 0000000..2f5d6a1
--- /dev/null
+++ b/apple-container/Cargo.toml
@@ -0,0 +1,29 @@
+# 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.
+
+[package]
+name = "apple-container"
+version = "0.1.0"
+edition = "2021"
+license = "Apache-2.0"
+description = "Apache CloudStack orchestrator extension for Apple container 
(Linux containers as lightweight VMs) on macOS"
+
+[dependencies]
+serde_json = { version = "1", features = ["preserve_order"] }
+
+[profile.release]
+strip = true
diff --git a/apple-container/README.txt b/apple-container/README.txt
new file mode 100644
index 0000000..deb1f46
--- /dev/null
+++ b/apple-container/README.txt
@@ -0,0 +1,167 @@
+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 Rust 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); it has no runtime dependencies besides the C library
+  - ssh client; sshpass only when password authentication is used
+
+Building:
+  - A current stable Rust toolchain with cargo (https://rustup.rs)
+
+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.
+  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
+  getconsole      Not supported, Apple container has no VNC endpoint.
+
+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>".
+  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
+
+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
+
+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
+
+2. Build the binary on (or for) the management server's platform, e.g. on
+   a Linux management server:
+
+     cargo build --release
+
+   and copy it to every management server:
+
+     mkdir -p /usr/share/cloudstack-management/extensions/apple-container
+     cp target/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 "cargo test".
+
+   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, 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, locally (url
+    localhost): deploy, stop, start, reboot and destroy from CloudStack,
+    the CloudStack MAC address inside the guest, GetIpAddresses and GetLogs
+    with alpine:3.22 and command "sleep infinity". SSH to a remote Mac and
+    multiple NICs still need verification.
diff --git a/apple-container/src/main.rs b/apple-container/src/main.rs
new file mode 100644
index 0000000..7f2990a
--- /dev/null
+++ b/apple-container/src/main.rs
@@ -0,0 +1,685 @@
+// 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.
+//!
+//! 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"
+//!   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)
+//!
+//! 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
+
+use serde_json::{json, Map, Value};
+use std::collections::BTreeMap;
+use std::fs;
+use std::io::Write;
+use std::process::{Command, Stdio};
+use std::sync::mpsc;
+use std::thread;
+use std::time::Duration;
+
+const DEFAULT_CONTAINER_PATH: &str = "/usr/local/bin/container";
+const DEFAULT_NETWORK: &str = "default";
+const MANAGED_LABEL: &str = "org.apache.cloudstack.managed";
+const LOCAL_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
+const DEFAULT_LOG_LINES: u64 = 100;
+
+type Result<T> = std::result::Result<T, String>;
+
+fn success_message(message: &str) -> Value {
+    json!({"status": "success", "message": message})
+}
+
+/// Quote an argument for a POSIX shell, like Python's shlex.quote.
+fn shell_quote(arg: &str) -> String {
+    let safe = |c: char| c.is_ascii_alphanumeric() || "@%+=:,./_-".contains(c);
+    if !arg.is_empty() && arg.chars().all(safe) {
+        arg.to_string()
+    } else {
+        format!("'{}'", arg.replace('\'', "'\"'\"'"))
+    }
+}
+
+/// Text of a JSON value that may be a string or a number.
+fn value_text(value: Option<&Value>) -> String {
+    match value {
+        Some(Value::String(s)) => s.clone(),
+        Some(Value::Null) | None => String::new(),
+        Some(other) => other.to_string(),
+    }
+}
+
+fn value_u64(value: Option<&Value>) -> Option<u64> {
+    match value {
+        Some(Value::Number(n)) => n.as_u64(),
+        Some(Value::String(s)) => s.trim().parse().ok(),
+        _ => None,
+    }
+}
+
+/// Memory argument for `container create`, in whole MiB as container has 1 
MiB granularity.
+fn memory_argument(bytes: u64) -> String {
+    format!("{}M", bytes.div_ceil(1024 * 1024).max(1))
+}
+
+/// The container network for each NIC, the last configured network repeating 
for further NICs.
+fn nic_networks(networks: &[String], nic_count: usize) -> Vec<String> {
+    let last = networks.last().cloned().unwrap_or_else(|| 
DEFAULT_NETWORK.to_string());
+    (0..nic_count.max(1)).map(|i| networks.get(i).cloned().unwrap_or_else(|| 
last.clone())).collect()
+}
+
+/// Runtime state of a container snapshot. `container list --format json` nests
+/// it in status.state, the ContainerSnapshot type serializes a bare status.
+fn container_state(snapshot: &Value) -> String {
+    match snapshot.get("status") {
+        Some(Value::Object(status)) => value_text(status.get("state")),
+        other => value_text(other),
+    }
+}
+
+fn container_networks(snapshot: &Value) -> Vec<Value> {
+    snapshot
+        .get("networks")
+        .or_else(|| snapshot.get("status").and_then(|s| s.get("networks")))
+        .and_then(Value::as_array)
+        .cloned()
+        .unwrap_or_default()
+}
+
+fn container_id(snapshot: &Value) -> String {
+    value_text(snapshot.get("configuration").and_then(|c| c.get("id")))
+}
+
+fn is_managed(snapshot: &Value) -> bool {
+    snapshot
+        .get("configuration")
+        .and_then(|c| c.get("labels"))
+        .and_then(|l| l.get(MANAGED_LABEL))
+        .map(|v| value_text(Some(v)) == "true")
+        .unwrap_or(false)
+}
+
+fn power_state(state: &str) -> &'static str {
+    match state {
+        "running" | "stopping" => "poweron",
+        "stopped" => "poweroff",
+        _ => "unknown",
+    }
+}
+
+/// IPv4 and IPv6 addresses of a container, without their prefix lengths.
+fn ip_addresses(snapshot: &Value) -> Vec<String> {
+    let mut addresses = Vec::new();
+    for network in container_networks(snapshot) {
+        for key in ["ipv4Address", "address", "ipv6Address"] {
+            let address = value_text(network.get(key));
+            let address = address.split('/').next().unwrap_or("");
+            if !address.is_empty() && !addresses.iter().any(|a| a == address) {
+                addresses.push(address.to_string());
+            }
+        }
+    }
+    addresses
+}
+
+struct Config {
+    url: String,
+    username: String,
+    password: String,
+    ssh_key: String,
+    ssh_port: String,
+    verify_host_key: bool,
+    local: bool,
+    container_path: String,
+    networks: Vec<String>,
+    wait_timeout: u64,
+    pull_timeout: u64,
+    image: String,
+    command: Vec<String>,
+    platform: String,
+    init: bool,
+    rosetta: bool,
+    vmname: String,
+    cpus: Option<u64>,
+    memory: Option<u64>,
+    macs: Vec<String>,
+    parameters: Map<String, Value>,
+}
+
+impl Config {
+    fn parse(json_data: &Value) -> Result<Config> {
+        let empty = Map::new();
+        let section = |value: Option<&Value>| 
value.and_then(Value::as_object).unwrap_or(&empty).clone();
+        let external = section(json_data.get("externaldetails"));
+        let extension = section(external.get("extension"));
+        let host = section(external.get("host"));
+        let vm = section(external.get("virtualmachine"));
+
+        let first = |sections: &[&Map<String, Value>], name: &str, default: 
&str| {
+            sections
+                .iter()
+                .map(|s| value_text(s.get(name)))
+                .find(|v| !v.is_empty())
+                .unwrap_or_else(|| default.to_string())
+        };
+        let detail = |name: &str, default: &str| first(&[&host, &extension], 
name, default);
+        let vm_detail = |name: &str, default: &str| first(&[&vm, &host, 
&extension], name, default);
+        let number = |name: &str, default: &str| -> Result<u64> {
+            detail(name, default).trim().parse().map_err(|_| format!("Error 
parsing JSON: invalid {name}"))
+        };
+
+        let url = detail("url", "");
+        let username = detail("username", "");
+        if url.is_empty() {
+            return Err("Missing required field in JSON: url".into());
+        }
+        let local = LOCAL_HOSTS.contains(&url.to_lowercase().as_str()) && 
username.is_empty();
+        if !local && username.is_empty() {
+            return Err("Missing required field in JSON: username".into());
+        }
+        let networks = detail("network", DEFAULT_NETWORK)
+            .split(',')
+            .map(|n| n.trim().to_string())
+            .filter(|n| !n.is_empty())
+            .collect();
+
+        let vm_details = section(json_data.get("cloudstack.vm.details"));
+        let mut nics: Vec<&Value> = 
vm_details.get("nics").and_then(Value::as_array).map(|n| 
n.iter().collect()).unwrap_or_default();
+        nics.sort_by_key(|nic| 
nic.get("deviceId").and_then(Value::as_i64).unwrap_or(0));
+        let macs = nics
+            .iter()
+            .map(|nic| value_text(nic.get("mac")))
+            .filter(|mac| !mac.is_empty())
+            .collect();
+
+        Ok(Config {
+            url,
+            username,
+            password: detail("password", ""),
+            ssh_key: detail("ssh_key", ""),
+            ssh_port: detail("ssh_port", "22"),
+            verify_host_key: detail("verify_host_key", "true").to_lowercase() 
== "true",
+            local,
+            container_path: detail("container_path", DEFAULT_CONTAINER_PATH),
+            networks,
+            wait_timeout: number("wait_timeout", "60")?,
+            pull_timeout: number("pull_timeout", "900")?,
+            image: vm_detail("image", ""),
+            command: vm_detail("command", 
"").split_whitespace().map(String::from).collect(),
+            platform: vm_detail("platform", ""),
+            init: vm_detail("init", "false").to_lowercase() == "true",
+            rosetta: vm_detail("rosetta", "false").to_lowercase() == "true",
+            vmname: value_text(vm_details.get("name")),
+            cpus: value_u64(vm_details.get("cpus")),
+            memory: value_u64(vm_details.get("minRam")),
+            macs,
+            parameters: section(json_data.get("parameters")),
+        })
+    }
+}
+
+struct ContainerManager {
+    config: Config,
+}
+
+impl ContainerManager {
+    fn ssh_command(&self, remote_argv: &[String]) -> Vec<String> {
+        let c = &self.config;
+        let mut cmd: Vec<String> = vec![
+            "ssh".into(),
+            "-o".into(), "ConnectTimeout=15".into(),
+            "-o".into(), format!("StrictHostKeyChecking={}", if 
c.verify_host_key { "yes" } else { "no" }),
+            "-p".into(), c.ssh_port.clone(),
+        ];
+        if !c.verify_host_key {
+            cmd.extend(["-o", "UserKnownHostsFile=/dev/null", "-o", 
"LogLevel=ERROR"].map(String::from));
+        }
+        if !c.ssh_key.is_empty() {
+            cmd.extend(["-i".to_string(), c.ssh_key.clone()]);
+        }
+        if !c.password.is_empty() {
+            cmd.splice(0..0, ["sshpass".to_string(), "-e".to_string()]);
+            cmd.extend(["-o", "BatchMode=no"].map(String::from));
+        } else {
+            cmd.extend(["-o", "BatchMode=yes"].map(String::from));
+        }
+        cmd.push(format!("{}@{}", c.username, c.url));
+        cmd.push("--".into());
+        cmd.push(remote_argv.iter().map(|a| 
shell_quote(a)).collect::<Vec<_>>().join(" "));
+        cmd
+    }
+
+    fn run_with_timeout(&self, argv: &[String], timeout: Duration) -> 
Result<String> {
+        let cmd = if self.config.local { argv.to_vec() } else { 
self.ssh_command(argv) };
+        let mut command = Command::new(&cmd[0]);
+        
command.args(&cmd[1..]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
+        if !self.config.local && !self.config.password.is_empty() {
+            command.env("SSHPASS", &self.config.password);
+        }
+        let child = command.spawn().map_err(|e| match e.kind() {
+            std::io::ErrorKind::NotFound => format!("Command not found: {}", 
cmd[0]),
+            _ => format!("Failed to run {}: {e}", cmd[0]),
+        })?;
+        let pid = child.id();
+        let (sender, receiver) = mpsc::channel();
+        thread::spawn(move || sender.send(child.wait_with_output()));
+        let output = match receiver.recv_timeout(timeout) {
+            Ok(output) => output.map_err(|e| format!("Failed to run {}: {e}", 
cmd[0]))?,
+            Err(_) => {
+                let _ = Command::new("kill").args(["-KILL", 
&pid.to_string()]).status();
+                return Err(format!("Timed out running: {}", argv.join(" ")));
+            }
+        };
+        let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
+        if !output.status.success() {
+            let stderr = String::from_utf8_lossy(&output.stderr);
+            let message = if stderr.trim().is_empty() { stdout.trim() } else { 
stderr.trim() };
+            return Err(if message.is_empty() {
+                format!("'{}' exited with {}", argv.join(" "), 
output.status.code().unwrap_or(-1))
+            } else {
+                message.to_string()
+            });
+        }
+        Ok(stdout)
+    }
+
+    /// Run the container tool with the given arguments, waiting at most the 
given seconds.
+    fn container_with_timeout(&self, args: &[&str], seconds: u64) -> 
Result<String> {
+        let mut argv = vec![self.config.container_path.clone()];
+        argv.extend(args.iter().map(|a| a.to_string()));
+        self.run_with_timeout(&argv, Duration::from_secs(seconds + 30))
+    }
+
+    fn container(&self, args: &[&str]) -> Result<String> {
+        self.container_with_timeout(args, self.config.wait_timeout)
+    }
+
+    fn require_vmname(&self) -> Result<()> {
+        let name = &self.config.vmname;
+        if name.is_empty() {
+            return Err("Missing required field in JSON: 
cloudstack.vm.details.name".into());
+        }
+        if !name.chars().all(|c| c.is_ascii_alphanumeric() || 
"_.-".contains(c)) || name.starts_with(['.', '-']) {
+            return Err(format!("Invalid instance name '{name}'"));
+        }
+        Ok(())
+    }
+
+    /// All containers, running or not, keyed by container ID.
+    fn list_containers(&self) -> Result<BTreeMap<String, Value>> {
+        let output = self.container(&["list", "--all", "--format", "json"])?;
+        let containers: Value = serde_json::from_str(output.trim())
+            .map_err(|e| format!("Failed to parse the container list: {e}"))?;
+        Ok(containers
+            .as_array()
+            .map(|list| list.iter().map(|c| (container_id(c), 
c.clone())).collect())
+            .unwrap_or_default())
+    }
+
+    fn find_container(&self) -> Result<Option<Value>> {
+        Ok(self.list_containers()?.remove(&self.config.vmname))
+    }
+
+    fn require_container(&self) -> Result<Value> {
+        self.find_container()?
+            .ok_or_else(|| format!("Container '{}' not found", 
self.config.vmname))
+    }
+
+    fn create_arguments(&self) -> Vec<String> {
+        let c = &self.config;
+        let mut args: Vec<String> = vec!["create".into(), "--name".into(), 
c.vmname.clone()];
+        args.extend(["--label".to_string(), format!("{MANAGED_LABEL}=true")]);
+        if let Some(cpus) = c.cpus {
+            args.extend(["--cpus".to_string(), cpus.max(1).to_string()]);
+        }
+        if let Some(memory) = c.memory {
+            args.extend(["--memory".to_string(), memory_argument(memory)]);
+        }
+        let networks = nic_networks(&c.networks, c.macs.len());
+        for (i, network) in networks.iter().enumerate() {
+            let spec = match c.macs.get(i) {
+                Some(mac) => format!("{network},mac={}", mac.to_lowercase()),
+                None => network.clone(),
+            };
+            args.extend(["--network".to_string(), spec]);
+        }
+        if !c.platform.is_empty() {
+            args.extend(["--platform".to_string(), c.platform.clone()]);
+        }
+        if c.init {
+            args.push("--init".into());
+        }
+        if c.rosetta {
+            args.push("--rosetta".into());
+        }
+        args.push(c.image.clone());
+        args.extend(c.command.iter().cloned());
+        args
+    }
+
+    fn pull_image(&self) -> Result<()> {
+        let c = &self.config;
+        let mut args = vec!["image", "pull", "--progress", "none"];
+        if !c.platform.is_empty() {
+            args.extend(["--platform", c.platform.as_str()]);
+        }
+        args.push(c.image.as_str());
+        self.container_with_timeout(&args, c.pull_timeout)
+            .map(|_| ())
+            .map_err(|e| format!("Failed to pull image '{}': {e}", c.image))
+    }
+
+    fn start_container(&self) -> Result<()> {
+        self.container(&["start", &self.config.vmname]).map(|_| ())
+    }
+
+    fn stop_container(&self) -> Result<()> {
+        let timeout = self.config.wait_timeout.to_string();
+        self.container(&["stop", "--time", &timeout, 
&self.config.vmname]).map(|_| ())
+    }
+
+    fn delete_container(&self) -> Result<()> {
+        self.container(&["delete", "--force", &self.config.vmname]).map(|_| ())
+    }
+
+    fn create(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        let name = self.config.vmname.clone();
+        if self.config.image.is_empty() {
+            return Err("Missing required field in JSON: image".into());
+        }
+        if self.config.cpus.is_none() || self.config.memory.is_none() {
+            return Err("Missing CPU or memory in 
cloudstack.vm.details".into());
+        }
+        if self.find_container()?.is_some() {
+            return Err(format!("A container named '{name}' already exists"));
+        }
+        self.pull_image()?;
+        let args = self.create_arguments();
+        self.container(&args.iter().map(String::as_str).collect::<Vec<_>>())?;
+        if let Err(e) = self.start_container() {
+            let _ = self.delete_container();
+            return Err(format!("Failed to start container '{name}': {e}"));
+        }
+        Ok(success_message("Instance created"))
+    }
+
+    fn start(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        if container_state(&self.require_container()?) != "running" {
+            self.start_container()?;
+        }
+        Ok(success_message("Instance started"))
+    }
+
+    fn stop(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        if let Some(snapshot) = self.find_container()? {
+            if container_state(&snapshot) != "stopped" {
+                self.stop_container()?;
+            }
+        }
+        Ok(success_message("Instance stopped"))
+    }
+
+    fn reboot(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        if container_state(&self.require_container()?) != "stopped" {
+            self.stop_container()?;
+        }
+        self.start_container()?;
+        Ok(success_message("Instance rebooted"))
+    }
+
+    fn delete(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        if self.find_container()?.is_some() {
+            self.delete_container()?;
+        }
+        Ok(success_message("Instance deleted"))
+    }
+
+    fn status(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        let state = match self.find_container()? {
+            Some(snapshot) => power_state(&container_state(&snapshot)),
+            None => "unknown",
+        };
+        Ok(json!({"status": "success", "power_state": state}))
+    }
+
+    fn statuses(&mut self) -> Result<Value> {
+        let mut power_states = Map::new();
+        for (id, snapshot) in self.list_containers()? {
+            if is_managed(&snapshot) {
+                power_states.insert(id, 
json!(power_state(&container_state(&snapshot))));
+            }
+        }
+        Ok(json!({"status": "success", "power_state": power_states}))
+    }
+
+    fn get_ip_addresses(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        let addresses = ip_addresses(&self.require_container()?);
+        Ok(json!({"status": "success", "printmessage": "true", "message": 
addresses}))
+    }
+
+    fn get_logs(&mut self) -> Result<Value> {
+        self.require_vmname()?;
+        let lines = 
value_u64(self.config.parameters.get("lines")).unwrap_or(DEFAULT_LOG_LINES).to_string();
+        let mut args = vec!["logs", "-n", lines.as_str()];
+        if value_text(self.config.parameters.get("boot")).to_lowercase() == 
"true" {
+            args.push("--boot");
+        }
+        args.push(&self.config.vmname);
+        let logs = self.container(&args)?;
+        Ok(json!({"status": "success", "printmessage": "true", "message": 
logs}))
+    }
+}
+
+fn execute(operation: &str, json_file_path: &str) -> Result<Value> {
+    let content = fs::read_to_string(json_file_path).map_err(|e| match 
e.kind() {
+        std::io::ErrorKind::NotFound => format!("JSON file not found: 
{json_file_path}"),
+        _ => format!("Failed to read {json_file_path}: {e}"),
+    })?;
+    let json_data: Value = serde_json::from_str(&content).map_err(|_| "Invalid 
JSON in file".to_string())?;
+    let mut manager = ContainerManager { config: Config::parse(&json_data)? };
+
+    match operation {
+        "create" => manager.create(),
+        "start" => manager.start(),
+        "stop" => manager.stop(),
+        "reboot" => manager.reboot(),
+        "delete" => manager.delete(),
+        "status" => manager.status(),
+        "statuses" => manager.statuses(),
+        "getconsole" => Err("Operation not supported".into()),
+        "getipaddresses" => manager.get_ip_addresses(),
+        "getlogs" => manager.get_logs(),
+        _ => Err("Invalid action".into()),
+    }
+}
+
+fn main() {
+    let args: Vec<String> = std::env::args().collect();
+    let result = if args.len() < 3 {
+        Err("Usage: apple-container <operation> 
'<json-file-path>'".to_string())
+    } else {
+        execute(&args[1].to_lowercase(), &args[2])
+    };
+    let mut stdout = std::io::stdout();
+    match result {
+        Ok(data) => {
+            let _ = writeln!(stdout, "{data}");
+        }
+        Err(message) => {
+            let _ = writeln!(stdout, "{}", json!({"status": "error", "error": 
message}));
+            std::process::exit(1);
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn config(data: Value) -> Config {
+        Config::parse(&data).unwrap()
+    }
+
+    fn base(extra_vm: Value) -> Value {
+        json!({
+            "externaldetails": {"extension": {"url": "localhost"},
+                                "host": {"network": "default, isolated"},
+                                "virtualmachine": extra_vm},
+            "cloudstack.vm.details": {"name": "i-2-10-VM", "cpus": 2, 
"minRam": 1610612736u64,
+                                      "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"}]}
+        })
+    }
+
+    #[test]
+    fn quotes_shell_arguments() {
+        assert_eq!(shell_quote("i-2-10-VM"), "i-2-10-VM");
+        assert_eq!(shell_quote(""), "''");
+        assert_eq!(shell_quote("a b"), "'a b'");
+        assert_eq!(shell_quote("it's"), "'it'\"'\"'s'");
+    }
+
+    #[test]
+    fn rounds_memory_up_to_mib() {
+        assert_eq!(memory_argument(1610612736), "1536M");
+        assert_eq!(memory_argument(1024 * 1024 + 1), "2M");
+        assert_eq!(memory_argument(0), "1M");
+    }
+
+    #[test]
+    fn repeats_last_network_for_extra_nics() {
+        let networks = vec!["a".to_string(), "b".to_string()];
+        assert_eq!(nic_networks(&networks, 3), vec!["a", "b", "b"]);
+        assert_eq!(nic_networks(&networks, 0), vec!["a"]);
+        assert_eq!(nic_networks(&[], 2), vec!["default", "default"]);
+    }
+
+    #[test]
+    fn parses_details() {
+        let c = config(base(json!({"image": "alpine:3.22", "command": "sleep  
infinity", "init": "TRUE"})));
+        assert!(c.local);
+        assert_eq!(c.container_path, DEFAULT_CONTAINER_PATH);
+        assert_eq!(c.networks, vec!["default", "isolated"]);
+        assert_eq!(c.macs, vec!["02:00:0a:00:00:01", "02:00:0A:00:00:02", 
"02:00:0a:00:00:03"]);
+        assert_eq!(c.command, vec!["sleep", "infinity"]);
+        assert!(c.init);
+        assert!(!c.rosetta);
+        assert_eq!(c.wait_timeout, 60);
+        assert!(Config::parse(&json!({"externaldetails": {"extension": {"url": 
"mac"}}})).is_err());
+        assert!(Config::parse(&json!({"externaldetails": {"extension": {"url": 
"localhost", "wait_timeout": "x"}}})).is_err());
+    }
+
+    #[test]
+    fn instance_image_overrides_host_default() {
+        let mut data = base(json!({"image": "alpine:3.22"}));
+        data["externaldetails"]["host"]["image"] = json!("ubuntu:24.04");
+        assert_eq!(config(data).image, "alpine:3.22");
+        let mut data = base(json!({}));
+        data["externaldetails"]["extension"]["image"] = json!("ubuntu:24.04");
+        assert_eq!(config(data).image, "ubuntu:24.04");
+    }
+
+    #[test]
+    fn builds_create_arguments() {
+        let manager = ContainerManager {
+            config: config(base(json!({"image": "alpine:3.22", "command": 
"sleep infinity", "init": "true"}))),
+        };
+        assert_eq!(
+            manager.create_arguments(),
+            vec![
+                "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",
+            ]
+        );
+    }
+
+    #[test]
+    fn rejects_unsafe_instance_names() {
+        let mut data = base(json!({}));
+        for name in ["", "-rf", "../x", "a b"] {
+            data["cloudstack.vm.details"]["name"] = json!(name);
+            assert!(ContainerManager { config: config(data.clone()) 
}.require_vmname().is_err(), "{name}");
+        }
+    }
+
+    #[test]
+    fn reads_container_snapshots() {
+        let current = json!({
+            "configuration": {"id": "i-2-10-VM", "labels": {MANAGED_LABEL: 
"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"}]
+        });
+        assert_eq!(container_id(&current), "i-2-10-VM");
+        assert_eq!(power_state(&container_state(&current)), "poweron");
+        assert!(is_managed(&current));
+        assert_eq!(ip_addresses(&current), vec!["192.168.64.3", "fd00::3"]);
+
+        let legacy = json!({
+            "configuration": {"id": "buildkit", "labels": {}},
+            "status": {"state": "stopped", "networks": [{"address": 
"192.168.64.2/24"}]}
+        });
+        assert_eq!(power_state(&container_state(&legacy)), "poweroff");
+        assert!(!is_managed(&legacy));
+        assert_eq!(ip_addresses(&legacy), vec!["192.168.64.2"]);
+        assert_eq!(power_state("unknown"), "unknown");
+    }
+}

Reply via email to