This is an automated email from the ASF dual-hosted git repository. DaanHoogland pushed a commit to branch vmnet-network-extension in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
commit 8f3fedad57d74e8251ed29bb93cd6df577cbaa45 Author: Daan Hoogland <[email protected]> AuthorDate: Thu Oct 8 18:15:41 2026 +0200 vmnet: initial version for containers --- vmnet/README.txt | 128 +++++++++++++++++++++++++++++++++++++++++++ vmnet/test_vmnet.py | 74 +++++++++++++++++++++++++ vmnet/vmnet.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) diff --git a/vmnet/README.txt b/vmnet/README.txt new file mode 100644 index 0000000..fd11732 --- /dev/null +++ b/vmnet/README.txt @@ -0,0 +1,128 @@ +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. + +vmnet Network Extension +======================= + +NetworkOrchestrator extension that gives CloudStack L2 networks to the +orchestrator extensions running instances on Macs, as macOS vmnet +networks. Each CloudStack network becomes its own vmnet network, so +instances on different CloudStack networks cannot reach each other. + +The networks are local to the Macs, so this extension only names them. +When CloudStack implements a network, the script returns the vmnet network +name and mode, which CloudStack stores as the network's broadcast URI: + + vs://cs-net-<network id>?mode=<nat|internal> + +Every NIC of an instance carries that URI. The orchestrator extension +running the instance reads it, creates the vmnet network on the Mac the +first time an instance needs it, attaches the NIC to it, and deletes the +network with its last instance. + +Orchestrator extensions that read these URIs: + apple-container container networks ("container network create") + +Requirements +------------ + +Management server: + - python3, the script uses only the standard library + - CloudStack with NetworkOrchestrator extensions + +Mac: whatever the orchestrator extension needs; for apple-container, macOS +26 or later, which can create vmnet networks with their own subnet. + +Supported networks and services +------------------------------- + + - L2 networks only. The vmnet networks hand out their own IP addresses + with DHCP, so CloudStack cannot manage the addresses of Isolated or + Shared networks; implementing such a network fails. + - Modes: nat, where instances reach the outside world through the Mac, + and internal (host-only), where they reach only the Mac and each other. + - UserData is declared but not delivered yet. CloudStack only uses an + extension for networks whose offering names it as a service provider, + and L2 offerings may only have the UserData service. All commands + besides ensure-network-device and implement-network are no-ops. + +Configuration details +--------------------- + +Physical network registration details (registerExtension): + mode Optional, nat (default) or internal + name_prefix Optional, prefix of the vmnet network names, default + cs-net-. Use another prefix per CloudStack installation when + several share a Mac. + +Setup +----- + +1. Copy vmnet.py to every management server: + + mkdir -p /usr/share/cloudstack-management/extensions/vmnet + cp vmnet.py /usr/share/cloudstack-management/extensions/vmnet/vmnet.py + chmod 755 /usr/share/cloudstack-management/extensions/vmnet/vmnet.py + chown -R cloud:cloud /usr/share/cloudstack-management/extensions/vmnet + + The unit tests run with: python3 -m unittest test_vmnet.py + +2. Create the extension: + + cmk create extension name=vmnet type=NetworkOrchestrator path=vmnet.py \ + "details[0].network.services=UserData" \ + "details[0].network.service.capabilities={}" \ + "details[0].network.isolation.method=NetworkExtension" \ + "details[0].network.allocate.extension.ip=false" + + network.isolation.method=NetworkExtension makes CloudStack store the + broadcast URI the script returns, instead of allocating a VLAN. + +3. Register it to the zone's physical network; this also adds the vmnet + network service provider, enabled: + + cmk register extension extensionid=<extension id> \ + resourcetype=PhysicalNetwork resourceid=<physical network id> \ + "details[0].mode=nat" + +4. Create and enable an L2 network offering with UserData from vmnet: + + cmk create networkoffering name=vmnet-l2 displaytext="L2 on vmnet" \ + guestiptype=L2 traffictype=GUEST supportedservices=UserData \ + "serviceproviderlist[0].service=UserData" \ + "serviceproviderlist[0].provider=vmnet" + cmk update networkoffering id=<offering id> state=Enabled + +5. Create networks from that offering and deploy instances on them to + clusters of an orchestrator extension that supports vmnet networks. + +Limitations +----------- + + - CloudStack does not know the instances' IP addresses; use the + orchestrator extension's GetIpAddresses custom action. + - The mode applies to the whole physical network. Changing it affects + networks created on the Mac afterwards; an existing network keeps its + mode until its last instance is deleted. + - A vmnet network exists per Mac: instances of one CloudStack network on + different Macs are on different segments. + - No user data, DHCP reservations, port forwarding or other services. + - Tested on macOS 26.6 with Apple container 1.5.0 and a local management + server: two instances on one internal network reach each other, not + the internet, and are not reachable from containers on other networks; + the network is created with the first instance, created again when it + was removed, and deleted with the last instance. diff --git a/vmnet/test_vmnet.py b/vmnet/test_vmnet.py new file mode 100644 index 0000000..75a2039 --- /dev/null +++ b/vmnet/test_vmnet.py @@ -0,0 +1,74 @@ +# 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. + +"""Unit tests for vmnet.py, run with: python3 -m unittest test_vmnet.py""" + +import json +import os +import tempfile +import unittest + +import vmnet + + +class VmnetTest(unittest.TestCase): + + def run_command(self, command, data): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh: + json.dump(data, fh) + try: + return vmnet.execute(command, fh.name) + finally: + os.unlink(fh.name) + + def test_implements_l2_network_with_default_mode(self): + output = self.run_command("implement-network", { + "physical-network-extension-details": {}, + "payload": {"network_id": 42, "guest_type": "l2"}, + }) + self.assertEqual(output, {"network.broadcast_domain_type": "Vswitch", + "network.broadcast_uri": "vs://cs-net-42?mode=nat"}) + + def test_reads_registration_details_given_as_json_string(self): + output = self.run_command("implement-network", { + "physical-network-extension-details": json.dumps({"mode": "Internal", "name_prefix": "lab-"}), + "payload": {"network_id": "7", "guest_type": "l2"}, + }) + self.assertEqual(output["network.broadcast_uri"], "vs://lab-7?mode=internal") + + def test_rejects_networks_with_cloudstack_managed_addresses(self): + with self.assertRaisesRegex(vmnet.VmnetError, "only L2"): + self.run_command("implement-network", {"payload": {"network_id": 1, "guest_type": "isolated"}}) + + def test_rejects_invalid_settings(self): + with self.assertRaisesRegex(vmnet.VmnetError, "Invalid mode"): + vmnet.network_settings({"physical-network-extension-details": {"mode": "bridged"}}) + with self.assertRaisesRegex(vmnet.VmnetError, "Invalid name_prefix"): + vmnet.network_settings({"physical-network-extension-details": {"name_prefix": "a_b"}}) + with self.assertRaisesRegex(vmnet.VmnetError, "network_id"): + vmnet.network_name("cs-net-", "") + + def test_other_commands(self): + self.assertEqual(self.run_command("ensure-network-device", {"payload": {}}), {}) + self.assertIsNone(self.run_command("destroy-network", {"payload": {"network_id": 1}})) + self.assertIsNone(self.run_command("save-userdata", {"payload": {"network_id": 1}})) + with self.assertRaisesRegex(vmnet.VmnetError, "Unsupported command"): + vmnet.execute("custom-action", "/nonexistent") + + +if __name__ == "__main__": + unittest.main() diff --git a/vmnet/vmnet.py b/vmnet/vmnet.py new file mode 100755 index 0000000..4a11ac8 --- /dev/null +++ b/vmnet/vmnet.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# 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 NetworkOrchestrator extension for macOS vmnet networks. + +The networks are local to the Macs that run the instances, so this provider +only names them: implement-network gives every CloudStack network a vmnet +network name and mode, which CloudStack stores as the network's broadcast +URI, e.g. vs://cs-net-42?mode=nat. The orchestrator extensions running the +instances (e.g. apple-container) read that URI from each NIC and create the +network on the Mac on first use. All other network commands are no-ops. + +Only L2 networks are accepted: the vmnet networks hand out their own IP +addresses, so CloudStack cannot manage them. CloudStack only uses an +extension for networks whose offering names it as a service provider, and +L2 offerings may only have the UserData service, so the extension declares +UserData. It does not deliver user data yet: those commands are no-ops. + +Physical network registration details (registerExtension): + mode optional, nat (default) or internal; nat networks reach the + outside world through the Mac, internal (host-only) networks + reach only the Mac and each other's instances + name_prefix optional, prefix of the vmnet network names, default cs-net- + +Invocation: vmnet.py <command> <payload-file> <timeout-seconds> +""" + +import json +import re +import sys + +MODES = ("nat", "internal") +DEFAULT_MODE = "nat" +DEFAULT_PREFIX = "cs-net-" +BROADCAST_SCHEME = "vs" +BROADCAST_DOMAIN_TYPE = "Vswitch" + +NO_OP_COMMANDS = { + "shutdown-network", "destroy-network", "restore-network", + "prepare-nic", "release-nic", + "assign-ip", "release-ip", + "add-static-nat", "delete-static-nat", "add-port-forward", "delete-port-forward", + "apply-fw-rules", "apply-network-acl", "apply-lb-rules", + "add-dhcp-entry", "remove-dhcp-entry", "config-dhcp-subnet", "remove-dhcp-subnet", "set-dhcp-options", + "add-dns-entry", "remove-dns-entry", "config-dns-subnet", "remove-dns-subnet", + "save-vm-data", "save-password", "save-userdata", "save-sshkey", "save-hypervisor-hostname", +} + + +class VmnetError(Exception): + pass + + +def as_object(value): + """Extension details arrive as a JSON object or as a JSON encoded string.""" + if isinstance(value, str): + try: + value = json.loads(value) if value.strip() else {} + except ValueError: + return {} + return value if isinstance(value, dict) else {} + + +def text(value): + return "" if value is None else str(value).strip() + + +def network_settings(data): + details = as_object(data.get("physical-network-extension-details")) + mode = text(details.get("mode")).lower() or DEFAULT_MODE + if mode not in MODES: + raise VmnetError(f"Invalid mode '{mode}', expected one of {', '.join(MODES)}") + prefix = text(details.get("name_prefix")) or DEFAULT_PREFIX + # The name ends up as the host part of the broadcast URI. + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", prefix): + raise VmnetError(f"Invalid name_prefix '{prefix}', use lowercase letters, digits and dashes") + return mode, prefix + + +def network_name(prefix, network_id): + network_id = text(network_id) + if not network_id.isdigit(): + raise VmnetError(f"Missing or invalid network_id '{network_id}'") + return f"{prefix}{network_id}" + + +def broadcast_uri(name, mode): + return f"{BROADCAST_SCHEME}://{name}?mode={mode}" + + +def implement_network(data): + payload = as_object(data.get("payload")) + guest_type = text(payload.get("guest_type")).lower() + if guest_type and guest_type != "l2": + raise VmnetError(f"vmnet networks assign their own IP addresses, so only L2 networks are supported, " + f"not {guest_type} network {payload.get('network_id')}") + mode, prefix = network_settings(data) + name = network_name(prefix, payload.get("network_id")) + return {"network.broadcast_domain_type": BROADCAST_DOMAIN_TYPE, + "network.broadcast_uri": broadcast_uri(name, mode)} + + +def execute(command, payload_file): + if command not in NO_OP_COMMANDS and command not in ("ensure-network-device", "implement-network"): + raise VmnetError(f"Unsupported command '{command}'") + try: + with open(payload_file, encoding="utf-8") as fh: + data = json.load(fh) + except OSError as e: + raise VmnetError(f"Cannot read payload file {payload_file}: {e}") + except ValueError: + raise VmnetError(f"Invalid JSON in payload file {payload_file}") + + if command == "implement-network": + return implement_network(data) + if command == "ensure-network-device": + # There is no central device: the networks live on the Macs. + return {} + return None + + +def main(argv): + if len(argv) < 3: + print("Usage: vmnet.py <command> <payload-file> [timeout-seconds]", file=sys.stderr) + return 1 + try: + output = execute(argv[1], argv[2]) + except VmnetError as e: + print(str(e), file=sys.stderr) + return 1 + if output is not None: + print(json.dumps(output, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))
