This is an automated email from the ASF dual-hosted git repository.
weizhou pushed a commit to branch network-namespace
in repository https://gitbox.apache.org/repos/asf/cloudstack-extensions.git
The following commit(s) were added to refs/heads/network-namespace by this push:
new 116ec9a Network Namespace: support custom actions for Policy-Based
Routing
116ec9a is described below
commit 116ec9a07809d0bba478184042995aa1dfe5829f
Author: Wei Zhou <[email protected]>
AuthorDate: Thu Apr 9 12:30:06 2026 +0200
Network Namespace: support custom actions for Policy-Based Routing
---
Network-Namespace/README.md | 177 +++++++++++++++++++++++
Network-Namespace/network-namespace-wrapper.sh | 191 ++++++++++++++++++++++++-
2 files changed, 364 insertions(+), 4 deletions(-)
diff --git a/Network-Namespace/README.md b/Network-Namespace/README.md
index b57982b..3889941 100644
--- a/Network-Namespace/README.md
+++ b/Network-Namespace/README.md
@@ -743,6 +743,56 @@ Actions (superset of shutdown):
> are NOT removed on destroy — they may still be used by other networks or for
> VM connectivity.
+### VPC lifecycle commands: `implement-vpc`, `shutdown-vpc`, `destroy-vpc`
+
+These commands manage VPC-level state. Called by `NetworkExtensionElement` when
+implementing, shutting down, or destroying a VPC (before or after per-tier
+network operations).
+
+#### `implement-vpc`
+
+```
+network-namespace-wrapper.sh implement-vpc \
+ --vpc-id <vpc-id> \
+ --cidr <vpc-cidr>
+```
+
+Actions:
+1. Create the shared VPC namespace `cs-vpc-<vpc-id>`.
+2. Enable IP forwarding inside the namespace.
+3. Create iptables chains for NAT and filter rules.
+4. Save VPC metadata (CIDR, gateway) to state files under
`/var/lib/cloudstack/<ext-name>/vpc-<vpc-id>/`.
+
+> This command runs **before** any tier networks are implemented. Tier networks
+> inherit the same namespace and host assignment.
+
+#### `shutdown-vpc`
+
+```
+network-namespace-wrapper.sh shutdown-vpc \
+ --vpc-id <vpc-id>
+```
+
+Actions:
+1. Flush all iptables rules (ingress, egress, NAT chains inside the namespace).
+2. Stop all services (dnsmasq, haproxy, apache2, password-server) for all
tiers.
+3. Keep the namespace and tier veths intact (tiers may restart).
+
+> Called when the VPC is shut down; tier networks may be restarted later.
+
+#### `destroy-vpc`
+
+```
+network-namespace-wrapper.sh destroy-vpc \
+ --vpc-id <vpc-id>
+```
+
+Actions:
+1. Remove the entire namespace `cs-vpc-<vpc-id>` (deletes all interfaces
inside).
+2. Remove VPC-wide state directory
`/var/lib/cloudstack/<ext-name>/vpc-<vpc-id>/`.
+
+> This is the final cleanup step; after this, the VPC namespace is gone.
+
### `assign-ip`
Called when a public IP is associated with the network (including source NAT).
@@ -929,6 +979,55 @@ iptables design (two independent parts, both inside the
namespace):
`default_egress_allow` policy (allow-by-default or deny-by-default) to VM
outbound traffic on `-i vn-<vlan>-<id>`.
+### `apply-network-acl`
+
+Apply Network ACL (Access Control List) rules for VPC networks.
+
+```
+network-namespace-wrapper.sh apply-network-acl \
+ --network-id <id> \
+ --vlan <vlan-id> \
+ --acl-rules <base64-json> \
+ [--vpc-id <vpc-id>]
+```
+
+The `--acl-rules` value is a Base64-encoded JSON array of ACL rule objects:
+```json
+[
+ {
+ "id": 1,
+ "number": 100,
+ "trafficType": "Ingress",
+ "action": "Allow",
+ "protocol": "tcp",
+ "portStart": 80,
+ "portEnd": 80,
+ "sourceCidrs": ["0.0.0.0/0"]
+ },
+ {
+ "id": 2,
+ "number": 200,
+ "trafficType": "Egress",
+ "action": "Allow",
+ "protocol": "all",
+ "destCidrs": ["0.0.0.0/0"]
+ }
+]
+```
+
+iptables design:
+
+* **Ingress rules** (filter FORWARD, chain `CS_EXTNET_ACL_IN_<networkId>`):
+ Matches `-i vn-<vlan>-<id>` (traffic entering the VM namespace),
+ ordered by rule number. Actions: ACCEPT or DROP.
+
+* **Egress rules** (filter FORWARD, chain `CS_EXTNET_ACL_OUT_<networkId>`):
+ Matches `-o vn-<vlan>-<id>` (traffic leaving the VM namespace),
+ ordered by rule number. Actions: ACCEPT or DROP.
+
+Both chains are inserted at position 1 of `CS_EXTNET_FWD_<networkId>` so ACL
rules
+take precedence over the catch-all ACCEPT rules.
+
### `config-dhcp-subnet` / `remove-dhcp-subnet`
Configure or tear down dnsmasq DHCP service for the network inside the
namespace.
@@ -1107,6 +1206,37 @@ Built-in actions:
|--------|-------------|
| `reboot-device` | Bounces the guest veth pair (`vh-<vlan>-<id>` down → up) |
| `dump-config` | Prints namespace IP addresses, iptables rules, and
per-network state to stdout |
+| `pbr-create-table` | Create or update a routing-table entry in
`/etc/iproute2/rt_tables` |
+| `pbr-delete-table` | Remove a routing-table entry from
`/etc/iproute2/rt_tables` |
+| `pbr-list-tables` | List non-comment routing-table entries from
`/etc/iproute2/rt_tables` |
+| `pbr-add-route` | Add/replace an `ip route` entry in a specific routing
table inside the namespace |
+| `pbr-delete-route` | Delete an `ip route` entry from a specific routing
table inside the namespace |
+| `pbr-list-routes` | List routes from one table (or all tables) inside the
namespace |
+| `pbr-add-rule` | Add an `ip rule` policy rule mapped to a specific routing
table inside the namespace |
+| `pbr-delete-rule` | Delete an `ip rule` policy rule mapped to a specific
routing table inside the namespace |
+| `pbr-list-rules` | List policy rules (or only rules for one table) inside
the namespace |
+
+PBR action parameter keys (`--action-params` JSON):
+
+| Action | Required keys | Optional keys |
+|--------|---------------|---------------|
+| `pbr-create-table` | `table-id` (or `id`), `table-name` (or `table`) | — |
+| `pbr-delete-table` | `table-id` or `table-name` | — |
+| `pbr-list-tables` | — | — |
+| `pbr-add-route` | `table`, `route` | — |
+| `pbr-delete-route` | `table`, `route` | — |
+| `pbr-list-routes` | — | `table` |
+| `pbr-add-rule` | `table`, `rule` | — |
+| `pbr-delete-rule` | `table`, `rule` | — |
+| `pbr-list-rules` | — | `table` |
+
+Examples (equivalent to direct Linux commands):
+
+* `{"table-id":"100","table-name":"isp1"}` → `100 isp1`
+* `{"table":"isp1","route":"default via 192.168.1.1 dev eth0"}`
+* `{"table":"vpn1","route":"default dev wg0"}`
+* `{"table":"isp1","rule":"from 10.10.1.0/24"}`
+* `{"table":"vpn1","rule":"to 10.10.2.0/24"}`
To add custom actions, place an executable script at
`${STATE_DIR}/hooks/custom-action-<name>.sh`
@@ -1207,6 +1337,34 @@ cmk runNetworkCustomAction \
"parameters[0].key=threshold" "parameters[0].value=90"
```
+### PBR custom-action examples
+
+```bash
+# 1) Create action definitions (once per extension)
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-create-table
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-add-route
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-add-rule
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-list-tables
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-list-routes
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-list-rules
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-delete-rule
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-delete-route
resourcetype=Network
+cmk addCustomAction extensionid=<ext-uuid> name=pbr-delete-table
resourcetype=Network
+
+# 2) Execute against a network
+cmk runNetworkCustomAction networkid=<network-uuid>
actionid=<pbr-create-table-id> \
+ "parameters[0].key=table-id" "parameters[0].value=100" \
+ "parameters[1].key=table-name" "parameters[1].value=isp1"
+
+cmk runNetworkCustomAction networkid=<network-uuid>
actionid=<pbr-add-route-id> \
+ "parameters[0].key=table" "parameters[0].value=isp1" \
+ "parameters[1].key=route" "parameters[1].value=default via 192.168.1.1 dev
eth0"
+
+cmk runNetworkCustomAction networkid=<network-uuid> actionid=<pbr-add-rule-id>
\
+ "parameters[0].key=table" "parameters[0].value=isp1" \
+ "parameters[1].key=rule" "parameters[1].value=from 10.10.1.0/24"
+```
+
CloudStack calls `NetworkExtensionElement.runCustomAction()`, which issues:
```bash
network-namespace.sh custom-action \
@@ -1226,6 +1384,23 @@ argument; hook scripts should parse the JSON argument as
needed.
## Developer / testing notes
+### VPC Support
+
+The extension now supports **VPC (Virtual Private Cloud)** networks in
addition to
+isolated networks. Key differences from isolated networks:
+
+* **Namespace sharing**: All tiers of a VPC share a single namespace
(`cs-vpc-<vpcId>`)
+ instead of each network getting its own (`cs-net-<networkId>`).
+* **Host affinity**: All tiers of a VPC land on the same KVM host via stable
hash-based
+ selection using the VPC ID as the routing key.
+* **VPC-level operations**: `implement-vpc`, `shutdown-vpc`, `destroy-vpc`
commands
+ manage VPC-wide state (namespace creation/teardown).
+* **VPC tier operations**: `implement-network`, `shutdown-network`,
`destroy-network`
+ commands manage per-tier bridges and routes; the namespace is preserved
across
+ tier lifecycle operations.
+
+### Integration tests
+
The integration smoke test at
`test/integration/smoke/test_network_extension_namespace.py`
exercises the full lifecycle against real KVM hosts in the zone.
@@ -1248,10 +1423,12 @@ The test covers:
* Create / list / update / delete external network device.
* Full network lifecycle: implement → assign-ip (source NAT) → static NAT →
port forwarding → firewall rules → DHCP/DNS → shutdown / destroy.
+* VPC multi-tier networks with shared namespace and automatic host affinity.
* NSP state transitions: Disabled → Enabled → Disabled → Deleted.
* Tests `test_04`, `test_05`, `test_06` (DHCP, DNS, LB) require `arping`,
`dnsmasq`, and `haproxy` on the KVM hosts; the test skips them automatically
if these tools are not installed.
+* Script cleanup on both management server and KVM hosts after each test.
Run the test:
```bash
diff --git a/Network-Namespace/network-namespace-wrapper.sh
b/Network-Namespace/network-namespace-wrapper.sh
index c8af377..d237de8 100755
--- a/Network-Namespace/network-namespace-wrapper.sh
+++ b/Network-Namespace/network-namespace-wrapper.sh
@@ -1397,7 +1397,7 @@ dhcp-option=3,${GATEWAY}
dhcp-hostsfile=${dhcp_hosts}
addn-hosts=${hosts}
dhcp-optsfile=${dhcp_opts}
-log-facility=/var/log/cloudstack/network-namespace-dnsmasq-${NETWORK_ID}.log
+log-facility=/var/log/cloudstack/extensions/${_WRAPPER_EXT_DIR}/dnsmasq-${NETWORK_ID}.log
EOF
# Add DHCP option 15 (domain-search) when provided by the caller
if [ -n "${DOMAIN}" ]; then
@@ -1649,7 +1649,7 @@ ${unixd_line}
${authz_line}
DocumentRoot ${www}
-ErrorLog /var/log/cloudstack/network-namespace-apache2-${NETWORK_ID}.log
+ErrorLog
/var/log/cloudstack/extensions/${_WRAPPER_EXT_DIR}/apache2-${NETWORK_ID}.log
<VirtualHost ${listen_ip}:80>
ServerName metadata
@@ -1718,7 +1718,7 @@ _svc_start_or_reload_passwd_server() {
local script_f; script_f=$(_passwd_server_script)
local pid_f; pid_f=$(_passwd_server_pid)
local passwd_f; passwd_f=$(_passwd_file)
- local log_f;
log_f="/var/log/cloudstack/network-namespace-passwd-${NETWORK_ID}.log"
+ local log_f;
log_f="/var/log/cloudstack/extensions/${_WRAPPER_EXT_DIR}/passwd-${NETWORK_ID}.log"
mkdir -p "$(dirname "${script_f}")"
touch "${passwd_f}"
@@ -2721,6 +2721,159 @@ PYEOF
##############################################################################
# Command: custom-action
+_pbr_param() {
+ # Return the first non-empty key from ACTION_PARAMS_JSON.
+ local _k _v
+ for _k in "$@"; do
+ _v=$(_json_get "${ACTION_PARAMS_JSON}" "${_k}")
+ if [ -n "${_v}" ]; then
+ echo "${_v}"
+ return 0
+ fi
+ done
+ echo ""
+}
+
+_pbr_table_file() { echo "/etc/iproute2/rt_tables"; }
+
+_pbr_create_table() {
+ local tid tname tf tmp
+ tid="$(_pbr_param table-id table_id id tableid)"
+ tname="$(_pbr_param table-name table_name name tablename table)"
+ [ -z "${tid}" ] && die "pbr-create-table: missing table id"
+ [ -z "${tname}" ] && die "pbr-create-table: missing table name"
+
+ tf="$(_pbr_table_file)"
+ grep -Eq "^[[:space:]]*${tid}[[:space:]]+${tname}([[:space:]]|$)" "${tf}"
2>/dev/null && {
+ echo "pbr-create-table: exists ${tid} ${tname}"
+ return 0
+ }
+
+ tmp=$(mktemp /tmp/cs-extnet-rt-tables-XXXXXX)
+ awk -v tid="${tid}" -v tname="${tname}" '
+ BEGIN { done = 0 }
+ {
+ if ($0 ~ "^[[:space:]]*#" || $0 ~ "^[[:space:]]*$") { print; next }
+ if ($1 == tid || $2 == tname) {
+ if (!done) {
+ print tid " " tname
+ done = 1
+ }
+ next
+ }
+ print
+ }
+ END {
+ if (!done) print tid " " tname
+ }
+ ' "${tf}" > "${tmp}"
+ cat "${tmp}" > "${tf}"
+ rm -f "${tmp}" 2>/dev/null || true
+ echo "pbr-create-table: OK ${tid} ${tname}"
+}
+
+_pbr_delete_table() {
+ local tid tname tf tmp
+ tid="$(_pbr_param table-id table_id id tableid)"
+ tname="$(_pbr_param table-name table_name name tablename table)"
+ [ -z "${tid}" ] && [ -z "${tname}" ] && die "pbr-delete-table: missing
table id/name"
+
+ tf="$(_pbr_table_file)"
+ tmp=$(mktemp /tmp/cs-extnet-rt-tables-XXXXXX)
+ awk -v tid="${tid}" -v tname="${tname}" '
+ {
+ if ($0 ~ "^[[:space:]]*#" || $0 ~ "^[[:space:]]*$") { print; next }
+ if ((tid != "" && $1 == tid) || (tname != "" && $2 == tname)) {
+ next
+ }
+ print
+ }
+ ' "${tf}" > "${tmp}"
+ cat "${tmp}" > "${tf}"
+ rm -f "${tmp}" 2>/dev/null || true
+ echo "pbr-delete-table: OK id=${tid:-n/a} name=${tname:-n/a}"
+}
+
+_pbr_list_tables() {
+ awk '
+ {
+ if ($0 ~ "^[[:space:]]*#" || $0 ~ "^[[:space:]]*$") next
+ print
+ }
+ ' "$(_pbr_table_file)"
+}
+
+_pbr_add_route() {
+ local table route
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ route="$(_pbr_param route route-spec route_spec)"
+ [ -z "${table}" ] && die "pbr-add-route: missing table"
+ [ -z "${route}" ] && die "pbr-add-route: missing route spec"
+ [ -z "${NAMESPACE}" ] && die "pbr-add-route: namespace not resolved"
+
+ # replace is idempotent and avoids duplicate route errors.
+ ip netns exec "${NAMESPACE}" sh -c "ip route replace ${route} table
${table}"
+ echo "pbr-add-route: OK table=${table} route=${route}"
+}
+
+_pbr_delete_route() {
+ local table route
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ route="$(_pbr_param route route-spec route_spec)"
+ [ -z "${table}" ] && die "pbr-delete-route: missing table"
+ [ -z "${route}" ] && die "pbr-delete-route: missing route spec"
+ [ -z "${NAMESPACE}" ] && die "pbr-delete-route: namespace not resolved"
+
+ ip netns exec "${NAMESPACE}" sh -c "ip route del ${route} table ${table}"
2>/dev/null || true
+ echo "pbr-delete-route: OK table=${table} route=${route}"
+}
+
+_pbr_list_routes() {
+ local table
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ [ -z "${NAMESPACE}" ] && die "pbr-list-routes: namespace not resolved"
+ if [ -n "${table}" ]; then
+ ip netns exec "${NAMESPACE}" ip route show table "${table}"
+ else
+ ip netns exec "${NAMESPACE}" ip route show table all
+ fi
+}
+
+_pbr_add_rule() {
+ local table rule
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ rule="$(_pbr_param rule rule-spec rule_spec)"
+ [ -z "${table}" ] && die "pbr-add-rule: missing table"
+ [ -z "${rule}" ] && die "pbr-add-rule: missing rule spec"
+ [ -z "${NAMESPACE}" ] && die "pbr-add-rule: namespace not resolved"
+
+ ip netns exec "${NAMESPACE}" sh -c "ip rule add ${rule} table ${table}"
2>/dev/null || true
+ echo "pbr-add-rule: OK table=${table} rule=${rule}"
+}
+
+_pbr_delete_rule() {
+ local table rule
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ rule="$(_pbr_param rule rule-spec rule_spec)"
+ [ -z "${table}" ] && die "pbr-delete-rule: missing table"
+ [ -z "${rule}" ] && die "pbr-delete-rule: missing rule spec"
+ [ -z "${NAMESPACE}" ] && die "pbr-delete-rule: namespace not resolved"
+
+ ip netns exec "${NAMESPACE}" sh -c "ip rule del ${rule} table ${table}"
2>/dev/null || true
+ echo "pbr-delete-rule: OK table=${table} rule=${rule}"
+}
+
+_pbr_list_rules() {
+ local table
+ table="$(_pbr_param table table-name table_name tablename table-id
table_id id tableid)"
+ [ -z "${NAMESPACE}" ] && die "pbr-list-rules: namespace not resolved"
+ if [ -n "${table}" ]; then
+ ip netns exec "${NAMESPACE}" ip rule show | grep -E
"[[:space:]]lookup[[:space:]]+${table}([[:space:]]|$)" || true
+ else
+ ip netns exec "${NAMESPACE}" ip rule show
+ fi
+}
+
cmd_custom_action() {
NETWORK_ID=""
VPC_ID=""
@@ -2753,6 +2906,7 @@ cmd_custom_action() {
CHOSEN_ID="${VPC_ID:-${NETWORK_ID}}"
_load_state
+ acquire_lock "${NETWORK_ID}"
log "custom-action: network=${NETWORK_ID} ns=${NAMESPACE}
action=${ACTION_NAME} params=${ACTION_PARAMS_JSON}"
@@ -2782,16 +2936,45 @@ cmd_custom_action() {
echo "=== VPC/shared state ($(_vpc_state_dir)) ==="
ls -la "$(_vpc_state_dir)/" 2>/dev/null || echo "(no vpc state)"
;;
+ pbr-create-table)
+ _pbr_create_table
+ ;;
+ pbr-delete-table)
+ _pbr_delete_table
+ ;;
+ pbr-list-tables)
+ _pbr_list_tables
+ ;;
+ pbr-add-route)
+ _pbr_add_route
+ ;;
+ pbr-delete-route)
+ _pbr_delete_route
+ ;;
+ pbr-list-routes)
+ _pbr_list_routes
+ ;;
+ pbr-add-rule)
+ _pbr_add_rule
+ ;;
+ pbr-delete-rule)
+ _pbr_delete_rule
+ ;;
+ pbr-list-rules)
+ _pbr_list_rules
+ ;;
*)
local hook="${STATE_DIR}/hooks/custom-action-${ACTION_NAME}.sh"
if [ -x "${hook}" ]; then
exec "${hook}" --network-id "${NETWORK_ID}" --action
"${ACTION_NAME}" \
--action-params "${ACTION_PARAMS_JSON}"
else
- die "Unknown action '${ACTION_NAME}'. Built-ins:
reboot-device, dump-config"
+ die "Unknown action '${ACTION_NAME}'. Built-ins:
reboot-device, dump-config, pbr-*"
fi
;;
esac
+
+ release_lock
}
##############################################################################