On 10/23/26 3:30 PM, Dumitru Ceara wrote:
> Hi Rosemarie,
>
> On 10/23/26 7:15 AM, Rosemarie O'Riorden wrote:
>> This commit adds a test that developers can run to check if the changes
>> they've made cause a regression in the performance of ovn-controller or
>> ovn-northd.
>>
>> Run from inside the sandbox and compare two commits' runtime and peak
>> memory usage for each process.
>>
>> This test creates a topology consisting of logical switches and routers,
>> ACLs, port groups, NAT, DHCP, DNS, QoS, routing policies, and load
>> balancers. The user can also provide their own database file for the
>> benchmark.
>>
>> * To run the test with defaults (200 nodes, track northd and controller):
>> ./ovn-benchmark.sh
>> * To run with 50 nodes: ./ovn-benchmark.sh 50
>> * To run with 30 nodes and only track ovn-northd:
>> ./ovn-benchmark.sh 30 northd
>> * To run with Valgrind Massif tracking allocations:
>> ./ovn-benchmark.sh --valgrind
>> * To run with a custom db file: ./ovn-benchmark.sh -f file.db
>> * To run with debug info printed: ./ovn-benchmark.sh --debug
>> * To see detailed usage instructions: ./ovn-benchmark.sh --help
>>
>> Each "node" is a router-switch pair with associated features:
>> - 1 gateway router (with NAT, static routes, routing policies)
>> - 1 logical switch (with 9 ports by default, DHCP, DNS, QoS)
>> - 5 load balancers (each with 5 backends)
>> - ACLs, port groups, and address sets (shared across all nodes)
>>
>> For example, 200 nodes creates:
>> - 200 routers + 200 switches
>> - 1,800 logical switch ports (9 per switch)
>> - 1,000 load balancers (5 per node)
>> - 1,200 NAT rules, 1,200 static routes, 800 routing policies
>> - Plus ACLs, DHCP/DNS records, QoS rules, etc.
>>
>> ovn-benchmark.py creates the topology and ovn-benchmark.sh is the
>> wrapper that tracks peak memory and execution time. ovn-benchmark.py was
>> built off of ovn-lb-benchmark.py as a starting point.
>>
>> VmPeak is tracked by default, but Valgrind can be used instead for more
>> precise results.
>>
>> Reported-at: https://redhat.atlassian.net/browse/FDP-978
>> Assisted-by: Claude Sonnet 4.5, Claude Code
>> Signed-off-by: Rosemarie O'Riorden <[email protected]>
>> ---
>> BTW:
>> A CI component for this patch, which would add a GH action that runs the
>> benchmark on the base upstream vs with the patch applied, and compares
>> the results to test for regressions, is coming in the future.
>>
>> v4 -> v5:
>> - Incremental engine coverage:
>> - Added 21 incremental phases (add/modify/delete) that run after
>> topology creation: Each phase operates on ~10% of nodes, and is
>> followed by an hv sync. These phases are timed.
>> - After each phase, queries inc-engine/show-stats to check whether
>> northd or lflow triggered a full recompute. Phases that use NULL
>> handlers (routes, policies, DHCP, DNS, QoS, LB disassociation)
>> are marked as expected recomputes; unexpected recomputes print a
>> WARNING. Failed phases print FAILED. If the results are unknown,
>> "?" prints. Otherwise, "OK" prints.
>> - Naming:
>> - Renamed gateway routers from lr-X to gw-X (ports lr2j/j2lr to
>> gw2j/j2gw). Topology diagram updated.
>> - Renamed all initial creation functions to use create_* prefix;
>> incremental functions use add_/modify_/delete_ prefixes.
>> - Timing:
>> - Removed per-sub-phase creation timing (DB generation, port
>> binding, initial sync) because they don't convey any useful info.
>> - Removed DB compaction timing from output (compaction still runs
>> for accurate memory readings).
>> - Incremental phases show per-phase timing alongside engine stats.
>> - Output:
>> - Config summary moved from stderr to stdout.
>> - Debug prints now show actual IP addresses, MAC addresses, and
>> operation details instead of just "Adding NAT rules to router 0".
>> - Shell script:
>> - Duplicate process detection now reports all duplicate PIDs before
>> exiting, not just the first.
>> - Engine stats use globbed control socket path
>> (sandbox/ovn-northd.*.ctl) instead of hardcoded path.
>> - Syntax and validation:
>> - Replaced sys.stderr.write()/sys.exit(1) with die() throughout.
>> - Added minimum bounds checks (nodes >= 1, ports/vips/backends >= 0).
>> - str.removesuffix() replaced with [:-3] for Python 3.7.
>> - Port groups dict built once and passed to both consumers.
>> - Transaction commit helper accepts UNCHANGED as valid.
>> - Guards for ports_per_switch=0 edge case in add/delete LSP phases.
>>
>> v3 -> v4:
>> - Update commit message to reflect # of NAT rules and static routes for IPv6
>> - Remove python 3.9 syntax (str.removesuffix()), change out for
>> python 3.7 (subnet[:-3])
>> - Add timeout for ovn-nbctl --wait=hv sync in time tracking part of
>> benchmark, and add warning if timeout is hit.
>>
>> v2 -> v3:
>> - Memory tracking:
>> - Dropped the background watcher loop that read RSS periodically.
>> Now VmPeak is read once from /proc/<pid>/status instead, so short
>> peaks aren't missed.
>> - Added --valgrind (-v) to rerun the tracked processes under Massif
>> and report peak heap. More accurate but 10-50x slower, so it
>> warns above 500 nodes.
>> - The processes valgrind hijacks get restarted on exit, Ctrl-C
>> included, from their saved cmdline and cwd.
>> - Timing:
>> - Total time is split into topology generation, OVN sync and db
>> compaction, so you can tell which phase regressed.
>> - Topology fixes:
>> - DHCP options were created but never attached to any port. They're
>> assigned now, v4 and v6.
>> - Same for the web/db/app address sets -- nothing referenced them, so
>> added ACLs that do.
>> - Split trusted_networks into v4 and v6 sets; one mixed set can't
>> serve both the ip4.dst and ip6.dst matches.
>> - Gateway join ports all shared one MAC and 10.0.0.1/8. Each gets
>> its own MAC and a real join subnet address now, + routes to match.
>> - The port group drop ACL matches outport instead of inport.
>> - Chassis names start at 1 to match the sandbox system-id.
>> - Safety:
>> - A missing router, switch, or port group used to be skipped quietly,
>> leaving a half-built topology. These error out now.
>> - Errors out on duplicate processe and script running outside the sandbox.
>> - Added a SIGINT trap, bounds checks for --backends and --vips, and a
>> fix for binding more ports than there are nodes.
>> - Docs:
>> - testing.rst covers both memory modes and the valgrind tradeoff.
>> - Small stuff:
>> - Removed an unused json RPC connection.
>> - Port group lookups use a dict instead of rescanning the table.
>> - Better warnings for misused args, and options that may be confusing.
>> - Quoting, output formatting, comments, etc.
>>
>> v1 -> v2:
>> - Topology:
>> - Restructured from 1:1 chassis:node to a nodes-per-chassis style
>> where each chassis handles a partition or 'batch' of the network (ascii
>> diagram added).
>> - Added --batch-size (-b) for nodes-per-chassis grouping.
>> - Added IPv6 coverage.
>> - IP addressing:
>> - ip_node()/ip6_node() helpers spread index across two octets to support
>> >255 nodes.
>> - Added bounds checks for node count and ports-per-switch.
>> - Shell fixes:
>> - pgrep -x for exact process match.
>> - break 2 to exit both watcher loops on process death.
>> - trap cleanup EXIT with mktemp -d for temp files.
>> - Renamed SECONDS bc it's a bash keyword.
>> - python -> python3.
>> - Quoted array expansions, fixed PID error messages.
>> - Code cleanup:
>> - Extracted _add_node(), _add_ports(), _add_acl() helpers.
>> - Removed dead code (find_by_name(), seqno).
>> - Fixed error variable shadowing imported module 'error'.
>> - Docs:
>> - Added ovn-benchmark section to testing.rst.
>> - Added note in submitting-patches.rst.
>> ---
>> .../contributing/submitting-patches.rst | 4 +
>> Documentation/topics/testing.rst | 39 +
>> tutorial/automake.mk | 4 +-
>> tutorial/ovn-benchmark.py | 1449 +++++++++++++++++
>> tutorial/ovn-benchmark.sh | 561 +++++++
>> 5 files changed, 2056 insertions(+), 1 deletion(-)
>> create mode 100755 tutorial/ovn-benchmark.py
>> create mode 100755 tutorial/ovn-benchmark.sh
>>
>> diff --git a/Documentation/internals/contributing/submitting-patches.rst
>> b/Documentation/internals/contributing/submitting-patches.rst
>> index abeb3e9d0..c96fdff84 100644
>> --- a/Documentation/internals/contributing/submitting-patches.rst
>> +++ b/Documentation/internals/contributing/submitting-patches.rst
>> @@ -68,6 +68,10 @@ Testing is also important:
>> feature. A bug fix patch should preferably add a test that would
>> fail if the bug recurs.
>>
>> +- To check for memory or performance regressions, you can run
>> + ``./ovn-benchmark.sh`` inside the OVN sandbox (``make sandbox``).
>> + See the "ovn-benchmark" section of :doc:`/topics/testing` for details.
>> +
>> If you are using GitHub, then you may utilize the GitHub Actions CI system.
>> This will run the above tests automatically when you push changes to your
>> repository.
>> diff --git a/Documentation/topics/testing.rst
>> b/Documentation/topics/testing.rst
>> index 951133c29..18508ba62 100644
>> --- a/Documentation/topics/testing.rst
>> +++ b/Documentation/topics/testing.rst
>> @@ -294,6 +294,45 @@ of these cached objects, be sure to rebuild the test.
>> The cached objects are stored under the relevant folder in
>> ``tests/perf-testsuite.dir/cached``.
>>
>> +ovn-benchmark
>> ++++++++++++++
>> +
>> +The ``tutorial/`` directory contains a memory and performance benchmarking
>> +tool that can be used to detect regressions between commits:
>> +
>> +- ``ovn-benchmark.sh``: Shell wrapper that tracks peak memory and
>> + execution time for ``ovn-northd`` and ``ovn-controller``. By default
>> + it reports peak virtual memory (VmPeak) from ``/proc/<pid>/status``,
>> + which captures all allocated memory including pages not yet accessed.
>> + Pass ``--valgrind`` to restart the tracked processes under Valgrind
>> + Massif and report peak heap memory instead; this is more accurate but
>> + 10-50x slower.
>> +- ``ovn-benchmark.py``: Python script that populates the Northbound database
>> + with a realistic ovn-kubernetes-style topology.
>> +
>> +The generated topology includes gateway routers, logical switches, NAT
>> rules,
>> +ACLs, DHCP/DHCPv6, DNS, load balancers, QoS rules, address sets, port
>> groups,
>> +static routes, and routing policies -- all with both IPv4 and IPv6 coverage.
>> +After creation, incremental add/modify/delete operations exercise the northd
>> +engine and flag unexpected recomputes.
>> +
>> +The benchmark must be run from inside the OVN sandbox. Run
>> +``./ovn-benchmark.sh --help`` for the full list of options::
>> +
>> + $ make sandbox
>> + $ ./ovn-benchmark.sh
>> +
>> +By default, the topology is generated through many individual OVSDB
>> +transactions via ``ovn-benchmark.py``.
>> +
>> +.. note::
>> +
>> + The ``-f`` option loads a Northbound database file via ``ovsdb-client
>> + restore`` instead, which applies the entire database as a single
>> + transaction. This may yield different memory and timing results because
>> + ``ovn-northd`` and ``ovn-controller`` process one large batch of changes
>> + rather than reacting to each transaction incrementally.
>> +
>> OVN Upgrade Testing
>> ~~~~~~~~~~~~~~~~~~~
>>
>> diff --git a/tutorial/automake.mk b/tutorial/automake.mk
>> index 631208639..fdea2449c 100644
>> --- a/tutorial/automake.mk
>> +++ b/tutorial/automake.mk
>> @@ -2,7 +2,9 @@ EXTRA_DIST += \
>> tutorial/ovn-sandbox \
>> tutorial/ovn-setup.sh \
>> tutorial/ovn-lb-benchmark.sh \
>> - tutorial/ovn-lb-benchmark.py
>> + tutorial/ovn-lb-benchmark.py \
>> + tutorial/ovn-benchmark.sh \
>> + tutorial/ovn-benchmark.py
>> sandbox: all
>> cd $(srcdir)/tutorial && MAKE=$(MAKE) HAVE_OPENSSL=$(HAVE_OPENSSL) \
>> ./ovn-sandbox -b $(abs_builddir) --ovs-src $(ovs_srcdir)
>> --ovs-build $(ovs_builddir) $(SANDBOXFLAGS)
>> diff --git a/tutorial/ovn-benchmark.py b/tutorial/ovn-benchmark.py
>> new file mode 100755
>> index 000000000..d2d4bb9fd
>> --- /dev/null
>> +++ b/tutorial/ovn-benchmark.py
>> @@ -0,0 +1,1449 @@
>> +#!/usr/bin/env python3
>> +"""OVN memory and performance regression testing tool.
>> +
>> +Creates a broad OVN topology, then exercises incremental engine paths
>> +by adding, modifying, and deleting resources. Designed to be run via
>> +ovn-benchmark.sh, which measures peak memory, per-phase timing, and
>> +checks for unexpected engine recomputes. (Run ./ovn-benchmark.sh
>> +--help to see usage).
>> +
>> +Topology (simulates the default ovn-kubernetes topology):
>> +
>> + lsp-0-* lsp-1-* (Workload ports)
>> + | |
>> + ls-0 ls-1 (Logical Switches)
>> + | |
>> + s2c-0/c2s-0 s2c-1/c2s-1
>> + \\ /
>> + +--- cluster (LR) ---+ (Cluster Router)
>> + |
>> + sjc/rcj
>> + |
>> + +---- join (LS) ----+ (Join Switch)
>> + / \\
>> + j2gw-0/gw2j-0 j2gw-1/gw2j-1
>> + | |
>> + gw-0 gw-1 (Gateway Routers)
>> +
>> + Chassis binding (with batch size B, default n/10):
>> + chassis-1: c2s-[0..B), gw-[0..B) (matches sandbox system-id)
>> + chassis-2: c2s-[B..2B), gw-[B..2B)
>> + ...
>> +
>> +Each node creates a dual-stack gateway router + logical switch pair
>> +with:
>> + - Dual-stack addressing (IPv4 10.x.x.x/24, IPv6 fd00:x::/64)
>> + - NAT (SNAT/DNAT/DNAT_AND_SNAT), static routes, routing policies
>> + - Configurable ports per switch with port security
>> + - Security: Address sets, port groups, ACLs
>> + - Services: DHCP, DHCPv6, DNS, load balancers
>> + - QoS: Bandwidth limiting, DSCP marking
>> +
>> +Note: Uses explicit (non-templated) load balancers to maximize memory
>> +usage for regression testing. For templated LB testing, see
>> +ovn-lb-benchmark.py.
>> +"""
>> +
>> +import argparse
>> +import sys
>> +
>> +import ovs.db.idl
>> +import ovs.poller
>> +import ovs.vlog
>> +from ovs.db import error
>> +
>> +vlog = ovs.vlog.Vlog('ovn-benchmark')
>> +vlog.set_levels_from_string('console:warn')
>> +vlog.init(None)
>> +
>> +SCHEMA = '../ovn-nb.ovsschema'
>> +
>> +
>> +def die(msg):
>> + sys.stderr.write(f'\nError: {msg}\n')
>> + sys.exit(1)
>> +
>> +
>> +def ip_node(i):
>> + """Convert node index to two IP octets, supporting up to 65535 nodes."""
>> + return f'{i >> 8}.{i & 0xff}'
>> +
>> +
>> +def ip6_node(i):
>> + """Convert node index to an IPv6 hextet for ULA addresses."""
>> + return f'{i:x}'
>> +
>> +
>> +def create_address_sets(idl, n):
>> + """Create address sets for security groups."""
>> + vlog.info(f'Creating address sets: web_servers ({n} v4 + {n} v6), '
>> + f'db_servers ({n} v4 + {n} v6), app_servers ({n} v4 + {n}
>> v6), '
>> + f'trusted_networks_v4, trusted_networks_v6')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + web_as = txn.insert(idl.tables['Address_Set'])
>> + web_as.name = 'web_servers'
>> + web_as.addresses = (
>> + [f'10.{ip_node(i)}.10' for i in range(n)]
>> + + [f'fd00:{ip6_node(i)}::a' for i in range(n)])
>> +
>> + db_as = txn.insert(idl.tables['Address_Set'])
>> + db_as.name = 'db_servers'
>> + db_as.addresses = (
>> + [f'10.{ip_node(i)}.20' for i in range(n)]
>> + + [f'fd00:{ip6_node(i)}::14' for i in range(n)])
>> +
>> + app_as = txn.insert(idl.tables['Address_Set'])
>> + app_as.name = 'app_servers'
>> + app_as.addresses = (
>> + [f'10.{ip_node(i)}.30' for i in range(n)]
>> + + [f'fd00:{ip6_node(i)}::1e' for i in range(n)])
>> +
>> + trusted_as_v4 = txn.insert(idl.tables['Address_Set'])
>> + trusted_as_v4.name = 'trusted_networks_v4'
>> + trusted_as_v4.addresses = ['192.168.0.0/16', '172.16.0.0/12']
>> +
>> + trusted_as_v6 = txn.insert(idl.tables['Address_Set'])
>> + trusted_as_v6.name = 'trusted_networks_v6'
>> + trusted_as_v6.addresses = ['fc00::/7']
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create address sets ({txn.get_error()})')
>> +
>> +
>> +def create_port_groups(idl):
>> + """Create port groups for security group implementation."""
>> + vlog.info('Creating port groups: web_tier, db_tier, app_tier')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + web_pg = txn.insert(idl.tables['Port_Group'])
>> + web_pg.name = 'web_tier'
>> +
>> + db_pg = txn.insert(idl.tables['Port_Group'])
>> + db_pg.name = 'db_tier'
>> +
>> + app_pg = txn.insert(idl.tables['Port_Group'])
>> + app_pg.name = 'app_tier'
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create port groups ({txn.get_error()})')
>> +
>> +
>> +def create_dhcp_options(idl, n):
>> + """Create DHCP options for each subnet."""
>> + for i in range(n):
>> + vlog.info(f'Creating DHCP options for node {i}: '
>> + f'10.{ip_node(i)}.0/24, fd00:{ip6_node(i)}::/64')
>> + txn = ovs.db.idl.Transaction(idl)
>> + dhcp_opts = txn.insert(idl.tables['DHCP_Options'])
>> + dhcp_opts.cidr = f'10.{ip_node(i)}.0/24'
>> + dhcp_opts.setkey('options', 'server_id', f'10.{ip_node(i)}.1')
>> + dhcp_opts.setkey('options', 'server_mac', '00:00:00:00:00:01')
>> + dhcp_opts.setkey('options', 'lease_time', '3600')
>> + dhcp_opts.setkey('options', 'router', f'10.{ip_node(i)}.1')
>> + dhcp_opts.setkey('options', 'dns_server', f'10.{ip_node(i)}.2')
>> + dhcp_opts.setkey('options', 'domain_name', '"example.com"')
>> + dhcp_opts.setkey('options', 'mtu', '1500')
>> + dhcp_opts.setkey('external_ids', 'subnet', f'ls-{i}')
>> +
>> + dhcpv6_opts = txn.insert(idl.tables['DHCP_Options'])
>> + dhcpv6_opts.cidr = f'fd00:{ip6_node(i)}::/64'
>> + dhcpv6_opts.setkey('options', 'server_id',
>> + '00:00:00:00:00:01')
>> + dhcpv6_opts.setkey('options', 'dns_server',
>> + f'fd00:{ip6_node(i)}::2')
>> + dhcpv6_opts.setkey('options', 'domain_search',
>> + '"example.com"')
>> + dhcpv6_opts.setkey('external_ids', 'subnet',
>> + f'ls-{i}-v6')
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create DHCP options for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_dhcp_assignments(idl, n):
>> + """Assign DHCP options to logical switch ports."""
>> + dhcp4 = {}
>> + dhcp6 = {}
>> + for row in idl.tables['DHCP_Options'].rows.values():
>> + subnet = row.external_ids.get('subnet', '')
>> + if subnet.endswith('-v6'):
>> + dhcp6[subnet[:-3]] = row
>> + else:
>> + dhcp4[subnet] = row
>> +
>> + for i in range(n):
>> + vlog.info(f'Assigning DHCP options for node {i}: '
>> + f'ls-{i} ports -> 10.{ip_node(i)}.0/24 + '
>> + f'fd00:{ip6_node(i)}::/64')
>> + txn = ovs.db.idl.Transaction(idl)
>> + opts4 = dhcp4.get(f'ls-{i}')
>> + opts6 = dhcp6.get(f'ls-{i}')
>> + for row in idl.tables['Logical_Switch_Port'].rows.values():
>> + if row.name.startswith(f'lsp-{i}-'):
>> + if opts4:
>> + row.dhcpv4_options = opts4.uuid
>> + if opts6:
>> + row.dhcpv6_options = opts6.uuid
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to assign DHCP options for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_qos_rules(idl, n, switches):
>> + """Create QoS rules for bandwidth limiting and DSCP marking."""
>> + for i in range(n):
>> + vlog.info(f'Creating QoS rules for node {i}: '
>> + f'rate-limit on lsp-{i}-0, DSCP/mark on ls-{i}')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + ls = switches.get(f'ls-{i}')
>> + if not ls:
>> + die(f'Missing switch ls-{i}')
>> +
>> + qos_bw = txn.insert(idl.tables['QoS'])
>> + qos_bw.priority = 100
>> + qos_bw.direction = 'to-lport'
>> + qos_bw.match = f'inport == "lsp-{i}-0"'
>> + qos_bw.setkey('bandwidth', 'rate', 1000)
>> + qos_bw.setkey('bandwidth', 'burst', 100)
>> + qos_bw.setkey('external_ids', 'type', 'rate-limit')
>> + ls.addvalue('qos_rules', qos_bw.uuid)
>> +
>> + qos_dscp = txn.insert(idl.tables['QoS'])
>> + qos_dscp.priority = 200
>> + qos_dscp.direction = 'from-lport'
>> + qos_dscp.match = 'ip4 && tcp.dst == 22'
>> + qos_dscp.setkey('action', 'dscp', 46)
>> + qos_dscp.setkey('external_ids', 'type', 'dscp-marking')
>> + ls.addvalue('qos_rules', qos_dscp.uuid)
>> +
>> + qos_mark = txn.insert(idl.tables['QoS'])
>> + qos_mark.priority = 150
>> + qos_mark.direction = 'from-lport'
>> + qos_mark.match = 'ip4 && udp'
>> + qos_mark.setkey('action', 'mark', 1)
>> + qos_mark.setkey('external_ids', 'type', 'packet-marking')
>> + ls.addvalue('qos_rules', qos_mark.uuid)
>> +
>> + qos_dscp_v6 = txn.insert(idl.tables['QoS'])
>> + qos_dscp_v6.priority = 200
>> + qos_dscp_v6.direction = 'from-lport'
>> + qos_dscp_v6.match = 'ip6 && tcp.dst == 22'
>> + qos_dscp_v6.setkey('action', 'dscp', 46)
>> + qos_dscp_v6.setkey('external_ids', 'type',
>> + 'dscp-marking-v6')
>> + ls.addvalue('qos_rules', qos_dscp_v6.uuid)
>> +
>> + qos_mark_v6 = txn.insert(idl.tables['QoS'])
>> + qos_mark_v6.priority = 150
>> + qos_mark_v6.direction = 'from-lport'
>> + qos_mark_v6.match = 'ip6 && udp'
>> + qos_mark_v6.setkey('action', 'mark', 1)
>> + qos_mark_v6.setkey('external_ids', 'type',
>> + 'packet-marking-v6')
>> + ls.addvalue('qos_rules', qos_mark_v6.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create QoS rules for node {i}
>> ({txn.get_error()})')
>> +
>> +
>> +def create_acls_for_port_group(idl, port_groups, pg_name, allowed_ports):
>> + """Create ACLs for a specific port group."""
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + pg = port_groups.get(pg_name)
>> + if not pg:
>> + die(f'Missing port group {pg_name}')
>> +
>> + acl_allow_est = txn.insert(idl.tables['ACL'])
>> + acl_allow_est.priority = 1100
>> + acl_allow_est.direction = 'to-lport'
>> + acl_allow_est.match = 'ct.est && !ct.rel && !ct.new && !ct.inv'
>> + acl_allow_est.action = 'allow-related'
>> + pg.addvalue('acls', acl_allow_est.uuid)
>> +
>> + acl_allow_rel = txn.insert(idl.tables['ACL'])
>> + acl_allow_rel.priority = 1100
>> + acl_allow_rel.direction = 'to-lport'
>> + acl_allow_rel.match = 'ct.rel && !ct.est && !ct.new && !ct.inv'
>> + acl_allow_rel.action = 'allow-related'
>> + pg.addvalue('acls', acl_allow_rel.uuid)
>> +
>> + for port in allowed_ports:
>> + acl_new = txn.insert(idl.tables['ACL'])
>> + acl_new.priority = 1050
>> + acl_new.direction = 'to-lport'
>> + acl_new.match = f'ct.new && tcp.dst == {port}'
>> + acl_new.action = 'allow-related'
>> + pg.addvalue('acls', acl_new.uuid)
>> +
>> + acl_drop = txn.insert(idl.tables['ACL'])
>> + acl_drop.priority = 1000
>> + acl_drop.direction = 'to-lport'
>> + acl_drop.match = f'outport == @{pg_name}'
>> + acl_drop.action = 'drop'
>> + pg.addvalue('acls', acl_drop.uuid)
>> +
>> + acl_arp = txn.insert(idl.tables['ACL'])
>> + acl_arp.priority = 1010
>> + acl_arp.direction = 'to-lport'
>> + acl_arp.match = 'arp || nd'
>> + acl_arp.action = 'allow'
>> + pg.addvalue('acls', acl_arp.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create ACLs for {pg_name} ({txn.get_error()})')
>> +
>> +
>> +def create_acls(idl, port_groups):
>> + """Create comprehensive ACLs for port groups.
>> +
>> + ACL Priority Allocation:
>> + 2500-2999: Security enforcement (anti-spoofing, etc.)
>> + 2000-2499: Management access (SSH, ICMP, DHCP)
>> + 1000-1499: Port group ACLs (security groups)
>> + 1100: Connection tracking (established, related)
>> + 1050: New connection per-port rules
>> + 1010: ARP/ND allow
>> + 1000: Default drop
>> +
>> + Switch ACLs (higher priority) can override port-group ACLs, with
>> + security enforcement (anti-spoofing) taking highest priority.
>> + """
>> + vlog.info('Creating ACLs for port groups')
>> + create_acls_for_port_group(idl, port_groups, 'web_tier', [80, 443])
>> + create_acls_for_port_group(idl, port_groups, 'app_tier', [8080, 9000])
>> + create_acls_for_port_group(idl, port_groups, 'db_tier', [5432, 3306])
>> +
>> +
>> +def _add_acl(txn, idl, ls, priority, direction, match, action,
>> + description=None):
>> + """Create an ACL and attach it to a logical switch."""
>> + acl = txn.insert(idl.tables['ACL'])
>> + acl.priority = priority
>> + acl.direction = direction
>> + acl.match = match
>> + acl.action = action
>> + if description:
>> + acl.setkey('external_ids', 'description', description)
>> + ls.addvalue('acls', acl.uuid)
>> +
>> +
>> +def create_switch_acls(idl, switch_name, node_id, switches):
>> + """Create ACLs on a logical switch.
>> +
>> + Creates switch-level ACLs for SSH, ICMP, anti-spoofing, and DHCP.
>> + Anti-spoofing (priority 2500) prevents VMs from using IPs outside
>> + their assigned subnet, blocking IP address spoofing attacks.
>> + """
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + ls = switches.get(switch_name)
>> + if not ls:
>> + die(f'Missing switch {switch_name}')
>> +
>> + _add_acl(txn, idl, ls, 2000, 'from-lport',
>> + 'tcp.dst == 22 && ip4.src == 10.0.0.0/8',
>> + 'allow', 'Allow SSH from internal')
>> + _add_acl(txn, idl, ls, 1500, 'from-lport',
>> + 'icmp4 || icmp6', 'allow')
>> + _add_acl(txn, idl, ls, 2500, 'from-lport',
>> + f'ip4.src != 10.{ip_node(node_id)}.0/24',
>> + 'drop', 'Anti-spoofing')
>> + _add_acl(txn, idl, ls, 2000, 'from-lport',
>> + 'udp.src == 68 && udp.dst == 67', 'allow')
>> +
>> + _add_acl(txn, idl, ls, 2000, 'from-lport',
>> + 'tcp.dst == 22 && ip6.src == fd00::/16',
>> + 'allow', 'Allow SSH from internal (IPv6)')
>> + _add_acl(txn, idl, ls, 2500, 'from-lport',
>> + f'ip6.src != fd00:{ip6_node(node_id)}::/64',
>> + 'drop', 'Anti-spoofing (IPv6)')
>> + _add_acl(txn, idl, ls, 2000, 'from-lport',
>> + 'udp.src == 546 && udp.dst == 547', 'allow')
>> +
>> + _add_acl(txn, idl, ls, 1800, 'to-lport',
>> + 'ip4.dst == $web_servers && tcp.dst == 80',
>> + 'allow-related', 'Allow HTTP to web servers')
>> + _add_acl(txn, idl, ls, 1800, 'to-lport',
>> + 'ip4.dst == $db_servers && tcp.dst == 5432',
>> + 'allow-related', 'Allow Postgres to DB servers')
>> + _add_acl(txn, idl, ls, 1800, 'to-lport',
>> + 'ip4.dst == $app_servers && tcp.dst == 8080',
>> + 'allow-related', 'Allow HTTP to app servers')
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create ACLs on switch {switch_name} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_dns_records(idl, n, switches):
>> + """Create DNS records in the NB database."""
>> + for i in range(n):
>> + vlog.info(f'Creating DNS records for node {i}: '
>> + f'web-{i}.example.com -> 10.{ip_node(i)}.10, '
>> + f'app-{i}.example.com -> 10.{ip_node(i)}.30, '
>> + f'db-{i}.example.com -> 10.{ip_node(i)}.20')
>> + txn = ovs.db.idl.Transaction(idl)
>> + dns = txn.insert(idl.tables['DNS'])
>> + dns.setkey('records', f'web-{i}.example.com',
>> + f'10.{ip_node(i)}.10 '
>> + f'fd00:{ip6_node(i)}::a')
>> + dns.setkey('records', f'app-{i}.example.com',
>> + f'10.{ip_node(i)}.30 '
>> + f'fd00:{ip6_node(i)}::1e')
>> + dns.setkey('records', f'db-{i}.example.com',
>> + f'10.{ip_node(i)}.20 '
>> + f'fd00:{ip6_node(i)}::14')
>> + dns.setkey('external_ids', 'zone', f'zone-{i}')
>> +
>> + ls = switches.get(f'ls-{i}')
>> + if not ls:
>> + die(f'Missing switch ls-{i}')
>> + ls.addvalue('dns_records', dns.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create DNS records for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_nat_rules(idl, n, routers):
>> + """Create NAT rules on routers.
>> +
>> + Creates SNAT, DNAT, and DNAT_AND_SNAT rules to exercise all NAT code
>> paths.
>> + """
>> + for i in range(n):
>> + vlog.info(f'Creating NAT rules on router gw-{i}: '
>> + f'snat 10.{ip_node(i)}.0/24 -> 192.{ip_node(i)}.1, '
>> + f'dnat .10 -> .10, dnat_and_snat .20 -> .20')
>> + vlog.info(f' IPv6: snat fd00:{ip6_node(i)}::/64 -> '
>> + f'fd01:{ip6_node(i)}::1, '
>> + f'dnat ::a -> ::a, dnat_and_snat ::14 -> ::14')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + die(f'Missing router gw-{i}')
>> +
>> + nat_snat = txn.insert(idl.tables['NAT'])
>> + nat_snat.type = 'snat'
>> + nat_snat.logical_ip = f'10.{ip_node(i)}.0/24'
>> + nat_snat.external_ip = f'192.{ip_node(i)}.1'
>> + lr.addvalue('nat', nat_snat.uuid)
>> +
>> + nat_dnat = txn.insert(idl.tables['NAT'])
>> + nat_dnat.type = 'dnat'
>> + nat_dnat.logical_ip = f'10.{ip_node(i)}.10'
>> + nat_dnat.external_ip = f'192.{ip_node(i)}.10'
>> + nat_dnat.setkey('external_ids', 'service', 'web')
>> + lr.addvalue('nat', nat_dnat.uuid)
>> +
>> + nat_dnat_and_snat = txn.insert(idl.tables['NAT'])
>> + nat_dnat_and_snat.type = 'dnat_and_snat'
>> + nat_dnat_and_snat.logical_ip = f'10.{ip_node(i)}.20'
>> + nat_dnat_and_snat.external_ip = f'192.{ip_node(i)}.20'
>> + nat_dnat_and_snat.setkey('external_ids', 'service', 'db')
>> + lr.addvalue('nat', nat_dnat_and_snat.uuid)
>> +
>> + nat_snat_v6 = txn.insert(idl.tables['NAT'])
>> + nat_snat_v6.type = 'snat'
>> + nat_snat_v6.logical_ip = f'fd00:{ip6_node(i)}::/64'
>> + nat_snat_v6.external_ip = f'fd01:{ip6_node(i)}::1'
>> + lr.addvalue('nat', nat_snat_v6.uuid)
>> +
>> + nat_dnat_v6 = txn.insert(idl.tables['NAT'])
>> + nat_dnat_v6.type = 'dnat'
>> + nat_dnat_v6.logical_ip = f'fd00:{ip6_node(i)}::a'
>> + nat_dnat_v6.external_ip = f'fd01:{ip6_node(i)}::a'
>> + nat_dnat_v6.setkey('external_ids',
>> + 'service', 'web-v6')
>> + lr.addvalue('nat', nat_dnat_v6.uuid)
>> +
>> + nat_ds_v6 = txn.insert(idl.tables['NAT'])
>> + nat_ds_v6.type = 'dnat_and_snat'
>> + nat_ds_v6.logical_ip = f'fd00:{ip6_node(i)}::14'
>> + nat_ds_v6.external_ip = f'fd01:{ip6_node(i)}::14'
>> + nat_ds_v6.setkey('external_ids',
>> + 'service', 'db-v6')
>> + lr.addvalue('nat', nat_ds_v6.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create NAT rules for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_static_routes(idl, n, routers):
>> + """Create static routes on routers."""
>> + for i in range(n):
>> + vlog.info(f'Creating static routes on router gw-{i}: '
>> + f'default 0.0.0.0/0 -> 100.64.0.1, '
>> + f'172.{ip_node(i)}.0/24 -> 10.{ip_node(i)}.254, '
>> + f'192.0.2.0/24 -> discard')
>> + vlog.info(f' IPv6: ::/0 -> fd01::1, '
>> + f'fd02:{ip6_node(i)}::/64 -> fd00:{ip6_node(i)}::fe, '
>> + f'2001:db8::/32 -> discard')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + die(f'Missing router gw-{i}')
>> +
>> + route_default = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_default.ip_prefix = '0.0.0.0/0'
>> + route_default.nexthop = '100.64.0.1'
>> + route_default.setkey('external_ids', 'type', 'default')
>> + lr.addvalue('static_routes', route_default.uuid)
>> +
>> + route_specific = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_specific.ip_prefix = f'172.{ip_node(i)}.0/24'
>> + route_specific.nexthop = f'10.{ip_node(i)}.254'
>> + route_specific.setkey('external_ids', 'type', 'specific')
>> + lr.addvalue('static_routes', route_specific.uuid)
>> +
>> + route_discard = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_discard.ip_prefix = '192.0.2.0/24'
>> + route_discard.nexthop = 'discard'
>> + route_discard.setkey('external_ids', 'type', 'blackhole')
>> + lr.addvalue('static_routes', route_discard.uuid)
>> +
>> + route_default_v6 = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_default_v6.ip_prefix = '::/0'
>> + route_default_v6.nexthop = 'fd01::1'
>> + route_default_v6.setkey('external_ids', 'type',
>> + 'default-v6')
>> + lr.addvalue('static_routes', route_default_v6.uuid)
>> +
>> + route_specific_v6 = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_specific_v6.ip_prefix = (
>> + f'fd02:{ip6_node(i)}::/64')
>> + route_specific_v6.nexthop = (
>> + f'fd00:{ip6_node(i)}::fe')
>> + route_specific_v6.setkey('external_ids', 'type',
>> + 'specific-v6')
>> + lr.addvalue('static_routes',
>> + route_specific_v6.uuid)
>> +
>> + route_discard_v6 = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route_discard_v6.ip_prefix = '2001:db8::/32'
>> + route_discard_v6.nexthop = 'discard'
>> + route_discard_v6.setkey('external_ids', 'type',
>> + 'blackhole-v6')
>> + lr.addvalue('static_routes',
>> + route_discard_v6.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create static routes for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def create_routing_policies(idl, n, routers):
>> + """Create routing policies on routers."""
>> + for i in range(n):
>> + vlog.info(f'Creating routing policies on router gw-{i}: '
>> + f'reroute 10.{ip_node(i)}.0/24 -> '
>> + f'10.{ip_node((i + 1) % n)}.1, '
>> + f'allow $trusted_networks_v4')
>> + vlog.info(f' IPv6: reroute fd00:{ip6_node(i)}::/64 -> '
>> + f'fd00:{ip6_node((i + 1) % n)}::1, '
>> + f'allow $trusted_networks_v6')
>> + txn = ovs.db.idl.Transaction(idl)
>> +
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + die(f'Missing router gw-{i}')
>> +
>> + policy_reroute = txn.insert(idl.tables['Logical_Router_Policy'])
>> + policy_reroute.priority = 100
>> + policy_reroute.match = f'ip4.src == 10.{ip_node(i)}.0/24'
>> + policy_reroute.action = 'reroute'
>> + policy_reroute.nexthops = [f'10.{ip_node((i + 1) % n)}.1']
>> + policy_reroute.setkey('external_ids', 'policy',
>> + 'traffic-engineering')
>> + lr.addvalue('policies', policy_reroute.uuid)
>> +
>> + policy_allow = txn.insert(idl.tables['Logical_Router_Policy'])
>> + policy_allow.priority = 50
>> + policy_allow.match = 'ip4.dst == $trusted_networks_v4'
>> + policy_allow.action = 'allow'
>> + lr.addvalue('policies', policy_allow.uuid)
>> +
>> + policy_reroute_v6 = txn.insert(
>> + idl.tables['Logical_Router_Policy'])
>> + policy_reroute_v6.priority = 100
>> + policy_reroute_v6.match = (
>> + f'ip6.src == fd00:{ip6_node(i)}::/64')
>> + policy_reroute_v6.action = 'reroute'
>> + policy_reroute_v6.nexthops = [
>> + f'fd00:{ip6_node((i + 1) % n)}::1']
>> + policy_reroute_v6.setkey(
>> + 'external_ids', 'policy',
>> + 'traffic-engineering-v6')
>> + lr.addvalue('policies',
>> + policy_reroute_v6.uuid)
>> +
>> + policy_allow_v6 = txn.insert(
>> + idl.tables['Logical_Router_Policy'])
>> + policy_allow_v6.priority = 50
>> + policy_allow_v6.match = (
>> + 'ip6.dst == $trusted_networks_v6')
>> + policy_allow_v6.action = 'allow'
>> + lr.addvalue('policies', policy_allow_v6.uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create routing policies for node {i} '
>> + f'({txn.get_error()})')
>> +
>> +
>> +def _add_ports(txn, idl, s, i, ports_per_switch):
>> + for p in range(ports_per_switch):
>> + lsp = txn.insert(idl.tables['Logical_Switch_Port'])
>> + lsp.name = f'lsp-{i}-{p}'
>> + # Offset by 10 to match the IP's +10 offset; wraps at 256 but
>> + # the port cap (244) means this only matters at the boundary.
>> + mac_byte = (p + 10) % 256
>> + mac = (f'00:00:{i >> 8:02x}:{i & 0xff:02x}'
>> + f':{p:02x}:{mac_byte:02x}')
>> + ip = f'10.{ip_node(i)}.{10 + p}'
>> + ip6 = f'fd00:{ip6_node(i)}::{10 + p:x}'
>> + lsp.addresses = [f'{mac} {ip} {ip6}']
>> + lsp.port_security = [f'{mac} {ip} {ip6}']
>> + vlog.info(f' lsp-{i}-{p}: {mac} {ip} {ip6}')
>> + lsp.setkey('external_ids', 'vm-id', f'vm-{i}-{p}')
>> +
>> + if p % 3 == 0:
>> + lsp.setkey('external_ids', 'tier', 'web')
>> + elif p % 3 == 1:
>> + lsp.setkey('external_ids', 'tier', 'app')
>> + else:
>> + lsp.setkey('external_ids', 'tier', 'db')
>> +
>> + s.addvalue('ports', lsp.uuid)
>> +
>> +
>> +def _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
>> + ports_per_switch):
>> + gwr = txn.insert(idl.tables['Logical_Router'])
>> + gwr.name = f'gw-{i}'
>> + gwr.addvalue('load_balancer_group', lbg.uuid)
>> + gwr.setkey('options', 'chassis', chassis)
>> +
>> + gwr2join = txn.insert(idl.tables['Logical_Router_Port'])
>> + gwr2join.name = f'gw2j-{i}'
>> + gwr2join.mac = f'00:00:01:{i >> 8:02x}:{i & 0xff:02x}:01'
>> + gwr2join.networks = [f'100.64.{ip_node(i)}/16',
>> + f'fd01:{ip6_node(i)}::1/48']
>> + gwr.addvalue('ports', gwr2join.uuid)
>> +
>> + join2gwr = txn.insert(idl.tables['Logical_Switch_Port'])
>> + join2gwr.name = f'j2gw-{i}'
>> + join2gwr.type = 'router'
>> + join2gwr.addresses = ['router']
>> + join2gwr.setkey('options', 'router-port', gwr2join.name)
>> + join_sw.addvalue('ports', join2gwr.uuid)
>> +
>> + s = txn.insert(idl.tables['Logical_Switch'])
>> + s.name = f'ls-{i}'
>> + s.addvalue('load_balancer_group', lbg.uuid)
>> + s.setkey('other_config', 'subnet', f'10.{ip_node(i)}.0/24')
>> + s.setkey('other_config', 'mcast_snoop', 'true')
>> +
>> + cluster2s = txn.insert(idl.tables['Logical_Router_Port'])
>> + cluster2s.name = f'c2s-{i}'
>> + cluster2s.mac = '00:00:00:00:00:01'
>> + cluster2s.networks = [f'10.{ip_node(i)}.1/24',
>> + f'fd00:{ip6_node(i)}::1/64']
>> + cluster_rtr.addvalue('ports', cluster2s.uuid)
>> +
>> + gw_chassis = txn.insert(idl.tables['Gateway_Chassis'])
>> + gw_chassis.name = f'{cluster2s.name}-{chassis}'
>> + gw_chassis.chassis_name = chassis
>> + gw_chassis.priority = 1
>> + cluster2s.addvalue('gateway_chassis', gw_chassis.uuid)
>> +
>> + s2cluster = txn.insert(idl.tables['Logical_Switch_Port'])
>> + s2cluster.name = f's2c-{i}'
>> + s2cluster.type = 'router'
>> + s2cluster.addresses = ['router']
>> + s2cluster.setkey('options', 'router-port', cluster2s.name)
>> + s.addvalue('ports', s2cluster.uuid)
>> +
>> + _add_ports(txn, idl, s, i, ports_per_switch)
>> +
>> +
>> +def create_topology(idl, n, ports_per_switch, batch_size):
>> + """Create the basic topology with routers, switches, and ports."""
>> + vlog.info('Creating topology')
>> + txn = ovs.db.idl.Transaction(idl)
>> + lbg = txn.insert(idl.tables['Load_Balancer_Group'])
>> + lbg.name = 'lbg'
>
> We never add load balancers through this LB group. That's something
> that would be nice to test for too. But I don't want to block this
> patch set any longer. Would you mind sending a follow up for that?
Yup, I can address this and the asan/ubsan in follow-ups.
>
>> +
>> + vlog.info('Creating join switch')
>> + join_sw = txn.insert(idl.tables['Logical_Switch'])
>> + join_sw.name = 'join'
>> +
>> + cluster_rtr = txn.insert(idl.tables['Logical_Router'])
>> + cluster_rtr.name = 'cluster'
>> +
>> + rcj = txn.insert(idl.tables['Logical_Router_Port'])
>> + rcj.name = 'rcj'
>> + rcj.mac = '00:00:00:00:00:01'
>> + rcj.networks = ['100.64.0.1/16', 'fd01::1/48']
>> + cluster_rtr.addvalue('ports', rcj.uuid)
>> +
>> + sjc = txn.insert(idl.tables['Logical_Switch_Port'])
>> + sjc.name = 'sjc'
>> + sjc.type = 'router'
>> + sjc.addresses = ['router']
>> + sjc.setkey('options', 'router-port', 'rcj')
>> + join_sw.addvalue('ports', sjc.uuid)
>> +
>> + for i in range(n):
>> + chassis = f'chassis-{i // batch_size + 1}'
>> + vlog.info(f'Provisioning node {i}: gw-{i} + ls-{i}, '
>> + f'10.{ip_node(i)}.0/24, fd00:{ip6_node(i)}::/64, '
>> + f'{chassis}')
>> + _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
>> + ports_per_switch)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create topology ({txn.get_error()})')
>> +
>> +
>> +def create_port_group_assignments(idl, port_groups):
>> + """Assign logical switch ports to port groups based on tier."""
>> + tier_ports = {'web': [], 'app': [], 'db': []}
>> +
>> + for row in idl.tables['Logical_Switch_Port'].rows.values():
>> + tier = row.external_ids.get('tier')
>> + if tier in tier_ports:
>> + tier_ports[tier].append(row.uuid)
>> +
>> + vlog.info(f'Assigning ports to port groups: '
>> + f'{len(tier_ports["web"])} web, '
>> + f'{len(tier_ports["app"])} app, '
>> + f'{len(tier_ports["db"])} db')
>> +
>> + txn = ovs.db.idl.Transaction(idl)
>> + tier_to_group = {'web': 'web_tier', 'app': 'app_tier', 'db': 'db_tier'}
>> + for tier, group_name in tier_to_group.items():
>> + pg = port_groups.get(group_name)
>> + if not pg:
>> + die(f'Missing port group {group_name}')
>> + for port_uuid in tier_ports[tier]:
>> + pg.addvalue('ports', port_uuid)
>> +
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to assign ports to groups ({txn.get_error()})')
>> +
>> +
>> +def create_explicit_lbs(idl, n, n_vips, n_backends, routers, switches):
>> + """Create explicit (non-templated) load balancers.
>> +
>> + Uses explicit (non-templated) LBs to maximize memory usage for
>> + regression testing.
>> + """
>> + for i in range(n):
>> + lr = routers.get(f'gw-{i}')
>> + ls = switches.get(f'ls-{i}')
>> + if not lr or not ls:
>> + die(f'Missing router or switch for node {i}')
>> + for j in range(n_vips):
>> + txn = ovs.db.idl.Transaction(idl)
>> + port = j + 1
>> + vlog.info(f'Creating LB lb-{j}-{i}: '
>> + f'42.42.{ip_node(i)}:{port} -> '
>> + f'{n_backends} backends')
>> + # Spread VIP index across two octets to stay within 0-249.
>> + # Unlike ip_node() which uses >>8/&0xff for node addresses,
>> + # this uses //250/%250 because VIP counts are small and 250
>> + # avoids addresses that look like broadcast/network octets.
>> + # Backend addresses depend only on j (not i), so every node
>> + # shares the same backend set -- intentional, since we are
>> + # testing memory, not realistic traffic distribution.
>> + j1 = (j + 1) // 250
>> + j2 = (j + 1) % 250
>> +
>> + lb = txn.insert(idl.tables['Load_Balancer'])
>> + lb.name = f'lb-{j}-{i}'
>> + lb.setkey('vips', f'42.42.{ip_node(i)}:{port}',
>> + ','.join(f'42.{k}.{j1}.{j2}:{port}'
>> + for k in range(n_backends)))
>> + lb.setkey(
>> + 'vips',
>> + f'[fd42::{ip6_node(i)}:{j:x}]:{port}',
>> + ','.join(f'[fd42:{k:x}::{j1:x}:{j2:x}]:{port}'
>> + for k in range(n_backends)))
>> + lb.protocol = 'tcp'
>> + lr.addvalue('load_balancer', lb.uuid)
>> + ls.addvalue('load_balancer', lb.uuid)
>> + if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
>> + die(f'Failed to create LB ({txn.get_error()})')
>> +
>> +
>> +def connect_idl(remote):
>> + """Connect to the NB database and return the IDL."""
>> + schema_helper = ovs.db.idl.SchemaHelper(SCHEMA)
>> + schema_helper.register_all()
>> + idl = ovs.db.idl.Idl(remote, schema_helper, leader_only=False)
>> +
>> + while idl.change_seqno == 0 and not idl.run():
>> + poller = ovs.poller.Poller()
>> + idl.wait(poller)
>> + poller.block()
>> + return idl
>> +
>> +
>> +def commit(txn, description):
>> + """Commit a transaction, accepting both SUCCESS and UNCHANGED."""
>> + status = txn.commit_block()
>> + if status not in (ovs.db.idl.Transaction.SUCCESS,
>> + ovs.db.idl.Transaction.UNCHANGED):
>> + die(f'{description} ({txn.get_error()})')
>> +
>> +
>> +def add_lsps(idl, n, ports_per_switch):
>> + """Add one new port to ~10% of switches."""
>> + if ports_per_switch < 1:
>> + vlog.info('Skipping add_lsps: ports_per_switch is 0')
>> + return
>> + count = max(1, n // 10)
>> + switches = {row.name: row
>> + for row in idl.tables['Logical_Switch'].rows.values()}
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + p = ports_per_switch
>> + for i in range(count):
>> + ls = switches.get(f'ls-{i}')
>> + if not ls:
>> + continue
>> + lsp = txn.insert(idl.tables['Logical_Switch_Port'])
>> + lsp.name = f'lsp-{i}-{p}'
>> + mac_byte = (p + 10) % 256
>> + mac = (f'00:00:{i >> 8:02x}:{i & 0xff:02x}'
>> + f':{p:02x}:{mac_byte:02x}')
>> + ip = f'10.{ip_node(i)}.{10 + p}'
>> + ip6 = f'fd00:{ip6_node(i)}::{10 + p:x}'
>> + lsp.addresses = [f'{mac} {ip} {ip6}']
>> + lsp.port_security = [f'{mac} {ip} {ip6}']
>> + lsp.setkey('external_ids', 'vm-id', f'vm-{i}-{p}')
>> + lsp.setkey('external_ids', 'tier', 'web')
>> + ls.addvalue('ports', lsp.uuid)
>> + added += 1
>> + commit(txn, f'Failed to add {count} LSPs')
>> + vlog.info(f'Added {added} LSPs '
>> + f'(lsp-0-{p}..lsp-{count-1}-{p}, '
>> + f'10.x.x.{10 + p})')
>> +
>> +
>> +def add_nat(idl, n):
>> + """Add one DNAT rule to ~10% of routers."""
>> + count = max(1, n // 10)
>> + routers = {row.name: row
>> + for row in idl.tables['Logical_Router'].rows.values()}
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + for i in range(count):
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + continue
>> + nat = txn.insert(idl.tables['NAT'])
>> + nat.type = 'dnat'
>> + nat.logical_ip = f'10.{ip_node(i)}.30'
>> + nat.external_ip = f'192.{ip_node(i)}.30'
>> + nat.setkey('external_ids', 'service', 'bench')
>> + lr.addvalue('nat', nat.uuid)
>> + added += 1
>> + commit(txn, f'Failed to add {count} NAT rules (service=bench)')
>> + vlog.info(f'Added {added} DNAT rules '
>> + f'(service=bench, .30 -> .30)')
>> +
>> +
>> +def add_routes(idl, n):
>> + """Add one specific route to ~10% of routers."""
>> + count = max(1, n // 10)
>> + routers = {row.name: row
>> + for row in idl.tables['Logical_Router'].rows.values()}
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + for i in range(count):
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + continue
>> + route = txn.insert(
>> + idl.tables['Logical_Router_Static_Route'])
>> + route.ip_prefix = f'10.{ip_node(i)}.128/25'
>> + route.nexthop = f'10.{ip_node(i)}.254'
>> + route.setkey('external_ids', 'type', 'bench')
>> + lr.addvalue('static_routes', route.uuid)
>> + added += 1
>> + commit(txn, f'Failed to add {count} static routes (type=bench)')
>> + vlog.info(f'Added {added} static routes '
>> + f'(10.x.x.128/25 -> 10.x.x.254)')
>> +
>> +
>> +def add_policies(idl, n):
>> + """Add one allow policy to ~10% of routers."""
>> + count = max(1, n // 10)
>> + routers = {row.name: row
>> + for row in idl.tables['Logical_Router'].rows.values()}
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + for i in range(count):
>> + lr = routers.get(f'gw-{i}')
>> + if not lr:
>> + continue
>> + policy = txn.insert(idl.tables['Logical_Router_Policy'])
>> + policy.priority = 75
>> + policy.match = f'ip4.src == 10.{ip_node(i)}.128/25'
>> + policy.action = 'allow'
>> + policy.setkey('external_ids', 'source', 'bench')
>> + lr.addvalue('policies', policy.uuid)
>> + added += 1
>> + commit(txn, f'Failed to add {count} routing policies (source=bench)')
>> + vlog.info(f'Added {added} routing policies '
>> + f'(priority=75, allow 10.x.x.128/25)')
>> +
>> +
>> +def modify_nat(idl, n):
>> + """Change external_ip on IPv4 SNAT rules for ~10% of routers."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['NAT'].rows.values():
>> + if modified >= count:
>> + break
>> + if row.type == 'snat' and row.logical_ip.startswith('10.'):
>> + prefix, last_octet = row.external_ip.rsplit('.', 1)
>> + row.external_ip = f'{prefix}.{int(last_octet) + 1}'
>> + modified += 1
>> + commit(txn, f'Failed to modify {count} SNAT external_ips (.1 -> .2)')
>> + vlog.info(f'Modified {modified} SNAT external_ips (.1 -> .2)')
>> +
>> +
>> +def modify_routes(idl, n):
>> + """Change nexthop on IPv4 default routes for ~10% of routers."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables[
>> + 'Logical_Router_Static_Route'].rows.values():
>> + if modified >= count:
>> + break
>> + if (row.external_ids.get('type') == 'default'
>> + and row.ip_prefix == '0.0.0.0/0'):
>> + row.nexthop = '100.64.0.2'
>> + modified += 1
>> + commit(txn,
>> + f'Failed to modify {count} default routes '
>> + f'(nexthop 100.64.0.1 -> .2)')
>> + vlog.info(f'Modified {modified} default route nexthops '
>> + f'(100.64.0.1 -> 100.64.0.2)')
>> +
>> +
>> +def modify_lbs(idl, n):
>> + """Change protocol on first LB for ~10% of nodes."""
>> + count = max(1, n // 10)
>> + targets = set(f'lb-0-{i}' for i in range(count))
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['Load_Balancer'].rows.values():
>> + if row.name in targets:
>> + row.protocol = 'udp' if row.protocol == 'tcp' else 'tcp'
>> + modified += 1
>> + commit(txn,
>> + f'Failed to modify {count} LB protocols (tcp <-> udp)')
>> + vlog.info(f'Modified {modified} LB protocols '
>> + f'(lb-0-0..lb-0-{count-1}, tcp <-> udp)')
>> +
>> +
>> +def modify_addr_sets(idl, n):
>> + """Add ~10% of n addresses to the web_servers address set."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + for row in idl.tables['Address_Set'].rows.values():
>> + if row.name == 'web_servers':
>> + for i in range(count):
>> + row.addvalue('addresses', f'10.{ip_node(i)}.200')
>> + added = count
>> + break
>> + commit(txn, f'Failed to modify web_servers address set '
>> + f'(+= {count} addresses)')
>> + vlog.info(f'Added {added} addresses to web_servers '
>> + f'(10.0.0.200..10.{ip_node(count-1)}.200)')
>> +
>> +
>> +def modify_port_groups(idl, n):
>> + """Add ~10% of app-tier ports to the web_tier port group."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + added = 0
>> + app_ports = []
>> + for row in idl.tables['Logical_Switch_Port'].rows.values():
>> + if row.external_ids.get('tier') == 'app':
>> + app_ports.append(row)
>> + if len(app_ports) >= count:
>> + break
>> + web_tier = None
>> + for row in idl.tables['Port_Group'].rows.values():
>> + if row.name == 'web_tier':
>> + web_tier = row
>> + break
>> + if web_tier:
>> + for port in app_ports:
>> + web_tier.addvalue('ports', port.uuid)
>> + added += 1
>> + commit(txn, f'Failed to modify web_tier port group '
>> + f'(+= {count} ports)')
>> + vlog.info(f'Added {added} app-tier ports to web_tier')
>> +
>> +
>> +def modify_port_security(idl, n):
>> + """Add an extra IP to port_security on ~10% of LSPs."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['Logical_Switch_Port'].rows.values():
>> + if modified >= count:
>> + break
>> + if not row.name.startswith('lsp-') or not row.name.endswith('-1'):
>> + continue
>> + parts = row.name.split('-')
>> + i = int(parts[1])
>> + if i >= count:
>> + continue
>> + ps = list(row.port_security)
>> + if ps:
>> + ps[0] = ps[0] + f' 10.{ip_node(i)}.200'
>> + row.port_security = ps
>> + modified += 1
>> + commit(txn,
>> + f'Failed to modify {count} LSP port_security '
>> + f'(+= 10.x.x.200)')
>> + vlog.info(f'Modified {modified} LSP port_security '
>> + f'(lsp-0-1..lsp-{count-1}-1, += 10.x.x.200)')
>> +
>> +
>> +def modify_dhcp(idl, n):
>> + """Change lease_time on ~10% of DHCPv4 option sets."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['DHCP_Options'].rows.values():
>> + if modified >= count:
>> + break
>> + subnet = row.external_ids.get('subnet', '')
>> + if subnet.startswith('ls-') and not subnet.endswith('-v6'):
>> + parts = subnet.split('-')
>> + i = int(parts[1])
>> + if i < count:
>> + row.setkey('options', 'lease_time', '7200')
>> + modified += 1
>> + commit(txn,
>> + f'Failed to modify {count} DHCP lease_time '
>> + f'(3600 -> 7200)')
>> + vlog.info(f'Modified {modified} DHCP lease_time '
>> + f'(3600 -> 7200)')
>> +
>> +
>> +def modify_dns(idl, n):
>> + """Add a DNS record to ~10% of nodes."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['DNS'].rows.values():
>> + if modified >= count:
>> + break
>> + zone = row.external_ids.get('zone', '')
>> + if zone.startswith('zone-'):
>> + i = int(zone.split('-')[1])
>> + if i < count:
>> + row.setkey('records', f'bench-{i}.example.com',
>> + f'10.{ip_node(i)}.200')
>> + modified += 1
>> + commit(txn,
>> + f'Failed to modify {count} DNS records '
>> + f'(+= bench-x.example.com)')
>> + vlog.info(f'Modified {modified} DNS records '
>> + f'(+= bench-x.example.com -> 10.x.x.200)')
>> +
>> +
>> +def delete_acls(idl, n):
>> + """Delete 'Allow SSH from internal' ACL from ~10% of switches."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(idl.tables['ACL'].rows.values()):
>> + if deleted >= count:
>> + break
>> + desc = row.external_ids.get('description', '')
>> + if desc == 'Allow SSH from internal':
>> + row.delete()
>> + deleted += 1
>> + commit(txn,
>> + f'Failed to delete {count} ACLs '
>> + f'(Allow SSH from internal)')
>> + vlog.info(f'Deleted {deleted} ACLs '
>> + f'(Allow SSH from internal)')
>> +
>> +
>> +def delete_policies(idl, n):
>> + """Delete IPv4 allow policies from ~10% of routers."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(
>> + idl.tables['Logical_Router_Policy'].rows.values()):
>> + if deleted >= count:
>> + break
>> + if (row.priority == 50
>> + and 'trusted_networks_v4' in row.match):
>> + row.delete()
>> + deleted += 1
>> + commit(txn,
>> + f'Failed to delete {count} routing policies '
>> + f'(priority=50, trusted_networks_v4)')
>> + vlog.info(f'Deleted {deleted} routing policies '
>> + f'(priority=50, trusted_networks_v4)')
>> +
>> +
>> +def delete_qos(idl, n):
>> + """Delete rate-limit QoS rules from ~10% of switches."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(idl.tables['QoS'].rows.values()):
>> + if deleted >= count:
>> + break
>> + if row.external_ids.get('type') == 'rate-limit':
>> + row.delete()
>> + deleted += 1
>> + commit(txn,
>> + f'Failed to delete {count} QoS rules (type=rate-limit)')
>> + vlog.info(f'Deleted {deleted} QoS rules (type=rate-limit)')
>> +
>> +
>> +def modify_lsps(idl, n):
>> + """Modify addresses on ~10% of LSPs to exercise incremental
>> processing."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['Logical_Switch_Port'].rows.values():
>> + if modified >= count:
>> + break
>> + name = row.name
>> + if not name.startswith('lsp-') or not name.endswith('-0'):
>> + continue
>> + parts = name.split('-')
>> + i = int(parts[1])
>> + if i >= count:
>> + continue
>> + mac = f'00:00:{i >> 8:02x}:{i & 0xff:02x}:00:0a'
>> + ip = f'10.{ip_node(i)}.210'
>> + ip6 = f'fd00:{ip6_node(i)}::d2'
>> + row.addresses = [f'{mac} {ip} {ip6}']
>> + row.port_security = [f'{mac} {ip} {ip6}']
>> + modified += 1
>> + commit(txn, f'Failed to modify {count} LSP addresses (.10 -> .210)')
>> + vlog.info(f'Modified {modified} LSP addresses '
>> + f'(lsp-0-0..lsp-{count-1}-0, .10 -> .210)')
>> +
>> +
>> +def modify_acls(idl, n):
>> + """Modify anti-spoofing ACLs on ~10% of switches."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + modified = 0
>> + for row in idl.tables['ACL'].rows.values():
>> + if modified >= count:
>> + break
>> + desc = row.external_ids.get('description', '')
>> + if desc == 'Anti-spoofing' and '/24' in row.match:
>> + row.match = row.match.replace('/24', '/23')
>> + modified += 1
>> + commit(txn, f'Failed to modify {count} anti-spoofing ACLs (/24 -> /23)')
>> + vlog.info(f'Modified {modified} anti-spoofing ACLs (/24 -> /23)')
>> +
>> +
>> +def delete_lsps(idl, n, ports_per_switch):
>> + """Delete the last port from ~10% of switches."""
>> + if ports_per_switch < 1:
>> + vlog.info('Skipping delete_lsps: no ports to delete')
>> + return
>> + count = max(1, n // 10)
>> + last_port = ports_per_switch - 1
>> + targets = set(f'lsp-{i}-{last_port}' for i in range(count))
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(idl.tables['Logical_Switch_Port'].rows.values()):
>> + if row.name in targets:
>> + row.delete()
>> + deleted += 1
>> + commit(txn, f'Failed to delete {count} LSPs '
>> + f'(lsp-0-{last_port}..lsp-{count-1}-{last_port})')
>> + vlog.info(f'Deleted {deleted} LSPs '
>> + f'(lsp-0-{last_port}..lsp-{count-1}-{last_port})')
>> +
>> +
>> +def delete_nat(idl, n):
>> + """Delete IPv4 DNAT rules from ~10% of routers."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(idl.tables['NAT'].rows.values()):
>> + if deleted >= count:
>> + break
>> + if (row.type == 'dnat'
>> + and row.external_ids.get('service') == 'web'):
>> + row.delete()
>> + deleted += 1
>> + commit(txn, f'Failed to delete {count} IPv4 DNAT rules (service=web)')
>> + vlog.info(f'Deleted {deleted} IPv4 DNAT rules (service=web)')
>> +
>> +
>> +def delete_routes(idl, n):
>> + """Delete IPv4 specific routes from ~10% of routers."""
>> + count = max(1, n // 10)
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(
>> + idl.tables['Logical_Router_Static_Route'].rows.values()):
>> + if deleted >= count:
>> + break
>> + if row.external_ids.get('type') == 'specific':
>> + row.delete()
>> + deleted += 1
>> + commit(txn, f'Failed to delete {count} IPv4 specific routes')
>> + vlog.info(f'Deleted {deleted} IPv4 specific routes')
>> +
>> +
>> +def delete_lbs(idl, n):
>> + """Delete one load balancer from ~10% of nodes."""
>> + count = max(1, n // 10)
>> + targets = set(f'lb-0-{i}' for i in range(count))
>> + txn = ovs.db.idl.Transaction(idl)
>> + deleted = 0
>> + for row in list(idl.tables['Load_Balancer'].rows.values()):
>> + if row.name in targets:
>> + row.delete()
>> + deleted += 1
>> + commit(txn, f'Failed to delete {count} load balancers '
>> + f'(lb-0-0..lb-0-{count-1})')
>> + vlog.info(f'Deleted {deleted} load balancers '
>> + f'(lb-0-0..lb-0-{count-1})')
>> +
>> +
>> +def run(remote, n, n_vips, n_backends, ports_per_switch, batch_size):
>> + """Main execution function."""
>> + idl = connect_idl(remote)
>> +
>> + # Check if database is clean before proceeding
>> + if (len(idl.tables['Load_Balancer_Group'].rows) > 0 or
>> + len(idl.tables['Logical_Switch'].rows) > 0 or
>> + len(idl.tables['Logical_Router'].rows) > 0):
>> + die('Database is not empty. Please restart the sandbox or clear the
>> '
>> + 'database before running this script.')
>> +
>> + create_topology(idl, n, ports_per_switch, batch_size)
>> +
>> + # Build lookup dictionaries for O(1) access to switches and routers
>> + switches = {row.name: row
>> + for row in idl.tables['Logical_Switch'].rows.values()}
>> + routers = {row.name: row
>> + for row in idl.tables['Logical_Router'].rows.values()}
>> +
>> + create_address_sets(idl, n)
>> + create_port_groups(idl)
>> + port_groups = {row.name: row
>> + for row in idl.tables['Port_Group'].rows.values()}
>> + create_port_group_assignments(idl, port_groups)
>> + create_dhcp_options(idl, n)
>> + create_dhcp_assignments(idl, n)
>> + create_dns_records(idl, n, switches)
>> + create_nat_rules(idl, n, routers)
>> + create_static_routes(idl, n, routers)
>> + create_acls(idl, port_groups)
>> + for i in range(n):
>> + create_switch_acls(idl, f'ls-{i}', i, switches)
>> + create_qos_rules(idl, n, switches)
>> + create_routing_policies(idl, n, routers)
>> + create_explicit_lbs(idl, n, n_vips, n_backends, routers, switches)
>> +
>> +
>> +PHASES = [
>> + 'create',
>> + 'add-lsps', 'add-nat', 'add-routes', 'add-policies',
>> + 'modify-lsps', 'modify-acls', 'modify-nat', 'modify-routes',
>> + 'modify-lbs', 'modify-addr-sets', 'modify-pg', 'modify-port-sec',
>> + 'modify-dhcp', 'modify-dns',
>> + 'delete-lsps', 'delete-nat', 'delete-routes', 'delete-lbs',
>> + 'delete-acls', 'delete-policies', 'delete-qos',
>> +]
>> +
>> +
>> +def main():
>> + """Parse arguments and run the benchmark."""
>> + parser = argparse.ArgumentParser(
>> + description='OVN benchmark: create topology, then exercise '
>> + 'incremental engine with add/modify/delete operations'
>> + )
>> + parser.add_argument(
>> + 'phase', nargs='?', default='create', choices=PHASES,
>> + help='Benchmark phase to run (default: create)',
>> + )
>> + parser.add_argument(
>> + '-r', '--remote', required=True, help='NB connection string'
>> + )
>> + parser.add_argument(
>> + '-n', '--nodes', type=int, required=True, help='Number of nodes'
>> + )
>> + parser.add_argument(
>> + '-p',
>> + '--ports-per-switch',
>> + type=int,
>> + default=9,
>> + help='Number of logical switch ports per switch (default: 9, '
>> + 'provides 3 ports per tier)',
>> + )
>> + parser.add_argument(
>> + '-v', '--vips', type=int, default=5,
>> + help='Number of LB VIPs per node (default: 5)'
>> + )
>> + parser.add_argument(
>> + '-B',
>> + '--backends',
>> + type=int,
>> + default=5,
>> + help='Number backends per VIP (default: 5)',
>> + )
>> + parser.add_argument(
>> + '-b',
>> + '--batch-size',
>> + type=int,
>> + default=0,
>> + help='Nodes per chassis (default: n/10)',
>> + )
>> + parser.add_argument(
>> + '-d', '--debug',
>> + action='store_true',
>> + help='Enable debug output (show info messages)',
>> + )
>> + args = parser.parse_args()
>> +
>> + if args.batch_size <= 0:
>> + args.batch_size = max(1, args.nodes // 10)
>> +
>> + if args.nodes < 1:
>> + die('node count must be at least 1')
>> + # Node index is split across two IP octets via ip_node(), so the
>> + # maximum is 2^16 - 1.
>> + if args.nodes > 65535:
>> + die('maximum supported node count is 65535')
>> +
>> + if args.ports_per_switch < 0:
>> + die('ports per switch must not be negative')
>> + # Port IPs start at 10.x.y.10 (see _add_ports), so the highest
>> + # address is 10.x.y.(10 + ports_per_switch - 1). Cap at 245 to
>> + # keep that <= 254 (valid IPv4 octet).
>> + if args.ports_per_switch > 245:
>> + die('maximum supported ports per switch is 245')
>> +
>> + if args.vips < 0:
>> + die('VIP count must not be negative')
>> + # VIP index is split via (j+1)//250 and (j+1)%250; the quotient
>> + # must fit in one octet, so max VIPs = 250 * 256 - 1 = 63999.
>> + if args.vips > 63999:
>> + die('maximum supported VIPs is 63999')
>> +
>> + if args.backends < 0:
>> + die('backend count must not be negative')
>> + # Backend addresses use 42.{k}.j1.j2; k must fit in one octet.
>> + if args.backends > 255:
>> + die('maximum supported backends is 255')
>> +
>> + if args.debug:
>> + vlog.set_levels_from_string('console:info')
>> +
>> + if args.phase == 'create':
>> + n_chassis = ((args.nodes + args.batch_size - 1)
>> + // args.batch_size)
>> + print(f'\n=== OVN Benchmark Configuration ===')
>> + print(f'Nodes: {args.nodes}'
>> + f' ({args.nodes} routers + {args.nodes} switches)')
>> + print(f'Ports per switch: {args.ports_per_switch} '
>> + f'({args.ports_per_switch * args.nodes} total ports)')
>> + print(f'Load balancer VIPs per node: {args.vips} '
>> + f'({args.vips * args.nodes} total VIPs)')
>> + print(f'Backends per VIP: {args.backends}')
>> + print(f'Total load balancers: '
>> + f'{args.nodes * args.vips}')
>> + print(f'Nodes per chassis (batch): '
>> + f'{args.batch_size} '
>> + f'({n_chassis} chassis)')
>> + print(f'Debug logging: '
>> + f'{"enabled" if args.debug else "disabled"}')
>> + print('===================================\n')
>> +
>> + run(args.remote, args.nodes, args.vips, args.backends,
>> + args.ports_per_switch, args.batch_size)
>> + else:
>> + idl = connect_idl(args.remote)
>> + n = args.nodes
>> + if args.phase == 'add-lsps':
>> + add_lsps(idl, n, args.ports_per_switch)
>> + elif args.phase == 'add-nat':
>> + add_nat(idl, n)
>> + elif args.phase == 'add-routes':
>> + add_routes(idl, n)
>> + elif args.phase == 'add-policies':
>> + add_policies(idl, n)
>> + elif args.phase == 'modify-lsps':
>> + modify_lsps(idl, n)
>> + elif args.phase == 'modify-acls':
>> + modify_acls(idl, n)
>> + elif args.phase == 'modify-nat':
>> + modify_nat(idl, n)
>> + elif args.phase == 'modify-routes':
>> + modify_routes(idl, n)
>> + elif args.phase == 'modify-lbs':
>> + modify_lbs(idl, n)
>> + elif args.phase == 'modify-addr-sets':
>> + modify_addr_sets(idl, n)
>> + elif args.phase == 'modify-pg':
>> + modify_port_groups(idl, n)
>> + elif args.phase == 'modify-port-sec':
>> + modify_port_security(idl, n)
>> + elif args.phase == 'modify-dhcp':
>> + modify_dhcp(idl, n)
>> + elif args.phase == 'modify-dns':
>> + modify_dns(idl, n)
>> + elif args.phase == 'delete-lsps':
>> + delete_lsps(idl, n, args.ports_per_switch)
>> + elif args.phase == 'delete-nat':
>> + delete_nat(idl, n)
>> + elif args.phase == 'delete-routes':
>> + delete_routes(idl, n)
>> + elif args.phase == 'delete-lbs':
>> + delete_lbs(idl, n)
>> + elif args.phase == 'delete-acls':
>> + delete_acls(idl, n)
>> + elif args.phase == 'delete-policies':
>> + delete_policies(idl, n)
>> + elif args.phase == 'delete-qos':
>> + delete_qos(idl, n)
>> +
>> +
>> +if __name__ == '__main__':
>> + try:
>> + main()
>> + except error.Error as e:
>> + sys.stderr.write(f'{e}\n')
>> + sys.exit(1)
>> diff --git a/tutorial/ovn-benchmark.sh b/tutorial/ovn-benchmark.sh
>> new file mode 100755
>> index 000000000..e68ef6a4e
>> --- /dev/null
>> +++ b/tutorial/ovn-benchmark.sh
>> @@ -0,0 +1,561 @@
>> +#!/bin/bash
>> +
>> +DEFAULT_NODES=200
>> +
>> +PROCESS_NAME=()
>> +FILE_NAME=""
>> +NODES=""
>> +PROCESS_PIDS=()
>> +FINAL_PEAK_KB=()
>> +DEBUG=false
>> +BATCH_SIZE=""
>> +VALGRIND_MODE=false
>> +VALGRIND_PIDS=()
>> +MASSIF_FILES=()
>> +BENCHMARK_TMPDIR=""
>> +
>> +on_interrupt() {
>> + echo ""
>> + if [ "$VALGRIND_MODE" = true ]; then
>> + echo "Exiting benchmark. Restarting tracked sandbox processes..."
>> + fi
>> + exit 1
>> +}
>> +trap on_interrupt INT
>> +
>> +# In valgrind mode, the original daemons are killed and replaced with
>> +# valgrind-wrapped copies. This cleanup ensures they are restored on
>> +# both normal exit and Ctrl+C so the sandbox is not left broken.
>> +cleanup() {
>> + # Stop any valgrind processes still running.
>> + for vpid in "${VALGRIND_PIDS[@]}"; do
>> + kill "$vpid" 2>/dev/null
>> + done
>> +
>> + if [ -n "$BENCHMARK_TMPDIR" ]; then
>> + deadline=$(($(date +%s) + 30))
>> + for vpid in "${VALGRIND_PIDS[@]}"; do
>> + while [ -e "/proc/$vpid" ]; do
>> + if [ "$(date +%s)" -ge "$deadline" ]; then
>> + echo "Warning: valgrind PID $vpid" \
>> + "did not exit; killing"
>> + kill -9 "$vpid" 2>/dev/null
>
> Nit: They're the same thing but I prefer "kill -KILL".
>
>> + break
>> + fi
>> + sleep 0.1
>> + done
>> + done
>> +
>> + # Restart each daemon from the cmdline/cwd saved before we
>> + # killed it, but only if it is not already running.
>> + for i in "${!PROCESS_NAME[@]}"; do
>> + pn="${PROCESS_NAME[$i]}"
>> + if [ -f "$BENCHMARK_TMPDIR/cmdline.$pn" ] \
>> + && ! pgrep -x "$pn" >/dev/null 2>&1; then
>> + restart_cwd=$(cat "$BENCHMARK_TMPDIR/cwd.$pn")
>> + mapfile -d '' restart_args < "$BENCHMARK_TMPDIR/cmdline.$pn"
>> + # Drop trailing empty element from /proc/*/cmdline's final
>> NUL.
>> + if [ ${#restart_args[@]} -gt 0 ] \
>> + && [ -z "${restart_args[-1]}" ]; then
>> + unset 'restart_args[-1]'
>> + fi
>> + (cd "$restart_cwd" && "${restart_args[@]}" >/dev/null 2>&1)
>> + fi
>> + done
>> +
>> + rm -rf "$BENCHMARK_TMPDIR"
>> + fi
>> +}
>> +trap cleanup EXIT
>> +
>> +while [[ $# -gt 0 ]]; do
>> + case "$1" in
>> + -h|--help|--usage)
>> + echo "Usage: $0 [OPTIONS] [NODES] [PROCESS...]"
>> + echo ""
>> + echo "Arguments:"
>> + echo " NODES Number of nodes to create" \
>> + "(default: $DEFAULT_NODES)"
>> + echo " PROCESS Process(es) to track:" \
>> + "ovn-northd, ovn-controller"
>> + echo " (default: both)"
>> + echo ""
>> + echo "Options:"
>> + echo " -f, --file FILE Load NB database from file"
>> + echo " -b, --batch-size N Nodes per chassis" \
>> + "(default: NODES/10)"
>> + echo " -v, --valgrind Track heap with Valgrind" \
>> + "Massif (slower, accurate)"
>> + echo " -d, --debug Enable debug output"
>> + echo " -h, --help Show this help message"
>> + echo ""
>> + echo "Memory tracking:"
>> + echo " Default: peak virtual memory (VmPeak) from"
>> + echo " /proc/<pid>/status."
>> + echo " --valgrind: peak heap via Valgrind Massif."
>> + echo " Expect 10-50x slowdown; 500 nodes or fewer"
>> + echo " recommended."
>> + echo ""
>> + echo "Examples:"
>> + echo " $0 # 200 nodes, track both
>> processes"
>> + echo " $0 50 # 50 nodes"
>> + echo " $0 50 ovn-northd # 50 nodes, track only
>> ovn-northd"
>> + echo " $0 --valgrind # Use Valgrind Massif, not
>> VmPeak"
>> + echo " $0 --file ovnnb_db.db # Load from file"
>> + echo " $0 --debug 20 # 20 nodes with debug output"
>> + echo " $0 50 -b 10 # 50 nodes, 10 per chassis"
>> + echo ""
>> + echo "Note: if only tracking one process, # of nodes is
>> required"
>> + exit 0
>> + ;;
>> + -b|--batch-size)
>> + if [ -z "$2" ]; then
>> + echo "Error: $1 requires an argument"
>> + exit 1
>> + fi
>> + BATCH_SIZE="$2"
>> + shift 2
>> + ;;
>> + -v|--valgrind)
>> + VALGRIND_MODE=true
>> + shift
>> + ;;
>> + -d|--debug)
>> + DEBUG=true
>> + shift
>> + ;;
>> + -f|--file)
>> + if [ -z "$2" ]; then
>> + echo "Error: $1 requires an argument"
>> + exit 1
>> + fi
>> + FILE_NAME="$2"
>> + shift 2
>> + ;;
>> + -*)
>> + echo "Unknown option: $1"
>> + exit 1
>> + ;;
>> + *)
>> + if [ -z "$NODES" ]; then
>> + NODES="$1"
>> + else
>> + # Normalize process names: accept both "northd" and
>> + # "ovn-northd".
>> + case "$1" in
>> + northd)
>> + PROCESS_NAME+=("ovn-northd")
>> + ;;
>> + controller)
>> + PROCESS_NAME+=("ovn-controller")
>> + ;;
>> + *)
>> + PROCESS_NAME+=("$1")
>> + ;;
>> + esac
>> + fi
>> + shift
>> + ;;
>> + esac
>> +done
>> +
>> +# Must be run from inside the sandbox (tutorial/sandbox/).
>> +if [ ! -d "$PWD/sandbox" ]; then
>> + echo "Error: must be run from inside the OVN sandbox."
>> + echo "Start one with: make sandbox"
>> + exit 1
>> +fi
>> +
>> +# Apply defaults if not set by user.
>> +NODES=${NODES:-$DEFAULT_NODES}
>> +
>> +if [ -z "$BATCH_SIZE" ]; then
>> + BATCH_SIZE=$((NODES / 10))
>> +fi
>> +if [ "$BATCH_SIZE" -lt 1 ]; then
>> + BATCH_SIZE=1
>> +fi
>> +
>> +# Track both processes if not specified.
>> +if [ ${#PROCESS_NAME[@]} -eq 0 ]; then
>> + PROCESS_NAME=("ovn-controller" "ovn-northd")
>> +fi
>> +
>> +if [ "$DEBUG" = true ]; then
>> + echo "Nodes: $NODES"
>> + echo "Batch size: $BATCH_SIZE"
>> + echo "Processes: ${PROCESS_NAME[*]}"
>> + echo "File: ${FILE_NAME:-None}"
>> +fi
>> +
>> +PID_ERRORS=false
>> +for pn in "${PROCESS_NAME[@]}"; do
>> + all_pids=$(pgrep -x "$pn")
>> + if [ -z "$all_pids" ]; then
>> + echo "Error: Could not find process matching '$pn'"
>> + PID_ERRORS=true
>> + continue
>> + fi
>> +
>> + if [ "$(echo "$all_pids" | wc -l)" -gt 1 ]; then
>> + echo "Error: Multiple $pn processes found" \
>> + "(PIDs: $(echo $all_pids | tr '\n' ' '))"
>> + PID_ERRORS=true
>> + continue
>> + fi
>> +
>> + PROCESS_PIDS+=("$all_pids")
>> +done
>> +
>> +if [ "$PID_ERRORS" = true ]; then
>> + echo "Kill stale processes or ensure only one sandbox is running."
>> + exit 1
>> +fi
>> +
>> +if [ "$DEBUG" = true ]; then
>> + for i in "${!PROCESS_NAME[@]}"; do
>> + echo "Tracking memory for ${PROCESS_NAME[$i]}" \
>> + "(PID: ${PROCESS_PIDS[$i]})"
>> + done
>> +fi
>> +
>> +if [ "$VALGRIND_MODE" = true ]; then
>> + if ! command -v valgrind >/dev/null 2>&1; then
>> + echo "Error: valgrind is not installed or not in PATH"
>> + exit 1
>> + fi
>> +
>> + if [ "$NODES" -gt 500 ]; then
>> + echo "Warning: valgrind mode with $NODES nodes may be very slow." \
>> + "Consider using 500 or fewer nodes for heap profiling."
>> + fi
>> +
>> + BENCHMARK_TMPDIR=$(mktemp -d)
>> + echo "Restarting processes under Valgrind Massif..."
>> + echo "(Expect 10-50x slowdown)"
>> +
>> + for i in "${!PROCESS_NAME[@]}"; do
>> + pn="${PROCESS_NAME[$i]}"
>> + pid="${PROCESS_PIDS[$i]}"
>> +
>> + # Read the original command line so we can restart the daemon later.
>> + # /proc/<pid>/cmdline is NUL-separated; the trailing NUL produces an
>> + # empty final element which must be dropped.
>> + mapfile -d '' orig_args < /proc/$pid/cmdline
>> + if [ ${#orig_args[@]} -gt 0 ] \
>> + && [ -z "${orig_args[-1]}" ]; then
>> + unset 'orig_args[-1]'
>> + fi
>> +
>> + proc_cwd=$(readlink /proc/$pid/cwd)
>> + massif_file="$BENCHMARK_TMPDIR/massif.$pn.out"
>> + MASSIF_FILES+=("$massif_file")
>> +
>> + # Save original cmdline and cwd so we can restart after valgrind.
>> + cp /proc/$pid/cmdline "$BENCHMARK_TMPDIR/cmdline.$pn"
>> + echo "$proc_cwd" > "$BENCHMARK_TMPDIR/cwd.$pn"
>> +
>> + # Valgrind runs the process in the foreground, so strip args
>> + # that assume daemonized execution:
>> + # --detach : can't daemonize under valgrind
>> + # --no-chdir : only meaningful with --detach
>> + # --pidfile : valgrind doesn't write one (OVS uses
>> + # optional_argument so bare --pidfile never
>> + # takes a separate arg; only --pidfile=FILE)
>> + # --monitor : would fork a monitor child under valgrind,
>> + # causing two processes to race on massif output
>> + filtered_args=()
>> + for arg in "${orig_args[@]}"; do
>> + case "$arg" in
>> + --detach|--no-chdir|--monitor) ;;
>> + --pidfile|--pid-file) ;;
>> + --pidfile=*|--pid-file=*) ;;
>> + *) filtered_args+=("$arg") ;;
>> + esac
>> + done
>> +
>> + if [ "$DEBUG" = true ]; then
>> + echo "Stopping $pn (PID $pid)..."
>> + fi
>> +
>> + kill "$pid"
>> + while [ -e "/proc/$pid" ]; do sleep 0.1; done
>
> This may end up looping indefinitely if for some reason "$pid" refuses
> to terminate, e.g., ignores SIGTERM.
>
> I think we'll need a sort of "kill_insist()" or similar function that
> would force a SIGKILL to be sent if the process doesn't end in time, e.g.:
>
> kill_insist() {
> local pid=$1
> local timeout_ms=${2:-30000}
>
> env kill -0 "$pid" 2> /dev/null && \
> env kill --timeout "$timeout_ms" KILL "$pid"
> }
>
> We could use this function inside cleanup() too where we're currently
> cleaning up any leftover valgrind processes.
>
> We need "env" because the bash builtin kill doesn't support --timeout.
>
> The rest looks good to me so, aside from the load balancer group comment
> and the check/note about valgrind not working with ubsan/asan (discussed
> in the other thread) which can be potential follow ups, I'm considering
> applying this patch to main with the following incremental change
> squashed in. Please let me know what you think about it.
This is a good edit, I'm fine with it being squashed into my patch.
Thank you so much!
>
> Thanks,
> Dumitru
>
> diff --git a/tutorial/ovn-benchmark.sh b/tutorial/ovn-benchmark.sh
> index e68ef6a4e6..01c090e846 100755
> --- a/tutorial/ovn-benchmark.sh
> +++ b/tutorial/ovn-benchmark.sh
> @@ -23,29 +23,24 @@ on_interrupt() {
> }
> trap on_interrupt INT
>
> +kill_insist() {
> + local pid=$1
> + local timeout_ms=${2:-30000}
> +
> + env kill -0 "$pid" 2> /dev/null && \
> + env kill --timeout "$timeout_ms" KILL "$pid"
> +}
> +
> # In valgrind mode, the original daemons are killed and replaced with
> # valgrind-wrapped copies. This cleanup ensures they are restored on
> # both normal exit and Ctrl+C so the sandbox is not left broken.
> cleanup() {
> # Stop any valgrind processes still running.
> for vpid in "${VALGRIND_PIDS[@]}"; do
> - kill "$vpid" 2>/dev/null
> + kill_insist $vpid
> done
>
> if [ -n "$BENCHMARK_TMPDIR" ]; then
> - deadline=$(($(date +%s) + 30))
> - for vpid in "${VALGRIND_PIDS[@]}"; do
> - while [ -e "/proc/$vpid" ]; do
> - if [ "$(date +%s)" -ge "$deadline" ]; then
> - echo "Warning: valgrind PID $vpid" \
> - "did not exit; killing"
> - kill -9 "$vpid" 2>/dev/null
> - break
> - fi
> - sleep 0.1
> - done
> - done
> -
> # Restart each daemon from the cmdline/cwd saved before we
> # killed it, but only if it is not already running.
> for i in "${!PROCESS_NAME[@]}"; do
> @@ -278,8 +273,7 @@ if [ "$VALGRIND_MODE" = true ]; then
> echo "Stopping $pn (PID $pid)..."
> fi
>
> - kill "$pid"
> - while [ -e "/proc/$pid" ]; do sleep 0.1; done
> + kill_insist $pid
>
> if [ "$DEBUG" = true ]; then
> echo "Starting $pn under valgrind:"
_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev