diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py index 0b2b6b1624c06acb657cca57ebb03a0817972cf0..bde96f2253572c1350cc0a550f33debe19d799d2 100644 --- a/gso/api/v1/imports.py +++ b/gso/api/v1/imports.py @@ -1,13 +1,13 @@ """:term:`GSO` :term:`API` endpoints that import different types of existing services.""" import ipaddress -from typing import Any +from typing import Any, Self from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.routing import APIRouter from orchestrator.services import processes -from pydantic import BaseModel, root_validator, validator +from pydantic import BaseModel, field_validator, model_validator from gso.auth.security import opa_security_default from gso.products.product_blocks.iptrunk import IptrunkType, PhysicalPortCapacity @@ -87,7 +87,7 @@ class IptrunkImportModel(BaseModel): for router in subscriptions.get_active_router_subscriptions(includes=["subscription_id"]) } - @validator("partner") + @field_validator("partner") def check_if_partner_exists(cls, value: str) -> str: """Validate that the partner exists.""" try: @@ -98,7 +98,7 @@ class IptrunkImportModel(BaseModel): return value - @validator("side_a_node_id", "side_b_node_id") + @field_validator("side_a_node_id", "side_b_node_id") def check_if_router_side_is_available(cls, value: str) -> str: """Both sides of the trunk must exist in :term:`GSO`.""" if value not in cls._get_active_routers(): @@ -107,7 +107,7 @@ class IptrunkImportModel(BaseModel): return value - @validator("side_a_ae_members", "side_b_ae_members") + @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)): @@ -116,25 +116,21 @@ class IptrunkImportModel(BaseModel): return value - @root_validator - def check_members(cls, values: dict[str, Any]) -> dict[str, Any]: + @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.""" - min_links = values["iptrunk_minimum_links"] - side_a_members = values.get("side_a_ae_members", []) - side_b_members = values.get("side_b_ae_members", []) + len_a = len(self.side_a_ae_members) + len_b = len(self.side_b_ae_members) - len_a = len(side_a_members) - len_b = len(side_b_members) - - if len_a < min_links: - msg = f"Side A members should be at least {min_links} (iptrunk_minimum_links)" + if len_a < self.iptrunk_minimum_links: + msg = f"Side A members should be at least {self.iptrunk_minimum_links} (iptrunk_minimum_links)" raise ValueError(msg) if len_a != len_b: msg = "Mismatch between Side A and B members" raise ValueError(msg) - return values + return self class SuperPopSwitchImportModel(BaseModel): diff --git a/gso/auth/opa.py b/gso/auth/opa.py new file mode 100644 index 0000000000000000000000000000000000000000..28c0cad8feff8d74207cda86078fdea0318b46ff --- /dev/null +++ b/gso/auth/opa.py @@ -0,0 +1,44 @@ +from http import HTTPStatus + +from fastapi.exceptions import HTTPException +from fastapi.params import Depends +from httpx import AsyncClient, NetworkError +from oauth2_lib.fastapi import OIDCUserModel, OPAAuthorization, OPAResult +from oauth2_lib.settings import oauth2lib_settings +from starlette.requests import Request +from structlog import get_logger + +from gso.auth.oidc import oidc_instance + +logger = get_logger(__name__) + + +class OPAAuthorization(OPAAuthorization): + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(OPAAuthorization, cls).__new__(cls) + return cls._instance + + async def authorize( + self, request: Request, user_info: OIDCUserModel = Depends(oidc_instance.authenticate) + ) -> bool | None: + return await super().authorize(request, user_info) + + async def get_decision(self, async_request: AsyncClient, opa_input: dict) -> OPAResult: + logger.debug("Posting input json to Policy agent", opa_url=self.opa_url, input=opa_input) + try: + result = await async_request.post(self.opa_url, json=opa_input) + except (NetworkError, TypeError) as exc: + logger.debug("Could not get decision from policy agent", error=str(exc)) + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Policy agent is unavailable") + + json_result = result.json() + logger.debug("Received decision from policy agent", decision=json_result) + return OPAResult(decision_id=json_result["decision_id"], result=json_result["result"]["allow"]) + + +opa_instance = OPAAuthorization( + opa_url=oauth2lib_settings.OPA_URL, +) diff --git a/gso/auth/settings.py b/gso/auth/settings.py index 29c1fc806a8589b38158a3f95dddf3f10cb8bdf3..b3ab1a6a569e2e594e181c23c231366e212f4905 100644 --- a/gso/auth/settings.py +++ b/gso/auth/settings.py @@ -6,7 +6,8 @@ with external authentication providers for enhanced security management. Todo: Remove token and sensitive data from OPA console and API. """ -from pydantic import BaseSettings, Field +from pydantic import Field +from pydantic_settings import BaseSettings class Oauth2LibSettings(BaseSettings): diff --git a/gso/migrations/env.py b/gso/migrations/env.py index 45dc109d4786205b3359743edf3681283ca58797..968abeb94a1145de0c923cdc8d27dd2030a55df7 100644 --- a/gso/migrations/env.py +++ b/gso/migrations/env.py @@ -15,7 +15,7 @@ config = context.config # This line sets up loggers basically. logger = logging.getLogger("alembic.env") -config.set_main_option("sqlalchemy.url", app_settings.DATABASE_URI) +config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI)) target_metadata = BaseModel.metadata diff --git a/gso/migrations/versions/2024-04-02_1ec810b289c0_add_orchestrator_2_1_2_migrations.py b/gso/migrations/versions/2024-04-02_1ec810b289c0_add_orchestrator_2_1_2_migrations.py new file mode 100644 index 0000000000000000000000000000000000000000..aa9593a8ba0279329a6361900a1965b2eddc365c --- /dev/null +++ b/gso/migrations/versions/2024-04-02_1ec810b289c0_add_orchestrator_2_1_2_migrations.py @@ -0,0 +1,23 @@ +"""remove subscription cancellation workflow. + +Revision ID: 1ec810b289c0 +Revises: +Create Date: 2024-04-02 10:21:08.539591 + +""" + +# revision identifiers, used by Alembic. +revision = '1ec810b289c0' +down_revision = '4ec89ab289c0' +branch_labels = None +# TODO: check it carefuly +depends_on = '048219045729' # in this revision, SURF has added a new columns to the workflow table like delted_at, so we need to add a dependency on the revision that added the columns to the workflow table. + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass + diff --git a/gso/products/product_blocks/iptrunk.py b/gso/products/product_blocks/iptrunk.py index 901f37e787805e68307fc598a95080b0e6cdbd08..b9442fad884d889659853acca92a0ebd542fc512 100644 --- a/gso/products/product_blocks/iptrunk.py +++ b/gso/products/product_blocks/iptrunk.py @@ -1,11 +1,13 @@ """IP trunk product block that has all parameters of a subscription throughout its lifecycle.""" import ipaddress -from typing import TypeVar +from typing import Annotated, TypeVar -from orchestrator.domain.base import ProductBlockModel -from orchestrator.forms.validators import UniqueConstrainedList +from annotated_types import Len +from orchestrator.domain.base import ProductBlockModel, T from orchestrator.types import SubscriptionLifecycle, strEnum +from pydantic import AfterValidator +from pydantic_forms.validators import validate_unique_list from gso.products.product_blocks.router import ( RouterBlock, @@ -33,11 +35,8 @@ class IptrunkType(strEnum): LEASED = "Leased" -T_co = TypeVar("T_co", covariant=True) - - -class LAGMemberList(UniqueConstrainedList[T_co]): # type: ignore[type-var] - """A list of :term:`LAG` member interfaces.""" +# A list of :term:`LAG` member interfaces. +LAGMemberList = Annotated[list[T], AfterValidator(validate_unique_list), Len(min_length=0, max_length=None)] class IptrunkInterfaceBlockInactive( @@ -65,11 +64,14 @@ class IptrunkInterfaceBlock(IptrunkInterfaceBlockProvisioning, lifecycle=[Subscr interface_description: str -class IptrunkSides(UniqueConstrainedList[T_co]): # type: ignore[type-var] - """A list of IP trunk interfaces that make up one side of a link.""" +def validate_unique_list(value): + if len(value) != len(set(value)): + raise ValueError("List items must be unique") + return value - min_items = 2 - max_items = 2 + +T_co = TypeVar("T_co", covariant=True) +IptrunkSides = Annotated[list[T_co], AfterValidator(validate_unique_list), Len(min_length=2, max_length=2)] class IptrunkSideBlockInactive( diff --git a/gso/products/product_blocks/site.py b/gso/products/product_blocks/site.py index 1852b24615076b2d76dde41db71a9e5d5fcc535f..8082abaa29f41ba9c3afd4dfae2fee98930cfb68 100644 --- a/gso/products/product_blocks/site.py +++ b/gso/products/product_blocks/site.py @@ -1,10 +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 ConstrainedStr +from pydantic import AfterValidator, Field class SiteTier(strEnum): @@ -20,44 +21,41 @@ class SiteTier(strEnum): TIER4 = 4 -class LatitudeCoordinate(ConstrainedStr): - """A latitude coordinate, modeled as a constrained 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 - """ - +def validate_latitude(v: float) -> float: regex = re.compile(r"^-?([1-8]?\d(\.\d+)?|90(\.0+)?)$") - - @classmethod - def validate(cls, value: str) -> str: - """Validate that a latitude coordinate is valid.""" - if not cls.regex.match(value): - msg = "Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'." - raise ValueError(msg) - - return value + if not regex.match(str(v)): + raise ValueError("Invalid latitude coordinate. Valid examples: '40.7128', '-74.0060', '90', '-90', '0'.") + return v -class LongitudeCoordinate(ConstrainedStr): - """A longitude coordinate, modeled as a constrained 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 - """ - +def validate_longitude(v: float) -> float: regex = re.compile(r"^-?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$") - - @classmethod - def validate(cls, value: str) -> str: - """Validate that a longitude coordinate is valid.""" - if not cls.regex.match(value): - msg = "Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180'" - raise ValueError(msg) - - return value + if not regex.match(str(v)): + raise ValueError("Invalid longitude coordinate. Valid examples: '40.7128', '-74.0060', '180', '-180', '0'.") + + return v + + +LatitudeCoordinate = Annotated[ + float, + Field( + ge=-90, + le=90, + example="40.7128", + description="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.", + ), + AfterValidator(validate_latitude) +] +LongitudeCoordinate = Annotated[ + float, + Field( + ge=-180, + le=180, + example="74.0060", + description="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.", + ), + AfterValidator(validate_longitude) +] class SiteBlockInactive( diff --git a/gso/schema/partner.py b/gso/schema/partner.py index 890adcb9b20b08f6c244e8986ad20eaf4def83fc..d8aa1b741a38d552ec625bfaa9c81905c2e32ab0 100644 --- a/gso/schema/partner.py +++ b/gso/schema/partner.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field from gso.db.models import PartnerType @@ -23,8 +23,4 @@ class PartnerCreate(BaseModel): partner_type: PartnerType created_at: datetime = Field(default_factory=lambda: datetime.now().astimezone()) updated_at: datetime = Field(default_factory=lambda: datetime.now().astimezone()) - - class Config: - """Pydantic model configuration.""" - - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/gso/services/lso_client.py b/gso/services/lso_client.py index 67e4b77cce1e4c03f1ba200b6633079cb2476437..9f7024649b9c4d7a394d18e791003f6a2beaa828 100644 --- a/gso/services/lso_client.py +++ b/gso/services/lso_client.py @@ -13,9 +13,10 @@ from orchestrator.config.assignee import Assignee from orchestrator.types import State from orchestrator.utils.errors import ProcessFailureError from orchestrator.workflow import Step, StepList, begin, callback_step, inputstep -from pydantic_forms.core import FormPage, ReadOnlyField +from pydantic import ConfigDict +from pydantic_forms.core import FormPage from pydantic_forms.types import FormGenerator -from pydantic_forms.validators import Label, LongText +from pydantic_forms.validators import Label, LongText, ReadOnlyField from gso import settings @@ -125,8 +126,7 @@ def _show_results(state: State) -> FormGenerator: return state class ConfirmRunPage(FormPage): - class Config: - title: str = state["lso_result_title"] + model_config = ConfigDict() if "lso_result_extra_label" in state: extra_label: Label = state["lso_result_extra_label"] diff --git a/gso/settings.py b/gso/settings.py index ced74ba5c01f59555976396dc43ad268bc786c0b..7f601e94d33fef2ebe3b6e426a0cedd92adb00e9 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -10,7 +10,8 @@ import logging import os from pathlib import Path -from pydantic import BaseSettings, NonNegativeInt +from pydantic import NonNegativeInt +from pydantic_settings import BaseSettings logger = logging.getLogger(__name__) diff --git a/gso/utils/helpers.py b/gso/utils/helpers.py index 6c30324ed0b81064bdc4c84e862f1a0ff671b9da..0a87cd51ec98bef4a3fff0e63c214f0f19900029 100644 --- a/gso/utils/helpers.py +++ b/gso/utils/helpers.py @@ -8,8 +8,7 @@ from uuid import UUID import pycountry from orchestrator.types import UUIDstr -from pydantic import BaseModel, validator -from pydantic.fields import ModelField +from pydantic import BaseModel, field_validator from pydantic_forms.validators import Choice from gso import settings @@ -211,31 +210,46 @@ class BaseSiteValidatorModel(BaseModel): site_tier: SiteTier site_ts_address: str - @validator("site_ts_address", check_fields=False, allow_reuse=True) + @classmethod + @field_validator("site_ts_address", check_fields=False) 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 - @validator("site_country_code", check_fields=False, allow_reuse=True) + @classmethod + @field_validator("site_country_code", check_fields=False) def country_code_must_exist(cls, country_code: str) -> str: """Validate that the country code exists.""" validate_country_code(country_code) return country_code - @validator( - "site_ts_address", - "site_internal_id", - "site_bgp_community_id", - "site_name", - check_fields=False, - allow_reuse=True, - ) - def validate_unique_fields(cls, value: str, field: ModelField) -> str | int: + @classmethod + @field_validator("site_ts_address", check_fields=False) + def site_ts_address_must_be_unique(cls, site_ts_address: str) -> str: + """Validate that the internal and :term:`BGP` community IDs are unique.""" + return validate_site_fields_is_unique("site_ts_address", site_ts_address) + + @classmethod + @field_validator("site_internal_id", check_fields=False) + def site_internal_id_must_be_unique(cls, site_internal_id: int) -> int: + """Validate that the internal and :term:`BGP` community IDs are unique.""" + return validate_site_fields_is_unique("site_internal_id", site_internal_id) + + @classmethod + @field_validator("site_bgp_community_id", check_fields=False) + def site_bgp_community_id_must_be_unique(cls, site_bgp_community_id: int) -> int: + """Validate that the internal and :term:`BGP` community IDs are unique.""" + return validate_site_fields_is_unique("site_bgp_community_id", site_bgp_community_id) + + @classmethod + @field_validator("site_name", check_fields=False) + def site_name_must_be_unique(cls, site_name: str) -> str: """Validate that the internal and :term:`BGP` community IDs are unique.""" - return validate_site_fields_is_unique(field.name, value) + return validate_site_fields_is_unique("site_name", site_name) - @validator("site_name", check_fields=False, allow_reuse=True) + @classmethod + @field_validator("site_name", check_fields=False) def site_name_must_be_valid(cls, site_name: str) -> str: """Validate the site name. diff --git a/gso/utils/shared_enums.py b/gso/utils/shared_enums.py index c0116e1690d6384cabd9ce16cf1ee79201a0d6b8..c5d6fa6d9b54c970830c2804e436f4010f9b3dbb 100644 --- a/gso/utils/shared_enums.py +++ b/gso/utils/shared_enums.py @@ -1,6 +1,8 @@ """Shared choices for the different models.""" -from pydantic import ConstrainedInt +from typing import Annotated + +from pydantic import Field from pydantic_forms.types import strEnum @@ -11,14 +13,17 @@ class Vendor(strEnum): NOKIA = "nokia" -class PortNumber(ConstrainedInt): - """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. - """ - - gt = 0 - le = 49151 +PortNumber = Annotated[ + int, + Field( + gt=0, + le=49151, + description=( + "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." + ), + ), +] class ConnectionStrategy(strEnum): diff --git a/gso/workflows/iptrunk/create_iptrunk.py b/gso/workflows/iptrunk/create_iptrunk.py index 386fb36bf3d1b61e2d0a52252fc832d301a7a05c..dd7b070abb7b3c86263c92a91b38e1d5404ddeaa 100644 --- a/gso/workflows/iptrunk/create_iptrunk.py +++ b/gso/workflows/iptrunk/create_iptrunk.py @@ -1,19 +1,21 @@ """A creation workflow that deploys a new IP trunk service.""" import json +from typing import Annotated from uuid import uuid4 +from annotated_types import Len from orchestrator.config.assignee import Assignee from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, Label, UniqueConstrainedList +from orchestrator.forms.validators import Choice, Label from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUIDstr from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, conditional, done, init, inputstep, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form -from pydantic import validator -from pydantic_forms.core import ReadOnlyField +from pydantic import AfterValidator, ConfigDict, field_validator +from pydantic_forms.validators import ReadOnlyField, validate_unique_list from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import ( @@ -52,8 +54,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: routers[str(router["subscription_id"])] = router["description"] class CreateIptrunkForm(FormPage): - class Config: - title = product_name + model_config = ConfigDict(title=product_name) tt_number: str partner: str = ReadOnlyField("GEANT") @@ -63,7 +64,8 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: iptrunk_speed: PhysicalPortCapacity iptrunk_minimum_links: int - @validator("tt_number", allow_reuse=True) + @field_validator("tt_number") + @classmethod def validate_tt_number(cls, tt_number: str) -> str: return validate_tt_number(tt_number) @@ -72,20 +74,18 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: router_enum_a = Choice("Select a router", zip(routers.keys(), routers.items(), strict=True)) # type: ignore[arg-type] class SelectRouterSideA(FormPage): - class Config: - title = "Select a router for side A of the trunk." + model_config = ConfigDict(title="Select a router for side A of the trunk.") side_a_node_id: router_enum_a # type: ignore[valid-type] - @validator("side_a_node_id", allow_reuse=True) + @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) user_input_router_side_a = yield SelectRouterSideA router_a = user_input_router_side_a.side_a_node_id.name - class JuniperAeMembers(UniqueConstrainedList[LAGMember]): - min_items = initial_user_input.iptrunk_minimum_links + JuniperAeMembers = Annotated[list[LAGMember], AfterValidator(validate_unique_list), Len(min_length=initial_user_input.iptrunk_minimum_links)] if get_router_vendor(router_a) == Vendor.NOKIA: @@ -95,22 +95,19 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: initial_user_input.iptrunk_speed, ) - class NokiaAeMembersA(UniqueConstrainedList[NokiaLAGMemberA]): - min_items = initial_user_input.iptrunk_minimum_links - - ae_members_side_a = NokiaAeMembersA + ae_members_side_a = Annotated[list[NokiaLAGMemberA], AfterValidator(validate_unique_list), Len(min_length=initial_user_input.iptrunk_minimum_links)] else: ae_members_side_a = JuniperAeMembers # type: ignore[assignment] class CreateIptrunkSideAForm(FormPage): - class Config: - title = "Provide subscription details for side A of the trunk." + model_config = ConfigDict(title="Provide subscription details for side A of the trunk.") side_a_ae_iface: available_lags_choices(router_a) or str # type: ignore[valid-type] side_a_ae_geant_a_sid: str side_a_ae_members: ae_members_side_a # type: ignore[valid-type] - @validator("side_a_ae_members", allow_reuse=True) + @field_validator("side_a_ae_members") + @classmethod 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) @@ -123,12 +120,12 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: router_enum_b = Choice("Select a router", zip(routers.keys(), routers.items(), strict=True)) # type: ignore[arg-type] class SelectRouterSideB(FormPage): - class Config: - title = "Select a router for side B of the trunk." + model_config = ConfigDict(title="Select a router for side B of the trunk.") side_b_node_id: router_enum_b # type: ignore[valid-type] - @validator("side_b_node_id", allow_reuse=True) + @field_validator("side_b_node_id") + @classmethod def validate_device_exists_in_netbox(cls, side_b_node_id: UUIDstr) -> str | None: return validate_router_in_netbox(side_b_node_id) @@ -143,24 +140,19 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: initial_user_input.iptrunk_speed, ) - class NokiaAeMembersB(UniqueConstrainedList): - min_items = len(user_input_side_a.side_a_ae_members) - max_items = len(user_input_side_a.side_a_ae_members) - item_type = NokiaLAGMemberB - - ae_members_side_b = NokiaAeMembersB + ae_members_side_b = Annotated[list[NokiaLAGMemberB], AfterValidator(validate_unique_list), Len(min_length=len(user_input_side_a.side_a_ae_members), max_length= len(user_input_side_a.side_a_ae_members))] else: ae_members_side_b = JuniperAeMembers # type: ignore[assignment] class CreateIptrunkSideBForm(FormPage): - class Config: - title = "Provide subscription details for side B of the trunk." + model_config = ConfigDict(title="Provide subscription details for side B of the trunk.") side_b_ae_iface: available_lags_choices(router_b) or str # type: ignore[valid-type] side_b_ae_geant_a_sid: str side_b_ae_members: ae_members_side_b # type: ignore[valid-type] - @validator("side_b_ae_members", allow_reuse=True) + @field_validator("side_b_ae_members") + @classmethod 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) @@ -479,8 +471,7 @@ def prompt_start_new_checklist(subscription: IptrunkProvisioning) -> FormGenerat oss_params = load_oss_params() class SharepointPrompt(FormPage): - class Config: - title = "Start new checklist" + model_config = ConfigDict(title="Start new checklist") info_label_1: Label = ( f"Visit {oss_params.SHAREPOINT.checklist_site_url} and start a new Sharepoint checklist for an IPtrunk " # type: ignore[assignment] diff --git a/gso/workflows/iptrunk/deploy_twamp.py b/gso/workflows/iptrunk/deploy_twamp.py index b9003078a016313bab3012cfee8eaf3f11f278ab..011b37c85f82c486dccb87c3a2d1627ae852be44 100644 --- a/gso/workflows/iptrunk/deploy_twamp.py +++ b/gso/workflows/iptrunk/deploy_twamp.py @@ -7,7 +7,7 @@ from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.workflow import StepList, done, init, 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 validator +from pydantic import field_validator from gso.products.product_types.iptrunk import Iptrunk from gso.services.lso_client import execute_playbook, lso_interaction @@ -25,7 +25,8 @@ def _initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) tt_number: str - @validator("tt_number", allow_reuse=True) + @field_validator("tt_number") + @classmethod def validate_tt_number(cls, tt_number: str) -> str: return validate_tt_number(tt_number) diff --git a/gso/workflows/iptrunk/migrate_iptrunk.py b/gso/workflows/iptrunk/migrate_iptrunk.py index add6d5f01d6c5d74f31dc426453856f4240bf963..fe97d66ecde2d019649cea61bf704225cd578b87 100644 --- a/gso/workflows/iptrunk/migrate_iptrunk.py +++ b/gso/workflows/iptrunk/migrate_iptrunk.py @@ -7,12 +7,14 @@ 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 +from annotated_types import Len from orchestrator import step, workflow from orchestrator.config.assignee import Assignee from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, Label, UniqueConstrainedList +from orchestrator.forms.validators import Choice, Label from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.utils.errors import ProcessFailureError @@ -20,8 +22,8 @@ from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, conditional, done, init, inputstep from orchestrator.workflows.steps import resync, store_process_subscription, unsync from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import validator -from pydantic_forms.core import ReadOnlyField +from pydantic import AfterValidator, ConfigDict, field_validator +from pydantic_forms.validators import ReadOnlyField, validate_unique_list from pynetbox.models.dcim import Interfaces from gso.products.product_blocks.iptrunk import IptrunkInterfaceBlock @@ -63,8 +65,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) class IPTrunkMigrateForm(FormPage): - class Config: - title = form_title + model_config = ConfigDict(title=form_title) tt_number: str replace_side: replaced_side_enum # type: ignore[valid-type] @@ -72,7 +73,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: migrate_to_different_site: bool = False restore_isis_metric: bool = True - @validator("tt_number", allow_reuse=True, pre=True, always=True) + @field_validator("tt_number", mode="before") def validate_tt_number(cls, tt_number: str) -> str: return validate_tt_number(tt_number) @@ -100,8 +101,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: new_router_enum = Choice("Select a new router", zip(routers.keys(), routers.items(), strict=True)) # type: ignore[arg-type] class NewSideIPTrunkRouterForm(FormPage): - class Config: - title = form_title + model_config = ConfigDict(title=form_title) new_node: new_router_enum # type: ignore[valid-type] @@ -118,18 +118,10 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: subscription.iptrunk.iptrunk_speed, ) - class NokiaAeMembers(UniqueConstrainedList[NokiaLAGMember]): - min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) - max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) - - ae_members = NokiaAeMembers + ae_members = Annotated[list[NokiaLAGMember], AfterValidator(validate_unique_list), 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))] else: - class JuniperLagMember(UniqueConstrainedList[LAGMember]): - min_items = len(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_members) - max_items = len(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members) - - ae_members = JuniperLagMember # type: ignore[assignment] + ae_members = Annotated[list[LAGMember], AfterValidator(validate_unique_list), 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))] replace_index = ( 0 @@ -146,14 +138,13 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ] class NewSideIPTrunkForm(FormPage): - class Config: - title = form_title + model_config = ConfigDict(title=form_title) new_lag_interface: side_a_ae_iface # type: ignore[valid-type] existing_lag_interface: list[LAGMember] = ReadOnlyField(existing_lag_ae_members) new_lag_member_interfaces: ae_members # type: ignore[valid-type] - @validator("new_lag_interface", allow_reuse=True, pre=True, always=True) + @field_validator("new_lag_interface", mode="before") 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}$") @@ -162,7 +153,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: raise ValueError(msg) return new_lag_interface - @validator("new_lag_member_interfaces", allow_reuse=True, pre=True, always=True) + @field_validator("new_lag_member_interfaces", mode="before") 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) @@ -394,8 +385,7 @@ def confirm_continue_move_fiber() -> FormGenerator: """Wait for confirmation from an operator that the physical fiber has been moved.""" class ProvisioningResultPage(FormPage): - class Config: - title = "Please confirm before continuing" + model_config = ConfigDict(title="Please confirm before continuing") info_label: Label = "New trunk interface has been deployed, wait for the physical connection to be moved." # type: ignore[assignment] @@ -484,8 +474,7 @@ def confirm_continue_restore_isis() -> FormGenerator: """Wait for an operator to confirm that the old :term:`ISIS` metric should be restored.""" class ProvisioningResultPage(FormPage): - class Config: - title = "Please confirm before continuing" + model_config = ConfigDict(title="Please confirm before continuing") info_label: Label = "ISIS config has been deployed, confirm if you want to restore the old metric." # type: ignore[assignment] diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index d3b5e60ee539660e7aa2fbd22088d20c8235e6cb..19edbdf60be481b5a1b3a02432920d82cf418c0f 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -2,18 +2,19 @@ import ipaddress import json +from typing import Annotated from uuid import UUID, uuid4 -from orchestrator.forms import FormPage, ReadOnlyField -from orchestrator.forms.validators import UniqueConstrainedList +from annotated_types import Len +from orchestrator.forms import FormPage from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.utils.json import json_dumps from orchestrator.workflow import StepList, conditional, done, init, 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 validator -from pydantic_forms.validators import Label +from pydantic import AfterValidator, ConfigDict, field_validator +from pydantic_forms.validators import Label, ReadOnlyField, validate_unique_list from gso.products.product_blocks.iptrunk import ( IptrunkInterfaceBlock, @@ -60,16 +61,10 @@ def initialize_ae_members(subscription: Iptrunk, initial_user_input: dict, side_ ) ) - class NokiaAeMembers(UniqueConstrainedList[NokiaLAGMember]): - min_items = iptrunk_minimum_link - - ae_members = NokiaAeMembers + ae_members = Annotated[list[NokiaLAGMember], AfterValidator(validate_unique_list), Len(min_length=iptrunk_minimum_link)] else: - class JuniperAeMembers(UniqueConstrainedList[LAGMember]): - min_items = iptrunk_minimum_link - - ae_members = JuniperAeMembers # type: ignore[assignment] + ae_members = Annotated[list[LAGMember], AfterValidator(validate_unique_list), Len(min_length=iptrunk_minimum_link)] return ae_members # type: ignore[return-value] @@ -92,7 +87,8 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: iptrunk_ipv4_network: ipaddress.IPv4Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv4_network) iptrunk_ipv6_network: ipaddress.IPv6Network = ReadOnlyField(subscription.iptrunk.iptrunk_ipv6_network) - @validator("tt_number", allow_reuse=True) + @field_validator("tt_number") + @classmethod def validate_tt_number(cls, tt_number: str) -> str: return validate_tt_number(tt_number) @@ -100,8 +96,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ae_members_side_a = initialize_ae_members(subscription, initial_user_input.dict(), 0) class ModifyIptrunkSideAForm(FormPage): - class Config: - title = "Provide subscription details for side A of the trunk." + model_config = ConfigDict(title="Provide subscription details for side A of the trunk.") side_a_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_fqdn) side_a_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[0].iptrunk_side_ae_iface) @@ -112,11 +107,13 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: else [] ) - @validator("side_a_ae_members", allow_reuse=True) + @field_validator("side_a_ae_members") + @classmethod 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) - @validator("side_a_ae_members", allow_reuse=True) + @field_validator("side_a_ae_members") + @classmethod 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) @@ -125,8 +122,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ae_members_side_b = initialize_ae_members(subscription, initial_user_input.dict(), 1) class ModifyIptrunkSideBForm(FormPage): - class Config: - title = "Provide subscription details for side B of the trunk." + model_config = ConfigDict(title="Provide subscription details for side B of the trunk.") side_b_node: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_fqdn) side_b_ae_iface: str = ReadOnlyField(subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_iface) @@ -137,11 +133,13 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: else [] ) - @validator("side_b_ae_members", allow_reuse=True) + @field_validator("side_b_ae_members") + @classmethod 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) - @validator("side_b_ae_members", allow_reuse=True) + @field_validator("side_b_ae_members") + @classmethod 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) diff --git a/gso/workflows/iptrunk/terminate_iptrunk.py b/gso/workflows/iptrunk/terminate_iptrunk.py index 7a2afe6c22f0e2a1f381c92734f0efd5a8dbbb25..bdd4e54183f411a2e87ebfa09632fa8d7bc14a43 100644 --- a/gso/workflows/iptrunk/terminate_iptrunk.py +++ b/gso/workflows/iptrunk/terminate_iptrunk.py @@ -16,7 +16,7 @@ from orchestrator.workflows.steps import ( unsync, ) from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import validator +from pydantic import field_validator from gso.products.product_blocks.iptrunk import IptrunkSideBlock from gso.products.product_types.iptrunk import Iptrunk @@ -41,7 +41,8 @@ def initial_input_form_generator() -> FormGenerator: clean_up_ipam: bool = True clean_up_netbox: bool = True - @validator("tt_number", allow_reuse=True) + @field_validator("tt_number") + @classmethod def validate_tt_number(cls, tt_number: str) -> str: return validate_tt_number(tt_number) diff --git a/gso/workflows/router/create_router.py b/gso/workflows/router/create_router.py index 289f201bd45b3064ad352c733a7c81aa1b7b7462..c10a9c77ee4129df3bfd366b6561afd9daa6274b 100644 --- a/gso/workflows/router/create_router.py +++ b/gso/workflows/router/create_router.py @@ -10,8 +10,8 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUID from orchestrator.workflow import StepList, conditional, done, init, inputstep, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form -from pydantic import validator -from pydantic_forms.core import ReadOnlyField +from pydantic import ConfigDict, field_validator +from pydantic_forms.validators import ReadOnlyField from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import RouterInactive, RouterProvisioning @@ -39,8 +39,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: """Gather information about the new router from the operator.""" class CreateRouterForm(FormPage): - class Config: - title = product_name + model_config = ConfigDict(title=product_name) tt_number: str partner: str = ReadOnlyField("GEANT") @@ -50,7 +49,8 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: ts_port: PortNumber router_role: RouterRole - @validator("hostname", allow_reuse=True) + @field_validator("hostname") + @classmethod def hostname_must_be_available(cls, hostname: str, **kwargs: dict[str, Any]) -> str: router_site = kwargs["values"].get("router_site") if not router_site: @@ -152,8 +152,7 @@ def prompt_reboot_router(subscription: RouterInactive) -> FormGenerator: """Wait for confirmation from an operator that the router has been rebooted.""" class RebootPrompt(FormPage): - class Config: - title = "Please reboot before continuing" + model_config = ConfigDict(title="Please reboot before continuing") if subscription.router.router_site and subscription.router.router_site.site_ts_address: info_label_1: Label = ( @@ -175,8 +174,7 @@ def prompt_console_login() -> FormGenerator: """Wait for confirmation from an operator that the router can be logged into.""" class ConsolePrompt(FormPage): - class Config: - title = "Verify local authentication" + model_config = ConfigDict(title="Verify local authentication") info_label_1: Label = ( "Verify that you are able to log in to the router via the console using the admin account." # type: ignore[assignment] @@ -193,8 +191,7 @@ def prompt_insert_in_ims() -> FormGenerator: """Wait for confirmation from an operator that the router has been inserted in IMS.""" class IMSPrompt(FormPage): - class Config: - title = "Update IMS mediation server" + model_config = ConfigDict(title="Update IMS mediation server") info_label_1: Label = "Insert the router into IMS." # type: ignore[assignment] info_label_2: Label = "Once this is done, press submit to continue the workflow." # type: ignore[assignment] @@ -209,8 +206,7 @@ def prompt_insert_in_radius(subscription: RouterInactive) -> FormGenerator: """Wait for confirmation from an operator that the router has been inserted in RADIUS.""" class RadiusPrompt(FormPage): - class Config: - title = "Update RADIUS clients" + model_config = ConfigDict(title="Update RADIUS clients") info_label_1: Label = ( f"Please go to https://kratos.geant.org/add_radius_client and add the {subscription.router.router_fqdn}" # type: ignore[assignment] @@ -229,8 +225,7 @@ def prompt_start_new_checklist(subscription: RouterProvisioning) -> FormGenerato oss_params = load_oss_params() class SharepointPrompt(FormPage): - class Config: - title = "Start new checklist" + model_config = ConfigDict(title="Start new checklist") info_label_1: Label = ( f"Visit {oss_params.SHAREPOINT.checklist_site_url} and start a new Sharepoint checklist for " diff --git a/gso/workflows/router/modify_connection_strategy.py b/gso/workflows/router/modify_connection_strategy.py index a3f5b5ae2f1f8cd0aa58d1d407d2daf28662c8a0..bc6be3ecc4b4a08605de17151ec7a14e740daf01 100644 --- a/gso/workflows/router/modify_connection_strategy.py +++ b/gso/workflows/router/modify_connection_strategy.py @@ -6,6 +6,7 @@ from orchestrator.types import FormGenerator, State, UUIDstr from orchestrator.workflow import StepList, done, init, 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 ConfigDict from gso.products.product_types.router import Router from gso.utils.shared_enums import ConnectionStrategy @@ -20,8 +21,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: ) class ModifyConnectionStrategyForm(FormPage): - class Config: - title = f"Modify the connection strategy of {subscription.router.router_fqdn}." + model_config = ConfigDict(title=f"Modify the connection strategy of {subscription.router.router_fqdn}.") connection_strategy: ConnectionStrategy = current_connection_strategy diff --git a/gso/workflows/router/update_ibgp_mesh.py b/gso/workflows/router/update_ibgp_mesh.py index abef7da440b8024b578a7122be54f4440eb6764a..27085db5f361ddc4248ea2e1de507f30a3a6e03d 100644 --- a/gso/workflows/router/update_ibgp_mesh.py +++ b/gso/workflows/router/update_ibgp_mesh.py @@ -10,7 +10,7 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUID from orchestrator.workflow import StepList, done, init, inputstep, 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 root_validator +from pydantic import ConfigDict, model_validator from gso.products.product_blocks.router import RouterRole from gso.products.product_types.router import Router @@ -29,12 +29,12 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: subscription = Router.from_subscription(subscription_id) class AddBGPSessionForm(FormPage): - class Config: - title = f"Add {subscription.router.router_fqdn} to the iBGP mesh?" + model_config = ConfigDict(title=f"Add {subscription.router.router_fqdn} to the iBGP mesh?") tt_number: str - @root_validator(allow_reuse=True) + @model_validator() + @classmethod def router_has_a_trunk(cls, values: dict[str, Any]) -> dict[str, Any]: terminating_trunks = get_trunks_that_terminate_on_router( subscription_id, SubscriptionLifecycle.PROVISIONING @@ -198,8 +198,7 @@ def prompt_insert_in_radius() -> FormGenerator: """Wait for confirmation from an operator that the router has been inserted in RADIUS.""" class RADIUSPrompt(FormPage): - class Config: - title = "Please update RADIUS before continuing" + model_config = ConfigDict(title="Please update RADIUS before continuing") info_label: Label = "Insert the router into RADIUS, and continue the workflow once this has been completed." # type: ignore[assignment] @@ -213,8 +212,7 @@ def prompt_radius_login() -> FormGenerator: """Wait for confirmation from an operator that the router can be logged into using RADIUS.""" class RADIUSPrompt(FormPage): - class Config: - title = "Please check RADIUS before continuing" + model_config = ConfigDict(title="Please check RADIUS before continuing") info_label: Label = "Log in to the router using RADIUS, and continue the workflow when this was successful." # type: ignore[assignment] diff --git a/gso/workflows/site/create_site.py b/gso/workflows/site/create_site.py index be9aab537c7fec01550b7f8009925b799a8c1fd9..6aefaba4b271315441641580621a22f5cc3b29e3 100644 --- a/gso/workflows/site/create_site.py +++ b/gso/workflows/site/create_site.py @@ -6,7 +6,8 @@ from orchestrator.types import FormGenerator, State, SubscriptionLifecycle, UUID from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription from orchestrator.workflows.utils import wrap_create_initial_input_form -from pydantic_forms.core import ReadOnlyField +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 @@ -19,8 +20,7 @@ def initial_input_form_generator(product_name: str) -> FormGenerator: """Get input from the operator about the new site subscription.""" class CreateSiteForm(FormPage, BaseSiteValidatorModel): - class Config: - title = product_name + model_config = ConfigDict(title=product_name) partner: str = ReadOnlyField("GEANT") site_name: str diff --git a/gso/workflows/site/modify_site.py b/gso/workflows/site/modify_site.py index 15b549dbbcf7f357b5aebc28b885a998a18d9daa..5fe150169802ff810359eefbf44cbdefa74d7762 100644 --- a/gso/workflows/site/modify_site.py +++ b/gso/workflows/site/modify_site.py @@ -11,9 +11,8 @@ from orchestrator.workflows.steps import ( unsync, ) from orchestrator.workflows.utils import wrap_modify_initial_input_form -from pydantic import validator -from pydantic.fields import ModelField -from pydantic_forms.core import ReadOnlyField +from pydantic import ConfigDict, field_validator +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 @@ -26,8 +25,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: subscription = Site.from_subscription(subscription_id) class ModifySiteForm(FormPage): - class Config: - title = "Modify Site" + model_config = ConfigDict(title="Modify Site") site_name: str = ReadOnlyField(subscription.site.site_name) site_city: str = subscription.site.site_city @@ -40,18 +38,27 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator: site_tier: site_pb.SiteTier = ReadOnlyField(subscription.site.site_tier) site_ts_address: str | None = subscription.site.site_ts_address - @validator("site_ts_address", allow_reuse=True) + @classmethod + @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_site_fields_is_unique("site_ts_address", site_ts_address) validate_ipv4_or_ipv6(site_ts_address) return site_ts_address - @validator("site_internal_id", "site_bgp_community_id", allow_reuse=True) - def validate_unique_fields(cls, value: str, field: ModelField) -> str | int: - if value == getattr(subscription.site, field.name): - return value - return validate_site_fields_is_unique(field.name, value) + @classmethod + @field_validator("site_internal_id") + def validate_site_internal_id(cls, site_internal_id: int) -> int: + if site_internal_id == subscription.site.site_internal_id: + return site_internal_id + return validate_site_fields_is_unique("site_internal_id", site_internal_id) + + @classmethod + @field_validator("site_bgp_community_id") + def validate_site_bgp_community_id(cls, site_bgp_community_id: int) -> int: + if site_bgp_community_id == subscription.site.site_bgp_community_id: + return site_bgp_community_id + return validate_site_fields_is_unique("site_bgp_community_id", site_bgp_community_id) user_input = yield ModifySiteForm diff --git a/gso/workflows/tasks/import_iptrunk.py b/gso/workflows/tasks/import_iptrunk.py index 648d954f94ae57f9471826bba04402684508de81..330215bca0cd5664b0668584f42a0fa671f86a07 100644 --- a/gso/workflows/tasks/import_iptrunk.py +++ b/gso/workflows/tasks/import_iptrunk.py @@ -1,15 +1,18 @@ """A creation workflow for adding an existing IP trunk to the service database.""" import ipaddress +from typing import Annotated from uuid import uuid4 from orchestrator import workflow from orchestrator.forms import FormPage -from orchestrator.forms.validators import Choice, UniqueConstrainedList +from orchestrator.forms.validators import Choice from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription +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 @@ -29,14 +32,16 @@ def _generate_routers() -> dict[str, str]: return routers +LAGMemberList = Annotated[list[LAGMember], AfterValidator(validate_unique_list)] + + 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): - class Config: - title = "Import Iptrunk" + model_config = ConfigDict(title="Import Iptrunk") partner: str geant_s_sid: str @@ -49,12 +54,12 @@ def initial_input_form_generator() -> FormGenerator: side_a_node_id: router_enum # type: ignore[valid-type] side_a_ae_iface: str side_a_ae_geant_a_sid: str - side_a_ae_members: UniqueConstrainedList[LAGMember] + side_a_ae_members: LAGMemberList side_b_node_id: router_enum # type: ignore[valid-type] side_b_ae_iface: str side_b_ae_geant_a_sid: str - side_b_ae_members: UniqueConstrainedList[LAGMember] + side_b_ae_members: LAGMemberList iptrunk_ipv4_network: ipaddress.IPv4Network iptrunk_ipv6_network: ipaddress.IPv6Network diff --git a/gso/workflows/tasks/import_office_router.py b/gso/workflows/tasks/import_office_router.py index 9168cdae0150a82a1893b6b3ceae450b5df542b5..99c2699c2a53c7221c6fca0669380a0f98fe18eb 100644 --- a/gso/workflows/tasks/import_office_router.py +++ b/gso/workflows/tasks/import_office_router.py @@ -8,6 +8,7 @@ from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from pydantic import ConfigDict from gso.products import ProductName from gso.products.product_types import office_router @@ -35,8 +36,7 @@ def initial_input_form_generator() -> FormGenerator: """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" class ImportOfficeRouter(FormPage): - class Config: - title = "Import an office router" + model_config = ConfigDict(title="Import an office router") partner: str office_router_site: str diff --git a/gso/workflows/tasks/import_router.py b/gso/workflows/tasks/import_router.py index c71ce26ee47a0e0929842d7261d7c0fd195d2e55..0e7a46139a76e52e3288d2cf1eb8c4cb7cd7dcce 100644 --- a/gso/workflows/tasks/import_router.py +++ b/gso/workflows/tasks/import_router.py @@ -8,6 +8,7 @@ from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from pydantic import ConfigDict from gso.products import ProductName from gso.products.product_blocks import router as router_pb @@ -38,8 +39,7 @@ def initial_input_form_generator() -> FormGenerator: """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" class ImportRouter(FormPage): - class Config: - title = "Import Router" + model_config = ConfigDict(title="Import Router") partner: str router_site: str diff --git a/gso/workflows/tasks/import_site.py b/gso/workflows/tasks/import_site.py index ff49808a5a86d1e73c6a741d74a75c4c7c233471..b2fa07a9a80efef5bdc7e8c2775cab4350760d26 100644 --- a/gso/workflows/tasks/import_site.py +++ b/gso/workflows/tasks/import_site.py @@ -7,6 +7,7 @@ from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step, workflow from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from pydantic import ConfigDict from gso.products import ProductName from gso.products.product_blocks.site import SiteTier @@ -36,8 +37,7 @@ def generate_initial_input_form() -> FormGenerator: """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" class ImportSite(FormPage): - class Config: - title = "Import Site" + model_config = ConfigDict(title="Import Site") site_name: str site_city: str diff --git a/gso/workflows/tasks/import_super_pop_switch.py b/gso/workflows/tasks/import_super_pop_switch.py index 5f2796c2c2325ad439a0570db5154f57a0b435f1..dbc59b4c8cf8016076d184b537643cfe249f86c0 100644 --- a/gso/workflows/tasks/import_super_pop_switch.py +++ b/gso/workflows/tasks/import_super_pop_switch.py @@ -8,6 +8,7 @@ from orchestrator.targets import Target from orchestrator.types import FormGenerator, State, SubscriptionLifecycle from orchestrator.workflow import StepList, done, init, step from orchestrator.workflows.steps import resync, set_status, store_process_subscription +from pydantic import ConfigDict from gso.products import ProductName from gso.products.product_types import super_pop_switch @@ -36,8 +37,7 @@ def initial_input_form_generator() -> FormGenerator: """Generate a form that is filled in using information passed through the :term:`API` endpoint.""" class ImportSuperPopSwitch(FormPage): - class Config: - title = "Import a Super PoP switch" + model_config = ConfigDict(title="Import a Super PoP switch") partner: str super_pop_switch_site: str diff --git a/log.txt b/log.txt new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/requirements.txt b/requirements.txt index 73bb46ff0e83e703d78bca7c58a728f95e7fa696..1083430c89b6b7da5d4e6ee2b485397a42349801 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -orchestrator-core==1.3.4 +orchestrator-core==2.1.2 requests==2.31.0 infoblox-client~=0.6.0 pycountry==22.3.5 diff --git a/test/auth/test_oidc_policy_helper.py b/test/auth/test_oidc_policy_helper.py index 14af9f6b4ee55c5025aaef64414017f85a8f7513..f51b2dcfa8c0f1d0c715ff46cdef733c437edd5a 100644 --- a/test/auth/test_oidc_policy_helper.py +++ b/test/auth/test_oidc_policy_helper.py @@ -7,13 +7,7 @@ from httpx import AsyncClient, NetworkError, Response from gso.auth.oidc_policy_helper import ( OIDCConfig, - OIDCUser, - OIDCUserModel, - OPAResult, - _evaluate_decision, - _get_decision, - _is_callback_step_endpoint, - opa_decision, + OIDCUser, OIDCUserModel, OPAResult, opa_decision, _get_decision, _evaluate_decision, _is_callback_step_endpoint, ) from gso.auth.settings import oauth2lib_settings