diff --git a/.gitignore b/.gitignore index fccb8082923cbdf2088055e1489862884abd02c3..5dbfe3a1a0edc59e25b82ec47454b370c5e8ed31 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ docs/vale/styles/* .idea .venv +.env \ No newline at end of file diff --git a/Changelog.md b/Changelog.md index 94ab4fd8d9c7cbd456b9a990830cb0d40bafc706..4a00b65ca1e21b6fe6e1abef87c8ee9f809cc6f8 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,16 @@ # Changelog +## [2.13] - 2024-09-18 +- Run cleanup at 1 AM only, not every minute between 1 and 2 AM. +- Add checklist to trunk migration and include ACTIVITY_TYPE column as discussed with SM. +- Fix SDP update logic in the promotion from P to PE. +- Update core lib to 2.7.4 +- Refactor validators into a separate types module. +- Add Tier3 model for Netbox. +- Fix: Increase initial connection timeout for LibreNMS client. +- Fix: Change type from LAGMember to dict for workflow step in trunk modification +- Make celery concurrency level to 1 + ## [2.12] - 2024-08-22 - Add promote P to PE workflow. - Add new cleanup task diff --git a/gso/api/v1/network.py b/gso/api/v1/network.py index b92a135e63ff1a4fcee89e987b3c568fccc28d7f..e4a19de9e90475f70261e4bf408498de68a95af9 100644 --- a/gso/api/v1/network.py +++ b/gso/api/v1/network.py @@ -10,11 +10,12 @@ from orchestrator.security import authorize from orchestrator.services.subscriptions import build_extended_domain_model from starlette import status -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.products.product_blocks.router import RouterRole -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate from gso.services.subscriptions import get_active_iptrunk_subscriptions from gso.utils.shared_enums import Vendor +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.interfaces import PhysicalPortCapacity router = APIRouter(prefix="/networks", tags=["Network"], dependencies=[Depends(authorize)]) diff --git a/gso/cli/imports.py b/gso/cli/imports.py index 406fa85d1dbc0e11d58187909a63dc5f6285285c..33a4636a0b5010d57a7f3c06053e468bb37e191b 100644 --- a/gso/cli/imports.py +++ b/gso/cli/imports.py @@ -13,17 +13,17 @@ import yaml from orchestrator.db import db from orchestrator.services.processes import start_process from orchestrator.types import SubscriptionLifecycle -from pydantic import BaseModel, EmailStr, ValidationError, field_validator, model_validator +from pydantic import BaseModel, ValidationError, field_validator, model_validator from sqlalchemy.exc import SQLAlchemyError from gso.db.models import PartnerTable from gso.products import ProductType -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.products.product_blocks.router import RouterRole from gso.services.partners import ( + PartnerEmail, + PartnerName, PartnerNotFoundError, - filter_partners_by_email, - filter_partners_by_name, get_partner_by_name, ) from gso.services.subscriptions import ( @@ -31,8 +31,10 @@ from gso.services.subscriptions import ( get_active_subscriptions_by_field_and_value, get_subscriptions, ) -from gso.utils.helpers import BaseSiteValidatorModel, LAGMember -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.base_site import BaseSiteValidatorModel +from gso.utils.types.interfaces import LAGMember, LAGMemberList, PhysicalPortCapacity +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType, PortNumber app: typer.Typer = typer.Typer() @@ -40,27 +42,8 @@ app: typer.Typer = typer.Typer() class CreatePartner(BaseModel): """Required inputs for creating a partner.""" - name: str - email: EmailStr - - @field_validator("name") - def validate_name(cls, name: str) -> str: - """Validate name.""" - if filter_partners_by_name(name=name, case_sensitive=False): - msg = "Partner with this name already exists." - raise ValueError(msg) - - return name - - @field_validator("email") - def validate_email(cls, email: str) -> EmailStr: - """Validate email.""" - email = email.lower() - if filter_partners_by_email(email=email, case_sensitive=False): - msg = "Partner with this email already exists." - raise ValueError(msg) - - return email + name: PartnerName + email: PartnerEmail class SiteImportModel(BaseSiteValidatorModel): @@ -115,11 +98,11 @@ class IptrunkImportModel(BaseModel): side_a_node_id: str side_a_ae_iface: str side_a_ae_geant_a_sid: str | None - side_a_ae_members: list[LAGMember] + side_a_ae_members: LAGMemberList[LAGMember] side_b_node_id: str side_b_ae_iface: str side_b_ae_geant_a_sid: str | None - side_b_ae_members: list[LAGMember] + side_b_ae_members: LAGMemberList[LAGMember] iptrunk_ipv4_network: ipaddress.IPv4Network iptrunk_ipv6_network: ipaddress.IPv6Network @@ -150,15 +133,6 @@ class IptrunkImportModel(BaseModel): return value - @field_validator("side_a_ae_members", "side_b_ae_members") - def check_side_uniqueness(cls, value: list[str]) -> list[str]: - """:term:`LAG` members must be unique.""" - if len(value) != len(set(value)): - msg = "Items must be unique" - raise ValueError(msg) - - return value - @model_validator(mode="after") def check_members(self) -> Self: """Amount of :term:`LAG` members has to match on side A and B, and meet the minimum requirement.""" diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py index 88ff75cb2afad1960c4bc4f03953903c23040745..ad83609defc7289d49ff01b9501ce87469e16f5b 100644 --- a/gso/products/product_blocks/iptrunk.py +++ b/gso/products/product_blocks/iptrunk.py @@ -15,18 +15,7 @@ from gso.products.product_blocks.router import ( RouterBlockInactive, RouterBlockProvisioning, ) - - -class PhysicalPortCapacity(strEnum): - """Physical port capacity enumerator. - - An enumerator that has the different possible capacities of ports that are available to use in subscriptions. - """ - - ONE_GIGABIT_PER_SECOND = "1G" - TEN_GIGABIT_PER_SECOND = "10G" - HUNDRED_GIGABIT_PER_SECOND = "100G" - FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G" +from gso.utils.types.interfaces import LAGMemberList, PhysicalPortCapacity class IptrunkType(strEnum): @@ -36,9 +25,6 @@ class IptrunkType(strEnum): LEASED = "Leased" -LAGMemberList = Annotated[ - list[T], AfterValidator(validate_unique_list), Len(min_length=0), Doc("A list of :term:`LAG` member interfaces.") -] IptrunkSides = Annotated[ list[T], AfterValidator(validate_unique_list), diff --git a/gso/products/product_blocks/lan_switch_interconnect.py b/gso/products/product_blocks/lan_switch_interconnect.py index a9b1c77366662e24499f936f9762d515b882b934..e2626de5c680bb983454ed32b523e61f1b78ad6d 100644 --- a/gso/products/product_blocks/lan_switch_interconnect.py +++ b/gso/products/product_blocks/lan_switch_interconnect.py @@ -3,9 +3,9 @@ from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum -from gso.products.product_blocks.iptrunk import LAGMemberList from gso.products.product_blocks.router import RouterBlock, RouterBlockInactive, RouterBlockProvisioning from gso.products.product_blocks.switch import SwitchBlock, SwitchBlockInactive, SwitchBlockProvisioning +from gso.utils.types.interfaces import LAGMemberList class LanSwitchInterconnectAddressSpace(strEnum): diff --git a/gso/products/product_blocks/office_router.py b/gso/products/product_blocks/office_router.py index 65eab0256a073c699f3ea2ef84d96e3352096722..bff50dd3018afd3f2bdee771cd0ca9405ae44b47 100644 --- a/gso/products/product_blocks/office_router.py +++ b/gso/products/product_blocks/office_router.py @@ -8,7 +8,8 @@ from gso.products.product_blocks.site import ( SiteBlockInactive, SiteBlockProvisioning, ) -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType, PortNumber class OfficeRouterBlockInactive( diff --git a/gso/products/product_blocks/router.py b/gso/products/product_blocks/router.py index 17deeccb1ac8a5ee9bcfaa14fa25f27360881e7c..6d7dabf9dff3eaded338a420834f9e509d8584a1 100644 --- a/gso/products/product_blocks/router.py +++ b/gso/products/product_blocks/router.py @@ -8,7 +8,8 @@ from gso.products.product_blocks.site import ( SiteBlockInactive, SiteBlockProvisioning, ) -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType, PortNumber class RouterRole(strEnum): diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py index be7d086adc021992ede2048b6ef4a843c1793755..eec868479f49f005aa077242a397a52f94e216b1 100644 --- a/gso/products/product_blocks/site.py +++ b/gso/products/product_blocks/site.py @@ -1,18 +1,11 @@ """The product block that describes a site subscription.""" -import re -from typing import Annotated - from orchestrator.domain.base import ProductBlockModel from orchestrator.types import SubscriptionLifecycle, strEnum -from pydantic import AfterValidator -from typing_extensions import Doc - -MAX_LONGITUDE = 180 -MIN_LONGITUDE = -180 -MAX_LATITUDE = 90 -MIN_LATITUDE = -90 +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.ip_address import IPAddress +from gso.utils.types.site_name import SiteName class SiteTier(strEnum): @@ -28,56 +21,6 @@ class SiteTier(strEnum): TIER4 = 4 -def validate_latitude(v: str) -> str: - """Validate a latitude coordinate.""" - msg = "Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'." - regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$") - if not regex.match(str(v)): - raise ValueError(msg) - - float_v = float(v) - if float_v > MAX_LATITUDE or float_v < MIN_LATITUDE: - raise ValueError(msg) - - return v - - -def validate_longitude(v: str) -> str: - """Validate a longitude coordinate.""" - regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$") - msg = "Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180', '0'." - if not regex.match(v): - raise ValueError(msg) - - float_v = float(v) - if float_v > MAX_LONGITUDE or float_v < MIN_LONGITUDE: - raise ValueError(msg) - - return v - - -LatitudeCoordinate = Annotated[ - str, - AfterValidator(validate_latitude), - Doc( - "A latitude coordinate, modeled as a string. " - "The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. " - "It can be a floating-point number or an integer. Valid examples: 40.7128, -74.0060, 90, -90, 0." - ), -] - -LongitudeCoordinate = Annotated[ - str, - AfterValidator(validate_longitude), - Doc( - "A longitude coordinate, modeled as a string. " - "The coordinate must match the format conforming to the longitude " - "range of -180 to +180 degrees. It can be a floating-point number or an integer. " - "Valid examples: 40.7128, -74.0060, 180, -180, 0." - ), -] - - class SiteBlockInactive( ProductBlockModel, lifecycle=[SubscriptionLifecycle.INITIAL], @@ -85,7 +28,7 @@ class SiteBlockInactive( ): """A site that's currently inactive, see :class:`SiteBlock`.""" - site_name: str | None = None + site_name: SiteName | None = None site_city: str | None = None site_country: str | None = None site_country_code: str | None = None @@ -94,13 +37,13 @@ class SiteBlockInactive( site_internal_id: int | None = None site_bgp_community_id: int | None = None site_tier: SiteTier | None = None - site_ts_address: str | None = None + site_ts_address: IPAddress | None = None class SiteBlockProvisioning(SiteBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]): """A site that's currently being provisioned, see :class:`SiteBlock`.""" - site_name: str + site_name: SiteName site_city: str site_country: str site_country_code: str @@ -109,7 +52,7 @@ class SiteBlockProvisioning(SiteBlockInactive, lifecycle=[SubscriptionLifecycle. site_internal_id: int site_bgp_community_id: int site_tier: SiteTier - site_ts_address: str + site_ts_address: IPAddress class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]): @@ -117,7 +60,7 @@ class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]) #: The name of the site, that will dictate part of the :term:`FQDN` of routers that are hosted at this site. For #: example: ``router.X.Y.geant.net``, where X denotes the name of the site. - site_name: str + site_name: SiteName #: The city at which the site is located. site_city: str #: The country in which the site is located. @@ -138,4 +81,4 @@ class SiteBlock(SiteBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]) #: The address of the terminal server that this router is connected to. The terminal server provides out of band #: access. This is required in case a link goes down, or when a router is initially added to the network and it #: does not have any IP trunks connected to it. - site_ts_address: str + site_ts_address: IPAddress diff --git a/gso/products/product_blocks/super_pop_switch.py b/gso/products/product_blocks/super_pop_switch.py index 3335b28cf90ee9d55abe59be528f404d44d905b8..872f37b59042b2534de234d26241cd2792c55c3e 100644 --- a/gso/products/product_blocks/super_pop_switch.py +++ b/gso/products/product_blocks/super_pop_switch.py @@ -8,7 +8,8 @@ from gso.products.product_blocks.site import ( SiteBlockInactive, SiteBlockProvisioning, ) -from gso.utils.shared_enums import IPv4AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, PortNumber class SuperPopSwitchBlockInactive( diff --git a/gso/products/product_blocks/switch.py b/gso/products/product_blocks/switch.py index f0aa0414e11409b58341fc2648f3066fa91c5aee..bdf2b4657f8d8674a5c4e19c192f455ef1e31524 100644 --- a/gso/products/product_blocks/switch.py +++ b/gso/products/product_blocks/switch.py @@ -9,7 +9,8 @@ from gso.products.product_blocks.site import ( SiteBlockInactive, SiteBlockProvisioning, ) -from gso.utils.shared_enums import PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import PortNumber class SwitchModel(strEnum): diff --git a/gso/schedules/task_vacuum.py b/gso/schedules/task_vacuum.py index de4d44842f485cc8df41f9c01eed3ead2d651a30..3ad873fde290e4b4cbfc30a3357e5d0c05f407c1 100644 --- a/gso/schedules/task_vacuum.py +++ b/gso/schedules/task_vacuum.py @@ -7,7 +7,7 @@ from gso.worker import celery @celery.task -@scheduler(CronScheduleConfig(name="Clean up tasks", hour="1")) +@scheduler(CronScheduleConfig(name="Clean up tasks", hour="1", minute="0")) def vacuum_tasks() -> None: """Run all cleanup tasks every 1 AM UTC.""" start_process("task_clean_up_tasks") diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index 22e56ab5a7215ed8026d1a3a80e7d72ee1ba916e..8e06be865a43b36474649a1cda3fc697a330e1f7 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -10,7 +10,7 @@ from infoblox_client.exceptions import ( ) from gso.settings import IPAMParams, load_oss_params -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType logger = getLogger(__name__) NULL_MAC = "00:00:00:00:00:00" diff --git a/gso/services/librenms_client.py b/gso/services/librenms_client.py index 2e04a866e3f85b539f22031b3f878b2149823d0f..b7c21f888ebe281ada67e64edb3e9fd671a0a655 100644 --- a/gso/services/librenms_client.py +++ b/gso/services/librenms_client.py @@ -10,7 +10,7 @@ from requests import HTTPError, Response from requests.adapters import HTTPAdapter from gso.settings import load_oss_params -from gso.utils.helpers import SNMPVersion +from gso.utils.types.snmp import SNMPVersion logger = logging.getLogger(__name__) @@ -40,7 +40,7 @@ class LibreNMSClient: ) -> Response: url = self.base_url + endpoint logger.debug("LibreNMS - Sending request", extra={"method": method, "endpoint": url, "form_data": data}) - result = self.session.request(method, url, json=data, timeout=(0.5, 75)) + result = self.session.request(method, url, json=data, timeout=(10, 75)) logger.debug("LibreNMS - Received response", extra=result.__dict__) return result diff --git a/gso/services/partners.py b/gso/services/partners.py index 63d55bb3f3d4044f60ce90f4f6cbd839e1efa069..c8cfeb084b4daa4136d1326ffd90f68de2399f59 100644 --- a/gso/services/partners.py +++ b/gso/services/partners.py @@ -1,29 +1,60 @@ """A module that returns the partners available in :term:`GSO`.""" from datetime import datetime -from typing import Any +from typing import Annotated, Any from uuid import uuid4 from orchestrator.db import db -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import AfterValidator, BaseModel, ConfigDict, EmailStr, Field from sqlalchemy import func from sqlalchemy.exc import NoResultFound from gso.db.models import PartnerTable +def validate_partner_name_unique(name: str) -> str: + """Validate that the name of a partner is unique.""" + if filter_partners_by_name(name=name, case_sensitive=False): + msg = "Partner with this name already exists." + raise ValueError(msg) + return name + + +def validate_partner_email_unique(email: EmailStr) -> EmailStr: + """Validate that the e-mail address of a partner is unique.""" + email = email.lower() + if filter_partners_by_email(email=email, case_sensitive=False): + msg = "Partner with this email already exists." + raise ValueError(msg) + return email + + +PartnerName = Annotated[str, AfterValidator(validate_partner_name_unique)] +PartnerEmail = Annotated[EmailStr, AfterValidator(validate_partner_email_unique)] + + class PartnerSchema(BaseModel): """Partner schema.""" partner_id: str = Field(default_factory=lambda: str(uuid4())) - name: str - email: EmailStr + name: PartnerName + email: PartnerEmail created_at: datetime = Field(default_factory=lambda: datetime.now().astimezone()) updated_at: datetime = Field(default_factory=lambda: datetime.now().astimezone()) model_config = ConfigDict(from_attributes=True) +class ModifiedPartnerSchema(PartnerSchema): + """Partner schema when making a modification. + + The name and email can be empty in this case, if they don't need changing. + """ + + name: PartnerName | None = None # type: ignore[assignment] + email: PartnerEmail | None = None # type: ignore[assignment] + + class PartnerNotFoundError(Exception): """Exception raised when a partner is not found.""" @@ -95,7 +126,7 @@ def create_partner( def edit_partner( - partner_data: PartnerSchema, + partner_data: ModifiedPartnerSchema, ) -> PartnerTable: """Edit an existing partner and update it in the database.""" partner = get_partner_by_id(partner_id=partner_data.partner_id) diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py index 25ed84e0593b2963656167737f17b4697ef2b0df..8a8bcf98eeaf7e490e294298c3e6b4af9a070c68 100644 --- a/gso/services/subscriptions.py +++ b/gso/services/subscriptions.py @@ -69,20 +69,6 @@ def get_subscriptions( return [dict(zip(includes, result, strict=True)) for result in results] -def get_active_site_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: - """Retrieve active subscriptions specifically for sites. - - :param includes: The fields to be included in the returned Subscription objects. - :type includes: list[str] - - :return: A list of Subscription objects for sites. - :rtype: list[Subscription] - """ - return get_subscriptions( - product_types=[ProductType.SITE], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes - ) - - def get_router_subscriptions( includes: list[str] | None = None, lifecycles: list[SubscriptionLifecycle] | None = None ) -> list[SubscriptionType]: @@ -122,6 +108,20 @@ def get_provisioning_router_subscriptions(includes: list[str] | None = None) -> ) +def get_active_switch_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: + """Retrieve active subscriptions specifically for switches. + + :param includes: The fields to be included in the returned Subscription objects. + :type includes: list[str] + + :return: A list of Subscription objects for switches. + :rtype: list[Subscription] + """ + return get_subscriptions( + product_types=[ProductType.SWITCH], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes + ) + + def get_active_iptrunk_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: """Retrieve active subscriptions specifically for IP trunks. @@ -233,6 +233,20 @@ def get_active_insync_subscriptions() -> list[SubscriptionTable]: ) +def get_active_site_subscriptions(includes: list[str] | None = None) -> list[SubscriptionType]: + """Retrieve active subscriptions specifically for sites. + + :param includes: The fields to be included in the returned Subscription objects. + :type includes: list[str] + + :return: A list of Subscription objects for sites. + :rtype: list[Subscription] + """ + return get_subscriptions( + product_types=[ProductType.SITE], lifecycles=[SubscriptionLifecycle.ACTIVE], includes=includes + ) + + def get_site_by_name(site_name: str) -> Site: """Get a site by its name. diff --git a/gso/settings.py b/gso/settings.py index d2a845f3e96b526675d8d0de57972a15218a43ce..f7d40de73c73cdb75ee7c6348210fb12c543100e 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -16,7 +16,7 @@ from pydantic_forms.types import UUIDstr, strEnum from pydantic_settings import BaseSettings from typing_extensions import Doc -from gso.utils.shared_enums import PortNumber +from gso.utils.types.ip_address import PortNumber logger = logging.getLogger(__name__) diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json index 108a28522a74747461352dbd5133ba5ccf63f3ec..f1ede053f813aefef202d92ffd31b6d48c3908a5 100644 --- a/gso/translations/en-GB.json +++ b/gso/translations/en-GB.json @@ -32,16 +32,18 @@ "migrate_to_different_site": "Migrating to a different Site", "remove_configuration": "Remove configuration from the router", "clean_up_ipam": "Clean up related entries in IPAM", - "restore_isis_metric": "Restore ISIS metric to original value" + "restore_isis_metric": "Restore ISIS metric to original value", + "confirm_info": "Please verify this form looks correct." } }, "workflow": { "activate_iptrunk": "Activate IP Trunk", - "activate_router": "Activate router", - "confirm_info": "Please verify this form looks correct.", + "activate_router": "Activate Router", + "activate_switch": "Activate Switch", "create_iptrunk": "Create IP Trunk", "create_router": "Create Router", "create_site": "Create Site", + "create_switch": "Create Switch", "deploy_twamp": "Deploy TWAMP", "migrate_iptrunk": "Migrate IP Trunk", "modify_isis_metric": "Modify the ISIS metric", @@ -52,6 +54,7 @@ "terminate_iptrunk": "Terminate IP Trunk", "terminate_router": "Terminate Router", "terminate_site": "Terminate Site", + "terminate_switch": "Terminate Switch", "redeploy_base_config": "Redeploy base config", "update_ibgp_mesh": "Update iBGP mesh", "create_imported_site": "NOT FOR HUMANS -- Import existing site", @@ -67,7 +70,8 @@ "import_super_pop_switch": "NOT FOR HUMANS -- Finalize import into a Super PoP switch", "import_opengear": "NOT FOR HUMANS -- Finalize import into an OpenGear", "validate_iptrunk": "Validate IP Trunk configuration", - "validate_router": "Validate router configuration", + "validate_router": "Validate Router configuration", + "validate_switch": "Validate Switch configuration", "task_validate_geant_products": "Validation task for GEANT products", "task_send_email_notifications": "Send email notifications for failed tasks", "task_create_partners": "Create partner task", diff --git a/gso/utils/device_info.py b/gso/utils/device_info.py index cfc81d06562775bf7709a6d7fbc68e4f884618c4..800d56779ae607a16a50a430a88a024f9cbdeb66 100644 --- a/gso/utils/device_info.py +++ b/gso/utils/device_info.py @@ -32,6 +32,13 @@ class TierInfo: breakout_interfaces_per_slot=[36, 35, 34, 33], total_10g_interfaces=60, ) + self.tier3 = ModuleInfo( + device_type="7750 SR2-se", + module_bays_slots=[1, 2], + module_type="XCMC-2SE-2", + breakout_interfaces_per_slot=[36, 35, 34, 33], + total_10g_interfaces=76, + ) def get_module_by_name(self, name: str) -> ModuleInfo: """Retrieve a module by name.""" diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index 13016c76f13028db17e8397ebff7ce23455a997a..4a78b989d41ddd49e2ee99b5f703a9dd519a7a07 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -1,42 +1,22 @@ """Helper methods that are used across :term:`GSO`.""" -import ipaddress import re -from enum import StrEnum +from typing import TYPE_CHECKING from uuid import UUID -import pycountry -from orchestrator.types import UUIDstr -from pydantic import BaseModel, field_validator -from pydantic_core.core_schema import ValidationInfo from pydantic_forms.validators import Choice from gso import settings -from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, PhysicalPortCapacity from gso.products.product_blocks.router import RouterRole -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate, SiteTier from gso.products.product_types.router import Router +from gso.services import subscriptions from gso.services.netbox_client import NetboxClient -from gso.services.subscriptions import get_active_router_subscriptions, get_active_subscriptions_by_field_and_value -from gso.utils.shared_enums import IPv4AddressType, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import PhysicalPortCapacity +from gso.utils.types.ip_address import IPv4AddressType - -class LAGMember(BaseModel): - """A :term:`LAG` member interface that consists of a name and description.""" - - interface_name: str - interface_description: str | None = None - - def __hash__(self) -> int: - """Calculate the hash based on the interface name and description, so that uniqueness can be determined.""" - return hash((self.interface_name, self.interface_description)) - - -class SNMPVersion(StrEnum): - """An enumerator for the two relevant versions of :term:`SNMP`: v2c and 3.""" - - V2C = "v2c" - V3 = "v3" +if TYPE_CHECKING: + from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: @@ -57,7 +37,7 @@ def available_interfaces_choices(router_id: UUID, speed: str) -> Choice | None: def available_interfaces_choices_including_current_members( router_id: UUID, speed: str, - interfaces: list[IptrunkInterfaceBlock], + interfaces: list["IptrunkInterfaceBlock"], ) -> Choice | None: """Return a list of available interfaces for a given router and speed including the current members. @@ -119,184 +99,6 @@ def iso_from_ipv4(ipv4_address: IPv4AddressType) -> str: return f"49.51e5.0001.{re_split}.00" -def validate_router_in_netbox(subscription_id: UUIDstr) -> UUIDstr: - """Verify if a device exists in Netbox. - - Raises a :class:`ValueError` if the device is not found. - - :param subscription_id: The :term:`UUID` of the router subscription. - :type subscription_id: :class:`UUIDstr` - - :return: The :term:`UUID` of the router subscription. - :rtype: :class:`UUIDstr` - """ - router_type = Router.from_subscription(subscription_id) - if router_type.router.vendor == Vendor.NOKIA: - device = NetboxClient().get_device_by_name(router_type.router.router_fqdn) - if not device: - msg = "The selected router does not exist in Netbox." - raise ValueError(msg) - return subscription_id - - -def validate_iptrunk_unique_interface(interfaces: list[LAGMember]) -> list[LAGMember]: - """Verify if the interfaces are unique. - - Raises a :class:`ValueError` if the interfaces are not unique. - - :param interfaces: The list of interfaces. - :type interfaces: list[:class:`LAGMember`] - - :return: The list of interfaces - :rtype: list[:class:`LAGMember`] - """ - interface_names = [member.interface_name for member in interfaces] - if len(interface_names) != len(set(interface_names)): - msg = "Interfaces must be unique." - raise ValueError(msg) - return interfaces - - -def validate_site_fields_is_unique(field_name: str, value: str | int) -> None: - """Validate that a site field is unique.""" - if len(get_active_subscriptions_by_field_and_value(field_name, str(value))) > 0: - msg = f"{field_name} must be unique" - raise ValueError(msg) - - -def validate_ipv4_or_ipv6(value: str) -> str: - """Validate that a value is a valid IPv4 or IPv6 address.""" - try: - ipaddress.ip_address(value) - except ValueError as e: - msg = "Enter a valid IPv4 or IPv6 address." - raise ValueError(msg) from e - else: - return value - - -def validate_country_code(country_code: str) -> str: - """Validate that a country code is valid.""" - # Check for the UK code before attempting to look it up since it's known as "GB" in the pycountry database. - if country_code != "UK": - try: - pycountry.countries.lookup(country_code) - except LookupError as e: - msg = "Invalid or non-existent country code, it must be in ISO 3166-1 alpha-2 format." - raise ValueError(msg) from e - return country_code - - -def validate_site_name(site_name: str) -> None: - """Validate the site name. - - The site name must consist of three uppercase letters, optionally followed by a single digit. - """ - pattern = re.compile(r"^[A-Z]{3}[0-9]?$") - if not pattern.match(site_name): - msg = ( - "Enter a valid site name. It must consist of three uppercase letters (A-Z), followed by an optional single " - f"digit (0-9). Received: {site_name}" - ) - raise ValueError(msg) - - -class BaseSiteValidatorModel(BaseModel): - """A base site validator model extended by create site and by import site.""" - - site_bgp_community_id: int - site_internal_id: int - site_tier: SiteTier - site_ts_address: str - site_country_code: str - site_name: str - site_city: str - site_country: str - site_latitude: LatitudeCoordinate - site_longitude: LongitudeCoordinate - partner: str - - @field_validator("site_ts_address") - def validate_ts_address(cls, site_ts_address: str) -> str: - """Validate that a terminal server address is valid.""" - validate_ipv4_or_ipv6(site_ts_address) - return site_ts_address - - @field_validator("site_country_code") - def country_code_must_exist(cls, country_code: str) -> str: - """Validate that the country code exists.""" - validate_country_code(country_code) - return country_code - - @field_validator("site_ts_address", "site_internal_id", "site_bgp_community_id", "site_name") - def field_must_be_unique(cls, value: str | int, info: ValidationInfo) -> str | int: - """Validate that a field is unique.""" - if not info.field_name: - msg = "Field name must be provided." - raise ValueError(msg) - - validate_site_fields_is_unique(info.field_name, value) - - return value - - @field_validator("site_name") - def site_name_must_be_valid(cls, site_name: str) -> str: - """Validate the site name. - - The site name must consist of three uppercase letters, followed by an optional single digit. - """ - validate_site_name(site_name) - return site_name - - -def validate_interface_name_list(interface_name_list: list, vendor: str) -> list: - """Validate that the provided interface name matches the expected pattern. - - The expected pattern for the interface name is one of 'ge', 'et', 'xe' followed by a dash '-', - then a number between 0 and 19, a forward slash '/', another number between 0 and 99, - another forward slash '/', and ends with a number between 0 and 99. - For example: 'xe-1/0/0'. - - :param list interface_name_list: List of interface names to validate. - :param str vendor: Router vendor to check interface names - - :return list: The list of interface names if all match was successful, otherwise it will throw a ValueError - exception. - """ - # For Nokia nothing to do - if vendor == Vendor.NOKIA: - return interface_name_list - pattern = re.compile(r"^(ge|et|xe)-1?[0-9]/[0-9]{1,2}/[0-9]{1,2}$") - for interface in interface_name_list: - if not bool(pattern.match(interface.interface_name)): - error_msg = ( - f"Invalid interface name. The interface name should be of format: xe-1/0/0. " - f"Got: [{interface.interface_name}]" - ) - raise ValueError(error_msg) - return interface_name_list - - -def validate_tt_number(tt_number: str) -> str: - """Validate a string to match a specific pattern. - - This method checks if the input string starts with 'TT#' and is followed by exactly 16 digits. - - :param str tt_number: The TT number as string to validate - - :return str: The TT number string if TT number match was successful, otherwise it will raise a ValueError. - """ - pattern = r"^TT#\d{16}$" - if not bool(re.match(pattern, tt_number)): - err_msg = ( - f"The given TT number: {tt_number} is not valid. " - f" A valid TT number starts with 'TT#' followed by 16 digits." - ) - raise ValueError(err_msg) - - return tt_number - - def generate_fqdn(hostname: str, site_name: str, country_code: str) -> str: """Generate an :term:`FQDN` from a hostname, site name, and a country code.""" oss = settings.load_oss_params() @@ -318,7 +120,9 @@ def generate_inventory_for_active_routers( :return: A dictionary representing the inventory of active routers. :rtype: dict[str, Any] """ - all_routers = [Router.from_subscription(r["subscription_id"]) for r in get_active_router_subscriptions()] + all_routers = [ + Router.from_subscription(r["subscription_id"]) for r in subscriptions.get_active_router_subscriptions() + ] exclude_routers = exclude_routers or [] return { @@ -351,3 +155,33 @@ def calculate_recommended_minimum_links(iptrunk_number_of_members: int, iptrunk_ if iptrunk_speed == PhysicalPortCapacity.FOUR_HUNDRED_GIGABIT_PER_SECOND: return iptrunk_number_of_members - 1 return iptrunk_number_of_members + + +def active_site_selector() -> Choice: + """Generate a dropdown selector for choosing an active site in an input form.""" + site_subscriptions = { + str(site["subscription_id"]): site["description"] + for site in subscriptions.get_active_site_subscriptions(includes=["subscription_id", "description"]) + } + + return Choice("Select a site", zip(site_subscriptions.keys(), site_subscriptions.items(), strict=True)) # type: ignore[arg-type] + + +def active_router_selector() -> Choice: + """Generate a dropdown selector for choosing an active Router in an input form.""" + router_subscriptions = { + str(router["subscription_id"]): router["description"] + for router in subscriptions.get_active_router_subscriptions(includes=["subscription_id", "description"]) + } + + return Choice("Select a router", zip(router_subscriptions.keys(), router_subscriptions.items(), strict=True)) # type: ignore[arg-type] + + +def active_switch_selector() -> Choice: + """Generate a dropdown selector for choosing an active Switch in an input form.""" + switch_subscriptions = { + str(switch["subscription_id"]): switch["description"] + for switch in subscriptions.get_active_switch_subscriptions(includes=["subscription_id", "description"]) + } + + return Choice("Select a switch", zip(switch_subscriptions.keys(), switch_subscriptions.items(), strict=True)) # type: ignore[arg-type] diff --git a/gso/utils/shared_enums.py b/gso/utils/shared_enums.py index 94538f78463821bf266bdd15d379ec61b0371d4b..929e5b0bc2c2553c90b36ce8f056c1f22fed74cc 100644 --- a/gso/utils/shared_enums.py +++ b/gso/utils/shared_enums.py @@ -1,16 +1,6 @@ """Shared choices for the different models.""" -import ipaddress -from typing import Annotated, Any - -from pydantic import Field, PlainSerializer from pydantic_forms.types import strEnum -from typing_extensions import Doc - - -def convert_to_str(value: Any) -> str: - """Convert the value to a string.""" - return str(value) class Vendor(strEnum): @@ -20,24 +10,6 @@ class Vendor(strEnum): NOKIA = "nokia" -PortNumber = Annotated[ - int, - Field( - gt=0, - le=49151, - ), - Doc( - "Constrained integer for valid port numbers. The range from 49152 to 65535 is marked as ephemeral, " - "and can therefore not be selected for permanent allocation." - ), -] - - -IPv4AddressType = Annotated[ipaddress.IPv4Address, PlainSerializer(convert_to_str, return_type=str, when_used="always")] - -IPv6AddressType = Annotated[ipaddress.IPv6Address, PlainSerializer(convert_to_str, return_type=str, when_used="always")] - - class ConnectionStrategy(strEnum): """An enumerator for the connection Strategies.""" diff --git a/gso/utils/types.py b/gso/utils/types.py deleted file mode 100644 index 3e1b4091b127d9a572c12c7fad462dc4887de9f7..0000000000000000000000000000000000000000 --- a/gso/utils/types.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Define custom types for use across the application.""" - -from typing import Annotated - -from pydantic import AfterValidator - -from gso.utils.helpers import validate_tt_number - -TTNumber = Annotated[str, AfterValidator(validate_tt_number)] diff --git a/gso/utils/types/__init__.py b/gso/utils/types/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c3d1994f0c0c918fc35c2aabd757c67488065aa9 --- /dev/null +++ b/gso/utils/types/__init__.py @@ -0,0 +1 @@ +"""Define custom types for use across the application.""" diff --git a/gso/utils/types/base_site.py b/gso/utils/types/base_site.py new file mode 100644 index 0000000000000000000000000000000000000000..c5af009ad460e56619752688f52dc2bdfa5f9e46 --- /dev/null +++ b/gso/utils/types/base_site.py @@ -0,0 +1,26 @@ +"""A base site type for validation purposes that can be extended elsewhere.""" + +from pydantic import BaseModel + +from gso.products.product_blocks.site import SiteTier +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.country_code import CountryCode +from gso.utils.types.ip_address import IPAddress +from gso.utils.types.site_name import SiteName +from gso.utils.types.unique_field import UniqueField + + +class BaseSiteValidatorModel(BaseModel): + """A base site validator model extended by create site and by import site.""" + + site_bgp_community_id: UniqueField[int] + site_internal_id: UniqueField[int] + site_tier: SiteTier + site_ts_address: UniqueField[IPAddress] + site_country_code: CountryCode + site_name: UniqueField[SiteName] + site_city: str + site_country: str + site_latitude: LatitudeCoordinate + site_longitude: LongitudeCoordinate + partner: str diff --git a/gso/utils/types/coordinates.py b/gso/utils/types/coordinates.py new file mode 100644 index 0000000000000000000000000000000000000000..91f1188fbe9196a564af4ab9f34a8d2aedeafb16 --- /dev/null +++ b/gso/utils/types/coordinates.py @@ -0,0 +1,61 @@ +"""Custom coordinate types for latitude and longitude.""" + +import re +from typing import Annotated + +from pydantic import AfterValidator +from typing_extensions import Doc + +MAX_LONGITUDE = 180 +MIN_LONGITUDE = -180 +MAX_LATITUDE = 90 +MIN_LATITUDE = -90 + + +def validate_latitude(v: str) -> str: + """Validate a latitude coordinate.""" + msg = "Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'." + regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$") + if not regex.match(str(v)): + raise ValueError(msg) + + float_v = float(v) + if float_v > MAX_LATITUDE or float_v < MIN_LATITUDE: + raise ValueError(msg) + + return v + + +def validate_longitude(v: str) -> str: + """Validate a longitude coordinate.""" + regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$") + msg = "Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180', '0'." + if not regex.match(v): + raise ValueError(msg) + + float_v = float(v) + if float_v > MAX_LONGITUDE or float_v < MIN_LONGITUDE: + raise ValueError(msg) + + return v + + +LatitudeCoordinate = Annotated[ + str, + AfterValidator(validate_latitude), + Doc( + "A latitude coordinate, modeled as a string. " + "The coordinate must match the format conforming to the latitude range of -90 to +90 degrees. " + "It can be a floating-point number or an integer. Valid examples: 40.7128, -74.0060, 90, -90, 0." + ), +] +LongitudeCoordinate = Annotated[ + str, + AfterValidator(validate_longitude), + Doc( + "A longitude coordinate, modeled as a string. " + "The coordinate must match the format conforming to the longitude " + "range of -180 to +180 degrees. It can be a floating-point number or an integer. " + "Valid examples: 40.7128, -74.0060, 180, -180, 0." + ), +] diff --git a/gso/utils/types/country_code.py b/gso/utils/types/country_code.py new file mode 100644 index 0000000000000000000000000000000000000000..065e6417a19320e75ef89b1002ce3e1f632f59f8 --- /dev/null +++ b/gso/utils/types/country_code.py @@ -0,0 +1,21 @@ +"""Country codes.""" + +from typing import Annotated + +import pycountry +from pydantic import AfterValidator + + +def validate_country_code(country_code: str) -> str: + """Validate that a country code is valid.""" + # Check for the UK code before attempting to look it up since it's known as "GB" in the pycountry database. + if country_code != "UK": + try: + pycountry.countries.lookup(country_code) + except LookupError as e: + msg = "Invalid or non-existent country code, it must be in ISO 3166-1 alpha-2 format." + raise ValueError(msg) from e + return country_code + + +CountryCode = Annotated[str, AfterValidator(validate_country_code)] diff --git a/gso/utils/types/interfaces.py b/gso/utils/types/interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..15c91167de822fee116600b71bf0b05dedfd87ae --- /dev/null +++ b/gso/utils/types/interfaces.py @@ -0,0 +1,102 @@ +"""Custom types for interfaces, both physical and logical.""" + +import re +from typing import Annotated + +from annotated_types import Len +from orchestrator.domain.base import T +from pydantic import AfterValidator, BaseModel +from pydantic_forms.types import strEnum +from pydantic_forms.validators import validate_unique_list +from typing_extensions import Doc + + +class LAGMember(BaseModel): + """A :term:`LAG` member interface that consists of a name and description.""" + + interface_name: str + interface_description: str | None = None + + def __hash__(self) -> int: + """Calculate the hash based on the interface name and description, so that uniqueness can be determined.""" + return hash(self.interface_name) + + +def validate_interface_names_are_unique(interfaces: list[LAGMember]) -> list[LAGMember]: + """Verify if interfaces are unique. + + Raises a :class:`ValueError` if the interfaces are not unique. + + :param interfaces: The list of interfaces. + :type interfaces: list[:class:`utils.types.LAGMember`] + + :return: The list of interfaces + :rtype: list[:class:`utils.types.LAGMember`] + """ + interface_names = [member.interface_name for member in interfaces] + if len(interface_names) != len(set(interface_names)): + msg = "Interfaces must be unique." + raise ValueError(msg) + return interfaces + + +def validate_juniper_phy_interface_name(interface_name: str) -> str: + """Validate that the provided interface name matches the expected pattern. + + The expected pattern for the interface name is one of 'ge', 'et', 'xe' followed by a dash '-', + then a number between 0 and 19, a forward slash '/', another number between 0 and 99, + another forward slash '/', and ends with a number between 0 and 99. + For example: 'xe-1/0/0'. This only applies to Juniper-brand hardware. + + :param str interface_name: Interface name to validate. + + :return str: The interface name if match was successful, otherwise it will throw a ValueError exception. + """ + pattern = re.compile(r"^(ge|et|xe)-1?[0-9]/[0-9]{1,2}/[0-9]{1,2}$") + if not bool(pattern.match(interface_name)): + error_msg = ( + f"Invalid interface name. The interface name should be of format: xe-1/0/0. " f"Got: {interface_name}" + ) + raise ValueError(error_msg) + return interface_name + + +def validate_juniper_ae_interface_name(interface_name: str) -> str: + """Validate that the provided interface name matches the expected pattern for a :term:`LAG` interface. + + Interface names must match the pattern 'ae' followed by a one- or two-digit number. + """ + juniper_lag_re = re.compile("^ae\\d{1,2}$") + if not juniper_lag_re.match(interface_name): + msg = "Invalid LAG name, please try again." + raise ValueError(msg) + return interface_name + + +JuniperPhyInterface = Annotated[str, AfterValidator(validate_juniper_phy_interface_name)] +JuniperAEInterface = Annotated[str, AfterValidator(validate_juniper_ae_interface_name)] +LAGMemberList = Annotated[ + list[T], + AfterValidator(validate_unique_list), + AfterValidator(validate_interface_names_are_unique), + Len(min_length=0), + Doc("A list of :term:`LAG` member interfaces."), +] + + +class JuniperLAGMember(LAGMember): + """A Juniper-specific :term:`LAG` member interface.""" + + interface_name: JuniperPhyInterface + + +class PhysicalPortCapacity(strEnum): + """Physical port capacity enumerator. + + An enumerator that has the different possible capacities of ports that are available to use in subscriptions. + """ + + ONE_GIGABIT_PER_SECOND = "1G" + TEN_GIGABIT_PER_SECOND = "10G" + HUNDRED_GIGABIT_PER_SECOND = "100G" + FOUR_HUNDRED_GIGABIT_PER_SECOND = "400G" diff --git a/gso/utils/types/ip_address.py b/gso/utils/types/ip_address.py new file mode 100644 index 0000000000000000000000000000000000000000..94cb8beaf498900e4785d3e222a73685f5d35bf6 --- /dev/null +++ b/gso/utils/types/ip_address.py @@ -0,0 +1,38 @@ +"""IP addresses.""" + +import ipaddress +from typing import Annotated, Any + +from pydantic import AfterValidator, Field, PlainSerializer +from typing_extensions import Doc + + +def validate_ipv4_or_ipv6(value: str) -> str: + """Validate that a value is a valid IPv4 or IPv6 address.""" + try: + ipaddress.ip_address(value) + except ValueError as e: + msg = "Enter a valid IPv4 or IPv6 address." + raise ValueError(msg) from e + else: + return value + + +def _str(value: Any) -> str: + return str(value) + + +IPv4AddressType = Annotated[ipaddress.IPv4Address, PlainSerializer(_str, return_type=str, when_used="always")] +IPv6AddressType = Annotated[ipaddress.IPv6Address, PlainSerializer(_str, return_type=str, when_used="always")] +IPAddress = Annotated[str, AfterValidator(validate_ipv4_or_ipv6)] +PortNumber = Annotated[ + int, + Field( + gt=0, + le=49151, + ), + Doc( + "Constrained integer for valid port numbers. The range from 49152 to 65535 is marked as ephemeral, " + "and can therefore not be selected for permanent allocation." + ), +] diff --git a/gso/utils/types/netbox_router.py b/gso/utils/types/netbox_router.py new file mode 100644 index 0000000000000000000000000000000000000000..2a302b5604363deb9062a9c6649ae2140a27a856 --- /dev/null +++ b/gso/utils/types/netbox_router.py @@ -0,0 +1,34 @@ +"""A router that must be present in Netbox.""" + +from typing import Annotated, TypeVar + +from pydantic import AfterValidator +from pydantic_forms.types import UUIDstr + +from gso.products.product_types.router import Router +from gso.services.netbox_client import NetboxClient +from gso.utils.shared_enums import Vendor + + +def validate_router_in_netbox(subscription_id: UUIDstr) -> UUIDstr: + """Verify if a device exists in Netbox. + + Raises a :class:`ValueError` if the device is not found. + + :param subscription_id: The :term:`UUID` of the router subscription. + :type subscription_id: :class:`UUIDstr` + + :return: The :term:`UUID` of the router subscription. + :rtype: :class:`UUIDstr` + """ + router_type = Router.from_subscription(subscription_id) + if router_type.router.vendor == Vendor.NOKIA: + device = NetboxClient().get_device_by_name(router_type.router.router_fqdn) + if not device: + msg = "The selected router does not exist in Netbox." + raise ValueError(msg) + return subscription_id + + +T = TypeVar("T") +NetboxEnabledRouter = Annotated[T, UUIDstr, AfterValidator(validate_router_in_netbox)] diff --git a/gso/utils/types/site_name.py b/gso/utils/types/site_name.py new file mode 100644 index 0000000000000000000000000000000000000000..903b3468abbe744280a77c27bb2cf7861e8a389e --- /dev/null +++ b/gso/utils/types/site_name.py @@ -0,0 +1,24 @@ +"""Type for the name of a site.""" + +import re +from typing import Annotated + +from pydantic import AfterValidator + + +def validate_site_name(site_name: str) -> str: + """Validate the site name. + + The site name must consist of three uppercase letters, optionally followed by a single digit. + """ + pattern = re.compile(r"^[A-Z]{3}[0-9]?$") + if not pattern.match(site_name): + msg = ( + "Enter a valid site name. It must consist of three uppercase letters (A-Z), followed by an optional single " + f"digit (0-9). Received: {site_name}" + ) + raise ValueError(msg) + return site_name + + +SiteName = Annotated[str, AfterValidator(validate_site_name)] diff --git a/gso/utils/types/snmp.py b/gso/utils/types/snmp.py new file mode 100644 index 0000000000000000000000000000000000000000..03581cf970c036db37ea901d8b159d25fc480136 --- /dev/null +++ b/gso/utils/types/snmp.py @@ -0,0 +1,10 @@ +"""An enumerator of SNMP version numbers.""" + +from enum import StrEnum + + +class SNMPVersion(StrEnum): + """An enumerator for the two relevant versions of :term:`SNMP`: v2c and 3.""" + + V2C = "v2c" + V3 = "v3" diff --git a/gso/utils/types/tt_number.py b/gso/utils/types/tt_number.py new file mode 100644 index 0000000000000000000000000000000000000000..a9e1d05c6ad9563047764d56461afe846b99be88 --- /dev/null +++ b/gso/utils/types/tt_number.py @@ -0,0 +1,29 @@ +"""A Trouble Ticket number.""" + +import re +from typing import Annotated + +from pydantic import AfterValidator + + +def validate_tt_number(tt_number: str) -> str: + """Validate a string to match a specific pattern. + + This method checks if the input string starts with 'TT#' and is followed by exactly 16 digits. + + :param str tt_number: The TT number as string to validate + + :return str: The TT number string if TT number match was successful, otherwise it will raise a ValueError. + """ + pattern = r"^TT#\d{16}$" + if not bool(re.match(pattern, tt_number)): + err_msg = ( + f"The given TT number: {tt_number} is not valid. " + f" A valid TT number starts with 'TT#' followed by 16 digits." + ) + raise ValueError(err_msg) + + return tt_number + + +TTNumber = Annotated[str, AfterValidator(validate_tt_number)] diff --git a/gso/utils/types/unique_field.py b/gso/utils/types/unique_field.py new file mode 100644 index 0000000000000000000000000000000000000000..32c3d9de43560998dea262b512625de60363de44 --- /dev/null +++ b/gso/utils/types/unique_field.py @@ -0,0 +1,21 @@ +"""An input field that must be unique in the database.""" + +from typing import Annotated, TypeVar + +from pydantic import AfterValidator +from pydantic_core.core_schema import ValidationInfo + +from gso.services import subscriptions + +T = TypeVar("T") + + +def validate_field_is_unique(value: T, info: ValidationInfo) -> T: + """Validate that a field is unique.""" + if len(subscriptions.get_active_subscriptions_by_field_and_value(str(info.field_name), str(value))) > 0: + msg = f"{info.field_name} must be unique" + raise ValueError(msg) + return value + + +UniqueField = Annotated[T, AfterValidator(validate_field_is_unique)] diff --git a/gso/workflows/iptrunk/create_imported_iptrunk.py b/gso/workflows/iptrunk/create_imported_iptrunk.py index 9b0e6b87a8a095073875721e79d72c9e03baa66d..4d75437504ac6536cf56c8b2f5231bea0a937db8 100644 --- a/gso/workflows/iptrunk/create_imported_iptrunk.py +++ b/gso/workflows/iptrunk/create_imported_iptrunk.py @@ -6,7 +6,6 @@ from uuid import uuid4 from orchestrator import workflow from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, begin, done, step @@ -15,30 +14,17 @@ from pydantic import AfterValidator, ConfigDict from pydantic_forms.validators import validate_unique_list from gso.products import ProductName -from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlockInactive, IptrunkType from gso.products.product_types.iptrunk import ImportedIptrunkInactive from gso.products.product_types.router import Router from gso.services import subscriptions from gso.services.partners import get_partner_by_name -from gso.utils.helpers import LAGMember - - -def _generate_routers() -> dict[str, str]: - """Generate a dictionary of router IDs and descriptions.""" - routers = {} - for subscription in subscriptions.get_active_router_subscriptions(includes=["subscription_id", "description"]): - routers[str(subscription["subscription_id"])] = subscription["description"] - - return routers - - -LAGMemberList = Annotated[list[LAGMember], AfterValidator(validate_unique_list)] +from gso.utils.helpers import active_router_selector +from gso.utils.types.interfaces import LAGMember, LAGMemberList, PhysicalPortCapacity def initial_input_form_generator() -> FormGenerator: """Take all information passed to this workflow by the :term:`API` endpoint that was called.""" - routers = _generate_routers() - router_enum = Choice("Select a router", zip(routers.keys(), routers.items(), strict=True)) # type: ignore[arg-type] class CreateIptrunkForm(FormPage): model_config = ConfigDict(title="Import Iptrunk") @@ -51,15 +37,15 @@ def initial_input_form_generator() -> FormGenerator: iptrunk_minimum_links: int iptrunk_isis_metric: int - side_a_node_id: router_enum # type: ignore[valid-type] + side_a_node_id: active_router_selector() # type: ignore[valid-type] side_a_ae_iface: str side_a_ae_geant_a_sid: str | None = None - side_a_ae_members: LAGMemberList + side_a_ae_members: Annotated[list[LAGMember], AfterValidator(validate_unique_list)] - side_b_node_id: router_enum # type: ignore[valid-type] + side_b_node_id: active_router_selector() # type: ignore[valid-type] side_b_ae_iface: str side_b_ae_geant_a_sid: str | None = None - side_b_ae_members: LAGMemberList + side_b_ae_members: Annotated[list[LAGMember], AfterValidator(validate_unique_list)] iptrunk_ipv4_network: ipaddress.IPv4Network iptrunk_ipv6_network: ipaddress.IPv6Network @@ -94,11 +80,11 @@ def initialize_subscription( side_a_node_id: str, side_a_ae_iface: str, side_a_ae_geant_a_sid: str | None, - side_a_ae_members: list[dict], + side_a_ae_members: LAGMemberList, side_b_node_id: str, side_b_ae_iface: str, side_b_ae_geant_a_sid: str | None, - side_b_ae_members: list[dict], + side_b_ae_members: LAGMemberList, ) -> State: """Take all input from the user, and store it in the database.""" subscription.iptrunk.geant_s_sid = geant_s_sid diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index 3cd8d81cd14b6932f7ceafac247e752ec54d625a..56907a6c22e52c5f7d82642563e78d0a62b09853 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -16,15 +16,14 @@ from orchestrator.workflow import StepList, begin, conditional, done, step, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form from ping3 import ping -from pydantic import AfterValidator, ConfigDict, field_validator -from pydantic_forms.validators import ReadOnlyField, validate_unique_list +from pydantic import ConfigDict +from pydantic_forms.validators import ReadOnlyField from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlockInactive, IptrunkSideBlockInactive, IptrunkType, - PhysicalPortCapacity, ) from gso.products.product_types.iptrunk import Iptrunk, IptrunkInactive, IptrunkProvisioning from gso.products.product_types.router import Router @@ -36,28 +35,25 @@ from gso.services.sharepoint import SharePointClient from gso.services.subscriptions import get_non_terminated_iptrunk_subscriptions from gso.settings import load_oss_params from gso.utils.helpers import ( - LAGMember, available_interfaces_choices, available_lags_choices, calculate_recommended_minimum_links, get_router_vendor, - validate_interface_name_list, - validate_iptrunk_unique_interface, - validate_router_in_netbox, ) from gso.utils.shared_enums import Vendor -from gso.utils.types import TTNumber +from gso.utils.types.interfaces import JuniperLAGMember, LAGMember, LAGMemberList, PhysicalPortCapacity +from gso.utils.types.netbox_router import NetboxEnabledRouter +from gso.utils.types.tt_number import TTNumber from gso.utils.workflow_steps import prompt_sharepoint_checklist_url def initial_input_form_generator(product_name: str) -> FormGenerator: """Gather input from the user in three steps. General information, and information on both sides of the trunk.""" - routers = {} - for router in subscriptions.get_active_router_subscriptions( + # Add both provisioning and active routers, since trunks are required for promoting a router to active. + active_and_provisioning_routers = subscriptions.get_active_router_subscriptions( includes=["subscription_id", "description"] - ) + subscriptions.get_provisioning_router_subscriptions(includes=["subscription_id", "description"]): - # Add both provisioning and active routers, since trunks are required for promoting a router to active. - routers[str(router["subscription_id"])] = router["description"] + ) + subscriptions.get_provisioning_router_subscriptions(includes=["subscription_id", "description"]) + routers = {str(router["subscription_id"]): router["description"] for router in active_and_provisioning_routers} class CreateIptrunkForm(FormPage): model_config = ConfigDict(title=product_name) @@ -86,19 +82,14 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: class SelectRouterSideA(FormPage): model_config = ConfigDict(title="Select a router for side A of the trunk.") - side_a_node_id: router_enum_a # type: ignore[valid-type] - - @field_validator("side_a_node_id") - def validate_device_exists_in_netbox(cls, side_a_node_id: UUIDstr) -> str | None: - return validate_router_in_netbox(side_a_node_id) + side_a_node_id: NetboxEnabledRouter[router_enum_a] # type: ignore[valid-type] user_input_router_side_a = yield SelectRouterSideA router_a = user_input_router_side_a.side_a_node_id.name router_a_fqdn = Router.from_subscription(router_a).router.router_fqdn juniper_ae_members = Annotated[ - list[LAGMember], - AfterValidator(validate_unique_list), + LAGMemberList[JuniperLAGMember], Len( min_length=initial_user_input.iptrunk_number_of_members, max_length=initial_user_input.iptrunk_number_of_members, @@ -114,8 +105,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: ) ae_members_side_a_type = Annotated[ - list[NokiaLAGMemberA], - AfterValidator(validate_unique_list), + LAGMemberList[NokiaLAGMemberA], Len( min_length=initial_user_input.iptrunk_number_of_members, max_length=initial_user_input.iptrunk_number_of_members, @@ -125,19 +115,12 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: ae_members_side_a_type = juniper_ae_members # type: ignore[assignment, misc] class CreateIptrunkSideAForm(FormPage): - model_config = ConfigDict(title=f"Provide subscription details for side A of the trunk.({router_a_fqdn})") + model_config = ConfigDict(title=f"Provide subscription details for side A of the trunk. ({router_a_fqdn})") side_a_ae_iface: available_lags_choices(router_a) or str # type: ignore[valid-type] side_a_ae_geant_a_sid: str | None side_a_ae_members: ae_members_side_a_type - @field_validator("side_a_ae_members") - def validate_side_a_ae_members(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: - validate_iptrunk_unique_interface(side_a_ae_members) - vendor = get_router_vendor(router_a) - validate_interface_name_list(side_a_ae_members, vendor) - return side_a_ae_members - user_input_side_a = yield CreateIptrunkSideAForm # Remove the selected router for side A, to prevent any loops routers.pop(str(router_a)) @@ -146,11 +129,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: class SelectRouterSideB(FormPage): model_config = ConfigDict(title="Select a router for side B of the trunk.") - side_b_node_id: router_enum_b # type: ignore[valid-type] - - @field_validator("side_b_node_id") - def validate_device_exists_in_netbox(cls, side_b_node_id: UUIDstr) -> str | None: - return validate_router_in_netbox(side_b_node_id) + side_b_node_id: NetboxEnabledRouter[router_enum_b] # type: ignore[valid-type] user_input_router_side_b = yield SelectRouterSideB router_b = user_input_router_side_b.side_b_node_id.name @@ -165,8 +144,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: ) ae_members_side_b = Annotated[ - list[NokiaLAGMemberB], - AfterValidator(validate_unique_list), + LAGMemberList[NokiaLAGMemberB], Len( min_length=len(user_input_side_a.side_a_ae_members), max_length=len(user_input_side_a.side_a_ae_members) ), @@ -175,19 +153,12 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: ae_members_side_b = juniper_ae_members # type: ignore[assignment, misc] class CreateIptrunkSideBForm(FormPage): - model_config = ConfigDict(title=f"Provide subscription details for side B of the trunk.({router_b_fqdn})") + model_config = ConfigDict(title=f"Provide subscription details for side B of the trunk. ({router_b_fqdn})") side_b_ae_iface: available_lags_choices(router_b) or str # type: ignore[valid-type] side_b_ae_geant_a_sid: str | None side_b_ae_members: ae_members_side_b - @field_validator("side_b_ae_members") - def validate_side_b_ae_members(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: - validate_iptrunk_unique_interface(side_b_ae_members) - vendor = get_router_vendor(router_b) - validate_interface_name_list(side_b_ae_members, vendor) - return side_b_ae_members - user_input_side_b = yield CreateIptrunkSideBForm return ( @@ -582,6 +553,7 @@ def create_new_sharepoint_checklist(subscription: IptrunkProvisioning, tt_number "Title": f"{subscription.description} - {subscription.iptrunk.geant_s_sid}", "TT_NUMBER": tt_number, "GAP_PROCESS_URL": f"{load_oss_params().GENERAL.public_hostname}/workflows/{process_id}", + "ACTIVITY_TYPE": "Creation", }, ) diff --git a/gso/workflows/iptrunk/deploy_twamp.py b/gso/workflows/iptrunk/deploy_twamp.py index a45b5eca61144577c5dbf58a251b0b1ca7c76f6d..11dce53ba260fd8c8dae08611ae66e4069619d49 100644 --- a/gso/workflows/iptrunk/deploy_twamp.py +++ b/gso/workflows/iptrunk/deploy_twamp.py @@ -13,7 +13,7 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from gso.products.product_types.iptrunk import Iptrunk from gso.services.lso_client import execute_playbook, lso_interaction -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber def _initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index be15848ae73f933fa262211e765b0901313dac66..c7021875a1d7f2e2fde5424b5493de028b209d58 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -6,7 +6,6 @@ configured to run from A to C. B is then no longer associated with this IP trunk import copy import json -import re from typing import Annotated from uuid import uuid4 @@ -21,8 +20,8 @@ from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, begin, conditional, done, inputstep from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import AfterValidator, ConfigDict, field_validator -from pydantic_forms.validators import ReadOnlyField, validate_unique_list +from pydantic import ConfigDict +from pydantic_forms.validators import ReadOnlyField from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock, IptrunkType @@ -31,17 +30,18 @@ from gso.products.product_types.router import Router from gso.services import infoblox from gso.services.lso_client import execute_playbook, lso_interaction from gso.services.netbox_client import NetboxClient +from gso.services.sharepoint import SharePointClient from gso.services.subscriptions import get_active_router_subscriptions +from gso.settings import load_oss_params from gso.utils.helpers import ( - LAGMember, available_interfaces_choices, available_lags_choices, get_router_vendor, - validate_interface_name_list, ) from gso.utils.shared_enums import Vendor -from gso.utils.types import TTNumber -from gso.utils.workflow_steps import set_isis_to_max +from gso.utils.types.interfaces import JuniperAEInterface, JuniperLAGMember, LAGMember, LAGMemberList +from gso.utils.types.tt_number import TTNumber +from gso.utils.workflow_steps import prompt_sharepoint_checklist_url, set_isis_to_max def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -101,7 +101,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: new_side_iptrunk_router_input = yield NewSideIPTrunkRouterForm new_router = new_side_iptrunk_router_input.new_node - side_a_ae_iface = available_lags_choices(new_router) or str + side_a_ae_iface = available_lags_choices(new_router) or JuniperAEInterface new_side_is_nokia = get_router_vendor(new_router) == Vendor.NOKIA if new_side_is_nokia: @@ -113,8 +113,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) ae_members = Annotated[ - list[NokiaLAGMember], - AfterValidator(validate_unique_list), + LAGMemberList[NokiaLAGMember], Len( min_length=len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members), max_length=len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members), @@ -122,8 +121,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ] else: ae_members = Annotated[ # type: ignore[assignment, misc] - list[LAGMember], - AfterValidator(validate_unique_list), + LAGMemberList[JuniperLAGMember], Len( min_length=len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members), max_length=len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members), @@ -148,23 +146,9 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: model_config = ConfigDict(title=form_title) new_lag_interface: side_a_ae_iface # type: ignore[valid-type] - existing_lag_interface: ReadOnlyField(existing_lag_ae_members, default_type=list[LAGMember]) # type: ignore[valid-type] + existing_lag_interface: ReadOnlyField(existing_lag_ae_members, default_type=LAGMemberList[LAGMember]) # type: ignore[valid-type] new_lag_member_interfaces: ae_members - @field_validator("new_lag_interface") - def lag_interface_proper_name(cls, new_lag_interface: str) -> str: - if get_router_vendor(new_router) == Vendor.JUNIPER: - juniper_lag_re = re.compile("^ae\\d{1,2}$") - if not juniper_lag_re.match(new_lag_interface): - msg = "Invalid LAG name, please try again." - raise ValueError(msg) - return new_lag_interface - - @field_validator("new_lag_member_interfaces") - def is_interface_names_valid_juniper(cls, new_lag_member_interfaces: list[LAGMember]) -> list[LAGMember]: - vendor = get_router_vendor(new_router) - return validate_interface_name_list(new_lag_member_interfaces, vendor) - new_side_input = yield NewSideIPTrunkForm return ( migrate_form_input.model_dump() @@ -807,6 +791,22 @@ def netbox_allocate_new_interfaces(subscription: Iptrunk, replace_index: int) -> return {"subscription": subscription} +@step("Create a new SharePoint checklist item") +def create_new_sharepoint_checklist(subscription: Iptrunk, tt_number: str, process_id: UUIDstr) -> State: + """Create a new checklist item in SharePoint for approving this migrated IPtrunk.""" + new_list_item_url = SharePointClient().add_list_item( + list_name="ip_trunk", + fields={ + "Title": f"{subscription.description} - {subscription.iptrunk.geant_s_sid}", + "TT_NUMBER": tt_number, + "GAP_PROCESS_URL": f"{load_oss_params().GENERAL.public_hostname}/workflows/{process_id}", + "ACTIVITY_TYPE": "Migration", + }, + ) + + return {"checklist_url": new_list_item_url} + + @workflow( "Migrate an IP Trunk", initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator), @@ -868,5 +868,7 @@ def migrate_iptrunk() -> StepList: >> old_side_is_nokia(netbox_remove_old_interfaces) >> new_side_is_nokia(netbox_allocate_new_interfaces) >> resync + >> create_new_sharepoint_checklist + >> prompt_sharepoint_checklist_url >> done ) diff --git a/gso/workflows/iptrunk/modify_isis_metric.py b/gso/workflows/iptrunk/modify_isis_metric.py index 285907b45508249794bb8c5fd486b62ed0b4dac6..da14d078cea88f0c52256e02ef3dce0429cf7374 100644 --- a/gso/workflows/iptrunk/modify_isis_metric.py +++ b/gso/workflows/iptrunk/modify_isis_metric.py @@ -12,7 +12,7 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from gso.products.product_types.iptrunk import Iptrunk from gso.services.lso_client import execute_playbook, lso_interaction -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index 394e369a88d1f64481750080410ffcb8f4335064..6d82090c56d539c3456344ec6c08b1580d33dcf4 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -1,7 +1,7 @@ """A modification workflow that updates the :term:`LAG` interfaces that are part of an existing IP trunk.""" import json -from typing import Annotated, TypeVar +from typing import Annotated from uuid import UUID, uuid4 from annotated_types import Len @@ -12,38 +12,34 @@ from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, begin, conditional, done, step, workflow from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import AfterValidator, ConfigDict, field_validator -from pydantic_forms.validators import Label, ReadOnlyField, validate_unique_list +from pydantic import ConfigDict +from pydantic_forms.validators import Label, ReadOnlyField from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlock, IptrunkSideBlock, IptrunkType, - PhysicalPortCapacity, ) from gso.products.product_types.iptrunk import Iptrunk from gso.services.lso_client import execute_playbook, lso_interaction from gso.services.netbox_client import NetboxClient from gso.utils.helpers import ( - LAGMember, available_interfaces_choices, available_interfaces_choices_including_current_members, calculate_recommended_minimum_links, get_router_vendor, - validate_interface_name_list, - validate_iptrunk_unique_interface, ) -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, Vendor -from gso.utils.types import TTNumber +from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import JuniperLAGMember, LAGMember, LAGMemberList, PhysicalPortCapacity +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType +from gso.utils.types.tt_number import TTNumber from gso.workflows.iptrunk.migrate_iptrunk import check_ip_trunk_optical_levels_pre from gso.workflows.iptrunk.validate_iptrunk import check_ip_trunk_isis -T = TypeVar("T", bound=LAGMember) - def initialize_ae_members( subscription: Iptrunk, initial_user_input: dict, side_index: int -) -> Annotated[list[LAGMember], ""]: +) -> type[LAGMemberList[LAGMember]]: """Initialize the list of AE members.""" router = subscription.iptrunk.iptrunk_sides[side_index].iptrunk_side_node router_vendor = get_router_vendor(router.owner_subscription_id) @@ -67,17 +63,15 @@ def initialize_ae_members( ) ) - return Annotated[ - list[NokiaLAGMember], - AfterValidator(validate_unique_list), + return Annotated[ # type: ignore[return-value] + LAGMemberList[NokiaLAGMember], Len(min_length=iptrunk_number_of_members, max_length=iptrunk_number_of_members), - ] # type: ignore[return-value] + ] - return Annotated[ - list[LAGMember], - AfterValidator(validate_unique_list), + return Annotated[ # type: ignore[return-value] + LAGMemberList[JuniperLAGMember], Len(min_length=iptrunk_number_of_members, max_length=iptrunk_number_of_members), - ] # type: ignore[return-value] + ] def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -132,15 +126,6 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: else [] ) - @field_validator("side_a_ae_members") - def validate_iptrunk_unique_interface_side_a(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: - return validate_iptrunk_unique_interface(side_a_ae_members) - - @field_validator("side_a_ae_members") - def validate_interface_name_members(cls, side_a_ae_members: list[LAGMember]) -> list[LAGMember]: - vendor = subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.vendor - return validate_interface_name_list(side_a_ae_members, vendor) - user_input_side_a = yield ModifyIptrunkSideAForm ae_members_side_b = initialize_ae_members(subscription, initial_user_input.model_dump(), 1) @@ -158,15 +143,6 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: else [] ) - @field_validator("side_b_ae_members") - def validate_iptrunk_unique_interface_side_b(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: - return validate_iptrunk_unique_interface(side_b_ae_members) - - @field_validator("side_b_ae_members") - def validate_interface_name_members(cls, side_b_ae_members: list[LAGMember]) -> list[LAGMember]: - vendor = subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.vendor - return validate_interface_name_list(side_b_ae_members, vendor) - user_input_side_b = yield ModifyIptrunkSideBForm return ( @@ -179,7 +155,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @step("Determine whether we should be running interface checks") def determine_change_in_capacity( - subscription: Iptrunk, iptrunk_speed: str, side_a_ae_members: list[LAGMember], side_b_ae_members: list[LAGMember] + subscription: Iptrunk, iptrunk_speed: str, side_a_ae_members: list[dict], side_b_ae_members: list[dict] ) -> State: """Determine whether we should run pre- and post-checks on the IP trunk. @@ -193,13 +169,13 @@ def determine_change_in_capacity( iptrunk_speed != subscription.iptrunk.iptrunk_speed or len(side_a_ae_members) != len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) or any( - old_interface.interface_name != new_interface.interface_name + old_interface.interface_name != new_interface["interface_name"] for old_interface, new_interface in zip( subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members, side_a_ae_members, strict=False ) ) or any( - old_interface.interface_name != new_interface.interface_name + old_interface.interface_name != new_interface["interface_name"] for old_interface, new_interface in zip( subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members, side_b_ae_members, strict=False ) diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index bb1a6fd90b3d9a5e3b9aa9bb633db58cc2eb1cd4..ac628d393a9c6dd52880ee28ceb39d403f023fd2 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -24,7 +24,7 @@ from gso.services.lso_client import execute_playbook, lso_interaction from gso.services.netbox_client import NetboxClient from gso.utils.helpers import get_router_vendor from gso.utils.shared_enums import Vendor -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber from gso.utils.workflow_steps import set_isis_to_max diff --git a/gso/workflows/lan_switch_interconnect/__init__.py b/gso/workflows/lan_switch_interconnect/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..30d685bf043395ff4a861ee3d9b50efffdf511d5 --- /dev/null +++ b/gso/workflows/lan_switch_interconnect/__init__.py @@ -0,0 +1 @@ +"""Workflows for the LAN Switch interconnect product.""" diff --git a/gso/workflows/lan_switch_interconnect/create_lan_switch_interconnect.py b/gso/workflows/lan_switch_interconnect/create_lan_switch_interconnect.py new file mode 100644 index 0000000000000000000000000000000000000000..973cf1d88495b4343e390c258d16c3c9f78f65c9 --- /dev/null +++ b/gso/workflows/lan_switch_interconnect/create_lan_switch_interconnect.py @@ -0,0 +1,162 @@ +"""A creation workflow for creating a new interconnect between a switch and a router.""" + +from typing import Annotated +from uuid import uuid4 + +from annotated_types import Len +from orchestrator.forms import FormPage +from orchestrator.targets import Target +from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr +from orchestrator.workflow import StepList, begin, done, 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 AfterValidator, ConfigDict +from pydantic_forms.validators import Divider, ReadOnlyField + +from gso.products.product_blocks.lan_switch_interconnect import ( + LanSwitchInterconnectAddressSpace, + LanSwitchInterconnectInterfaceBlockInactive, +) +from gso.products.product_types.lan_switch_interconnect import LanSwitchInterconnectInactive +from gso.products.product_types.router import Router +from gso.products.product_types.switch import Switch +from gso.services.partners import get_partner_by_name +from gso.utils.helpers import ( + active_router_selector, + active_switch_selector, + available_interfaces_choices, + available_lags_choices, +) +from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import ( + JuniperAEInterface, + JuniperLAGMember, + JuniperPhyInterface, + LAGMember, + PhysicalPortCapacity, + validate_interface_names_are_unique, +) +from gso.utils.types.tt_number import TTNumber + + +def _initial_input_form(product_name: str) -> FormGenerator: + class CreateLANSwitchInterconnectForm(FormPage): + model_config = ConfigDict(title=product_name) + + tt_number: TTNumber + router_side: active_router_selector() # type: ignore[valid-type] + switch_side: active_switch_selector() # type: ignore[valid-type] + address_space: LanSwitchInterconnectAddressSpace + description: str + minimum_link_count: int + divider: Divider + vlan_id: ReadOnlyField(111, default_type=int) # type: ignore[valid-type] + + user_input = yield CreateLANSwitchInterconnectForm + router = Router.from_subscription(user_input.router_side) + + if router.router.vendor == Vendor.NOKIA: + + class NokiaLAGMemberA(LAGMember): + interface_name: available_interfaces_choices( # type: ignore[valid-type] + router.subscription_id, + PhysicalPortCapacity.TEN_GIGABIT_PER_SECOND, + ) + + router_side_ae_member_list = Annotated[ + list[NokiaLAGMemberA], + AfterValidator(validate_interface_names_are_unique), + Len(min_length=user_input.minimum_link_count), + ] + else: + router_side_ae_member_list = Annotated[ # type: ignore[assignment, misc] + list[JuniperLAGMember], + AfterValidator(validate_interface_names_are_unique), + Len(min_length=user_input.minimum_link_count), + ] + + class InterconnectRouterSideForm(FormPage): + model_config = ConfigDict(title="Please enter interface names and descriptions for the router side.") + + router_side_iface: available_lags_choices(user_input.router_side) or JuniperAEInterface # type: ignore[valid-type] + router_side_ae_members: router_side_ae_member_list + + router_side_input = yield InterconnectRouterSideForm + + switch_side_ae_member_list = Annotated[ + list[JuniperLAGMember], + AfterValidator(validate_interface_names_are_unique), + Len( + min_length=len(router_side_input.router_side_ae_members), + max_length=len(router_side_input.router_side_ae_members), + ), + ] + + class InterconnectSwitchSideForm(FormPage): + model_config = ConfigDict(title="Please enter interface names and descriptions for the switch side.") + + switch_side_iface: JuniperPhyInterface + switch_side_ae_members: switch_side_ae_member_list + + switch_side_input = yield InterconnectSwitchSideForm + + return user_input.model_dump() | router_side_input.model_dump() | switch_side_input.model_dump() + + +@step("Create subscription") +def create_subscription(product: UUIDstr, partner: str) -> State: + """Create a new subscription object in the database.""" + subscription = LanSwitchInterconnectInactive.from_product_id(product, get_partner_by_name(partner)["partner_id"]) + + return {"subscription": subscription} + + +@step("Initialize subscription") +def initialize_subscription( + subscription: LanSwitchInterconnectInactive, + description: str, + address_space: LanSwitchInterconnectAddressSpace, + minimum_link_count: int, + router_side: UUIDstr, + router_side_iface: JuniperPhyInterface, + router_side_ae_members: list[dict], + switch_side: UUIDstr, + switch_side_iface: JuniperPhyInterface, + switch_side_ae_members: list[dict], +) -> State: + """Update the product model with all input from the operator.""" + subscription.lan_switch_interconnect.lan_switch_interconnect_description = description + subscription.lan_switch_interconnect.address_space = address_space + subscription.lan_switch_interconnect.minimum_links = minimum_link_count + subscription.lan_switch_interconnect.router_side.node = Router.from_subscription(router_side).router + subscription.lan_switch_interconnect.router_side.ae_iface = router_side_iface + for member in router_side_ae_members: + subscription.lan_switch_interconnect.router_side.ae_members.append( + LanSwitchInterconnectInterfaceBlockInactive.new(subscription_id=uuid4(), **member) + ) + subscription.lan_switch_interconnect.switch_side.node = Switch.from_subscription(switch_side).switch + subscription.lan_switch_interconnect.switch_side.ae_iface = switch_side_iface + for member in switch_side_ae_members: + subscription.lan_switch_interconnect.switch_side.ae_members.append( + LanSwitchInterconnectInterfaceBlockInactive.new(subscription_id=uuid4(), **member) + ) + + return {"subscription": subscription} + + +@workflow( + "Create LAN switch interconnect", + initial_input_form=wrap_create_initial_input_form(_initial_input_form), + target=Target.CREATE, +) +def create_lan_switch_interconnect() -> StepList: + """Create a new LAN interconnect between a Switch and a Router.""" + return ( + begin + >> create_subscription + >> store_process_subscription(Target.CREATE) + >> initialize_subscription + >> set_status(SubscriptionLifecycle.ACTIVE) + >> resync + >> done + ) diff --git a/gso/workflows/office_router/create_imported_office_router.py b/gso/workflows/office_router/create_imported_office_router.py index e82f69cbcb31ecc7eadfbd16c757232a3d47bb2a..8b8306343f6dcc29ae97ef7526c8d399e0b6e444 100644 --- a/gso/workflows/office_router/create_imported_office_router.py +++ b/gso/workflows/office_router/create_imported_office_router.py @@ -13,7 +13,8 @@ from gso.products.product_types.office_router import ImportedOfficeRouterInactiv from gso.services import subscriptions from gso.services.partners import get_partner_by_name from gso.services.subscriptions import get_site_by_name -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType, PortNumber @step("Create subscription") diff --git a/gso/workflows/opengear/create_imported_opengear.py b/gso/workflows/opengear/create_imported_opengear.py index e7699986018efc53ddd6254c2d827a47ee791cfd..ce4bd3cf039e7323ce861c11feea41f87e647947 100644 --- a/gso/workflows/opengear/create_imported_opengear.py +++ b/gso/workflows/opengear/create_imported_opengear.py @@ -12,7 +12,7 @@ from gso.products import ProductName from gso.products.product_types.opengear import ImportedOpengearInactive from gso.services.partners import get_partner_by_name from gso.services.subscriptions import get_product_id_by_name, get_site_by_name -from gso.utils.shared_enums import IPv4AddressType +from gso.utils.types.ip_address import IPv4AddressType @step("Create subscription") diff --git a/gso/workflows/router/create_imported_router.py b/gso/workflows/router/create_imported_router.py index c8ba442bc30bf8d26b563e83f35b7d4fb1573774..12a71c79b51e0166d976a175d9833870300af1f5 100644 --- a/gso/workflows/router/create_imported_router.py +++ b/gso/workflows/router/create_imported_router.py @@ -14,7 +14,8 @@ from gso.products.product_types.router import ImportedRouterInactive from gso.services.partners import get_partner_by_name from gso.services.subscriptions import get_product_id_by_name, get_site_by_name from gso.utils.helpers import generate_fqdn -from gso.utils.shared_enums import IPv4AddressType, IPv6AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, IPv6AddressType, PortNumber @step("Create subscription") diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 59dd6210578d9556fdf79ecacf7df0b5518fdbd5..04d38b750f5fa4ca87591f7affe04674208685bf 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -24,8 +24,9 @@ from gso.services.partners import get_partner_by_name from gso.services.sharepoint import SharePointClient from gso.settings import load_oss_params from gso.utils.helpers import generate_fqdn, iso_from_ipv4 -from gso.utils.shared_enums import PortNumber, Vendor -from gso.utils.types import TTNumber +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import PortNumber +from gso.utils.types.tt_number import TTNumber from gso.utils.workflow_steps import ( deploy_base_config_dry, deploy_base_config_real, diff --git a/gso/workflows/router/promote_p_to_pe.py b/gso/workflows/router/promote_p_to_pe.py index 74f478c075e615900ea5e3a8c0e996aca94b364c..0f0b31da1fcf7efe8049e9d24cd1e20726bff9c0 100644 --- a/gso/workflows/router/promote_p_to_pe.py +++ b/gso/workflows/router/promote_p_to_pe.py @@ -23,7 +23,7 @@ from gso.services.lso_client import lso_interaction from gso.services.subscriptions import get_all_active_sites from gso.utils.helpers import generate_inventory_for_active_routers from gso.utils.shared_enums import Vendor -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -440,7 +440,7 @@ def update_subscription_model(subscription: Router) -> State: return {"subscription": subscription} -@step("[DRY RUN] Add all P to PE") +@step("[DRY RUN] Add all P to this new PE") def add_all_p_to_pe_dry(subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr) -> None: """Perform a dry run of adding all P routers to the PE router.""" extra_vars = { @@ -448,7 +448,9 @@ def add_all_p_to_pe_dry(subscription: dict[str, Any], callback_route: str, tt_nu "subscription": subscription, "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Add all P-routers to this new PE", "verb": "add_all_p_to_pe", - "p_router_list": generate_inventory_for_active_routers(RouterRole.P)["all"]["hosts"], + "p_router_list": generate_inventory_for_active_routers( + RouterRole.P, exclude_routers=[subscription["router"]["router_fqdn"]] + )["all"]["hosts"], } lso_client.execute_playbook( @@ -459,7 +461,7 @@ def add_all_p_to_pe_dry(subscription: dict[str, Any], callback_route: str, tt_nu ) -@step("[FOR REAL] Add all P to PE") +@step("[FOR REAL] Add all P to this new PE") def add_all_p_to_pe_real( subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr ) -> None: @@ -469,7 +471,9 @@ def add_all_p_to_pe_real( "subscription": subscription, "commit_comment": f"GSO_PROCESS_ID: {process_id} - TT_NUMBER: {tt_number} - Add all P-routers to this new PE", "verb": "add_all_p_to_pe", - "p_router_list": generate_inventory_for_active_routers(RouterRole.P)["all"]["hosts"], + "p_router_list": generate_inventory_for_active_routers( + RouterRole.P, exclude_routers=[subscription["router"]["router_fqdn"]] + )["all"]["hosts"], } lso_client.execute_playbook( @@ -480,7 +484,7 @@ def add_all_p_to_pe_real( ) -@step("[DRY RUN] Add PE to all P") +@step("[DRY RUN] Add this new PE to all P") def add_pe_to_all_p_dry(subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr) -> None: """Perform a dry run of adding promoted router to all PE routers in iGEANT/iGEANT6.""" extra_vars = { @@ -494,12 +498,14 @@ def add_pe_to_all_p_dry(subscription: dict[str, Any], callback_route: str, tt_nu lso_client.execute_playbook( playbook_name="update_ibgp_mesh.yaml", callback_route=callback_route, - inventory=generate_inventory_for_active_routers(RouterRole.P), + inventory=generate_inventory_for_active_routers( + RouterRole.P, exclude_routers=[subscription["router"]["router_fqdn"]] + ), extra_vars=extra_vars, ) -@step("[FOR REAL] Add PE to all P") +@step("[FOR REAL] Add this new PE to all P") def add_pe_to_all_p_real( subscription: dict[str, Any], callback_route: str, tt_number: str, process_id: UUIDstr ) -> None: @@ -515,7 +521,9 @@ def add_pe_to_all_p_real( lso_client.execute_playbook( playbook_name="update_ibgp_mesh.yaml", callback_route=callback_route, - inventory=generate_inventory_for_active_routers(RouterRole.P), + inventory=generate_inventory_for_active_routers( + RouterRole.P, exclude_routers=[subscription["router"]["router_fqdn"]] + ), extra_vars=extra_vars, ) diff --git a/gso/workflows/router/redeploy_base_config.py b/gso/workflows/router/redeploy_base_config.py index b30d02f16b6bb197a20257f9016eabb6dad0d8fa..48348abb6757ff38b40f650c88cb920bc3fda95c 100644 --- a/gso/workflows/router/redeploy_base_config.py +++ b/gso/workflows/router/redeploy_base_config.py @@ -10,7 +10,7 @@ from orchestrator.workflows.utils import wrap_modify_initial_input_form from gso.products.product_types.router import Router from gso.services.lso_client import lso_interaction -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber from gso.utils.workflow_steps import deploy_base_config_dry, deploy_base_config_real diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py index 5b0c6ef546c9cbb08d93c5cd277f8e31e71f57c1..f7caa6c9936154f3afff8320f5942504f9477414 100644 --- a/gso/workflows/router/terminate_router.py +++ b/gso/workflows/router/terminate_router.py @@ -30,7 +30,7 @@ from gso.services.netbox_client import NetboxClient from gso.settings import load_oss_params from gso.utils.helpers import generate_inventory_for_active_routers from gso.utils.shared_enums import Vendor -from gso.utils.types import TTNumber +from gso.utils.types.tt_number import TTNumber logger = logging.getLogger(__name__) diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py index 8fbb2813c94cff8443647a033c146e93f34b7ca4..a506e625ae15014439c538070fb0cf074c605309 100644 --- a/gso/workflows/router/update_ibgp_mesh.py +++ b/gso/workflows/router/update_ibgp_mesh.py @@ -17,8 +17,9 @@ from gso.products.product_types.router import Router from gso.services import librenms_client, lso_client from gso.services.lso_client import lso_interaction from gso.services.subscriptions import get_trunks_that_terminate_on_router -from gso.utils.helpers import SNMPVersion, generate_inventory_for_active_routers -from gso.utils.types import TTNumber +from gso.utils.helpers import generate_inventory_for_active_routers +from gso.utils.types.snmp import SNMPVersion +from gso.utils.types.tt_number import TTNumber def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: diff --git a/gso/workflows/site/create_imported_site.py b/gso/workflows/site/create_imported_site.py index 1fd9d59d7aeddbb04faac6131a0cc68533a7b1d4..8a9b3d9eaf3160e47a810a56f5c95d1421f36130 100644 --- a/gso/workflows/site/create_imported_site.py +++ b/gso/workflows/site/create_imported_site.py @@ -10,11 +10,13 @@ from orchestrator.workflows.steps import resync, set_status, store_process_subsc from pydantic import ConfigDict from gso.products import ProductName -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate, SiteTier +from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import ImportedSiteInactive from gso.services import subscriptions from gso.services.partners import get_partner_by_name -from gso.utils.helpers import BaseSiteValidatorModel +from gso.utils.types.base_site import BaseSiteValidatorModel +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.ip_address import IPAddress @step("Create subscription") @@ -51,7 +53,7 @@ def initialize_subscription( site_longitude: LongitudeCoordinate, site_bgp_community_id: int, site_internal_id: int, - site_ts_address: str, + site_ts_address: IPAddress, site_tier: SiteTier, ) -> State: """Initialise the subscription object with all input.""" diff --git a/gso/workflows/site/create_site.py b/gso/workflows/site/create_site.py index d2e99d510e678a7f7e654de4cbee1a162868ed2f..eb5409d778c019e41ae82c85376ec48fcf4bdcc6 100644 --- a/gso/workflows/site/create_site.py +++ b/gso/workflows/site/create_site.py @@ -10,10 +10,11 @@ from pydantic import ConfigDict from pydantic_forms.validators import ReadOnlyField from gso.products.product_blocks import site as site_pb -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate from gso.products.product_types import site from gso.services.partners import get_partner_by_name -from gso.utils.helpers import BaseSiteValidatorModel +from gso.utils.types.base_site import BaseSiteValidatorModel +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.ip_address import IPAddress def initial_input_form_generator(product_name: str) -> FormGenerator: @@ -50,7 +51,7 @@ def initialize_subscription( site_longitude: LongitudeCoordinate, site_bgp_community_id: int, site_internal_id: int, - site_ts_address: str, + site_ts_address: IPAddress, site_tier: site_pb.SiteTier, ) -> State: """Initialise the subscription object with all user input.""" diff --git a/gso/workflows/site/modify_site.py b/gso/workflows/site/modify_site.py index 0fb2b50d3b54432138b2c15addfffddc39de4e66..9c94e55032ae9712856603463b71299cf8e37fe7 100644 --- a/gso/workflows/site/modify_site.py +++ b/gso/workflows/site/modify_site.py @@ -1,5 +1,7 @@ """A modification workflow for a site.""" +from typing import Annotated + from orchestrator.forms import FormPage from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr @@ -11,13 +13,14 @@ from orchestrator.workflows.steps import ( unsync, ) from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import ConfigDict, field_validator -from pydantic_core.core_schema import ValidationInfo +from pydantic import ConfigDict from pydantic_forms.validators import ReadOnlyField -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate, SiteTier +from gso.products.product_blocks.site import SiteTier from gso.products.product_types.site import Site -from gso.utils.helpers import validate_ipv4_or_ipv6, validate_site_fields_is_unique +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.ip_address import IPAddress +from gso.utils.types.unique_field import UniqueField def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: @@ -33,30 +36,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: site_country_code: ReadOnlyField(subscription.site.site_country_code, default_type=str) # type: ignore[valid-type] site_latitude: LatitudeCoordinate = subscription.site.site_latitude site_longitude: LongitudeCoordinate = subscription.site.site_longitude - site_bgp_community_id: int = subscription.site.site_bgp_community_id - site_internal_id: int = subscription.site.site_internal_id + site_bgp_community_id: UniqueField[int] = subscription.site.site_bgp_community_id + site_internal_id: UniqueField[int] = subscription.site.site_internal_id site_tier: ReadOnlyField(subscription.site.site_tier, default_type=SiteTier) # type: ignore[valid-type] - site_ts_address: str | None = subscription.site.site_ts_address - - @field_validator("site_ts_address", "site_internal_id", "site_bgp_community_id") - def field_must_be_unique(cls, value: str | int, info: ValidationInfo) -> str | int: - if not info.field_name: - msg = "Field name must be provided." - raise ValueError(msg) - - if value and value == getattr(subscription.site, info.field_name): - return value - - validate_site_fields_is_unique(info.field_name, value) - - return value - - @field_validator("site_ts_address") - def validate_ts_address(cls, site_ts_address: str) -> str: - if site_ts_address and site_ts_address != subscription.site.site_ts_address: - validate_ipv4_or_ipv6(site_ts_address) - - return site_ts_address + site_ts_address: Annotated[IPAddress, UniqueField] | None = subscription.site.site_ts_address user_input = yield ModifySiteForm @@ -71,7 +54,7 @@ def modify_site_subscription( site_longitude: LongitudeCoordinate, site_bgp_community_id: int, site_internal_id: int, - site_ts_address: str, + site_ts_address: IPAddress, ) -> State: """Update the subscription model in the service database.""" subscription.site.site_city = site_city diff --git a/gso/workflows/super_pop_switch/create_imported_super_pop_switch.py b/gso/workflows/super_pop_switch/create_imported_super_pop_switch.py index 1cdb7d09d996baae8d1c186db35bfbf3fd27f676..da99dd68249318c5f2931d6196e08c0ae6140737 100644 --- a/gso/workflows/super_pop_switch/create_imported_super_pop_switch.py +++ b/gso/workflows/super_pop_switch/create_imported_super_pop_switch.py @@ -14,7 +14,8 @@ from gso.services import subscriptions from gso.services.partners import get_partner_by_name from gso.services.subscriptions import get_site_by_name from gso.utils.helpers import generate_fqdn -from gso.utils.shared_enums import IPv4AddressType, PortNumber, Vendor +from gso.utils.shared_enums import Vendor +from gso.utils.types.ip_address import IPv4AddressType, PortNumber @step("Create subscription") diff --git a/gso/workflows/tasks/create_partners.py b/gso/workflows/tasks/create_partners.py index b04c5c68d4f2ba7ddc350c87d047313af111e23c..5febaf4ea38452335d96541fb1449a9f1aebacff 100644 --- a/gso/workflows/tasks/create_partners.py +++ b/gso/workflows/tasks/create_partners.py @@ -4,9 +4,9 @@ from orchestrator.forms import FormPage from orchestrator.targets import Target from orchestrator.types import FormGenerator, State from orchestrator.workflow import StepList, begin, done, step, workflow -from pydantic import ConfigDict, EmailStr, field_validator +from pydantic import ConfigDict -from gso.services.partners import PartnerSchema, create_partner, filter_partners_by_email, filter_partners_by_name +from gso.services.partners import PartnerEmail, PartnerName, PartnerSchema, create_partner def initial_input_form_generator() -> FormGenerator: @@ -15,25 +15,8 @@ def initial_input_form_generator() -> FormGenerator: class CreatePartnerForm(FormPage): model_config = ConfigDict(title="Create a Partner") - name: str - email: EmailStr - - @field_validator("name") - def validate_name(cls, name: str) -> str: - if filter_partners_by_name(name=name, case_sensitive=False): - msg = "Partner with this name already exists." - raise ValueError(msg) - - return name - - @field_validator("email") - def validate_email(cls, email: str) -> EmailStr: - email = email.lower() - if filter_partners_by_email(email=email, case_sensitive=False): - msg = "Partner with this email already exists." - raise ValueError(msg) - - return email + name: PartnerName + email: PartnerEmail initial_user_input = yield CreatePartnerForm @@ -42,8 +25,8 @@ def initial_input_form_generator() -> FormGenerator: @step("Save partner information to database") def save_partner_to_database( - name: str, - email: EmailStr, + name: PartnerName, + email: PartnerEmail, ) -> State: """Save user input as a new partner in database.""" partner = create_partner( diff --git a/gso/workflows/tasks/modify_partners.py b/gso/workflows/tasks/modify_partners.py index 0e82521c3ee72cbc8912b5cdde136387c1948cea..46f912cc10824a83769480935011791e35739f3d 100644 --- a/gso/workflows/tasks/modify_partners.py +++ b/gso/workflows/tasks/modify_partners.py @@ -9,7 +9,7 @@ from pydantic_forms.types import UUIDstr from pydantic_forms.validators import Choice from gso.services.partners import ( - PartnerSchema, + ModifiedPartnerSchema, edit_partner, filter_partners_by_email, filter_partners_by_name, @@ -38,23 +38,25 @@ def initial_input_form_generator() -> FormGenerator: class ModifyPartnerForm(FormPage): model_config = ConfigDict(title="Modify a Partner") - name: str = partner["name"] - email: EmailStr = partner["email"] + name: str | None = partner["name"] + email: EmailStr | None = partner["email"] @field_validator("name") - def validate_name(cls, name: str) -> str: - if partner["name"] != name and filter_partners_by_name(name=name, case_sensitive=False): + def validate_name(cls, name: str) -> str | None: + if partner["name"] == name: + return None + if filter_partners_by_name(name=name, case_sensitive=False): msg = "Partner with this name already exists." raise ValueError(msg) - return name @field_validator("email") - def validate_email(cls, email: str) -> EmailStr: - if partner["email"] != email and filter_partners_by_email(email=email, case_sensitive=False): + def validate_email(cls, email: str) -> EmailStr | None: + if partner["email"] == email: + return None + if filter_partners_by_email(email=email, case_sensitive=False): msg = "Partner with this email already exists." raise ValueError(msg) - return email user_input = yield ModifyPartnerForm @@ -65,12 +67,12 @@ def initial_input_form_generator() -> FormGenerator: @step("Save partner information to database") def save_partner_to_database( partner_id: UUIDstr, - name: str, - email: EmailStr, + name: str | None, + email: EmailStr | None, ) -> State: """Save modified partner in database.""" partner = edit_partner( - partner_data=PartnerSchema( + partner_data=ModifiedPartnerSchema( partner_id=partner_id, name=name, email=email, diff --git a/requirements.txt b/requirements.txt index b751e9c033cdc1edb98813563e8174876037a363..632c74b3af3c83c1ea442da2e4bf11530f682e7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -orchestrator-core==2.6.1 +orchestrator-core==2.7.4 requests==2.31.0 infoblox-client~=0.6.0 pycountry==23.12.11 diff --git a/setup.py b/setup.py index 3eb6376d0268c2b5f6291676a11ac698f24314d1..e6ae8c9b6e2e7f37e6c7179ecc4462d4a09d960e 100644 --- a/setup.py +++ b/setup.py @@ -4,14 +4,14 @@ from setuptools import find_packages, setup setup( name="geant-service-orchestrator", - version="2.12", + version="2.13", author="GÉANT Orchestration and Automation Team", author_email="goat@geant.org", description="GÉANT Service Orchestrator", url="https://gitlab.software.geant.org/goat/gap/geant-service-orchestrator", packages=find_packages(), install_requires=[ - "orchestrator-core==2.6.1", + "orchestrator-core==2.7.4", "requests==2.31.0", "infoblox-client~=0.6.0", "pycountry==23.12.11", diff --git a/start-worker.sh b/start-worker.sh index 3c18dd4422ae6d60e16c78f45aa7189659dd5ca9..cccd84bff48ebc3e7392e15847e8f898c5120ffb 100755 --- a/start-worker.sh +++ b/start-worker.sh @@ -4,4 +4,4 @@ set -o errexit set -o nounset cd /app -python -m celery -A gso.worker worker --loglevel=info +python -m celery -A gso.worker worker --loglevel=info --concurrency=1 diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py index 8933627b82b06a35ca17e5a2a4417dc9cd9859c6..d794d0f0a63dd55f97f5ac98ec49774de574d31d 100644 --- a/test/cli/test_imports.py +++ b/test/cli/test_imports.py @@ -13,11 +13,12 @@ from gso.cli.imports import ( import_super_pop_switches, ) from gso.products import Router, Site -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.products.product_blocks.router import RouterRole from gso.products.product_blocks.site import SiteTier from gso.utils.helpers import iso_from_ipv4 from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import PhysicalPortCapacity ############## @@ -327,11 +328,11 @@ def test_import_iptrunk_non_unique_members_side_a_and_b(mock_start_process, mock assert "Validation error: 2 validation errors for IptrunkImportModel" in captured_output assert ( """side_a_ae_members - Value error, Items must be unique [type=value_error, input_value=[{'interface_name':""" + List must be unique [type=unique_list, input_value=[{'interface_name':""" ) in captured_output assert ( """side_b_ae_members - Value error, Items must be unique [type=value_error, input_value=[{'interface_name':""" + List must be unique [type=unique_list, input_value=[{'interface_name':""" ) in captured_output assert mock_start_process.call_count == 0 diff --git a/test/conftest.py b/test/conftest.py index 0c4e36e4f52d60d8270493b9dd234ffb080ba507..2eb7e6a28089c621b620fa56d9a55b293c75b099 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -34,7 +34,7 @@ from urllib3_mock import Responses from gso.main import init_gso_app from gso.services.partners import PartnerSchema, create_partner -from gso.utils.helpers import LAGMember +from gso.utils.types.interfaces import LAGMember, LAGMemberList from test.fixtures import ( # noqa: F401 iptrunk_side_subscription_factory, iptrunk_subscription_factory, @@ -107,7 +107,7 @@ class FakerProvider(BaseProvider): def network_interface(self) -> str: return self.generator.numerify("ge-@#/@#/@#") - def link_members_juniper(self) -> list[LAGMember]: + def link_members_juniper(self) -> LAGMemberList[LAGMember]: iface_amount = self.generator.random_int(min=2, max=5) interface_names = [f"{prefix}{i}" for prefix in ["xe-1/0/", "ge-3/0/", "xe-2/1/"] for i in range(iface_amount)] return [ @@ -115,7 +115,7 @@ class FakerProvider(BaseProvider): for interface_name in interface_names ] - def link_members_nokia(self) -> list[LAGMember]: + def link_members_nokia(self) -> LAGMemberList[LAGMember]: iface_amount = self.generator.random_int(min=2, max=5) return [ LAGMember(interface_name=f"Interface{i}", interface_description=self.generator.sentence()) diff --git a/test/fixtures.py b/test/fixtures.py index 84481eda9c9d816e438414636cd335ecb2ef1b63..0bb920c0cfb98e7387d5c9766df77c36a503bf88 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -26,7 +26,6 @@ from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlock, IptrunkSideBlock, IptrunkType, - PhysicalPortCapacity, ) from gso.products.product_blocks.router import RouterRole from gso.products.product_blocks.site import SiteTier @@ -38,6 +37,7 @@ from gso.products.product_types.site import ImportedSiteInactive, Site, SiteInac from gso.products.product_types.super_pop_switch import ImportedSuperPopSwitchInactive, SuperPopSwitchInactive from gso.services import subscriptions from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import PhysicalPortCapacity from test.workflows import WorkflowInstanceForTests @@ -65,7 +65,7 @@ def site_subscription_factory(faker, geant_partner): partner = geant_partner description = description or "Site Subscription" - site_name = site_name or faker.domain_word() + site_name = site_name or faker.site_name() site_city = site_city or faker.city() site_country = site_country or faker.country() site_country_code = site_country_code or faker.country_code() diff --git a/test/schemas/test_types.py b/test/schemas/test_types.py index a968084f06e2674828b6a59df58484e1fd965851..d34f35d78fe148bcaf7edc4753d27620a4a73253 100644 --- a/test/schemas/test_types.py +++ b/test/schemas/test_types.py @@ -1,7 +1,7 @@ import pytest from pydantic import BaseModel, ValidationError -from gso.products.product_blocks.site import LatitudeCoordinate, LongitudeCoordinate +from gso.utils.types.coordinates import LatitudeCoordinate, LongitudeCoordinate class LatitudeModel(BaseModel): diff --git a/test/services/test_librenms_client.py b/test/services/test_librenms_client.py index e28eaef74742d59d8b443ba0fad1d323f5b26254..c64b5933ba2f4b845fe34c288cd2be34c8655aca 100644 --- a/test/services/test_librenms_client.py +++ b/test/services/test_librenms_client.py @@ -5,7 +5,7 @@ import pytest from requests import HTTPError from gso.services.librenms_client import LibreNMSClient -from gso.utils.helpers import SNMPVersion +from gso.utils.types.snmp import SNMPVersion @pytest.fixture() diff --git a/test/utils/test_helpers.py b/test/utils/test_helpers.py index dc7854eaa3287b4b98132a8243bf2068636a684a..28779cd706a2eb8f5e12caf1f5bd003ebcd8474f 100644 --- a/test/utils/test_helpers.py +++ b/test/utils/test_helpers.py @@ -9,9 +9,9 @@ from gso.products.product_blocks.router import RouterRole from gso.utils.helpers import ( available_interfaces_choices_including_current_members, generate_inventory_for_active_routers, - validate_tt_number, ) from gso.utils.shared_enums import Vendor +from gso.utils.types.tt_number import validate_tt_number @pytest.fixture() diff --git a/test/workflows/iptrunk/test_create_imported_iptrunk.py b/test/workflows/iptrunk/test_create_imported_iptrunk.py index c08ddbe643a122e0ed710cadffdddea70d78add3..d172dc8d7ec3d38c5e35ca6d61b7ec1cdcc0db12 100644 --- a/test/workflows/iptrunk/test_create_imported_iptrunk.py +++ b/test/workflows/iptrunk/test_create_imported_iptrunk.py @@ -2,8 +2,9 @@ import pytest from orchestrator.types import SubscriptionLifecycle from gso.products import ProductName -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.products.product_types.iptrunk import ImportedIptrunk +from gso.utils.types.interfaces import PhysicalPortCapacity from test.workflows import ( assert_complete, extract_state, diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index 117444b7a33b5725df7558c2a3f7cd67b1cd00f1..0b7e00e638db87ddb27674d524bcbdde990d6067 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -5,9 +5,10 @@ import pytest from infoblox_client.objects import HostRecord from gso.products import Iptrunk, ProductName -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.services.subscriptions import get_product_id_by_name from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import PhysicalPortCapacity from test import USER_CONFIRM_EMPTY_FORM from test.services.conftest import MockedNetboxClient, MockedSharePointClient from test.workflows import ( diff --git a/test/workflows/iptrunk/test_migrate_iptrunk.py b/test/workflows/iptrunk/test_migrate_iptrunk.py index 5a8f49b3ad3e54bf106fafbb0d4392be8c696937..083d85d9c09461099343c1b267a3724bf886fd63 100644 --- a/test/workflows/iptrunk/test_migrate_iptrunk.py +++ b/test/workflows/iptrunk/test_migrate_iptrunk.py @@ -3,11 +3,12 @@ from unittest.mock import patch import pytest -from gso.products import Iptrunk +from gso.products.product_types.iptrunk import Iptrunk from gso.products.product_types.router import Router from gso.utils.shared_enums import Vendor from test import USER_CONFIRM_EMPTY_FORM from test.conftest import UseJuniperSide +from test.services.conftest import MockedSharePointClient from test.workflows import ( assert_complete, assert_lso_interaction_success, @@ -121,7 +122,9 @@ def interface_lists_are_equal(list1, list2): @patch("gso.services.netbox_client.NetboxClient.allocate_interface") @patch("gso.services.netbox_client.NetboxClient.free_interface") @patch("gso.services.netbox_client.NetboxClient.delete_interface") +@patch("gso.workflows.iptrunk.migrate_iptrunk.SharePointClient") def test_migrate_iptrunk_success( + mock_sharepoint_client, mocked_delete_interface, mocked_free_interface, mocked_allocate_interface, @@ -146,6 +149,7 @@ def test_migrate_iptrunk_success( mocked_create_interface.return_value = mocked_netbox.create_interface() mocked_get_available_lags.return_value = mocked_netbox.get_available_lags() mocked_delete_interface.return_value = mocked_netbox.delete_interface() + mock_sharepoint_client.return_value = MockedSharePointClient result, process_stat, step_log = run_workflow("migrate_iptrunk", migrate_form_input) @@ -164,6 +168,10 @@ def test_migrate_iptrunk_success( for _ in range(1): result, step_log = assert_lso_interaction_success(result, process_stat, step_log) + # Continue workflow after it has displayed a checklist URL. + assert_suspended(result) + result, step_log = resume_workflow(process_stat, step_log, input_data=USER_CONFIRM_EMPTY_FORM) + assert_complete(result) state = extract_state(result) diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py index 1383ea2edc1380613900ae62b0024b4622ac6d57..85c395e8bff7491b700cc7cda33219a25c69a9ab 100644 --- a/test/workflows/iptrunk/test_modify_trunk_interface.py +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -3,8 +3,9 @@ from unittest.mock import patch import pytest from gso.products import Iptrunk -from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity +from gso.products.product_blocks.iptrunk import IptrunkType from gso.utils.shared_enums import Vendor +from gso.utils.types.interfaces import LAGMemberList, PhysicalPortCapacity from test.conftest import UseJuniperSide from test.workflows import ( assert_complete, @@ -172,7 +173,7 @@ def test_iptrunk_modify_trunk_interface_success( assert subscription.iptrunk.iptrunk_minimum_links == input_form_iptrunk_data[1]["iptrunk_number_of_members"] - 1 assert subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_geant_a_sid == new_side_a_sid - def _find_interface_by_name(interfaces: list[dict[str, str]], name: str): + def _find_interface_by_name(interfaces: LAGMemberList, name: str): for interface in interfaces: if interface.interface_name == name: return interface