From 8af2a396a6e6c11a8c2ca301a00c25575243dec3 Mon Sep 17 00:00:00 2001 From: Thomas Bachman Date: Wed, 13 May 2026 21:17:21 +0000 Subject: [PATCH 1/3] Add support for IPv6 metadata Starting in the Victoria release, OpenStack supported using IPv6 for metadata for VMs. This patch adds support to the namespace proxy for IPv6 metadata. (cherry picked from commit 819cb6a94eadf9ff276e9c3d99fb0e30252ed89d) (cherry picked from commit f12fcaf37a0aaafb2e3720648b1d483702e33b8c) (cherry picked from commit a052abb4925b7520b89d73d8090bceb97e5fc064) (cherry picked from commit f6c7196770aabbcc9a9077336cdf04a009d40c3b) (cherry picked from commit 29a07318adabf4927f2b226021c976a37072bf44) (cherry picked from commit bf86fa31d11de8e66bba3f4b1cd3252284254147) (cherry picked from commit 838be8a6403b457b58c2ee95530a5a2c115bf43f) (cherry picked from commit adcc908b26bb135602002fe5300c96376c55869a) --- opflexagent/as_metadata_manager.py | 182 ++++++++++---- opflexagent/constants.py | 2 + opflexagent/test/test_as_metadata_mgr.py | 223 +++++++++++++++++- .../test/test_endpoint_file_manager.py | 62 ++++- opflexagent/test/test_gbp_ovs_agent.py | 6 +- opflexagent/test/test_gbp_vpp_agent.py | 10 +- .../ep_managers/endpoint_file_manager.py | 74 ++++-- 7 files changed, 480 insertions(+), 79 deletions(-) diff --git a/opflexagent/as_metadata_manager.py b/opflexagent/as_metadata_manager.py index 005b6a7c..785cbd3d 100644 --- a/opflexagent/as_metadata_manager.py +++ b/opflexagent/as_metadata_manager.py @@ -38,6 +38,7 @@ from oslo_utils import encodeutils from opflexagent._i18n import _ +from opflexagent import constants as ofcst from opflexagent import config as oscfg # noqa from opflexagent.utils import utils as opflexagent_utils @@ -71,6 +72,10 @@ SVC_IP_SIZE = 1000 SVC_IP_CIDR = 16 SVC_NEXTHOP = "169.254.1.1" +SVC_V6_IP_DEFAULT = "fd00::a9fe:102" +SVC_V6_IP_BASE = int(netaddr.IPAddress("fd00::a9fe:f003")) +SVC_V6_IP_CIDR = 64 +SVC_V6_NEXTHOP = "fd00::a9fe:101" SVC_NS = "of-svc" SVC_NS_PORT = "of-svc-nsport" SVC_OVS_PORT = "of-svc-ovsport" @@ -106,6 +111,21 @@ def write_jsonfile(name, data): LOG.warning("Exception in writing file: %s", str(e)) +def normalize_ipv6_next_hop(ipaddr): + if not ipaddr: + return ipaddr + addr = netaddr.IPAddress(ipaddr) + if not addr.is_link_local(): + return ipaddr + words = list(addr.words) + words[0] = 0xfd00 + words[1] = 0 + words[2] = 0 + words[3] = 0 + return str(netaddr.IPAddress(sum(word << (16 * (7 - idx)) + for idx, word in enumerate(words)))) + + class AddressPool(object): def __init__(self, base, size): self.base = base @@ -337,9 +357,15 @@ def process(self, files): curr_svc = read_jsonfile(self.svcfile) ip_pool = AddressPool(SVC_IP_BASE, SVC_IP_SIZE) + ip6_pool = AddressPool(SVC_V6_IP_BASE, SVC_IP_SIZE) for domain_uuid in curr_svc: thisip = netaddr.IPAddress(curr_svc[domain_uuid]['next-hop-ip']) ip_pool.reserve(int(thisip)) + thisip6 = curr_svc[domain_uuid].get('next-hop-ipv6') + if thisip6: + thisip6 = netaddr.IPAddress(normalize_ipv6_next_hop(thisip6)) + if not thisip6.is_link_local(): + ip6_pool.reserve(int(thisip6)) new_svc = {} new_nets = {} @@ -376,13 +402,26 @@ def process(self, files): as_uuid = domain_uuid as_addr = netaddr.IPAddress(ip_pool.get_addr()) as_addr = str(as_addr) + as_addr_v6 = netaddr.IPAddress(ip6_pool.get_addr()) + as_addr_v6 = str(as_addr_v6) new_svc[domain_uuid] = { 'domain-name': domain_name, 'domain-policy-space': domain_tenant, 'next-hop-ip': as_addr, + 'next-hop-ipv6': as_addr_v6, 'uuid': as_uuid, } else: + thisip6 = curr_svc[domain_uuid].get('next-hop-ipv6') + thisip6 = normalize_ipv6_next_hop(thisip6) + if not thisip6: + updated = True + thisip6 = str(netaddr.IPAddress( + ip6_pool.get_addr())) + elif thisip6 != curr_svc[domain_uuid].get( + 'next-hop-ipv6'): + updated = True + curr_svc[domain_uuid]['next-hop-ipv6'] = thisip6 new_svc[domain_uuid] = curr_svc[domain_uuid] del curr_svc[domain_uuid] @@ -402,9 +441,9 @@ def process(self, files): if curr_svc: updated = True + write_jsonfile(self.netsfile, new_nets) if updated: write_jsonfile(self.svcfile, new_svc) - write_jsonfile(self.netsfile, new_nets) class StateWatcher(FileWatcher): @@ -412,6 +451,7 @@ def __init__(self): root_helper = cfg.CONF.AGENT.root_helper self.mgr = AsMetadataManager(LOG, root_helper) self.svcfile = "%s/%s" % (MD_DIR, STATE_FILENAME_SVC) + self.netsfile = "%s/%s" % (MD_DIR, STATE_FILENAME_NETS) self.svc_ovsport_mac = self.mgr.get_asport_mac()[:17] self.disable_proxy = cfg.CONF.OPFLEX.disable_metadata_proxy @@ -426,6 +466,10 @@ def terminate(self, signum, frame): def process(self, files): LOG.debug("State Event: %s", files) + if not os.path.isfile(self.netsfile): + LOG.debug("Waiting for instance network state file: %s", + self.netsfile) + return curr_alloc = read_jsonfile(self.svcfile) @@ -463,16 +507,26 @@ def as_equal(self, asvc, alloc): for idx in ["uuid", "domain-name", "domain-policy-space"]: if asvc[idx] != alloc[idx]: return False - if asvc["service-mapping"][0]["next-hop-ip"] != alloc["next-hop-ip"]: - return False - return True + service_map = { + svc["service-ip"]: svc["next-hop-ip"] + for svc in asvc.get("service-mapping", []) + } + alloc_ipv6 = normalize_ipv6_next_hop(alloc.get("next-hop-ipv6")) + return ( + service_map.get(ofcst.METADATA_DEFAULT_IP) == + alloc["next-hop-ip"] and + service_map.get(ofcst.METADATA_DEFAULT_IPV6) == + alloc_ipv6) def as_del(self, filename, asvc): - try: - self.mgr.del_ip(asvc["service-mapping"][0]["next-hop-ip"]) - except Exception as e: - LOG.warning("EPwatcher: Exception in deleting IP: %s", - str(e)) + for svc in asvc.get("service-mapping", []): + try: + self.mgr.del_ip(svc["next-hop-ip"]) + except Exception as e: + LOG.warn("EPwatcher: Exception in deleting IP: %s", + str(e)) + LOG.warning("EPwatcher: Exception in deleting IP: %s", + str(e)) proxyfilename = PROXY_FILE_NAME_FORMAT % asvc["uuid"] proxyfilename = "%s/%s" % (MD_DIR, proxyfilename) @@ -483,6 +537,8 @@ def as_del(self, filename, asvc): LOG.warning("EPwatcher: Exception in deleting file: %s", str(e)) def as_create(self, alloc): + alloc_ipv6 = normalize_ipv6_next_hop(alloc["next-hop-ipv6"]) + alloc["next-hop-ipv6"] = alloc_ipv6 asvc = { "uuid": alloc["uuid"], "interface-name": SVC_OVS_PORT, @@ -491,18 +547,24 @@ def as_create(self, alloc): "domain-name": alloc["domain-name"], "service-mapping": [ { - "service-ip": "169.254.169.254", - "gateway-ip": "169.254.1.1", + "service-ip": ofcst.METADATA_DEFAULT_IP, + "gateway-ip": SVC_NEXTHOP, "next-hop-ip": alloc["next-hop-ip"], }, + { + "service-ip": ofcst.METADATA_DEFAULT_IPV6, + "gateway-ip": SVC_V6_NEXTHOP, + "next-hop-ip": alloc_ipv6, + }, ], } - try: - self.mgr.add_ip(alloc["next-hop-ip"]) - except Exception as e: - LOG.warning("EPwatcher: Exception in adding IP: %s", - str(e)) + for addr in [alloc["next-hop-ip"], alloc_ipv6]: + try: + self.mgr.add_ip(addr) + except Exception as e: + LOG.warning("EPwatcher: Exception in adding IP: %s", + str(e)) asfilename = AS_FILE_NAME_FORMAT % asvc["uuid"] asfilename = "%s/%s" % (AS_MAPPING_DIR, asfilename) @@ -516,33 +578,38 @@ def as_create(self, alloc): with open(proxyfilename, "w") as f: f.write(proxystr) pidfile = PID_FILE_NAME_FORMAT % asvc["uuid"] - self.mgr.sh("rm -f %s" % pidfile) + self.mgr.sh("rm -f %s %s-v4.pid %s-v6.pid" % ( + pidfile, pidfile[:-4], pidfile[:-4])) except Exception as e: LOG.warning("EPwatcher: Exception in writing proxy file: %s", str(e)) def proxyconfig(self, alloc): duuid = alloc["uuid"] - ipaddr = alloc["next-hop-ip"] - proxystr = "\n".join([ - "[program:opflex-ns-proxy-%s]" % duuid, - "command=ip netns exec of-svc " - "/usr/bin/opflex-ns-proxy " - "--metadata_proxy_socket=/var/lib/neutron/metadata_proxy " - "--state_path=/var/lib/neutron " - "--pid_file=/var/lib/neutron/external/pids/%s.pid " - "--domain_id=%s --metadata_host %s --metadata_port=80 " - "--log-dir=/var/log/neutron --log-file=opflex-ns-proxy-%s.log" % ( - duuid, duuid, ipaddr, duuid[:8]), - "exitcodes=0,2", - "stopasgroup=true", - "startsecs=10", - "startretries=3", - "stopwaitsecs=10", - "stdout_logfile=NONE", - "stderr_logfile=NONE", - ]) - return proxystr + proxy_configs = [] + for family, ipaddr in [("v4", alloc["next-hop-ip"]), + ("v6", normalize_ipv6_next_hop( + alloc["next-hop-ipv6"]))]: + proxy_configs.append("\n".join([ + "[program:opflex-ns-proxy-%s-%s]" % (duuid, family), + "command=ip netns exec of-svc " + "/usr/bin/opflex-ns-proxy " + "--metadata_proxy_socket=/var/lib/neutron/metadata_proxy " + "--state_path=/var/lib/neutron " + "--pid_file=/var/lib/neutron/external/pids/%s-%s.pid " + "--domain_id=%s --metadata_host %s --metadata_port=80 " + "--log-dir=/var/log/neutron " + "--log-file=opflex-ns-proxy-%s-%s.log" % ( + duuid, family, duuid, ipaddr, duuid[:8], family), + "exitcodes=0,2", + "stopasgroup=true", + "startsecs=10", + "startretries=3", + "stopwaitsecs=10", + "stdout_logfile=NONE", + "stderr_logfile=NONE", + ])) + return "\n".join(proxy_configs) class SnatConnTrackHandler(object): @@ -666,7 +733,7 @@ def write_file(self, name, data): def clean_files(self): def rm_files(dirname, extension): - ignorelist = ['anycast_services.state'] + ignorelist = [STATE_FILENAME_SVC, STATE_FILENAME_NETS] try: for filename in os.listdir(dirname): if (filename.endswith('.' + extension) and @@ -695,31 +762,48 @@ def stop_supervisor(self): self.sh("supervisorctl -c %s shutdown" % self.md_filename) time.sleep(30) - def add_default_route(self, nexthop): - self.sh("ip netns exec %s ip route add default via %s" % - (SVC_NS, nexthop)) + def add_default_route(self, nexthop, ip_version=4): + if ip_version == 6: + self.sh("ip netns exec %s ip -6 route add default via %s " + "dev %s" % (SVC_NS, nexthop, SVC_NS_PORT)) + else: + self.sh("ip netns exec %s ip route add default via %s" % + (SVC_NS, nexthop)) def has_ip(self, ipaddr): - outp = self.sh("ip netns exec %s ip addr show dev %s" % - (SVC_NS, SVC_NS_PORT)) + ip_version = netaddr.IPAddress(ipaddr).version + cmd = "ip netns exec %s ip addr show dev %s" + if ip_version == 6: + cmd = "ip netns exec %s ip -6 addr show dev %s" + outp = self.sh(cmd % (SVC_NS, SVC_NS_PORT)) return 'net %s/' % (ipaddr, ) in outp def add_ip(self, ipaddr): if self.has_ip(ipaddr): return - self.sh("ip netns exec %s ip addr add %s/%s dev %s" % - (SVC_NS, ipaddr, SVC_IP_CIDR, SVC_NS_PORT)) + ip_version = netaddr.IPAddress(ipaddr).version + if ip_version == 6: + self.sh("ip netns exec %s ip -6 addr add %s/%s dev %s" % + (SVC_NS, ipaddr, SVC_V6_IP_CIDR, SVC_NS_PORT)) + else: + self.sh("ip netns exec %s ip addr add %s/%s dev %s" % + (SVC_NS, ipaddr, SVC_IP_CIDR, SVC_NS_PORT)) def del_ip(self, ipaddr): if not self.has_ip(ipaddr): return - self.sh("ip netns exec %s ip addr del %s/%s dev %s" % - (SVC_NS, ipaddr, SVC_IP_CIDR, SVC_NS_PORT)) + ip_version = netaddr.IPAddress(ipaddr).version + if ip_version == 6: + self.sh("ip netns exec %s ip -6 addr del %s/%s dev %s" % + (SVC_NS, ipaddr, SVC_V6_IP_CIDR, SVC_NS_PORT)) + else: + self.sh("ip netns exec %s ip addr del %s/%s dev %s" % + (SVC_NS, ipaddr, SVC_IP_CIDR, SVC_NS_PORT)) def get_asport_mac(self): return self.sh( "ip netns exec %s ip link show %s | " - "gawk -e '/link\/ether/ {print $2}'" % + "gawk -e '/link\\/ether/ {print $2}'" % (SVC_NS, SVC_NS_PORT)) def init_host(self): @@ -747,7 +831,9 @@ def init_host(self): self.sh("ip netns exec %s ip link set dev %s up" % (SVC_NS, SVC_NS_PORT)) self.add_ip(SVC_IP_DEFAULT) + self.add_ip(SVC_V6_IP_DEFAULT) self.add_default_route(SVC_NEXTHOP) + self.add_default_route(SVC_V6_NEXTHOP, ip_version=6) self.sh("ethtool --offload %s tx off" % SVC_OVS_PORT) self.sh("ip netns exec %s ethtool --offload %s tx off" % (SVC_NS, SVC_NS_PORT)) diff --git a/opflexagent/constants.py b/opflexagent/constants.py index 56296cfd..7610bb96 100644 --- a/opflexagent/constants.py +++ b/opflexagent/constants.py @@ -18,5 +18,7 @@ TYPE_OPFLEX = 'opflex' VHOST_USER_VPP_PLUG = 'vhostuser_vpp_plug' METADATA_DEFAULT_IP = '169.254.169.254' +METADATA_DEFAULT_IPV6 = 'fe80::a9fe:a9fe' METADATA_SUBNET = '169.254.0.0/16' +METADATA_SUBNET_V6 = 'fe80::a9fe:a9fe/128' VPCMODULE_NAME = 'vpc-%s-%s' diff --git a/opflexagent/test/test_as_metadata_mgr.py b/opflexagent/test/test_as_metadata_mgr.py index 266f7451..4c6626e0 100644 --- a/opflexagent/test/test_as_metadata_mgr.py +++ b/opflexagent/test/test_as_metadata_mgr.py @@ -14,6 +14,7 @@ # under the License. import copy +import os import sys from unittest import mock @@ -38,12 +39,14 @@ "domain-name": "sauto_k8s-bm-1_l3out-1_vrf", "domain-policy-space": "common", "next-hop-ip": "169.254.240.3", + "next-hop-ipv6": "fd00::a9fe:f003", "uuid": "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9" }, "99e788f5-f579-83d2-6b9f-3051a21f63ab": { "domain-name": "k8s-bm-1_UnroutedVRF", "domain-policy-space": "common", "next-hop-ip": "169.254.240.4", + "next-hop-ipv6": "fd00::a9fe:f004", "uuid": "99e788f5-f579-83d2-6b9f-3051a21f63ab" } } @@ -52,6 +55,16 @@ "domain-name": "sauto_k8s-bm-1_l3out-1_vrf", "domain-policy-space": "common", "next-hop-ip": "169.254.240.3", + "next-hop-ipv6": "fd00::a9fe:f003", + "uuid": "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9" + } +} +legacy_curr_alloc_json = { + "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9": { + "domain-name": "sauto_k8s-bm-1_l3out-1_vrf", + "domain-policy-space": "common", + "next-hop-ip": "169.254.240.3", + "next-hop-ipv6": "fe80::a9fe:f003", "uuid": "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9" } } @@ -66,6 +79,30 @@ "service-ip": "169.254.169.254", "gateway-ip": "169.254.1.1", "next-hop-ip": "169.254.240.3" + }, + { + "service-ip": "fe80::a9fe:a9fe", + "gateway-ip": "fd00::a9fe:101", + "next-hop-ip": "fd00::a9fe:f003" + } + ] +} +legacy_link_local_fileA = { + "uuid": "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9", + "interface-name": "of-svc-ovsport", + "service-mac": "02:6a:66:eb:26:6a", + "domain-policy-space": "common", + "domain-name": "sauto_k8s-bm-1_l3out-1_vrf", + "service-mapping": [ + { + "service-ip": "169.254.169.254", + "gateway-ip": "169.254.1.1", + "next-hop-ip": "169.254.240.3" + }, + { + "service-ip": "fe80::a9fe:a9fe", + "gateway-ip": "fe80::a9fe:101", + "next-hop-ip": "fe80::a9fe:f003" } ] } @@ -80,6 +117,11 @@ "service-ip": "169.254.169.254", "gateway-ip": "169.254.1.1", "next-hop-ip": "169.254.240.3" + }, + { + "service-ip": "fe80::a9fe:a9fe", + "gateway-ip": "fd00::a9fe:101", + "next-hop-ip": "fd00::a9fe:f003" } ] } @@ -94,6 +136,11 @@ "service-ip": "169.254.169.254", "gateway-ip": "169.254.1.1", "next-hop-ip": "169.254.240.4" + }, + { + "service-ip": "fe80::a9fe:a9fe", + "gateway-ip": "fd00::a9fe:101", + "next-hop-ip": "fd00::a9fe:f004" } ] } @@ -129,11 +176,116 @@ def test_write_json_file(self): write_string = ''.join(write_list) self.assertEqual(write_string, JSON_FILE_DATA) + @mock.patch('opflexagent.as_metadata_manager.write_jsonfile') + @mock.patch('opflexagent.as_metadata_manager.read_jsonfile') + @mock.patch('os.listdir', return_value=['test.ep']) + def test_process_writes_instance_networks_before_services( + self, listdir_patch, read_jsonfile_patch, write_jsonfile_patch): + watcher = as_metadata_manager.EpWatcher.__new__( + as_metadata_manager.EpWatcher) + watcher.svcfile = '/state/anycast_services.state' + watcher.netsfile = '/state/instance_networks.state' + + ep_file = { + 'neutron-metadata-optimization': True, + 'domain-name': TEST_NAME, + 'domain-policy-space': TEST_TENANT, + 'neutron-network': 'net_uuid', + 'anycast-return-ip': ['fe80::f816:3eff:fe77:364b'], + } + read_jsonfile_patch.side_effect = [{}, ep_file] + + watcher.process('test') + + domain_uuid = watcher.gen_domain_uuid(TEST_TENANT, TEST_NAME) + self.assertEqual( + [ + mock.call( + watcher.netsfile, + {domain_uuid: { + 'fe80::f816:3eff:fe77:364b': 'net_uuid'}}), + mock.call( + watcher.svcfile, + {domain_uuid: { + 'domain-name': TEST_NAME, + 'domain-policy-space': TEST_TENANT, + 'next-hop-ip': '169.254.240.3', + 'next-hop-ipv6': 'fd00::a9fe:f003', + 'uuid': domain_uuid}}), + ], + write_jsonfile_patch.call_args_list) + + +class TestAsMetadataManager(base.BaseTestCase): + + def setUp(self): + super(TestAsMetadataManager, self).setUp() + + @mock.patch('os.remove') + @mock.patch('os.listdir') + def test_clean_files_preserves_state_files(self, listdir_patch, + remove_patch): + mgr = as_metadata_manager.AsMetadataManager.__new__( + as_metadata_manager.AsMetadataManager) + listdir_patch.side_effect = [ + [], + ['anycast_services.state', 'instance_networks.state', + 'old.proxy.state'], + [], + [], + ] + + mgr.clean_files() + + remove_patch.assert_called_once_with( + '%s/old.proxy.state' % as_metadata_manager.MD_DIR) + class TestStateWatcher(base.BaseTestCase): def setUp(self): super(TestStateWatcher, self).setUp() + real_isfile = os.path.isfile + netsfile = "%s/%s" % (as_metadata_manager.MD_DIR, + as_metadata_manager.STATE_FILENAME_NETS) + + def isfile(path): + if path == netsfile: + return True + return real_isfile(path) + + self.isfile_patch = mock.patch('os.path.isfile', side_effect=isfile) + self.isfile_mock = self.isfile_patch.start() + self.addCleanup(self.isfile_patch.stop) + + @mock.patch('opflexagent.as_metadata_manager.write_jsonfile') + @mock.patch('os.remove') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager' + '.update_supervisor') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager.del_ip') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager.add_ip') + @mock.patch('opflexagent.as_metadata_manager.FileProcessor.run') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager' + '.get_asport_mac', + return_value="ff-ff-ff-ff-ff-ff") + @mock.patch('opflexagent.as_metadata_manager.read_jsonfile') + @mock.patch('os.listdir') + def test_process_waits_for_instance_networks_state( + self, listdir_patch, read_jsonfile_patch, asport_mac_patch, + fileprocessor_run_patch, add_ip_patch, del_ip_patch, + update_sv_patch, os_remove_patch, write_jsonfile_patch): + watcher = as_metadata_manager.StateWatcher() + self.isfile_mock.side_effect = None + self.isfile_mock.return_value = False + watcher.process("test") + + self.assertFalse(read_jsonfile_patch.called) + self.assertFalse(listdir_patch.called) + self.assertFalse(write_jsonfile_patch.called) + self.assertFalse(add_ip_patch.called) + self.assertFalse(del_ip_patch.called) + self.assertFalse(update_sv_patch.called) + self.assertFalse(os_remove_patch.called) @mock.patch('opflexagent.as_metadata_manager.write_jsonfile') @mock.patch('os.remove') @@ -192,7 +344,51 @@ def test_process_outdated_file(self, listdir_patch, read_jsonfile_patch, self.assertEqual(write_jsonfile_patch.call_count, 1) self.assertEqual(read_jsonfile_patch.call_count, 3) self.assertEqual(os_remove_patch.call_count, 2) - self.assertEqual(add_ip_patch.call_count, 1) + self.assertEqual(add_ip_patch.call_count, 2) + + @mock.patch('opflexagent.as_metadata_manager.write_jsonfile') + @mock.patch('os.remove') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager' + '.update_supervisor') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager.del_ip') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager.add_ip') + @mock.patch('opflexagent.as_metadata_manager.FileProcessor.run') + @mock.patch('opflexagent.as_metadata_manager.AsMetadataManager' + '.get_asport_mac', + return_value="ff-ff-ff-ff-ff-ff") + @mock.patch('opflexagent.as_metadata_manager.read_jsonfile', + side_effect=[copy.deepcopy(curr_alloc_json), + copy.deepcopy(legacy_link_local_fileA), + copy.deepcopy(nochange_fileB)]) + @mock.patch('os.listdir', + return_value=["44f67ef0-1fd8-7a7e-2bfb-e650cee859a9.as", + "99e788f5-f579-83d2-6b9f-3051a21f63ab.as"]) + def test_process_legacy_link_local_file(self, listdir_patch, + read_jsonfile_patch, + asport_mac_patch, + fileprocessor_run_patch, + add_ip_patch, del_ip_patch, + update_sv_patch, + os_remove_patch, + write_jsonfile_patch): + with mock.patch(MOCK_MODULE, + new=mock.mock_open()) as open_file, mock.patch( + 'opflexagent.as_metadata_manager' + '.AsMetadataManager.sh'): + watcher = as_metadata_manager.StateWatcher() + watcher.disable_proxy = False + watcher.process("test") + self.assertEqual(write_jsonfile_patch.call_count, 1) + self.assertEqual(read_jsonfile_patch.call_count, 3) + self.assertEqual(os_remove_patch.call_count, 2) + self.assertEqual(del_ip_patch.call_count, 2) + self.assertEqual(add_ip_patch.call_count, 2) + self.assertEqual(update_sv_patch.call_count, 1) + proxy = ''.join(call[0][0] + for call in open_file().write.call_args_list) + self.assertIn("--metadata_host fd00::a9fe:f003 --metadata_port=80", + proxy) + self.assertNotIn("--metadata_host fe80::a9fe:f003", proxy) @mock.patch('opflexagent.as_metadata_manager.write_jsonfile') @mock.patch('os.remove') @@ -219,7 +415,7 @@ def test_process_create_file(self, listdir_patch, read_jsonfile_patch, watcher.process("test") self.assertEqual(write_jsonfile_patch.call_count, 1) self.assertEqual(read_jsonfile_patch.call_count, 2) - self.assertEqual(add_ip_patch.call_count, 1) + self.assertEqual(add_ip_patch.call_count, 2) self.assertFalse(os_remove_patch.called) self.assertFalse(del_ip_patch.called) @@ -249,3 +445,26 @@ def test_process_delete_file(self, listdir_patch, read_jsonfile_patch, watcher.process("test") self.assertEqual(os_remove_patch.call_count, 2) self.assertEqual(read_jsonfile_patch.call_count, 3) + self.assertEqual(del_ip_patch.call_count, 2) + + def test_proxyconfig_dual_stack(self): + watcher = as_metadata_manager.StateWatcher.__new__( + as_metadata_manager.StateWatcher) + proxy = watcher.proxyconfig(curr_alloc_json[ + "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9"]) + self.assertIn( + "opflex-ns-proxy-44f67ef0-1fd8-7a7e-2bfb-e650cee859a9-v4", proxy) + self.assertIn( + "opflex-ns-proxy-44f67ef0-1fd8-7a7e-2bfb-e650cee859a9-v6", proxy) + self.assertIn("--metadata_host 169.254.240.3 --metadata_port=80", + proxy) + self.assertIn("--metadata_host fd00::a9fe:f003 --metadata_port=80", + proxy) + + def test_proxyconfig_legacy_link_local_next_hop(self): + watcher = as_metadata_manager.StateWatcher.__new__( + as_metadata_manager.StateWatcher) + proxy = watcher.proxyconfig(legacy_curr_alloc_json[ + "44f67ef0-1fd8-7a7e-2bfb-e650cee859a9"]) + self.assertIn("--metadata_host fd00::a9fe:f003 --metadata_port=80", + proxy) diff --git a/opflexagent/test/test_endpoint_file_manager.py b/opflexagent/test/test_endpoint_file_manager.py index d98b88b0..6fe89ebc 100644 --- a/opflexagent/test/test_endpoint_file_manager.py +++ b/opflexagent/test/test_endpoint_file_manager.py @@ -165,7 +165,8 @@ def test_port_bound(self): "domain-name": 'name_of_l3p', "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) # Send same port info again self.manager._write_vrf_file.reset_mock() @@ -225,7 +226,8 @@ def test_port_bound(self): "domain-name": 'name_of_l3p', "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) self.manager.snat_iptables.setup_snat_for_es.assert_called_with( 'EXT-1', '200.0.0.10', None, '200.0.0.1/8', None, None, None, 'aa:bb:cc:00:11:44', mtu=9000) @@ -251,7 +253,8 @@ def test_port_bound(self): "domain-policy-space": 'apic_tenant', "domain-name": 'name_of_l3p', "internal-subnets": sorted(['192.170.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) self._check_call_list([mock.call('EXT-1', snat_ep_file)], self.manager._write_endpoint_file.call_args_list, False) @@ -711,7 +714,8 @@ def test_port_unbound_delete_vrf_file(self): "domain-name": 'name_of_l3p', "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) def test_port_bound_no_mapping(self): port = self._port() @@ -890,6 +894,10 @@ def test_interface_mtu(self): self.assertTrue('dhcp4' in ep_file) self.assertEqual(ep_file['dhcp4']['interface-mtu'], 1800) self.assertEqual(ep_file['dhcp4']['lease-time'], 100) + self.assertEqual(ep_file['dhcp4']['static-routes'], [{ + 'dest': '169.254.169.254', + 'dest-prefix': 32, + 'next-hop': '192.168.0.2'}]) self.assertEqual(ep_file['security-group'], [{'policy-space': 'common', 'name': 'gbp_default'}]) @@ -1023,7 +1031,8 @@ def test_endpoint_vrf_change(self): "domain-name": 'name_of_l3p', "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) self.assertEqual('l3p_id_1', self.manager.vif_to_vrf[port_1.vif_id]) self.assertEqual('l3p_id', self.manager.vif_to_vrf[port_2.vif_id]) self.assertEqual(set([port_1.vif_id]), @@ -1070,9 +1079,15 @@ def test_v6_subnets(self): 'ip_version': 6, 'dns_nameservers': [V6_DNS], 'cidr': '2001:db8::/64', + 'dhcp_server_ports': { + 'fa:16:3e:a7:a3:aa': [ + 'fe80::a9fe:1' + ] + }, 'host_routes': []}], dhcp_lease_time=100) port = self._port() + port.fixed_ips = [{'subnet_id': 'id1', 'ip_address': '2001:db8::2'}] self.manager._release_int_fip = mock.Mock() self.manager.declare_endpoint(port, mapping) # no MTU set whatsoever @@ -1085,6 +1100,43 @@ def test_v6_subnets(self): self.assertTrue('dhcp6' in ep_file) self.assertEqual(ep_file['dhcp6']['interface-mtu'], 1800) self.assertEqual(ep_file['dhcp6']['dns-servers'], [V6_DNS]) + self.assertEqual(ep_file['dhcp6']['static-routes'], [{ + 'dest': 'fe80::a9fe:a9fe', + 'dest-prefix': 128, + 'next-hop': 'fe80::a9fe:1'}]) + self.assertEqual(['2001:db8::2', 'fe80::a8bb:ccff:fe00:1122'], + ep_file['anycast-return-ip']) + + def test_v6_metadata_route_without_dns(self): + mapping = self._get_gbp_details(enable_dhcp_optimization=True, + interface_mtu=1800, + subnets=[{'id': 'id1', + 'enable_dhcp': True, + 'ip_version': 6, + 'dns_nameservers': [], + 'cidr': '2001:db8::/64', + 'dhcp_server_ports': { + 'fa:16:3e:a7:a3:aa': [ + 'fe80::a9fe:1' + ] + }, + 'host_routes': []}], + dhcp_lease_time=100) + port = self._port() + port.fixed_ips = [{'subnet_id': 'id1', 'ip_address': '2001:db8::2'}] + self.manager._release_int_fip = mock.Mock() + self.manager.declare_endpoint(port, mapping) + ep_file = None + for arg in self.manager._write_endpoint_file.call_args_list: + if port.vif_id in arg[0][0]: + self.assertIsNone(ep_file) + ep_file = arg[0][1] + self.assertIsNotNone(ep_file) + self.assertTrue('dhcp6' in ep_file) + self.assertEqual(ep_file['dhcp6']['static-routes'], [{ + 'dest': 'fe80::a9fe:a9fe', + 'dest-prefix': 128, + 'next-hop': 'fe80::a9fe:1'}]) def test_port_trunk_details(self): mapping = self._get_gbp_details() diff --git a/opflexagent/test/test_gbp_ovs_agent.py b/opflexagent/test/test_gbp_ovs_agent.py index 633fea12..2bcb8eb8 100644 --- a/opflexagent/test/test_gbp_ovs_agent.py +++ b/opflexagent/test/test_gbp_ovs_agent.py @@ -281,7 +281,8 @@ def test_process_network_ports(self): "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', '1.1.1.0/24', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) def test_stale_endpoints_in_process_network_ports(self): self.agent.ep_manager.undeclare_endpoint = mock.Mock() @@ -443,7 +444,8 @@ def test_process_vrf_update(self): "domain-name": mapping['vrf_name'], "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) self.assertFalse(self.agent.ep_manager._delete_vrf_file.called) # Now simulate a deletion diff --git a/opflexagent/test/test_gbp_vpp_agent.py b/opflexagent/test/test_gbp_vpp_agent.py index a54f8b2f..7bd95b26 100755 --- a/opflexagent/test/test_gbp_vpp_agent.py +++ b/opflexagent/test/test_gbp_vpp_agent.py @@ -97,7 +97,9 @@ def start(self, interval=0): mock.patch('oslo_service.loopingcall.FixedIntervalLoopingCall', new=MockFixedIntervalLoopingCall), mock.patch('opflexagent.gbp_agent.GBPOpflexAgent.' - '_report_state')] + '_report_state'), + mock.patch('opflexagent.as_metadata_manager.' + 'SnatConnTrackHandler')] with base.nested_context_manager(*resources): agent = gbp_agent.GBPOpflexAgent(**kwargs) @@ -219,7 +221,8 @@ def test_process_network_ports(self): "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', '1.1.1.0/24', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) def test_dead_port(self): port = mock.Mock(ofport=1) @@ -374,7 +377,8 @@ def test_process_vrf_update(self): "domain-name": mapping['vrf_name'], "internal-subnets": sorted(['192.168.0.0/16', '192.169.0.0/16', - '169.254.0.0/16'])}) + '169.254.0.0/16', + 'fe80::a9fe:a9fe/128'])}) self.assertFalse(self.agent.ep_manager._delete_vrf_file.called) # Now simulate a deletion diff --git a/opflexagent/utils/ep_managers/endpoint_file_manager.py b/opflexagent/utils/ep_managers/endpoint_file_manager.py index 32f4d2d8..ede7e746 100644 --- a/opflexagent/utils/ep_managers/endpoint_file_manager.py +++ b/opflexagent/utils/ep_managers/endpoint_file_manager.py @@ -94,6 +94,8 @@ def initialize(self, host, bridge_manager, config): 6: netaddr.IPSet(config['internal_floating_ip6_pool'])} if ofcst.METADATA_DEFAULT_IP in self.int_fip_pool[4]: self.int_fip_pool[4].remove(ofcst.METADATA_DEFAULT_IP) + if ofcst.METADATA_DEFAULT_IPV6 in self.int_fip_pool[6]: + self.int_fip_pool[6].remove(ofcst.METADATA_DEFAULT_IPV6) self.snat_iptables = snat_iptables_manager.SnatIptablesManager( bridge_manager) @@ -450,8 +452,15 @@ def filter_cidr_aaps(aaps): if virtual_ips: mapping_dict['virtual-ip'] = sorted(virtual_ips, key=lambda x: x['ip']) - if ips or ips_aap: - mapping_dict['anycast-return-ip'] = sorted(ips + ips_aap) + anycast_return_ips = ips + ips_aap + if (mapping['enable_metadata_optimization'] and + any(netaddr.IPNetwork(ip).version == 6 + for ip in anycast_return_ips)): + anycast_return_ips.append( + str(netaddr.EUI(mac).ipv6_link_local())) + if anycast_return_ips: + mapping_dict['anycast-return-ip'] = sorted( + set(anycast_return_ips)) if 'active_active_aap' in mapping: mapping_dict['active-active-aap'] = mapping['active_active_aap'] @@ -626,6 +635,26 @@ def _handle_host_snat_ip(self, host_snat_ips): def _map_dhcp_info(self, fixed_ips, mapping, mapping_dict): """ Add DHCP specific info to the EP file.""" + def append_static_route(routes, destination, next_hop): + cidr = netaddr.IPNetwork(destination) + route = {'dest': str(cidr.network), + 'dest-prefix': cidr.prefixlen, + 'next-hop': next_hop} + if route not in routes: + routes.append(route) + + def get_dhcp_server_ip(sn, ip_version): + for ip_addr in sn.get('dhcp_server_ips', []) or []: + if netaddr.IPAddress(ip_addr).version == ip_version: + return ip_addr, None + + for mac, ip_addrs in ( + sn.get('dhcp_server_ports', {}) or {}).items(): + for ip_addr in ip_addrs: + if netaddr.IPAddress(ip_addr).version == ip_version: + return ip_addr, mac + return None, None + subnets = mapping['subnets'] v4subnets = {k['id']: k for k in subnets if k['ip_version'] == 4 and k['enable_dhcp']} @@ -644,34 +673,41 @@ def _map_dhcp_info(self, fixed_ips, mapping, mapping_dict): 'prefix-len': netaddr.IPNetwork(sn['cidr']).prefixlen} dhcp4['static-routes'] = [] for hr in sn['host_routes']: - cidr = netaddr.IPNetwork(hr['destination']) - dhcp4['static-routes'].append( - {'dest': str(cidr.network), - 'dest-prefix': cidr.prefixlen, - 'next-hop': hr['nexthop']}) - if 'dhcp_server_ips' in sn and sn['dhcp_server_ips']: - dhcp4['server-ip'] = sn['dhcp_server_ips'][0] - if 'dhcp_server_ports' in sn and sn['dhcp_server_ports']: + append_static_route(dhcp4['static-routes'], + hr['destination'], hr['nexthop']) + dhcp_server_ip, dhcp_mac = get_dhcp_server_ip(sn, 4) + if dhcp_server_ip: + dhcp4['server-ip'] = dhcp_server_ip + append_static_route(dhcp4['static-routes'], + "%s/32" % ofcst.METADATA_DEFAULT_IP, + dhcp_server_ip) + if dhcp_mac: # REVISIT: The agent currenlty only supports a single # IP, so just use the first IP from the first entry in # the dict. Once the agent supports additional IPs, we # can provide the full dict. - dhcp_mac = list(sn['dhcp_server_ports'].keys())[0] dhcp4['server-mac'] = dhcp_mac - dhcp4['server-ip'] = sn['dhcp_server_ports'][dhcp_mac][0] if 'interface_mtu' in mapping: dhcp4['interface-mtu'] = mapping['interface_mtu'] if 'dhcp_lease_time' in mapping: dhcp4['lease-time'] = mapping['dhcp_lease_time'] mapping_dict['dhcp4'] = dhcp4 break - if len(v6subnets) > 0 and list(v6subnets.values())[0][ - 'dns_nameservers']: - mapping_dict['dhcp6'] = { - 'dns-servers': list(v6subnets.values())[0]['dns_nameservers']} + if len(v6subnets) > 0: + dhcp6 = {} + v6subnet = list(v6subnets.values())[0] + if v6subnet['dns_nameservers']: + dhcp6['dns-servers'] = v6subnet['dns_nameservers'] + dhcp_server_ip, _dhcp_mac = get_dhcp_server_ip(v6subnet, 6) + if dhcp_server_ip: + dhcp6['static-routes'] = [] + append_static_route(dhcp6['static-routes'], + "%s/128" % ofcst.METADATA_DEFAULT_IPV6, + dhcp_server_ip) if 'interface_mtu' in mapping: - mapping_dict['dhcp6']['interface-mtu'] = mapping[ - 'interface_mtu'] + dhcp6['interface-mtu'] = mapping['interface_mtu'] + if dhcp6: + mapping_dict['dhcp6'] = dhcp6 def _load_es_next_hop_info(self, es_cfg): def parse_range(val): @@ -932,7 +968,7 @@ def vrf_info_to_file(self, mapping, vif_id=None): vrf_info_copy = copy.deepcopy(vrf_info) vrf_info_copy['internal-subnets'] = sorted(list( vrf_info_copy['internal-subnets']) + - [ofcst.METADATA_SUBNET]) + [ofcst.METADATA_SUBNET, ofcst.METADATA_SUBNET_V6]) self._write_vrf_file(mapping['l3_policy_id'], vrf_info_copy) curr_vrf['info'] = vrf_info if vif_id: From f23cae2d72439e1aacae6d722467f35f05a13356 Mon Sep 17 00:00:00 2001 From: Thomas Bachman Date: Wed, 13 May 2026 21:17:21 +0000 Subject: [PATCH 2/3] Add support for IPv6 metadata Starting in the Victoria release, OpenStack supported using IPv6 for metadata for VMs. This patch adds support to the namespace proxy for IPv6 metadata. (cherry picked from commit 819cb6a94eadf9ff276e9c3d99fb0e30252ed89d) (cherry picked from commit f12fcaf37a0aaafb2e3720648b1d483702e33b8c) (cherry picked from commit a052abb4925b7520b89d73d8090bceb97e5fc064) (cherry picked from commit f6c7196770aabbcc9a9077336cdf04a009d40c3b) (cherry picked from commit 29a07318adabf4927f2b226021c976a37072bf44) (cherry picked from commit bf86fa31d11de8e66bba3f4b1cd3252284254147) (cherry picked from commit 838be8a6403b457b58c2ee95530a5a2c115bf43f) (cherry picked from commit adcc908b26bb135602002fe5300c96376c55869a) (cherry picked from commit 8af2a396a6e6c11a8c2ca301a00c25575243dec3) (cherry picked from commit c671e3c013847ab0aa3cf40ddced71c9504090d6) --- opflexagent/as_metadata_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/opflexagent/as_metadata_manager.py b/opflexagent/as_metadata_manager.py index 785cbd3d..41e80a96 100644 --- a/opflexagent/as_metadata_manager.py +++ b/opflexagent/as_metadata_manager.py @@ -40,6 +40,7 @@ from opflexagent._i18n import _ from opflexagent import constants as ofcst from opflexagent import config as oscfg # noqa +from opflexagent import constants as ofcst from opflexagent.utils import utils as opflexagent_utils LOG = logging.getLogger(__name__) From 9cbbb04e390d5ec220d30cedd457647719662b4f Mon Sep 17 00:00:00 2001 From: Thomas Bachman Date: Wed, 13 May 2026 21:17:21 +0000 Subject: [PATCH 3/3] Add support for IPv6 metadata Starting in the Victoria release, OpenStack supported using IPv6 for metadata for VMs. This patch adds support to the namespace proxy for IPv6 metadata. (cherry picked from commit 819cb6a94eadf9ff276e9c3d99fb0e30252ed89d) (cherry picked from commit f12fcaf37a0aaafb2e3720648b1d483702e33b8c) (cherry picked from commit a052abb4925b7520b89d73d8090bceb97e5fc064) (cherry picked from commit f6c7196770aabbcc9a9077336cdf04a009d40c3b) (cherry picked from commit 29a07318adabf4927f2b226021c976a37072bf44) (cherry picked from commit bf86fa31d11de8e66bba3f4b1cd3252284254147) (cherry picked from commit 838be8a6403b457b58c2ee95530a5a2c115bf43f) (cherry picked from commit adcc908b26bb135602002fe5300c96376c55869a) --- opflexagent/as_metadata_manager.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/opflexagent/as_metadata_manager.py b/opflexagent/as_metadata_manager.py index 41e80a96..5413901c 100644 --- a/opflexagent/as_metadata_manager.py +++ b/opflexagent/as_metadata_manager.py @@ -127,6 +127,21 @@ def normalize_ipv6_next_hop(ipaddr): for idx, word in enumerate(words)))) +def normalize_ipv6_next_hop(ipaddr): + if not ipaddr: + return ipaddr + addr = netaddr.IPAddress(ipaddr) + if not addr.is_link_local(): + return ipaddr + words = list(addr.words) + words[0] = 0xfd00 + words[1] = 0 + words[2] = 0 + words[3] = 0 + return str(netaddr.IPAddress(sum(word << (16 * (7 - idx)) + for idx, word in enumerate(words)))) + + class AddressPool(object): def __init__(self, base, size): self.base = base @@ -526,8 +541,6 @@ def as_del(self, filename, asvc): except Exception as e: LOG.warn("EPwatcher: Exception in deleting IP: %s", str(e)) - LOG.warning("EPwatcher: Exception in deleting IP: %s", - str(e)) proxyfilename = PROXY_FILE_NAME_FORMAT % asvc["uuid"] proxyfilename = "%s/%s" % (MD_DIR, proxyfilename) @@ -564,7 +577,7 @@ def as_create(self, alloc): try: self.mgr.add_ip(addr) except Exception as e: - LOG.warning("EPwatcher: Exception in adding IP: %s", + LOG.warn("EPwatcher: Exception in adding IP: %s", str(e)) asfilename = AS_FILE_NAME_FORMAT % asvc["uuid"]