diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index 9945df6eafdec56de12291a06417b76f6d29c3f9..556a760953cae6cc5c55eb6f093f1ef08b9e7549 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -121,6 +121,16 @@ def allocate_v6_network(service_type: str, comment: str | None = "") -> ipaddres return ipaddress.IPv6Network(_allocate_network(conn, dns_view, netmask, containers, comment)) +def find_network_by_cidr(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> objects.Network | None: + """Find a network in Infoblox by its {term}`CIDR`. + + :param ip_network: The {term}`CIDR` that is searched. + :type ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network + """ + conn, _ = _setup_connection() + return objects.Network.search(conn, cidr=str(ip_network)) + + def delete_network(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> None: """Delete a network in Infoblox. @@ -130,8 +140,7 @@ def delete_network(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> :param ip_network: The network that should get deleted. :type ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network """ - conn, _ = _setup_connection() - network = objects.Network.search(conn, cidr=str(ip_network)) + network = find_network_by_cidr(ip_network) if network: network.delete() else: @@ -139,7 +148,7 @@ def delete_network(ip_network: ipaddress.IPv4Network | ipaddress.IPv6Network) -> def allocate_host( - hostname: str, service_type: str, cname_aliases: list[str], comment: str | None = "" + hostname: str, service_type: str, cname_aliases: list[str], comment: str ) -> tuple[ipaddress.IPv4Address, ipaddress.IPv6Address]: """Allocate a new host record in Infoblox. @@ -154,8 +163,9 @@ def allocate_host( :param cname_aliases: A list of any {term}`CNAME` aliases that should be associated with this host. Most often this will be a single loopback address. :type cname_aliases: list[str] - :param comment: Optionally, a comment can be added to the host record in Infoblox. - :type comment: str, optional + :param comment: A comment that is added to the host record in Infoblox, should be the `subscription_id` of the new + {class}`Router` subscription. + :type comment: str """ if not hostname_available(hostname): raise AllocationError(f"Cannot allocate new host, FQDN {hostname} already taken.") @@ -199,6 +209,32 @@ def allocate_host( return created_v4, created_v6 +def find_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> objects.HostRecord | None: + """Find a host record in Infoblox by its associated IP address. + + :param ip_addr: The IP address of a host that is searched for. + :type ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address + """ + conn, _ = _setup_connection() + if ip_addr.version == 4: + return objects.HostRecord.search( + conn, ipv4addr=ip_addr, return_fields=["ipv4addrs", "name", "view", "aliases", "comment"] + ) + return objects.HostRecord.search( + conn, ipv6addr=ip_addr, return_fields=["ipv6addrs", "name", "view", "aliases", "comment"] + ) + + +def find_host_by_fqdn(fqdn: str) -> objects.HostRecord | None: + """Find a host record by its associated {term}`FQDN`. + + :param fqdn: The {term}`FQDN` of a host that is searched for. + :type fqdn: str + """ + conn, _ = _setup_connection() + return objects.HostRecord.search(conn, name=fqdn, return_fields=["ipv4addrs", "name", "view", "aliases", "comment"]) + + def delete_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None: """Delete a host from Infoblox. @@ -208,8 +244,7 @@ def delete_host_by_ip(ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> :param ip_addr: The IP address of the host record that should get deleted. :type ip_addr: ipaddress.IPv4Address | ipaddress.IPv6Address """ - conn, _ = _setup_connection() - host = objects.HostRecord.search(conn, ipv4addr=ip_addr) + host = find_host_by_ip(ip_addr) if host: host.delete() else: @@ -225,6 +260,8 @@ def delete_host_by_fqdn(fqdn: str) -> None: :param fqdn: The FQDN of the host record that should get deleted. :type fqdn: str """ - conn, _ = _setup_connection() - host = objects.HostRecord.search(conn, name=fqdn) - host.delete() + host = find_host_by_fqdn(fqdn) + if host: + host.delete() + else: + raise DeletionError(f"Could not find host at {fqdn}, nothing has been deleted.") diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 593ef2d3253ec88d10662d73ed0b45ec2581e5aa..952d6eaf9a05daf287eabf50d0c6f6274d98ddd1 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -1,5 +1,7 @@ from typing import Optional +import infoblox_client.objects + # noinspection PyProtectedMember from orchestrator.forms import FormPage from orchestrator.forms.validators import Choice @@ -8,15 +10,15 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUID from orchestrator.workflow import StepList, conditional, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form +from pydantic import validator -from gso.products.product_blocks import router as router_pb -from gso.products.product_types import router from gso.products.product_types.router import RouterInactive, RouterProvisioning from gso.products.product_types.site import Site from gso.products.shared import PortNumber from gso.services import infoblox, provisioning_proxy, subscriptions from gso.services.provisioning_proxy import pp_interaction from gso.workflows.utils import customer_selector, iso_from_ipv4 +from products.product_blocks.router import RouterRole, RouterVendor, generate_fqdn def _site_selector() -> Choice: @@ -39,10 +41,19 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: router_site: _site_selector() # type: ignore hostname: str ts_port: PortNumber - router_vendor: router_pb.RouterVendor - router_role: router_pb.RouterRole + router_vendor: RouterVendor + router_role: RouterRole is_ias_connected: Optional[bool] + @validator("hostname", allow_reuse=True) + def hostname_must_be_available(cls, hostname: str, **kwargs) -> str: + selected_site = Site.from_subscription(kwargs["values"].get("router_site", "")).site + input_fqdn = generate_fqdn(hostname, selected_site.site_name, selected_site.site_country_code) + if not infoblox.hostname_available(input_fqdn): + raise ValueError(f'FQDN "{input_fqdn}" is not available.') + + return hostname + user_input = yield CreateRouterForm return user_input.dict() @@ -58,10 +69,35 @@ def create_subscription(product: UUIDstr, customer: UUIDstr) -> State: } +@step("Initialize subscription") +def initialize_subscription( + subscription: RouterInactive, + hostname: str, + ts_port: PortNumber, + router_vendor: RouterVendor, + router_site: str, + router_role: RouterRole, +) -> State: + subscription.router.router_ts_port = ts_port + subscription.router.router_vendor = router_vendor + subscription.router.router_site = Site.from_subscription(router_site).site + fqdn = generate_fqdn( + hostname, subscription.router.router_site.site_name, subscription.router.router_site.site_country_code + ) + subscription.router.router_fqdn = fqdn + subscription.router.router_role = router_role + subscription.router.router_access_via_ts = True + subscription.description = f"Router {fqdn}" + + subscription = RouterProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) + + return {"subscription": subscription} + + @step("Allocate loopback interfaces in IPAM") def ipam_allocate_loopback(subscription: RouterProvisioning, is_ias_connected: bool) -> State: fqdn = subscription.router.router_fqdn - loopback_v4, loopback_v6 = infoblox.allocate_host(f"lo0.{fqdn}", "LO", [fqdn]) + loopback_v4, loopback_v6 = infoblox.allocate_host(f"lo0.{fqdn}", "LO", [fqdn], str(subscription.subscription_id)) subscription.router.router_lo_ipv4_address = loopback_v4 subscription.router.router_lo_ipv6_address = loopback_v6 @@ -75,36 +111,15 @@ def ipam_allocate_loopback(subscription: RouterProvisioning, is_ias_connected: b def ipam_allocate_ias_networks(subscription: RouterProvisioning) -> State: fqdn = subscription.router.router_fqdn - subscription.router.router_si_ipv4_network = infoblox.allocate_v4_network("SI", f"SI for {fqdn}") - subscription.router.router_ias_lt_ipv4_network = infoblox.allocate_v4_network("LT_IAS", f"LT for {fqdn}") - subscription.router.router_ias_lt_ipv6_network = infoblox.allocate_v6_network("LT_IAS", f"LT for {fqdn}") - - return {"subscription": subscription} - - -@step("Initialize subscription") -def initialize_subscription( - subscription: router.RouterInactive, - hostname: str, - ts_port: PortNumber, - router_vendor: router_pb.RouterVendor, - router_site: str, - router_role: router_pb.RouterRole, -) -> State: - subscription.router.router_ts_port = ts_port - subscription.router.router_vendor = router_vendor - subscription.router.router_site = Site.from_subscription(router_site).site - fqdn = ( - f"{hostname}.{subscription.router.router_site.site_name.lower()}." - f"{subscription.router.router_site.site_country_code.lower()}" - ".geant.net" + subscription.router.router_si_ipv4_network = infoblox.allocate_v4_network( + "SI", f"SI for {fqdn} - {subscription.subscription_id}" + ) + subscription.router.router_ias_lt_ipv4_network = infoblox.allocate_v4_network( + "LT_IAS", f"LT for {fqdn} - {subscription.subscription_id}" + ) + subscription.router.router_ias_lt_ipv6_network = infoblox.allocate_v6_network( + "LT_IAS", f"LT for {fqdn} - {subscription.subscription_id}" ) - subscription.router.router_fqdn = fqdn - subscription.router.router_role = router_role - subscription.router.router_access_via_ts = True - subscription.description = f"Router {fqdn}" - - subscription = router.RouterProvisioning.from_other_lifecycle(subscription, SubscriptionLifecycle.PROVISIONING) return {"subscription": subscription} @@ -135,6 +150,44 @@ def provision_router_real(subscription: RouterProvisioning, process_id: UUIDstr) } +@step("Verify IPAM resources for loopback interface") +def verify_ipam_loopback(subscription: RouterProvisioning) -> State: + host_record = infoblox.find_host_by_fqdn(f"lo0.{subscription.router.router_fqdn}") + if not host_record or str(subscription.subscription_id) not in host_record.comment: + return { + "ipam_warning": "!!! Loopback record is incorrectly configured in IPAM, please investigate this manually. !!!" + } + + return {"subscription": subscription} + + +@step("Verify IPAM resources for IAS/LT networks") +def verify_ipam_ias(subscription: RouterProvisioning) -> State: + si_ipv4_network = infoblox.find_network_by_cidr(subscription.router.router_si_ipv4_network) + ias_lt_ipv4_network = infoblox.find_network_by_cidr(subscription.router.router_ias_lt_ipv4_network) + ias_lt_ipv6_network = infoblox.find_network_by_cidr(subscription.router.router_ias_lt_ipv6_network) + + new_state = {} + + if not si_ipv4_network or str(subscription.subscription_id) not in si_ipv4_network.comment: + new_state = { + "ipam_si_warning": f"SI IPv4 network expected at {subscription.router.router_si_ipv4_network}, " + f"but it was not found or misconfigured, please investigate and adjust if necessary." + } + if not ias_lt_ipv4_network or str(subscription.subscription_id) not in ias_lt_ipv4_network.comment: + new_state = new_state | { + "ipam_ias_lt_ipv4_warning": f"IAS/LT IPv4 network expected at {subscription.router.router_ias_lt_ipv4_network}, " + f"but it was not found or misconfigured, please investigate and adjust if necessary." + } + if not ias_lt_ipv6_network or str(subscription.subscription_id) not in ias_lt_ipv6_network.comment: + new_state = new_state | { + "ipam_ias_lt_ipv6_warning": f"IAS/LT IPv6 network expected at {subscription.router.router_ias_lt_ipv6_network}, " + f"but it was not found or misconfigured, please investigate and adjust if necessary." + } + + return new_state + + @workflow( "Create router", initial_input_form=wrap_create_initial_input_form(initial_input_form_generator), @@ -152,6 +205,8 @@ def create_router() -> StepList: >> should_allocate_ias(ipam_allocate_ias_networks) >> pp_interaction(provision_router_dry, 3) >> pp_interaction(provision_router_real, 3) + >> verify_ipam_loopback + >> should_allocate_ias(verify_ipam_ias) >> set_status(SubscriptionLifecycle.ACTIVE) >> resync >> done