diff --git a/Changelog.md b/Changelog.md
index 6a9206e1dd3b14427089b11501ac23acbe81f4ff..0b67a304a44bc42e797276063a4a2af94c52847a 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,5 +1,15 @@
# Changelog
+## [3.12] - 2025-06-26
+- Add a pre-check command to the CLI to get BGP status from LSO.
+- Add an option to the router termination workflow that skips deleting the loopback address from IPAM.
+- Add a redeploy workflow to all Layer 3 services.
+- Add BGP local preference and MED attibutes to R&E Layer 3 services.
+- Cleaned up the confirmation page for LSO interactions.
+- Enable the `modify_note` workflow on all existing products.
+- More robust error handling in mass base config redeploy.
+- Allow for skipping nightly validation by applying a note `SKIP VALIDATION: --reason--` on a subscription.
+
## [3.11] - 2025-06-18
- Update subscription descriptions for Layer 2 Circuit products.
diff --git a/gso/__init__.py b/gso/__init__.py
index 99f1d386081a807a5c7b34527ba890cafe7aab1e..bd869b6657e6253a2888ac6a60f7e27b52276a18 100644
--- a/gso/__init__.py
+++ b/gso/__init__.py
@@ -65,11 +65,12 @@ def init_gso_app() -> OrchestratorCore:
def init_cli_app() -> typer.Typer:
"""Initialise GSO as a CLI application."""
- from gso.cli import imports, netbox, schedule # noqa: PLC0415
+ from gso.cli import imports, lso_calls, netbox, schedule # noqa: PLC0415
cli_app.add_typer(imports.app, name="import-cli")
cli_app.add_typer(netbox.app, name="netbox-cli")
cli_app.add_typer(schedule.app, name="schedule-cli")
+ cli_app.add_typer(lso_calls.app, name="lso-cli")
return cli_app()
diff --git a/gso/cli/lso_calls.py b/gso/cli/lso_calls.py
new file mode 100644
index 0000000000000000000000000000000000000000..c8f228efc18d1839d3f3b75fb708c47525d87842
--- /dev/null
+++ b/gso/cli/lso_calls.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""CLI for GSO pre-check using LSO remote exec endpoint."""
+
+import json
+import logging
+from pathlib import Path
+
+import click
+import httpx
+import structlog
+import typer
+from orchestrator.db import db
+from orchestrator.db.database import transactional
+from pydantic import ValidationError
+
+from gso import settings
+from gso.db.models import BgpStatusPreCheckTable
+from gso.services.partners import filter_partners_by_name
+from gso.utils.types.lso_response import ExecutableRunResponse
+
+logger = structlog.get_logger(__name__)
+app = typer.Typer()
+
+_IMPORT_FILE_ARG = typer.Argument(
+ ...,
+ exists=True,
+ file_okay=True,
+ dir_okay=False,
+ readable=True,
+ help="Path to the JSON import file to embed in the check",
+)
+
+
+def _validate_partner(partner: str) -> None:
+ if not filter_partners_by_name(name=partner, case_sensitive=True):
+ typer.echo(f"Error: partner '{partner}' not found in database.")
+ raise typer.Exit(1)
+
+
+def _load_import_file(import_file_path: Path) -> str:
+ """Read a JSON file from the given path, return it as a compact JSON string, Exits on error."""
+ try:
+ with import_file_path.open("r", encoding="utf-8") as f:
+ data = json.load(f)
+ return json.dumps(data, separators=(",", ":"))
+ except Exception as e:
+ logger.exception("Failed to read import file")
+ typer.echo(f"Error: could not read or parse '{import_file_path}': {e}")
+ raise typer.Exit(2) from e
+
+
+def _call_lso(
+ host: str,
+ partner: str,
+ import_json_str: str,
+) -> ExecutableRunResponse:
+ oss = settings.load_oss_params()
+ proxy = oss.PROVISIONING_PROXY
+ url = f"{proxy.scheme}://{proxy.api_base}/api/execute/"
+ payload = {
+ "executable_name": "bgp_status_pre_check.py",
+ "args": [host, partner, import_json_str],
+ "is_async": False,
+ }
+ try:
+ resp = httpx.post(url, json=payload, timeout=30)
+ resp.raise_for_status()
+ except Exception as e:
+ logger.exception("LSO call failed")
+ typer.echo(f"Error: failed to call LSO: {e}")
+ raise typer.Exit(1) from e
+
+ try:
+ return ExecutableRunResponse(**resp.json())
+ except ValidationError as e:
+ logger.exception("Invalid response from LSO")
+ typer.echo("Error: invalid JSON returned by LSO:")
+ typer.echo(str(e))
+ raise typer.Exit(1) from e
+
+
+def _print_full(exec_resp: ExecutableRunResponse) -> None:
+ full_json = exec_resp.model_dump(mode="json")
+ typer.echo(typer.style("\nFull LSO response:", fg=typer.colors.GREEN))
+ typer.echo(json.dumps(full_json, indent=2))
+
+
+def _print_parsed_output(exec_resp: ExecutableRunResponse) -> None:
+ output_str = exec_resp.result.output if exec_resp.result else ""
+ typer.echo(typer.style("\nParsed `result.output` as JSON:", fg=typer.colors.CYAN))
+
+ try:
+ parsed = json.loads(output_str)
+ rendered = json.dumps(parsed, indent=2)
+ max_lines = settings.load_oss_params().GENERAL.pre_check_cli_max_output_lines
+ if rendered.count("\n") > max_lines:
+ click.echo_via_pager(rendered)
+ else:
+ typer.echo(rendered)
+ except json.JSONDecodeError:
+ typer.echo("(not valid JSON, raw string below)")
+ typer.echo(output_str)
+
+
+def _maybe_save(
+ host: str,
+ partner: str,
+ exec_resp: ExecutableRunResponse,
+) -> None:
+ prompt = (
+ f"\nIf you are happy with the above output for router '{host}' "
+ f"(partner: {partner}), shall we save it to the database?"
+ )
+ if not typer.confirm(prompt, default=False):
+ typer.echo("Alright, not saving. You can re-run when ready.")
+ return
+
+ try:
+ with db.database_scope(), transactional(db, logger):
+ record = BgpStatusPreCheckTable(
+ router_fqdn=host,
+ partner=partner,
+ result=exec_resp.result.model_dump(mode="json") if exec_resp.result else {},
+ )
+ db.session.add(record)
+ except Exception as e:
+ logger.exception("Failed to save pre-check record")
+ typer.echo("Error: could not save pre-check to database.")
+ raise typer.Exit(2) from e
+
+ typer.echo("Pre-check result saved.")
+
+
+@app.command()
+def bgp_status_precheck(
+ host: str = typer.Argument(..., help="FQDN of the router to pre-check"),
+ partner: str = typer.Argument(..., help="Partner name for import file path"),
+ import_file_path: Path = _IMPORT_FILE_ARG,
+) -> None:
+ """Trigger the bgp_status_pre-check script on LSO, print results, and optionally save."""
+ _validate_partner(partner)
+ import_json_str = _load_import_file(import_file_path)
+ exec_resp = _call_lso(host, partner, import_json_str)
+ _print_full(exec_resp)
+ _print_parsed_output(exec_resp)
+ _maybe_save(host, partner, exec_resp)
+
+
+if __name__ == "__main__":
+ logging.basicConfig(level=logging.INFO)
+ app()
diff --git a/gso/db/models.py b/gso/db/models.py
index c6382b1c81d06f9192ed4f416e186a2d990a5a45..0649003e8d5222536d3ad75b73c23259c451bda9 100644
--- a/gso/db/models.py
+++ b/gso/db/models.py
@@ -4,6 +4,7 @@ import structlog
from orchestrator.db import UtcTimestamp
from orchestrator.db.database import BaseModel
from sqlalchemy import (
+ JSON,
String,
text,
)
@@ -25,3 +26,43 @@ class PartnerTable(BaseModel):
updated_at = mapped_column(
UtcTimestamp, server_default=text("current_timestamp"), nullable=False, onupdate=text("current_timestamp")
)
+
+
+class BgpStatusPreCheckTable(BaseModel):
+ """Database table for storing per router BGP satus pre-check results."""
+
+ __tablename__ = "bgp_status_pre_checks"
+
+ pre_check_id = mapped_column(
+ String,
+ server_default=text("uuid_generate_v4"),
+ primary_key=True,
+ )
+ router_fqdn = mapped_column(
+ String,
+ nullable=False,
+ index=True,
+ comment="The FQDN of the router under check",
+ )
+ partner = mapped_column(
+ String,
+ nullable=False,
+ comment="Name of the partner (used in import file path)",
+ )
+ result = mapped_column(
+ JSON,
+ nullable=False,
+ comment="Raw JSON blob returned by LSO bgp_status_pre_check script",
+ )
+
+ created_at = mapped_column(
+ UtcTimestamp,
+ server_default=text("current_timestamp"),
+ nullable=False,
+ )
+ updated_at = mapped_column(
+ UtcTimestamp,
+ server_default=text("current_timestamp"),
+ nullable=False,
+ onupdate=text("current_timestamp"),
+ )
diff --git a/gso/migrations/env.py b/gso/migrations/env.py
index fb535946527dd0a487e75c52d3f6856b9f4eda34..198b6499028754c353efc9e76c1fb7eaa7822bc4 100644
--- a/gso/migrations/env.py
+++ b/gso/migrations/env.py
@@ -5,7 +5,7 @@ from orchestrator.db.database import BaseModel
from orchestrator.settings import app_settings
from sqlalchemy import engine_from_config, pool, text
-from gso.db.models import PartnerTable # noqa: F401
+from gso.db.models import PartnerTable, BgpStatusPreCheckTable # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
diff --git a/gso/migrations/versions/2025-06-20_24858fd1d805_add_modify_note_workflow_to_existing_.py b/gso/migrations/versions/2025-06-20_24858fd1d805_add_modify_note_workflow_to_existing_.py
new file mode 100644
index 0000000000000000000000000000000000000000..7414647ef21f79530bef8db6e0f022605f218c74
--- /dev/null
+++ b/gso/migrations/versions/2025-06-20_24858fd1d805_add_modify_note_workflow_to_existing_.py
@@ -0,0 +1,70 @@
+"""Add modify note workflow to existing products.
+
+Revision ID: 24858fd1d805
+Revises: 550e3aebc1c5
+Create Date: 2025-06-20 10:51:57.321841
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '24858fd1d805'
+down_revision = '550e3aebc1c5'
+branch_labels = None
+depends_on = None
+
+
+from orchestrator.migrations.helpers import create_workflow, delete_workflow, add_products_to_workflow_by_product_tag, \
+ remove_products_from_workflow_by_product_tag
+
+product_tags = [
+ "ER",
+ "COP",
+ "EP",
+ "G_IP",
+ "G_PLUS",
+ "IAS",
+ "IMP_ER",
+ "IMP_COP",
+ "IMP_EP",
+ "IMP_G_IP",
+ "IMP_G_PLUS",
+ "IMP_IAS",
+ "IMP_IP_TRUNK",
+ "IMP_LSI",
+ "IMP_LHC",
+ "IMP_OFFICE_RTR",
+ "IMPORTED_OPENGEAR",
+ "IMP_RE_LHCONE",
+ "IMP_RE_PEER",
+ "IMP_RTR",
+ "IMP_SITE",
+ "IMP_SPOP_SWITCH",
+ "IMP_SWITCH",
+ "IPTRUNK",
+ "LSI",
+ "LHC",
+ "OFFICE_ROUTER",
+ "OPENGEAR",
+ "POP_VLAN",
+ "RE_LHCONE",
+ "RE_PEER",
+ "RTR",
+ "SITE",
+ "Super_POP_SWITCH",
+ "SWITCH",
+ "VRF",
+]
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ for product in product_tags:
+ add_products_to_workflow_by_product_tag(conn, "modify_note", product)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ for product in product_tags:
+ remove_products_from_workflow_by_product_tag(conn, "modify_note", product)
diff --git a/gso/migrations/versions/2025-06-20_7c3094cd282a_remove_obsolete_validation_task.py b/gso/migrations/versions/2025-06-20_7c3094cd282a_remove_obsolete_validation_task.py
new file mode 100644
index 0000000000000000000000000000000000000000..3c64047723ba152582b3c1633b46d8845f5f641a
--- /dev/null
+++ b/gso/migrations/versions/2025-06-20_7c3094cd282a_remove_obsolete_validation_task.py
@@ -0,0 +1,52 @@
+"""Remove obsolete validation task.
+
+Revision ID: 7c3094cd282a
+Revises: 24858fd1d805
+Create Date: 2025-06-20 11:34:08.439370
+
+"""
+import sqlalchemy as sa
+from alembic import op
+from orchestrator.migrations.helpers import create_task, delete_workflow
+
+# revision identifiers, used by Alembic.
+revision = '7c3094cd282a'
+down_revision = '24858fd1d805'
+branch_labels = None
+depends_on = None
+
+old_task = {
+ "name": "task_validate_geant_products",
+ "description": "Validate GEANT products"
+}
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(sa.text(f"""
+DO $$DECLARE wf_id UUID;BEGIN
+ SELECT workflow_id
+ INTO wf_id
+ FROM workflows
+ WHERE NAME = '{old_task["name"]}';
+
+ DELETE
+ FROM input_states
+ WHERE pid IN
+ (
+ SELECT pid
+ FROM processes
+ WHERE workflow_id = wf_id);
+
+ DELETE
+ FROM processes
+ WHERE workflow_id = wf_id;
+
+END$$;
+ """))
+ delete_workflow(conn, old_task["name"])
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ create_task(conn, old_task)
diff --git a/gso/migrations/versions/2025-06-20_b2b5137ef0c7_add_attributes_to_r_e_product_block.py b/gso/migrations/versions/2025-06-20_b2b5137ef0c7_add_attributes_to_r_e_product_block.py
new file mode 100644
index 0000000000000000000000000000000000000000..188bcd79a088d6321679189522c9510d780e71a3
--- /dev/null
+++ b/gso/migrations/versions/2025-06-20_b2b5137ef0c7_add_attributes_to_r_e_product_block.py
@@ -0,0 +1,113 @@
+"""Add attributes to R&E product block.
+
+Revision ID: b2b5137ef0c7
+Revises: 550e3aebc1c5
+Create Date: 2025-06-20 16:45:01.403416
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = 'b2b5137ef0c7'
+down_revision = '7c3094cd282a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('v6_bgp_local_preference', 'BGP Local Preference for IPv6') RETURNING resource_types.resource_type_id
+ """))
+ conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('v4_bgp_local_preference', 'BGP Local Preference for IPv4') RETURNING resource_types.resource_type_id
+ """))
+ conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('v4_bgp_med', 'BGP Multi Exit Discriminant for IPv4') RETURNING resource_types.resource_type_id
+ """))
+ conn.execute(sa.text("""
+INSERT INTO resource_types (resource_type, description) VALUES ('v6_bgp_med', 'BGP Multi Exit Discriminant for IPv6') RETURNING resource_types.resource_type_id
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med')))
+ """))
+ conn.execute(sa.text("""
+INSERT INTO product_block_resource_types (product_block_id, resource_type_id) VALUES ((SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')), (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med')))
+ """))
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndELHCOneBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_local_preference'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v4_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM product_block_resource_types WHERE product_block_resource_types.product_block_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock')) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values USING product_block_resource_types WHERE subscription_instance_values.subscription_instance_id IN (SELECT subscription_instances.subscription_instance_id FROM subscription_instances WHERE subscription_instances.subscription_instance_id IN (SELECT product_blocks.product_block_id FROM product_blocks WHERE product_blocks.name IN ('RAndEPeerBlock'))) AND product_block_resource_types.resource_type_id = (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM subscription_instance_values WHERE subscription_instance_values.resource_type_id IN (SELECT resource_types.resource_type_id FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference', 'v4_bgp_local_preference', 'v4_bgp_med', 'v6_bgp_med'))
+ """))
+ conn.execute(sa.text("""
+DELETE FROM resource_types WHERE resource_types.resource_type IN ('v6_bgp_local_preference', 'v4_bgp_local_preference', 'v4_bgp_med', 'v6_bgp_med')
+ """))
diff --git a/gso/migrations/versions/2025-06-24_06242291zb30_add_bgp_status_pre_check_table.py b/gso/migrations/versions/2025-06-24_06242291zb30_add_bgp_status_pre_check_table.py
new file mode 100644
index 0000000000000000000000000000000000000000..e5cfb9290139c34b35f560c609fe63672ce3eab3
--- /dev/null
+++ b/gso/migrations/versions/2025-06-24_06242291zb30_add_bgp_status_pre_check_table.py
@@ -0,0 +1,40 @@
+"""Add bgp_status_pre_checks table.
+
+Revision ID: 06242291zb30
+Revises: b2b5137ef0c7
+Create Date: 2025-06-24 11:00
+
+"""
+import sqlalchemy as sa
+from alembic import op
+from orchestrator.db import UtcTimestamp
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '06242291zb30'
+down_revision = 'b2b5137ef0c7'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'bgp_status_pre_checks',
+ sa.Column('pre_check_id', sa.String(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
+ sa.Column('router_fqdn', sa.String(), nullable=False),
+ sa.Column('partner', sa.String(), nullable=False),
+ sa.Column('result', postgresql.JSON(), nullable=False), # type: ignore[no-untyped-call]
+ sa.Column('created_at', UtcTimestamp(timezone=True), server_default=sa.text('current_timestamp'), nullable=False),
+ sa.Column('updated_at', UtcTimestamp(timezone=True), server_default=sa.text('current_timestamp'),
+ nullable=False, onupdate=sa.text('current_timestamp')),
+ sa.PrimaryKeyConstraint('pre_check_id'),
+ )
+ # indexes for faster lookup
+ op.create_index('ix_bgp_status_pre_checks_router_fqdn', 'bgp_status_pre_checks', ['router_fqdn'])
+
+
+def downgrade() -> None:
+ # drop indexes, then table
+ op.drop_index('ix_bgp_status_pre_checks_router_fqdn', table_name='bgp_status_pre_checks')
+ op.drop_index('ix_bgp_status_pre_checks_router_id', table_name='bgp_status_pre_checks')
+ op.drop_table('bgp_status_pre_checks')
\ No newline at end of file
diff --git a/gso/migrations/versions/2025-06-24_285954f5ec04_add_l3_service_redeploy_workflow.py b/gso/migrations/versions/2025-06-24_285954f5ec04_add_l3_service_redeploy_workflow.py
new file mode 100644
index 0000000000000000000000000000000000000000..36f256ce7a0cae9a1103809024a7f4bdec26a5e1
--- /dev/null
+++ b/gso/migrations/versions/2025-06-24_285954f5ec04_add_l3_service_redeploy_workflow.py
@@ -0,0 +1,42 @@
+"""Add L3 service redeploy workflow.
+
+Revision ID: 285954f5ec04
+Revises: 06242291zb30
+Create Date: 2025-06-24 16:49:06.495691
+
+"""
+from alembic import op
+from orchestrator.migrations.helpers import (
+ add_products_to_workflow_by_product_tag,
+ create_workflow,
+ delete_workflow,
+ remove_products_from_workflow_by_product_tag
+)
+
+# revision identifiers, used by Alembic.
+revision = '285954f5ec04'
+down_revision = '06242291zb30'
+branch_labels = None
+depends_on = None
+
+new_workflow = {
+ "name": "redeploy_l3_core_service",
+ "target": "MODIFY",
+ "description": "Redeploy Layer 3 service",
+ "product_type": "GeantIP"
+}
+additional_product_tags = ["IAS", "LHC", "COP", "RE_LHCONE", "RE_PEER"]
+
+
+def upgrade() -> None:
+ conn = op.get_bind()
+ create_workflow(conn, new_workflow)
+ for product in additional_product_tags:
+ add_products_to_workflow_by_product_tag(conn, new_workflow["name"], product)
+
+
+def downgrade() -> None:
+ conn = op.get_bind()
+ for product in additional_product_tags:
+ remove_products_from_workflow_by_product_tag(conn, new_workflow["name"], product)
+ delete_workflow(conn, new_workflow["name"])
diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json
index 98e41280d1f1d47480b64702d2d587452d846d08..decd0de5331c94fb604f09da978a2d5a62708c6c 100644
--- a/gso/oss-params-example.json
+++ b/gso/oss-params-example.json
@@ -3,7 +3,8 @@
"public_hostname": "https://gap.geant.org",
"internal_hostname": "http://gso-api:9000",
"isis_high_metric": 999999,
- "environment": "development"
+ "environment": "development",
+ "pre_check_cli_max_output_lines": 50
},
"NETBOX": {
"api": "https://127.0.0.1:8000",
diff --git a/gso/products/product_blocks/r_and_e_lhcone.py b/gso/products/product_blocks/r_and_e_lhcone.py
index e4dda2e22a52953ddf2dcf333640bed98a9ef76e..8b5248678e65b572d18b6ed273aa4c6d0936b51b 100644
--- a/gso/products/product_blocks/r_and_e_lhcone.py
+++ b/gso/products/product_blocks/r_and_e_lhcone.py
@@ -2,12 +2,14 @@
from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle
+from pydantic import NonNegativeInt
from gso.products.product_blocks.l3_core_service import (
L3CoreServiceBlock,
L3CoreServiceBlockInactive,
L3CoreServiceBlockProvisioning,
)
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
class RAndELHCOneBlockInactive(
@@ -16,15 +18,27 @@ class RAndELHCOneBlockInactive(
"""An inactive R&E LHCONE product block. See `RAndELHCOneBlock`."""
l3_core: L3CoreServiceBlockInactive
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
class RAndELHCOneBlockProvisioning(RAndELHCOneBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
"""A provisioning R&E LHCONE product block. See `RAndELHCOneBlock`."""
l3_core: L3CoreServiceBlockProvisioning
+ v4_bgp_local_preference: NonNegativeInt
+ v4_bgp_med: MultiExitDiscriminator
+ v6_bgp_local_preference: NonNegativeInt
+ v6_bgp_med: MultiExitDiscriminator
class RAndELHCOneBlock(RAndELHCOneBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
"""An active R&E LHCONE product block."""
l3_core: L3CoreServiceBlock
+ v4_bgp_local_preference: NonNegativeInt
+ v4_bgp_med: MultiExitDiscriminator
+ v6_bgp_local_preference: NonNegativeInt
+ v6_bgp_med: MultiExitDiscriminator
diff --git a/gso/products/product_blocks/r_and_e_peer.py b/gso/products/product_blocks/r_and_e_peer.py
index 8fbbeb84e62ee88b944eeb4782ee1800d723a405..c83222d7371abebedae61e6e901feb39c6416ba2 100644
--- a/gso/products/product_blocks/r_and_e_peer.py
+++ b/gso/products/product_blocks/r_and_e_peer.py
@@ -2,12 +2,14 @@
from orchestrator.domain.base import ProductBlockModel
from orchestrator.types import SubscriptionLifecycle
+from pydantic import NonNegativeInt
from gso.products.product_blocks.l3_core_service import (
L3CoreServiceBlock,
L3CoreServiceBlockInactive,
L3CoreServiceBlockProvisioning,
)
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
class RAndEPeerBlockInactive(
@@ -16,15 +18,27 @@ class RAndEPeerBlockInactive(
"""An inactive R&E Peer product block. See `RAndEPeerBlock`."""
l3_core: L3CoreServiceBlockInactive
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
class RAndEPeerBlockProvisioning(RAndEPeerBlockInactive, lifecycle=[SubscriptionLifecycle.PROVISIONING]):
"""A provisioning R&E Peer product block. See `RAndEPeerBlock`."""
l3_core: L3CoreServiceBlockProvisioning
+ v4_bgp_local_preference: NonNegativeInt
+ v4_bgp_med: MultiExitDiscriminator
+ v6_bgp_local_preference: NonNegativeInt
+ v6_bgp_med: MultiExitDiscriminator
class RAndEPeerBlock(RAndEPeerBlockProvisioning, lifecycle=[SubscriptionLifecycle.ACTIVE]):
"""An active R&E Peer product block."""
l3_core: L3CoreServiceBlock
+ v4_bgp_local_preference: NonNegativeInt
+ v4_bgp_med: MultiExitDiscriminator
+ v6_bgp_local_preference: NonNegativeInt
+ v6_bgp_med: MultiExitDiscriminator
diff --git a/gso/schedules/validate_products.py b/gso/schedules/validate_products.py
index efc441ad21f3a4894b82a030e99fd7f0653e0a99..1e693fe3ac48458741710d6a46be65bf548d5c25 100644
--- a/gso/schedules/validate_products.py
+++ b/gso/schedules/validate_products.py
@@ -4,12 +4,10 @@ from celery import shared_task
from orchestrator.services.processes import start_process
from gso.schedules.scheduling import CronScheduleConfig, scheduler
-from gso.services.processes import count_incomplete_validate_products
@shared_task
@scheduler(CronScheduleConfig(name="Validate Products and inactive subscriptions", minute="30", hour="2"))
def validate_products() -> None:
"""Validate all products."""
- if count_incomplete_validate_products() == 0:
- start_process("task_validate_geant_products")
+ start_process("task_validate_products")
diff --git a/gso/schedules/validate_subscriptions.py b/gso/schedules/validate_subscriptions.py
index db3573802cd6d9fb29438068fa3737a57f0b4bf9..fb728a1a967d1b24156bd2d48f6cf17201e36a5c 100644
--- a/gso/schedules/validate_subscriptions.py
+++ b/gso/schedules/validate_subscriptions.py
@@ -7,6 +7,8 @@ From this list, each workflow is selected that meets the following:
* The name of the workflow follows the pattern `validate_*`.
"""
+import re
+
import structlog
from celery import shared_task
from orchestrator.services.processes import get_execution_context
@@ -37,6 +39,17 @@ def validate_subscriptions() -> None:
return
for subscription in subscriptions:
+ if re.search(r"SKIP VALIDATION: .+", subscription.note or ""):
+ # The subscription is marked to skip validation altogether. We continue to the next subscription.
+ logger.warning(
+ "Manually skipped validation workflows for a subscription.",
+ product=subscription.product.name,
+ subscription_id=subscription.subscription_id,
+ subscription_description=subscription.description,
+ skip_reason=subscription.note,
+ )
+ continue
+
found_a_validation_workflow = False
for workflow in subscription.product.workflows:
if workflow.target == Target.SYSTEM and workflow.name.startswith("validate_"):
diff --git a/gso/services/lso_client.py b/gso/services/lso_client.py
index 8cfa6785a55d693283093ed80872a999908cf9b9..23c2ecf47b106b05d2eb48970460da2da9f47e91 100644
--- a/gso/services/lso_client.py
+++ b/gso/services/lso_client.py
@@ -15,7 +15,7 @@ from orchestrator.utils.errors import ProcessFailureError
from orchestrator.workflow import Step, StepList, begin, callback_step, conditional, inputstep
from pydantic import ConfigDict
from pydantic_forms.types import FormGenerator, State, UUIDstr
-from pydantic_forms.validators import Label, LongText, ReadOnlyField
+from pydantic_forms.validators import Label, LongText
from unidecode import unidecode
from gso import settings
@@ -146,13 +146,11 @@ def _show_results(state: State) -> FormGenerator:
class ConfirmRunPage(SubmitFormPage):
model_config = ConfigDict()
- if "lso_result_extra_label" in state:
- extra_label: Label = state["lso_result_extra_label"]
- run_status: ReadOnlyField(state["callback_result"]["status"], default_type=str) # type: ignore[valid-type]
- run_results: ReadOnlyField(json.dumps(state["callback_result"], indent=4), default_type=LongText) # type: ignore[valid-type]
+ run_status: Label = f"Callback result: {state["callback_result"]["status"]}"
+ run_results: LongText = json.dumps(state["callback_result"], indent=4)
yield ConfirmRunPage
- return state
+ return {}
@step("Clean up keys from state")
@@ -160,8 +158,6 @@ def _clean_state() -> State:
return {
"__remove_keys": [
"run_results",
- "lso_result_title",
- "lso_result_extra_label",
"callback_result",
"playbook_name",
"callback_route",
@@ -198,10 +194,6 @@ def lso_interaction(provisioning_step: Step) -> StepList:
to provision service subscriptions. If the playbook fails, this step will also fail, allowing for the user to retry
provisioning from the UI.
- Optionally, the keys `lso_result_title` and `lso_result_extra_label` can be added to the state before running
- this interaction. They will be used to customise the input step that shows the outcome of the LSO
- interaction.
-
Args:
provisioning_step: A workflow step that performs an operation remotely using the provisioning proxy.
@@ -216,7 +208,6 @@ def lso_interaction(provisioning_step: Step) -> StepList:
>> callback_step(
name=RUNNING_ANSIBLE_PLAYBOOK_STEP_NAME, action_step=_execute_playbook, validate_step=_evaluate_results
)
- >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name})
>> _show_results
)
>> _clean_state
@@ -248,7 +239,6 @@ def indifferent_lso_interaction(provisioning_step: Step) -> StepList:
>> callback_step(
name=RUNNING_ANSIBLE_PLAYBOOK_STEP_NAME, action_step=_execute_playbook, validate_step=_ignore_results
)
- >> step("Inject result title")(lambda: {"lso_result_title": provisioning_step.name})
>> _show_results
)
>> _clean_state
diff --git a/gso/services/processes.py b/gso/services/processes.py
index 30caa96fddf3d997fa9ac91254d5934f5707a830..22bfe4e0d7cdcf4aa08141d2a3bce5c78c07ff8d 100644
--- a/gso/services/processes.py
+++ b/gso/services/processes.py
@@ -18,19 +18,6 @@ def get_processes_by_workflow_name(workflow_name: str) -> Query:
return ProcessTable.query.join(WorkflowTable).filter(WorkflowTable.name == workflow_name)
-def count_incomplete_validate_products() -> int:
- """Count the number of incomplete validate_geant_products processes.
-
- Returns:
- The count of incomplete 'validate_geant_products' processes.
- """
- return (
- get_processes_by_workflow_name("validate_geant_products")
- .filter(ProcessTable.last_status != ProcessStatus.COMPLETED)
- .count()
- )
-
-
def get_failed_tasks() -> list[ProcessTable]:
"""Get all tasks that have failed."""
return ProcessTable.query.filter(
diff --git a/gso/settings.py b/gso/settings.py
index 8c1b0ec8e2d00c52e89c133ee7474d78289face3..7bcb5549f5c41b0ed2029b91b2c44dd24c211747 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -41,13 +41,16 @@ class GeneralParams(BaseSettings):
"""The hostname of GSO that is for internal use, such as the provisioning proxy."""
isis_high_metric: int
environment: EnvironmentEnum
+ """The environment in which GSO is running, such as development, test, uat, or production."""
+ pre_check_cli_max_output_lines: int = 50
+ """The maximum number of lines to print when displaying the output of a bgp_status_precheck CLI command."""
class CelerySettings(BaseSettings):
"""Parameters for Celery."""
broker_url: str = "redis://localhost:6379/0"
- result_backend: str = "rpc://localhost:6379/0"
+ result_backend: str = "redis://localhost:6379/0"
result_expires: int = 3600
class Config:
diff --git a/gso/tasks/massive_redeploy_base_config.py b/gso/tasks/massive_redeploy_base_config.py
index 011df8dac042af59d2d580484917c947852c59fb..7dda4e42cb4e12591bf38cbc41ee7466d124ddb7 100644
--- a/gso/tasks/massive_redeploy_base_config.py
+++ b/gso/tasks/massive_redeploy_base_config.py
@@ -23,10 +23,11 @@ def process_one_router(router_id: UUIDstr, tt_number: TTNumber) -> tuple[str, bo
Returns (router_fqdn, succeeded:bool, message:str).
"""
- router_fqdn = Router.from_subscription(router_id).router.router_fqdn
+ router_fqdn = router_id
succeeded = False
message = ""
try:
+ router_fqdn = Router.from_subscription(router_id).router.router_fqdn
pid = start_process(
"redeploy_base_config",
user_inputs=[
@@ -50,7 +51,7 @@ def process_one_router(router_id: UUIDstr, tt_number: TTNumber) -> tuple[str, bo
except FormValidationError as e:
message = f"Validation error: {e}"
except Exception as e: # noqa: BLE001
- message = f"Unexpected error: {e}"
+ message = f"Unexpected error: {e}, router_fqdn: {router_fqdn}"
return router_fqdn, succeeded, message
diff --git a/gso/translations/en-GB.json b/gso/translations/en-GB.json
index e3ea7bc6c1dd75b253f09bbec50844103087f84b..d2fa50f4813747a140061cda8f108434fe7c8ea4 100644
--- a/gso/translations/en-GB.json
+++ b/gso/translations/en-GB.json
@@ -63,6 +63,8 @@
"v4_bgp_is_passive": "IPv4 - BGP is passive",
"v4_bgp_send_default_route": "IPv4 - BGP send default route",
"v4_bgp_add_v4_multicast": "IPv4 - BGP add multicast",
+ "v4_bgp_local_preference": "IPv4 - BGP local preference",
+ "v4_bgp_med": "IPv4 - BGP Multi Exit Discriminator",
"v6_bfd_enabled": "IPv6 - BFD enabled",
"v6_bfd_multiplier": "IPv6 - BFD multiplier",
"v6_bfd_interval_rx": "IPv6 - BFD interval RX",
@@ -76,7 +78,9 @@
"v6_bgp_ttl_security": "IPv6 - BGP TTL security",
"v6_bgp_is_passive": "IPv6 - BGP is passive",
"v6_bgp_send_default_route": "IPv6 - BGP send default route",
- "v6_bgp_add_v6_multicast": "IPv6 - BGP add multicast"
+ "v6_bgp_add_v6_multicast": "IPv6 - BGP add multicast",
+ "v6_bgp_local_preference": "IPv6 - BGP local preference",
+ "v6_bgp_med": "IPv6 - BGP Multi Exit Discriminator"
}
},
"workflow": {
@@ -155,6 +159,7 @@
"modify_r_and_e_lhcone": "Modify R&E LHCONE",
"promote_p_to_pe": "Promote P to PE",
"redeploy_base_config": "Redeploy base config",
+ "redeploy_l3_core_service": "Redeploy Layer 3 service",
"redeploy_vrf": "Redeploy VRF router list",
"task_check_site_connectivity": "Check NETCONF connectivity of a Site",
"task_clean_old_tasks": "Remove old cleanup tasks",
@@ -163,7 +168,6 @@
"task_modify_partners": "Modify partner task",
"task_redeploy_base_config": "Redeploy base config on multiple routers",
"task_send_email_notifications": "Send email notifications for failed tasks",
- "task_validate_geant_products": "Validation task for GEANT products",
"terminate_edge_port": "Terminate Edge Port",
"terminate_iptrunk": "Terminate IP Trunk",
"terminate_geant_ip": "Terminate GÉANT IP",
diff --git a/gso/utils/shared_enums.py b/gso/utils/shared_enums.py
index dfbf5361d696896f7c14746ff224032c7e973fc1..34599a0fe3585b4de9b020f4a245f53bfd7baefc 100644
--- a/gso/utils/shared_enums.py
+++ b/gso/utils/shared_enums.py
@@ -41,6 +41,8 @@ class APType(strEnum):
"""Backup."""
LOAD_BALANCED = "LOAD_BALANCED"
"""Load-balanced."""
+ IGNORE = "IGNORE"
+ """Ignored."""
class SBPType(strEnum):
diff --git a/gso/utils/types/lso_response.py b/gso/utils/types/lso_response.py
new file mode 100644
index 0000000000000000000000000000000000000000..d67808a0ce162056ff3a9f9f2905d94be5f477b8
--- /dev/null
+++ b/gso/utils/types/lso_response.py
@@ -0,0 +1,28 @@
+"""This module defines types used for pre-check operations."""
+
+from enum import StrEnum
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class JobStatus(StrEnum):
+ """Enumeration of possible job statuses."""
+
+ SUCCESSFUL = "successful"
+ FAILED = "failed"
+
+
+class ExecutionResult(BaseModel):
+ """Model for capturing the result of an executable run."""
+
+ output: str
+ return_code: int
+ status: JobStatus
+
+
+class ExecutableRunResponse(BaseModel):
+ """Response for running an arbitrary executable."""
+
+ job_id: UUID
+ result: ExecutionResult
diff --git a/gso/utils/types/multi_exit_discriminator.py b/gso/utils/types/multi_exit_discriminator.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0dd02c0978f158f57b2ecd3e0e11263263835a8
--- /dev/null
+++ b/gso/utils/types/multi_exit_discriminator.py
@@ -0,0 +1,22 @@
+"""Type definition for a BGP Multi Exit Discriminator."""
+
+import contextlib
+from typing import Annotated
+
+from pydantic import AfterValidator, BeforeValidator
+
+
+def _multi_exit_discriminator_valid(value: str) -> str:
+ with contextlib.suppress(ValueError):
+ int_value = int(value)
+ if int_value >= 0:
+ return value
+
+ if value in {"igp", "min-igp"}:
+ return value
+
+ msg = "Multi Exit Discriminator must be either a positive integer, 'igp', or 'min-igp'"
+ raise ValueError(msg)
+
+
+MultiExitDiscriminator = Annotated[str, BeforeValidator(str), AfterValidator(_multi_exit_discriminator_valid)]
diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py
index 3456115a4439eb0bdfe98e2291b9bcb65bde0952..d37cd65309eb7fdd4da0edfe5c8af299c77a7890 100644
--- a/gso/workflows/__init__.py
+++ b/gso/workflows/__init__.py
@@ -104,7 +104,6 @@ LazyWorkflowInstance("gso.workflows.opengear.import_opengear", "import_opengear"
# Tasks
LazyWorkflowInstance("gso.workflows.tasks.send_email_notifications", "task_send_email_notifications")
-LazyWorkflowInstance("gso.workflows.tasks.validate_geant_products", "task_validate_geant_products")
LazyWorkflowInstance("gso.workflows.tasks.create_partners", "task_create_partners")
LazyWorkflowInstance("gso.workflows.tasks.modify_partners", "task_modify_partners")
LazyWorkflowInstance("gso.workflows.tasks.delete_partners", "task_delete_partners")
@@ -121,6 +120,9 @@ LazyWorkflowInstance("gso.workflows.edge_port.create_imported_edge_port", "creat
LazyWorkflowInstance("gso.workflows.edge_port.import_edge_port", "import_edge_port")
LazyWorkflowInstance("gso.workflows.edge_port.migrate_edge_port", "migrate_edge_port")
+# All L3 core services
+LazyWorkflowInstance("gso.workflows.l3_core_service.redeploy_l3_core_service", "redeploy_l3_core_service")
+
# IAS workflows
LazyWorkflowInstance("gso.workflows.l3_core_service.ias.create_ias", "create_ias")
LazyWorkflowInstance("gso.workflows.l3_core_service.ias.modify_ias", "modify_ias")
diff --git a/gso/workflows/l3_core_service/base_create_l3_core_service.py b/gso/workflows/l3_core_service/base_create_l3_core_service.py
index 90221ec0ed482de386762c097c88eb7a9e8298e0..0fb2c84357aa58e891f6578ae0c99b0de91838cc 100644
--- a/gso/workflows/l3_core_service/base_create_l3_core_service.py
+++ b/gso/workflows/l3_core_service/base_create_l3_core_service.py
@@ -38,12 +38,17 @@ def initial_input_form_generator(product_name: str) -> FormGenerator:
model_config = ConfigDict(title=f"{product_name} - Select partner")
tt_number: TTNumber
+ label_a: Label = Field(f"Please select the partner for this {product_name}.", exclude=True)
partner: partner_choice() # type: ignore[valid-type]
+ label_b: Label = Field(
+ f"Please select the partner who owns the Edge Port this {product_name} will be deployed on.", exclude=True
+ )
+ edge_port_partner: partner_choice() # type: ignore[valid-type]
initial_user_input = yield CreateL3CoreServiceForm
class EdgePortSelection(BaseModel):
- edge_port: active_edge_port_selector(partner_id=initial_user_input.partner) # type: ignore[valid-type]
+ edge_port: active_edge_port_selector(partner_id=initial_user_input.edge_port_partner) # type: ignore[valid-type]
ap_type: APType
custom_service_name: str | None = None
diff --git a/gso/workflows/l3_core_service/base_migrate_l3_core_service.py b/gso/workflows/l3_core_service/base_migrate_l3_core_service.py
index de2cae8950883b8c43affa8631339902cf09bc0e..8a18543b36302693aa0a2e9bfb1078abde5cfdb1 100644
--- a/gso/workflows/l3_core_service/base_migrate_l3_core_service.py
+++ b/gso/workflows/l3_core_service/base_migrate_l3_core_service.py
@@ -25,6 +25,7 @@ from gso.products.product_types.edge_port import EdgePort
from gso.services.lso_client import LSOState
from gso.services.partners import get_partner_by_id
from gso.services.subscriptions import get_active_edge_port_subscriptions
+from gso.utils.helpers import partner_choice
from gso.utils.types.tt_number import TTNumber
from gso.utils.workflow_steps import IS_HUMAN_INITIATED_WF_KEY, MOODI_EXTRA_KWARGS_KEY, SKIP_MOODI_KEY
from gso.workflows.shared import create_summary_form
@@ -33,9 +34,17 @@ from gso.workflows.shared import create_summary_form
def initial_input_form(subscription_id: UUIDstr) -> FormGenerator:
"""Gather input from the operator on what destination Edge Ports this L3 Core Service should be migrated to."""
subscription = SubscriptionModel.from_subscription(subscription_id)
- partner_id = subscription.customer_id
ap_list = subscription.l3_core.ap_list # type: ignore[attr-defined]
+ class PartnerSelectionForm(FormPage):
+ model_config = ConfigDict(title=f"Migrating a(n) {subscription.product.name} AP to a new Edge Port")
+ label: Label = Field(
+ "Please select the partner who owns the Edge Port which we are migrating to.", exclude=True
+ )
+ edge_port_partner: partner_choice() = subscription.customer_id # type: ignore[valid-type]
+
+ partner_input = yield PartnerSelectionForm
+
current_ep_list = {
str(
ap.sbp.edge_port.owner_subscription_id
@@ -51,14 +60,16 @@ def initial_input_form(subscription_id: UUIDstr) -> FormGenerator:
model_config = ConfigDict(title=f"Migrating a(n) {subscription.product.name} AP to a new Edge Port")
tt_number: TTNumber
- divider: Divider = Field(None, exclude=True)
+ divider_a: Divider = Field(None, exclude=True)
skip_moodi: bool = False
is_human_initiated_wf: bool = True
- source_edge_port: source_edge_port_selector | str # type: ignore[valid-type]
expected_number_of_ipv4_received_routes: int | None = None
expected_number_of_ipv4_advertised_routes: int | None = None
expected_number_of_ipv6_received_routes: int | None = None
expected_number_of_ipv6_advertised_routes: int | None = None
+ divider_b: Divider = Field(None, exclude=True)
+ label: Label = Field("Source Edge Port", exclude=True)
+ source_edge_port: source_edge_port_selector | str # type: ignore[valid-type]
source_ep_input = yield L3CoreServiceSourceEdgePortSelectionForm
@@ -78,7 +89,8 @@ def initial_input_form(subscription_id: UUIDstr) -> FormGenerator:
)
class L3CoreServiceEdgePortSelectionForm(FormPage):
- destination_edge_port: _destination_edge_port_selector(partner_id) | str # type: ignore[valid-type]
+ label: Label = Field("Destination Edge Port", exclude=True)
+ destination_edge_port: _destination_edge_port_selector(partner_input.edge_port_partner) | str # type: ignore[valid-type]
destination_ep_user_input = yield L3CoreServiceEdgePortSelectionForm
if source_ep_input.is_human_initiated_wf:
diff --git a/gso/workflows/l3_core_service/base_modify_l3_core_service.py b/gso/workflows/l3_core_service/base_modify_l3_core_service.py
index e16364ad1095734f4904c7288d54f46f9dbd808c..4b04a4c6a2bb0c19fb8189ac1dc382c4af4483ea 100644
--- a/gso/workflows/l3_core_service/base_modify_l3_core_service.py
+++ b/gso/workflows/l3_core_service/base_modify_l3_core_service.py
@@ -15,6 +15,7 @@ from gso.products.product_blocks.l3_core_service import AccessPort
from gso.products.product_blocks.service_binding_port import BFDSettings, ServiceBindingPort
from gso.products.product_types.edge_port import EdgePort
from gso.services.subscriptions import generate_unique_id, get_active_edge_port_subscriptions
+from gso.utils.helpers import partner_choice
from gso.utils.shared_enums import APType, SBPType
from gso.utils.types.geant_ids import IMPORTED_GS_ID
from gso.utils.types.ip_address import IPv4AddressType, IPv4Netmask, IPv6AddressType, IPv6Netmask
@@ -115,6 +116,15 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
match initial_input.operation:
case Operation.ADD:
+ class PartnerSelectionForm(FormPage):
+ model_config = ConfigDict(title=f"Add an Edge Port to a {product_name}")
+ label: Label = Field(
+ "Please select the partner who owns the Edge Port which is to be added.", exclude=True
+ )
+ edge_port_partner: partner_choice() = subscription.customer_id # type: ignore[valid-type]
+
+ partner_input = yield PartnerSelectionForm
+
class AccessPortListItem(BaseModel):
edge_port: str
ap_type: str
@@ -122,7 +132,7 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
def available_new_edge_port_selector() -> TypeAlias:
"""Generate a dropdown selector for choosing an active Edge Port in an input form."""
- edge_ports = get_active_edge_port_subscriptions(partner_id=subscription.customer_id)
+ edge_ports = get_active_edge_port_subscriptions(partner_id=partner_input.edge_port_partner)
options = {
str(edge_port.subscription_id): edge_port.description
diff --git a/gso/workflows/l3_core_service/r_and_e_lhcone/create_imported_r_and_e_lhcone.py b/gso/workflows/l3_core_service/r_and_e_lhcone/create_imported_r_and_e_lhcone.py
index a4447da019a640d0c0832ebf8f5baa5be0a1a2c9..f3f50c825fa6440004cab5e26a62f90586a05ec5 100644
--- a/gso/workflows/l3_core_service/r_and_e_lhcone/create_imported_r_and_e_lhcone.py
+++ b/gso/workflows/l3_core_service/r_and_e_lhcone/create_imported_r_and_e_lhcone.py
@@ -1,19 +1,42 @@
"""A creation workflow for adding an existing Imported R&E LHCONE to the service database."""
from orchestrator import workflow
+from orchestrator.forms import SubmitFormPage
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
from orchestrator.workflow import StepList, begin, done, step
from orchestrator.workflows.steps import resync, set_status, store_process_subscription
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator
from gso.products import ProductName
from gso.products.product_types.r_and_e_lhcone import ImportedRAndELHCOneInactive
from gso.services.partners import get_partner_by_name
from gso.services.subscriptions import get_product_id_by_name
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.workflows.l3_core_service.base_create_imported_l3_core_service import (
- initial_input_form_generator,
+ ServiceBindingPort,
initialize_subscription,
)
+from gso.workflows.l3_core_service.r_and_e_lhcone.shared import update_r_and_e_lhcone_subscription_model
+from gso.workflows.l3_core_service.shared import L3ProductNameType
+
+
+def initial_input_form_generator() -> FormGenerator:
+ """Initial input form generator for creating a new imported R&E LHCOne subscription."""
+
+ class ImportL3CoreServiceForm(SubmitFormPage):
+ partner: str
+ service_binding_ports: list[ServiceBindingPort]
+ product_name: L3ProductNameType
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
+
+ user_input = yield ImportL3CoreServiceForm
+
+ return user_input.model_dump()
@step("Create subscription")
@@ -37,6 +60,7 @@ def create_imported_r_and_e_lhcone() -> StepList:
>> create_subscription
>> store_process_subscription(Target.CREATE)
>> initialize_subscription
+ >> update_r_and_e_lhcone_subscription_model
>> set_status(SubscriptionLifecycle.ACTIVE)
>> resync
>> done
diff --git a/gso/workflows/l3_core_service/r_and_e_lhcone/create_r_and_e_lhcone.py b/gso/workflows/l3_core_service/r_and_e_lhcone/create_r_and_e_lhcone.py
index 2063c415f9f2ed1e6cb49a3b32ea4b5fabefecc7..195c70bdb84c0a8a3fa551569336140db1798d0d 100644
--- a/gso/workflows/l3_core_service/r_and_e_lhcone/create_r_and_e_lhcone.py
+++ b/gso/workflows/l3_core_service/r_and_e_lhcone/create_r_and_e_lhcone.py
@@ -1,14 +1,17 @@
"""Create R&E LHCONE subscription workflow."""
+from orchestrator.forms import FormPage
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
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_forms.types import State, UUIDstr
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator, State, UUIDstr
from gso.products.product_types.r_and_e_lhcone import RAndELHCOneInactive
from gso.services.lso_client import lso_interaction
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.utils.workflow_steps import prompt_sharepoint_checklist_url, start_moodi, stop_moodi
from gso.workflows.l3_core_service.base_create_l3_core_service import (
check_bgp_peers,
@@ -16,12 +19,31 @@ from gso.workflows.l3_core_service.base_create_l3_core_service import (
create_new_sharepoint_checklist,
deploy_bgp_peers_dry,
deploy_bgp_peers_real,
- initial_input_form_generator,
initialize_subscription,
provision_sbp_dry,
provision_sbp_real,
update_dns_records,
)
+from gso.workflows.l3_core_service.base_create_l3_core_service import (
+ initial_input_form_generator as base_initial_input_form_generator,
+)
+from gso.workflows.l3_core_service.r_and_e_lhcone.shared import update_r_and_e_lhcone_subscription_model
+
+
+def initial_input_form_generator(product_name: str) -> FormGenerator:
+ """Initial input form generator for creating a new R&E LHCOne subscription."""
+ initial_generator = base_initial_input_form_generator(product_name)
+ initial_user_input = yield from initial_generator
+
+ # Additional R&E LHCOne step
+ class RAndELHCOneExtraForm(FormPage):
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
+
+ r_and_e_lhcone_extra_form = yield RAndELHCOneExtraForm
+ return initial_user_input | r_and_e_lhcone_extra_form.model_dump()
@step("Create subscription")
@@ -44,6 +66,7 @@ def create_r_and_e_lhcone() -> StepList:
>> create_subscription
>> store_process_subscription(Target.CREATE)
>> initialize_subscription
+ >> update_r_and_e_lhcone_subscription_model
>> start_moodi()
>> lso_interaction(provision_sbp_dry)
>> lso_interaction(provision_sbp_real)
diff --git a/gso/workflows/l3_core_service/r_and_e_lhcone/modify_r_and_e_lhcone.py b/gso/workflows/l3_core_service/r_and_e_lhcone/modify_r_and_e_lhcone.py
index 7182a48b221894630cd96da21d426ad1ea19dceb..82bf3c71bfeab4a6fda595cc550bf0a96aceaff9 100644
--- a/gso/workflows/l3_core_service/r_and_e_lhcone/modify_r_and_e_lhcone.py
+++ b/gso/workflows/l3_core_service/r_and_e_lhcone/modify_r_and_e_lhcone.py
@@ -1,11 +1,16 @@
"""Modification workflow for an R&E LHCONE subscription."""
from orchestrator import begin, conditional, done, workflow
+from orchestrator.forms import FormPage
from orchestrator.targets import Target
from orchestrator.workflow import StepList
from orchestrator.workflows.steps import resync, store_process_subscription, unsync
from orchestrator.workflows.utils import wrap_modify_initial_input_form
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator, UUIDstr
+from gso.products.product_types.r_and_e_lhcone import RAndELHCOne
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.workflows.l3_core_service.base_modify_l3_core_service import (
Operation,
create_new_sbp,
@@ -13,11 +18,30 @@ from gso.workflows.l3_core_service.base_modify_l3_core_service import (
modify_existing_sbp,
remove_old_sbp,
)
+from gso.workflows.l3_core_service.r_and_e_lhcone.shared import update_r_and_e_lhcone_subscription_model
+
+
+def modify_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
+ """Initial form generator for modifying the custom attributes of an existing IAS subscription."""
+ initial_generator = initial_input_form_generator(subscription_id)
+ initial_user_input = yield from initial_generator
+
+ subscription = RAndELHCOne.from_subscription(subscription_id)
+
+ # Additional R&E LHCOne step
+ class RAndELHCOneExtraForm(FormPage):
+ v4_bgp_local_preference: NonNegativeInt = subscription.r_and_e_lhcone.v4_bgp_local_preference
+ v4_bgp_med: MultiExitDiscriminator = subscription.r_and_e_lhcone.v4_bgp_med
+ v6_bgp_local_preference: NonNegativeInt = subscription.r_and_e_lhcone.v6_bgp_local_preference
+ v6_bgp_med: MultiExitDiscriminator = subscription.r_and_e_lhcone.v6_bgp_med
+
+ r_and_e_lhcone_extra_form = yield RAndELHCOneExtraForm
+ return initial_user_input | r_and_e_lhcone_extra_form.model_dump()
@workflow(
"Modify R&E LHCONE",
- initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
+ initial_input_form=wrap_modify_initial_input_form(modify_input_form_generator),
target=Target.MODIFY,
)
def modify_r_and_e_lhcone() -> StepList:
@@ -30,6 +54,7 @@ def modify_r_and_e_lhcone() -> StepList:
begin
>> store_process_subscription(Target.MODIFY)
>> unsync
+ >> update_r_and_e_lhcone_subscription_model
>> access_port_is_added(create_new_sbp)
>> access_port_is_removed(remove_old_sbp)
>> access_port_is_modified(modify_existing_sbp)
diff --git a/gso/workflows/l3_core_service/r_and_e_lhcone/shared.py b/gso/workflows/l3_core_service/r_and_e_lhcone/shared.py
new file mode 100644
index 0000000000000000000000000000000000000000..532eb69b267549899810d07c22a4f50fd7038105
--- /dev/null
+++ b/gso/workflows/l3_core_service/r_and_e_lhcone/shared.py
@@ -0,0 +1,25 @@
+"""Shared logic for R&E LHCOne service workflows."""
+
+from orchestrator import step
+from orchestrator.domain import SubscriptionModel
+from pydantic import NonNegativeInt
+from pydantic_forms.types import State
+
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
+
+
+@step("Update R&E LHCOne-specific attributes")
+def update_r_and_e_lhcone_subscription_model(
+ subscription: SubscriptionModel,
+ v4_bgp_local_preference: NonNegativeInt,
+ v4_bgp_med: MultiExitDiscriminator,
+ v6_bgp_local_preference: NonNegativeInt,
+ v6_bgp_med: MultiExitDiscriminator,
+) -> State:
+ """Update the subscription model of an R&E LHCOne subscription."""
+ subscription.r_and_e_lhcone.v4_bgp_local_preference = v4_bgp_local_preference # type: ignore[attr-defined]
+ subscription.r_and_e_lhcone.v4_bgp_med = v4_bgp_med # type: ignore[attr-defined]
+ subscription.r_and_e_lhcone.v6_bgp_local_preference = v6_bgp_local_preference # type: ignore[attr-defined]
+ subscription.r_and_e_lhcone.v6_bgp_med = v6_bgp_med # type: ignore[attr-defined]
+
+ return {"subscription": subscription}
diff --git a/gso/workflows/l3_core_service/r_and_e_peer/create_imported_r_and_e_peer.py b/gso/workflows/l3_core_service/r_and_e_peer/create_imported_r_and_e_peer.py
index 6e56e435edefda5fcfd09cab33ad291483093898..6a7d87725d382c6ff343f76d860b2984454ab1d9 100644
--- a/gso/workflows/l3_core_service/r_and_e_peer/create_imported_r_and_e_peer.py
+++ b/gso/workflows/l3_core_service/r_and_e_peer/create_imported_r_and_e_peer.py
@@ -1,19 +1,42 @@
"""A creation workflow for adding an existing Imported R&E Peer to the service database."""
from orchestrator import workflow
+from orchestrator.forms import SubmitFormPage
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
from orchestrator.workflow import StepList, begin, done, step
from orchestrator.workflows.steps import resync, set_status, store_process_subscription
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator
from gso.products import ProductName
from gso.products.product_types.r_and_e_peer import ImportedRAndEPeerInactive
from gso.services.partners import get_partner_by_name
from gso.services.subscriptions import get_product_id_by_name
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.workflows.l3_core_service.base_create_imported_l3_core_service import (
- initial_input_form_generator,
+ ServiceBindingPort,
initialize_subscription,
)
+from gso.workflows.l3_core_service.r_and_e_peer.shared import update_r_and_e_peer_subscription_model
+from gso.workflows.l3_core_service.shared import L3ProductNameType
+
+
+def initial_input_form_generator() -> FormGenerator:
+ """Initial input form generator for creating a new imported R&E Peer subscription."""
+
+ class ImportL3CoreServiceForm(SubmitFormPage):
+ partner: str
+ service_binding_ports: list[ServiceBindingPort]
+ product_name: L3ProductNameType
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
+
+ user_input = yield ImportL3CoreServiceForm
+
+ return user_input.model_dump()
@step("Create subscription")
@@ -37,6 +60,7 @@ def create_imported_r_and_e_peer() -> StepList:
>> create_subscription
>> store_process_subscription(Target.CREATE)
>> initialize_subscription
+ >> update_r_and_e_peer_subscription_model
>> set_status(SubscriptionLifecycle.ACTIVE)
>> resync
>> done
diff --git a/gso/workflows/l3_core_service/r_and_e_peer/create_r_and_e_peer.py b/gso/workflows/l3_core_service/r_and_e_peer/create_r_and_e_peer.py
index 93493e9d3749b6b7ff7811914e5d90189f174b5e..2ba1dab69d5db4294a6da5d5e059fda95492b8d0 100644
--- a/gso/workflows/l3_core_service/r_and_e_peer/create_r_and_e_peer.py
+++ b/gso/workflows/l3_core_service/r_and_e_peer/create_r_and_e_peer.py
@@ -1,14 +1,17 @@
"""Create R&E Peer subscription workflow."""
+from orchestrator.forms import FormPage
from orchestrator.targets import Target
from orchestrator.types import SubscriptionLifecycle
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_forms.types import State, UUIDstr
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator, State, UUIDstr
from gso.products.product_types.r_and_e_peer import RAndEPeerInactive
from gso.services.lso_client import lso_interaction
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.utils.workflow_steps import prompt_sharepoint_checklist_url, start_moodi, stop_moodi
from gso.workflows.l3_core_service.base_create_l3_core_service import (
check_bgp_peers,
@@ -16,12 +19,31 @@ from gso.workflows.l3_core_service.base_create_l3_core_service import (
create_new_sharepoint_checklist,
deploy_bgp_peers_dry,
deploy_bgp_peers_real,
- initial_input_form_generator,
initialize_subscription,
provision_sbp_dry,
provision_sbp_real,
update_dns_records,
)
+from gso.workflows.l3_core_service.base_create_l3_core_service import (
+ initial_input_form_generator as base_initial_input_form_generator,
+)
+from gso.workflows.l3_core_service.r_and_e_peer.shared import update_r_and_e_peer_subscription_model
+
+
+def initial_input_form_generator(product_name: str) -> FormGenerator:
+ """Initial input form generator for creating a new R&E Peer subscription."""
+ initial_generator = base_initial_input_form_generator(product_name)
+ initial_user_input = yield from initial_generator
+
+ # Additional R&E Peer step
+ class RAndEPeerExtraForm(FormPage):
+ v4_bgp_local_preference: NonNegativeInt = 100
+ v4_bgp_med: MultiExitDiscriminator = "igp"
+ v6_bgp_local_preference: NonNegativeInt = 100
+ v6_bgp_med: MultiExitDiscriminator = "igp"
+
+ r_and_e_peer_extra_form = yield RAndEPeerExtraForm
+ return initial_user_input | r_and_e_peer_extra_form.model_dump()
@step("Create subscription")
@@ -44,6 +66,7 @@ def create_r_and_e_peer() -> StepList:
>> create_subscription
>> store_process_subscription(Target.CREATE)
>> initialize_subscription
+ >> update_r_and_e_peer_subscription_model
>> start_moodi()
>> lso_interaction(provision_sbp_dry)
>> lso_interaction(provision_sbp_real)
diff --git a/gso/workflows/l3_core_service/r_and_e_peer/modify_r_and_e_peer.py b/gso/workflows/l3_core_service/r_and_e_peer/modify_r_and_e_peer.py
index f04009dfe04e2e9c7fac2c1c0e766baebecf7886..7ed008a073f4361a70be110ee8043ae3056ec17d 100644
--- a/gso/workflows/l3_core_service/r_and_e_peer/modify_r_and_e_peer.py
+++ b/gso/workflows/l3_core_service/r_and_e_peer/modify_r_and_e_peer.py
@@ -1,11 +1,16 @@
"""Modification workflow for an R&E Peer subscription."""
from orchestrator import begin, conditional, done, workflow
+from orchestrator.forms import FormPage
from orchestrator.targets import Target
from orchestrator.workflow import StepList
from orchestrator.workflows.steps import resync, store_process_subscription, unsync
from orchestrator.workflows.utils import wrap_modify_initial_input_form
+from pydantic import NonNegativeInt
+from pydantic_forms.types import FormGenerator, UUIDstr
+from gso.products.product_types.r_and_e_peer import RAndEPeer
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
from gso.workflows.l3_core_service.base_modify_l3_core_service import (
Operation,
create_new_sbp,
@@ -13,11 +18,30 @@ from gso.workflows.l3_core_service.base_modify_l3_core_service import (
modify_existing_sbp,
remove_old_sbp,
)
+from gso.workflows.l3_core_service.r_and_e_peer.shared import update_r_and_e_peer_subscription_model
+
+
+def modify_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
+ """Initial form generator for modifying the custom attributes of an existing IAS subscription."""
+ initial_generator = initial_input_form_generator(subscription_id)
+ initial_user_input = yield from initial_generator
+
+ subscription = RAndEPeer.from_subscription(subscription_id)
+
+ # Additional R&E Peer step
+ class RAndEPeerExtraForm(FormPage):
+ v4_bgp_local_preference: NonNegativeInt = subscription.r_and_e_peer.v4_bgp_local_preference
+ v4_bgp_med: MultiExitDiscriminator = subscription.r_and_e_peer.v4_bgp_med
+ v6_bgp_local_preference: NonNegativeInt = subscription.r_and_e_peer.v6_bgp_local_preference
+ v6_bgp_med: MultiExitDiscriminator = subscription.r_and_e_peer.v6_bgp_med
+
+ r_and_e_peer_extra_form = yield RAndEPeerExtraForm
+ return initial_user_input | r_and_e_peer_extra_form.model_dump()
@workflow(
"Modify R&E Peer",
- initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
+ initial_input_form=wrap_modify_initial_input_form(modify_input_form_generator),
target=Target.MODIFY,
)
def modify_r_and_e_peer() -> StepList:
@@ -30,6 +54,7 @@ def modify_r_and_e_peer() -> StepList:
begin
>> store_process_subscription(Target.MODIFY)
>> unsync
+ >> update_r_and_e_peer_subscription_model
>> access_port_is_added(create_new_sbp)
>> access_port_is_removed(remove_old_sbp)
>> access_port_is_modified(modify_existing_sbp)
diff --git a/gso/workflows/l3_core_service/r_and_e_peer/shared.py b/gso/workflows/l3_core_service/r_and_e_peer/shared.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6af00f10cc2a35cb6816e2c0086b04fd727754c
--- /dev/null
+++ b/gso/workflows/l3_core_service/r_and_e_peer/shared.py
@@ -0,0 +1,25 @@
+"""Shared logic for R&E Peer service workflows."""
+
+from orchestrator import step
+from orchestrator.domain import SubscriptionModel
+from pydantic import NonNegativeInt
+from pydantic_forms.types import State
+
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
+
+
+@step("Update R&E Peer-specific attributes")
+def update_r_and_e_peer_subscription_model(
+ subscription: SubscriptionModel,
+ v4_bgp_local_preference: NonNegativeInt,
+ v4_bgp_med: MultiExitDiscriminator,
+ v6_bgp_local_preference: NonNegativeInt,
+ v6_bgp_med: MultiExitDiscriminator,
+) -> State:
+ """Update the subscription model of an R&E Peer subscription."""
+ subscription.r_and_e_peer.v4_bgp_local_preference = v4_bgp_local_preference # type: ignore[attr-defined]
+ subscription.r_and_e_peer.v4_bgp_med = v4_bgp_med # type: ignore[attr-defined]
+ subscription.r_and_e_peer.v6_bgp_local_preference = v6_bgp_local_preference # type: ignore[attr-defined]
+ subscription.r_and_e_peer.v6_bgp_med = v6_bgp_med # type: ignore[attr-defined]
+
+ return {"subscription": subscription}
diff --git a/gso/workflows/l3_core_service/redeploy_l3_core_service.py b/gso/workflows/l3_core_service/redeploy_l3_core_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..cfaaacc7f75dff4462488fd6519b30adf07b9054
--- /dev/null
+++ b/gso/workflows/l3_core_service/redeploy_l3_core_service.py
@@ -0,0 +1,87 @@
+"""Base functionality for modifying an L3 Core Service subscription."""
+
+from typing import TypeAlias, cast
+
+from orchestrator import workflow
+from orchestrator.domain import SubscriptionModel
+from orchestrator.forms import FormPage
+from orchestrator.targets import Target
+from orchestrator.workflow import StepList, begin, done
+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 pydantic_forms.types import FormGenerator, UUIDstr
+from pydantic_forms.validators import Choice
+
+from gso.products.product_blocks.l3_core_service import AccessPort
+from gso.products.product_types.edge_port import EdgePort
+from gso.services.lso_client import lso_interaction
+from gso.services.partners import get_partner_by_id
+from gso.utils.types.tt_number import TTNumber
+from gso.workflows.l3_core_service.base_create_l3_core_service import (
+ deploy_bgp_peers_dry,
+ deploy_bgp_peers_real,
+ provision_sbp_dry,
+ provision_sbp_real,
+)
+
+
+def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
+ """Get input which Access Port should be re-deployed."""
+ subscription = SubscriptionModel.from_subscription(subscription_id)
+ product_name = subscription.product.name
+
+ def access_port_selector() -> TypeAlias:
+ """Generate a dropdown selector for choosing an Access Port in an input form."""
+ access_ports = subscription.l3_core.ap_list # type: ignore[attr-defined]
+ options = {
+ str(access_port.subscription_instance_id): (
+ f"{access_port.sbp.gs_id} on "
+ f"{EdgePort.from_subscription(access_port.sbp.edge_port.owner_subscription_id).description} "
+ f"({access_port.ap_type})"
+ )
+ for access_port in access_ports
+ }
+
+ return cast(
+ type[Choice],
+ Choice.__call__(
+ "Select an Access Port",
+ zip(options.keys(), options.items(), strict=True),
+ ),
+ )
+
+ class AccessPortSelectionForm(FormPage):
+ model_config = ConfigDict(title=f"Re-deploy {product_name} subscription")
+
+ tt_number: TTNumber
+ access_port: access_port_selector() # type: ignore[valid-type]
+
+ user_input = yield AccessPortSelectionForm
+ partner_name = get_partner_by_id(subscription.customer_id).name
+ access_port = AccessPort.from_db(user_input.access_port)
+ access_port_fqdn = EdgePort.from_subscription(
+ access_port.sbp.edge_port.owner_subscription_id
+ ).edge_port.node.router_fqdn
+
+ return user_input.model_dump() | {"edge_port_fqdn_list": [access_port_fqdn], "partner_name": partner_name}
+
+
+@workflow(
+ "Redeploy Layer 3 service",
+ initial_input_form=wrap_modify_initial_input_form(initial_input_form_generator),
+ target=Target.MODIFY,
+)
+def redeploy_l3_core_service() -> StepList:
+ """Redeploy a Layer 3 subscription."""
+ return (
+ begin
+ >> store_process_subscription(Target.MODIFY)
+ >> unsync
+ >> lso_interaction(provision_sbp_dry)
+ >> lso_interaction(provision_sbp_real)
+ >> lso_interaction(deploy_bgp_peers_dry)
+ >> lso_interaction(deploy_bgp_peers_real)
+ >> resync
+ >> done
+ )
diff --git a/gso/workflows/router/terminate_router.py b/gso/workflows/router/terminate_router.py
index 4599c6f099555b79a9a5a6a5f1229f4878dd8013..75a78281d7cb8914059e69b44bedeb90148a9f97 100644
--- a/gso/workflows/router/terminate_router.py
+++ b/gso/workflows/router/terminate_router.py
@@ -15,6 +15,7 @@ The workflow consists of the following steps:
- Set the subscription status to `TERMINATED`.
"""
+import datetime
import ipaddress
import json
import logging
@@ -34,6 +35,7 @@ from orchestrator.workflows.steps import (
unsync,
)
from orchestrator.workflows.utils import wrap_modify_initial_input_form
+from pydantic import Field
from pydantic_forms.types import FormGenerator, State, UUIDstr
from requests import HTTPError
@@ -63,19 +65,28 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
class TerminateForm(SubmitFormPage):
if router.status == SubscriptionLifecycle.INITIAL:
- info_label_2: Label = (
+ info_label_2: Label = Field(
"This will immediately mark the subscription as terminated, preventing any other workflows from "
- "interacting with this product subscription."
+ "interacting with this product subscription.",
+ exclude=True,
+ )
+ info_label_3: Label = Field(
+ "ONLY EXECUTE THIS WORKFLOW WHEN YOU ARE ABSOLUTELY SURE WHAT YOU ARE DOING.", exclude=True
)
- info_label_3: Label = "ONLY EXECUTE THIS WORKFLOW WHEN YOU ARE ABSOLUTELY SURE WHAT YOU ARE DOING."
tt_number: TTNumber
- termination_label: Label = "Please confirm whether configuration should get removed from the router."
+ termination_label: Label = Field(
+ "Please confirm whether configuration should get removed from the router.", exclude=True
+ )
remove_configuration: bool = False
- update_ibgp_mesh_label: Label = "Please confirm whether the iBGP mesh should get updated."
+ update_ibgp_mesh_label: Label = Field("Please confirm whether the iBGP mesh should get updated.", exclude=True)
update_ibgp_mesh: bool = True
- update_sdp_mesh_label: Label = "Please confirm whether the SDP mesh should get updated."
+ update_sdp_mesh_label: Label = Field("Please confirm whether the SDP mesh should get updated.", exclude=True)
update_sdp_mesh: bool = True
+ remove_loopback_from_ipam_label: Label = Field(
+ "Please confirm whether the loopback address should be released in IPAM.", exclude=True
+ )
+ remove_loopback_from_ipam: bool = False
user_input = yield TerminateForm
return user_input.model_dump() | {
@@ -85,11 +96,20 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
@step("Deprovision loopback IPs from IPAM")
-def deprovision_loopback_ips(subscription: Router) -> dict:
+def deprovision_loopback_ips(subscription: Router, remove_loopback_from_ipam: bool, process_id: UUIDstr) -> None: # noqa: FBT001
"""Clear up the loopback addresses from IPAM."""
- infoblox.delete_host_by_ip(ipaddress.IPv4Address(subscription.router.router_lo_ipv4_address))
-
- return {"subscription": subscription}
+ if remove_loopback_from_ipam:
+ infoblox.delete_host_by_ip(ipaddress.IPv4Address(subscription.router.router_lo_ipv4_address))
+ else:
+ record = infoblox.find_host_by_fqdn(subscription.router.router_fqdn)
+ if record:
+ # We keep the record in IPAM but add a comment stating that this router is terminated.
+ # This is done to prevent an address from being re-used.
+ record.comment = (
+ f"This router was terminated by GAP process {process_id} on "
+ f"{datetime.datetime.now(tz=datetime.UTC).strftime("%d/%m/%Y")}."
+ )
+ record.update()
@step("[DRY RUN] Remove configuration from router")
diff --git a/gso/workflows/tasks/validate_geant_products.py b/gso/workflows/tasks/validate_geant_products.py
deleted file mode 100644
index 8fe39f61b762f46683336f802af1885d447ab339..0000000000000000000000000000000000000000
--- a/gso/workflows/tasks/validate_geant_products.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""A task that checks for all products in the database to be well-kept."""
-
-# <!-- vale off -->
-# Copyright 2019-2020 SURF.
-# Copyright 2024 GÉANT Vereniging.
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-# <!-- vale on -->
-
-from orchestrator.targets import Target
-from orchestrator.workflow import StepList, done, init, workflow
-from orchestrator.workflows.tasks.validate_products import (
- check_all_workflows_are_in_db,
- check_db_fixed_input_config,
- check_subscription_models,
- check_that_products_have_create_modify_and_terminate_workflows,
- check_workflows_for_matching_targets_and_descriptions,
-)
-
-
-@workflow("Validate GEANT products", target=Target.SYSTEM)
-def task_validate_geant_products() -> StepList:
- """Validate products in the database.
-
- This task is based on the ``task_validate_products`` present in ``orchestrator-core`` but it does not check for the
- existence of the ``modify_note`` workflow on all products, since this workflow is currently not used in GEANT.
- """
- return (
- init
- >> check_all_workflows_are_in_db
- >> check_workflows_for_matching_targets_and_descriptions
- # >> check_that_products_have_at_least_one_workflow FIXME: Uncomment as soon as this would pass again
- >> check_db_fixed_input_config
- >> check_that_products_have_create_modify_and_terminate_workflows
- >> check_subscription_models
- >> done
- )
diff --git a/setup.py b/setup.py
index 7a013462e2b7170b142309c2ea0c5fb685505bed..727d17cf74949905824a31810779dcf37388681b 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ from setuptools import find_packages, setup
setup(
name="geant-service-orchestrator",
- version="3.11",
+ version="3.12",
author="GÉANT Orchestration and Automation Team",
author_email="goat@geant.org",
description="GÉANT Service Orchestrator",
diff --git a/test/cli/test_imports.py b/test/cli/test_imports.py
index 371980aa4aae8827603f57f33a045d05dd192bb5..45a64f7780e921fddf8c12a63e95c70a519cbca1 100644
--- a/test/cli/test_imports.py
+++ b/test/cli/test_imports.py
@@ -73,12 +73,12 @@ def iptrunk_data(temp_file, router_subscription_factory, faker) -> (Path, dict):
},
"nodeA": {
"name": side_a_node or router_side_a.router.router_fqdn,
- "ae_name": side_a_ae_name or faker.nokia_lag_interface_name(),
+ "ae_name": side_a_ae_name or faker.unique.nokia_lag_interface_name(),
"port_ga_id": faker.imported_ga_id(),
"members": side_a_members
or [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(5)
@@ -88,12 +88,12 @@ def iptrunk_data(temp_file, router_subscription_factory, faker) -> (Path, dict):
},
"nodeB": {
"name": side_b_node or router_side_b.router.router_fqdn,
- "ae_name": side_b_ae_name or faker.nokia_lag_interface_name(),
+ "ae_name": side_b_ae_name or faker.unique.nokia_lag_interface_name(),
"port_ga_id": faker.imported_ga_id(),
"members": side_b_members
or [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(5)
@@ -234,18 +234,21 @@ def lan_switch_interconnect_data(temp_file, faker, switch_subscription_factory,
"minimum_links": 1,
"router_side": {
"node": str(router_subscription_factory().subscription_id),
- "ae_iface": faker.nokia_lag_interface_name(),
+ "ae_iface": faker.unique.nokia_lag_interface_name(),
"ae_members": [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {
+ "interface_name": faker.unique.nokia_physical_interface_name(),
+ "interface_description": faker.sentence(),
+ }
for _ in range(2)
],
},
"switch_side": {
"switch": str(switch_subscription_factory().subscription_id),
- "ae_iface": faker.juniper_ae_interface_name(),
+ "ae_iface": faker.unique.juniper_ae_interface_name(),
"ae_members": [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -277,7 +280,7 @@ def edge_port_data(temp_file, faker, router_subscription_factory, partner_factor
"ignore_if_down": False,
"ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -584,7 +587,7 @@ def test_import_iptrunk_invalid_router_id_side_a_and_b(mock_start_process, mock_
@patch("gso.cli.imports.start_process")
def test_import_iptrunk_non_unique_members_side_a_and_b(mock_start_process, mock_sleep, iptrunk_data, faker, capfd):
duplicate_interface = {
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
side_a_members = [duplicate_interface for _ in range(5)]
@@ -611,11 +614,11 @@ def test_import_iptrunk_non_unique_members_side_a_and_b(mock_start_process, mock
@patch("gso.cli.imports.start_process")
def test_import_iptrunk_side_a_member_count_mismatch(mock_start_process, mock_sleep, iptrunk_data, faker, capfd):
side_a_members = [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {"interface_name": faker.unique.nokia_physical_interface_name(), "interface_description": faker.sentence()}
for _ in range(5)
]
side_b_members = [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {"interface_name": faker.unique.nokia_physical_interface_name(), "interface_description": faker.sentence()}
for _ in range(6)
]
broken_data = iptrunk_data(side_a_members=side_a_members, side_b_members=side_b_members)
diff --git a/test/cli/test_lso_calls.py b/test/cli/test_lso_calls.py
new file mode 100644
index 0000000000000000000000000000000000000000..86eec0b6cace3f633644847a7c779632f0f832d4
--- /dev/null
+++ b/test/cli/test_lso_calls.py
@@ -0,0 +1,203 @@
+import json
+
+import click
+import httpx
+import pytest
+from orchestrator.db import db
+from typer.testing import CliRunner
+
+from gso.cli.lso_calls import app
+from gso.db.models import BgpStatusPreCheckTable
+
+runner = CliRunner()
+
+# A valid LSO response payload
+BASE_RESPONSE = {
+ "job_id": "2c19843b-c721-4662-8014-b1a7a22f1734",
+ "result": {
+ "output": json.dumps({"bgp": {"asn": 65001}, "neighbors": []}),
+ "return_code": 0,
+ "status": "successful",
+ },
+}
+
+
+class DummyResponse:
+ def __init__(self, json_data, status=200):
+ self._json = json_data
+ self.status_code = status
+
+ def json(self):
+ return self._json
+
+ def raise_for_status(self):
+ if not (200 <= self.status_code < 300):
+ raise httpx.HTTPStatusError("error", request=None, response=self) # noqa: RUF100,EM101
+
+
+@pytest.fixture()
+def import_file(tmp_path):
+ """Create a temporary JSON import file."""
+ path = tmp_path / "import.json"
+ path.write_text(json.dumps({"foo": "bar"}))
+ return path
+
+
+@pytest.fixture()
+def mock_http_success(monkeypatch):
+ """Return a successful dummy response for httpx.post."""
+ dummy_resp = DummyResponse(BASE_RESPONSE)
+ monkeypatch.setattr("httpx.post", lambda *args, **kwargs: dummy_resp) # noqa: ARG005
+ return dummy_resp
+
+
+@pytest.fixture()
+def mock_http_error(monkeypatch):
+ """Simulate httpx.post throwing an exception."""
+
+ def raise_exc(*args, **kwargs):
+ """Raise an exception to simulate a network error."""
+ msg = "timeout"
+ raise Exception(msg) # noqa: TRY002
+
+ monkeypatch.setattr("httpx.post", raise_exc)
+ return raise_exc
+
+
+@pytest.fixture()
+def mock_http_bad_shape(monkeypatch):
+ """Return JSON that does not fit ExecutableRunResponse."""
+ bad = {"unexpected": "data"}
+ dummy_resp = DummyResponse(bad)
+ monkeypatch.setattr("httpx.post", lambda *args, **kwargs: dummy_resp) # noqa: ARG005
+ return dummy_resp
+
+
+def test_invalid_partner_name(mock_http_success, import_file):
+ """Step 0: unknown partner should abort before any HTTP call."""
+ result = runner.invoke(
+ app,
+ ["rt1.ams.geant.org", "UNKNOWN", str(import_file)],
+ input="",
+ )
+ assert result.exit_code == 1
+ assert "partner 'unknown' not found" in result.stdout.lower()
+ assert db.session.query(BgpStatusPreCheckTable).count() == 0
+
+
+def test_no_save_leaves_table_empty(mock_http_success, partner_factory, import_file):
+ """If user declines save, table remains empty."""
+ partner_factory("SURF")
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(import_file)],
+ input="n\n",
+ )
+ assert result.exit_code == 0
+ assert "not saving" in result.stdout.lower()
+ assert db.session.query(BgpStatusPreCheckTable).count() == 0
+
+
+def test_prompt_save_yes_persists_record(mock_http_success, partner_factory, import_file):
+ """Typing 'y' at prompt should also persist."""
+ partner_factory("SURF")
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(import_file)],
+ input="y\n",
+ )
+ assert result.exit_code == 0
+ assert db.session.query(BgpStatusPreCheckTable).count() == 1
+
+
+def test_http_failure_aborts(mock_http_error, partner_factory, import_file):
+ """Network/timeout errors should abort with exit code 1."""
+ partner_factory("SURF")
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(import_file)],
+ )
+ assert result.exit_code == 1
+ # Now stderr is separately captured:
+ assert "error: failed to call lso: timeout" in result.stdout.lower()
+
+ # Table still empty
+ assert db.session.query(BgpStatusPreCheckTable).count() == 0
+
+
+def test_invalid_shape_aborts(mock_http_bad_shape, partner_factory, import_file):
+ """Malformed top-level JSON shape should abort."""
+ partner_factory("SURF")
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(import_file)],
+ )
+ assert result.exit_code == 1
+ assert "invalid JSON returned by LSO" in result.stdout
+ assert db.session.query(BgpStatusPreCheckTable).count() == 0
+
+
+def test_parse_output_nonjson(monkeypatch, partner_factory, import_file):
+ """If output is not valid JSON, we still complete without saving."""
+ partner_factory("SURF")
+ # Patch BASE_RESPONSE to use non-JSON output
+ bad = dict(BASE_RESPONSE)
+ bad["result"] = dict(bad["result"])
+ bad["result"]["output"] = "not a json"
+ # monkeypatch
+ import httpx as _httpx
+
+ _orig = _httpx.post
+ _httpx.post = lambda *args, **kwargs: DummyResponse(bad) # noqa: ARG005
+ try:
+ result = runner.invoke(app, ["rt1.example.com", "SURF", str(import_file)], input="n\n")
+ assert result.exit_code == 0
+ assert "(not valid JSON, raw string below)" in result.stdout
+ finally:
+ _httpx.post = _orig
+
+
+def test_pagination_on_large_output(monkeypatch, partner_factory, import_file):
+ """Parsed output >50 lines should trigger click.echo_via_pager."""
+ partner_factory("SURF")
+ big = {"x": ["line"] * 100}
+ payload = dict(BASE_RESPONSE)
+ payload["result"] = dict(payload["result"])
+ payload["result"]["output"] = json.dumps(big)
+ monkeypatch.setattr("httpx.post", lambda *args, **kwargs: DummyResponse(payload)) # noqa: ARG005
+
+ paged = False
+
+ def fake_pager(text):
+ nonlocal paged
+ paged = True
+
+ monkeypatch.setattr(click, "echo_via_pager", fake_pager)
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(import_file)],
+ input="n\n",
+ )
+ assert result.exit_code == 0
+ assert paged, "Expected parsed-output pager for large JSON"
+
+
+def test_invalid_import_file(tmp_path, partner_factory):
+ """Invalid JSON import file should abort with exit code 2 and no DB write."""
+ # create invalid JSON file
+ bad_file = tmp_path / "bad_import.json"
+ bad_file.write_text("{invalid_json}")
+ partner_factory("SURF")
+
+ # Invoke with the malformed JSON file
+ result = runner.invoke(
+ app,
+ ["rt1.example.com", "SURF", str(bad_file)],
+ )
+
+ # Expect exit code 2 from _load_import_file
+ assert result.exit_code == 2
+ assert "Error: could not read or parse" in result.stdout
+
+ # Ensure no record was written
+ assert db.session.query(BgpStatusPreCheckTable).count() == 0
diff --git a/test/conftest.py b/test/conftest.py
index 3e4a765d70cfefbd4ae800de2e18d35423ec8d6f..71112c3390527c4f4051f95964027e9e2070ab81 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -160,6 +160,15 @@ def faker() -> Faker:
return fake
+@pytest.fixture(autouse=True)
+def _clear_faker_uniqueness(faker):
+ """Reset the already seen values generated by faker.
+
+ The generators in Faker that require uniqueness, only do so on a per-test basis. Therefore, we can reset this after
+ every test to avoid ``UniquenessException``s."""
+ faker.unique.clear()
+
+
@pytest.fixture(scope="session")
def db_uri():
"""Provide a unique database URI for each pytest-xdist worker, or a default URI if running without xdist."""
diff --git a/test/fixtures/edge_port_fixtures.py b/test/fixtures/edge_port_fixtures.py
index 57f8481c380c83f355074662ed8d0124d3e6573b..7913e3324b1523278a7b1e5f46f9fd8b7cfe5c08 100644
--- a/test/fixtures/edge_port_fixtures.py
+++ b/test/fixtures/edge_port_fixtures.py
@@ -56,7 +56,7 @@ def edge_port_subscription_factory(faker, geant_partner, router_subscription_fac
edge_port_subscription.edge_port.edge_port_description = description or faker.text(max_nb_chars=30)
edge_port_subscription.edge_port.ga_id = ga_id or faker.ga_id()
edge_port_subscription.edge_port.node = node
- edge_port_subscription.edge_port.edge_port_name = name or faker.nokia_lag_interface_name()
+ edge_port_subscription.edge_port.edge_port_name = name or faker.unique.nokia_lag_interface_name()
edge_port_subscription.edge_port.edge_port_description = edge_port_description or faker.sentence()
edge_port_subscription.edge_port.enable_lacp = enable_lacp
edge_port_subscription.edge_port.encapsulation = encapsulation
@@ -69,16 +69,16 @@ def edge_port_subscription_factory(faker, geant_partner, router_subscription_fac
edge_port_subscription.edge_port.edge_port_ae_members = edge_port_ae_members or [
EdgePortAEMemberBlock.new(
faker.uuid(),
- interface_name=faker.nokia_physical_interface_name()
+ interface_name=faker.unique.nokia_physical_interface_name()
if node.vendor == Vendor.NOKIA
- else faker.juniper_physical_interface_name(),
+ else faker.unique.juniper_physical_interface_name(),
interface_description=faker.sentence(),
),
EdgePortAEMemberBlock.new(
faker.uuid(),
- interface_name=faker.nokia_physical_interface_name()
+ interface_name=faker.unique.nokia_physical_interface_name()
if node.vendor == Vendor.NOKIA
- else faker.juniper_physical_interface_name(),
+ else faker.unique.juniper_physical_interface_name(),
interface_description=faker.sentence(),
),
]
diff --git a/test/fixtures/iptrunk_fixtures.py b/test/fixtures/iptrunk_fixtures.py
index 94f409d47461529cf8d2d858f7e321145daa6d7d..fc241447a0261c99f95efc766e4a48bc262aca4b 100644
--- a/test/fixtures/iptrunk_fixtures.py
+++ b/test/fixtures/iptrunk_fixtures.py
@@ -31,18 +31,18 @@ def iptrunk_side_subscription_factory(router_subscription_factory, faker):
iptrunk_side_node=iptrunk_side_node.router
if iptrunk_side_node
else router_subscription_factory(vendor=Vendor.NOKIA, router_access_via_ts=side_node_access_via_ts).router,
- iptrunk_side_ae_iface=iptrunk_side_ae_iface or faker.nokia_lag_interface_name(),
+ iptrunk_side_ae_iface=iptrunk_side_ae_iface or faker.unique.nokia_lag_interface_name(),
ga_id=ga_id or faker.ga_id(),
iptrunk_side_ae_members=iptrunk_side_ae_members
or [
IptrunkInterfaceBlock.new(
faker.uuid(),
- interface_name=faker.nokia_physical_interface_name(),
+ interface_name=faker.unique.nokia_physical_interface_name(),
interface_description=faker.sentence(),
),
IptrunkInterfaceBlock.new(
faker.uuid(),
- interface_name=faker.nokia_physical_interface_name(),
+ interface_name=faker.unique.nokia_physical_interface_name(),
interface_description=faker.sentence(),
),
],
diff --git a/test/fixtures/lan_switch_interconnect_fixtures.py b/test/fixtures/lan_switch_interconnect_fixtures.py
index 50cea443cf5c70cf1593193137b2b8cd4b8496eb..094de9758a14680bfafa328cf015031d9ac6c66c 100644
--- a/test/fixtures/lan_switch_interconnect_fixtures.py
+++ b/test/fixtures/lan_switch_interconnect_fixtures.py
@@ -60,9 +60,9 @@ def lan_switch_interconnect_subscription_factory(
LanSwitchInterconnectInterfaceBlockInactive.new(
uuid4(),
interface_name=(
- faker.nokia_physical_interface_name()
+ faker.unique.nokia_physical_interface_name()
if router_side_node.vendor == Vendor.NOKIA
- else faker.juniper_physical_interface_name()
+ else faker.unique.juniper_physical_interface_name()
),
interface_description=faker.sentence(),
)
@@ -70,7 +70,9 @@ def lan_switch_interconnect_subscription_factory(
]
switch_side_ae_members = switch_side_ae_members or [
LanSwitchInterconnectInterfaceBlockInactive.new(
- uuid4(), interface_name=faker.juniper_physical_interface_name(), interface_description=faker.sentence()
+ uuid4(),
+ interface_name=faker.unique.juniper_physical_interface_name(),
+ interface_description=faker.sentence(),
)
for _ in range(2)
]
@@ -84,16 +86,16 @@ def lan_switch_interconnect_subscription_factory(
node=router_side_node,
ae_iface=router_side_ae_iface
or (
- faker.nokia_lag_interface_name()
+ faker.unique.nokia_lag_interface_name()
if router_side_node.vendor == Vendor.NOKIA
- else faker.juniper_ae_interface_name()
+ else faker.unique.juniper_ae_interface_name()
),
ae_members=router_side_ae_members,
)
subscription.lan_switch_interconnect.switch_side = LanSwitchInterconnectSwitchSideBlockInactive.new(
uuid4(),
switch=switch_side_switch.site if switch_side_switch else switch_subscription_factory().switch,
- ae_iface=switch_side_ae_iface or faker.juniper_ae_interface_name(),
+ ae_iface=switch_side_ae_iface or faker.unique.juniper_ae_interface_name(),
ae_members=switch_side_ae_members,
)
subscription.lan_switch_interconnect.dcn_management_vlan_id = (
diff --git a/test/schedules/test_scheduling.py b/test/schedules/test_scheduling.py
index a1eb56b48a4d0023c527635701ba85dbfa5a4bab..8f6feafcc1b62b7522e4f85786e8a4e288217bb8 100644
--- a/test/schedules/test_scheduling.py
+++ b/test/schedules/test_scheduling.py
@@ -93,7 +93,10 @@ def test_subscriptions_without_system_target_workflow(
mock_logger,
validate_subscriptions,
):
- mock_get_active_subscriptions.return_value = [MagicMock(product=MagicMock(workflows=[]))]
+ subscription_mock = MagicMock()
+ subscription_mock.product.workflows = []
+ subscription_mock.note = None
+ mock_get_active_subscriptions.return_value = [subscription_mock]
validate_subscriptions()
mock_logger.warning.assert_called_once()
@@ -106,6 +109,7 @@ def test_subscription_status_not_usable(
subscription_mock = MagicMock()
subscription_mock.product.workflows = [MagicMock(target=Target.SYSTEM, name="workflow_name")]
subscription_mock.status = "Not Usable Status"
+ subscription_mock.note = None
mock_get_active_subscriptions.return_value = [subscription_mock]
validate_subscriptions()
@@ -123,6 +127,7 @@ def test_valid_subscriptions_for_validation(
mocked_workflow = MagicMock(target=Target.SYSTEM, name="workflow_name")
subscription_mock.product.workflows = [mocked_workflow]
subscription_mock.status = "active"
+ subscription_mock.note = None
mock_get_active_subscriptions.return_value = [subscription_mock]
validate_subscriptions()
validate_func = mock_get_execution_context()["validate"]
@@ -130,3 +135,18 @@ def test_valid_subscriptions_for_validation(
mocked_workflow.name,
json=[{"subscription_id": str(subscription_mock.subscription_id)}],
)
+
+
+def test_subscription_skipped_with_note(
+ mock_get_active_subscriptions,
+ mock_get_execution_context,
+ validate_subscriptions,
+):
+ subscription_mock = MagicMock()
+ subscription_mock.product.workflows = [MagicMock(target=Target.SYSTEM, name="workflow_name")]
+ subscription_mock.note = "SKIP VALIDATION: Because we don't want to."
+ mock_get_active_subscriptions.return_value = [subscription_mock]
+ validate_subscriptions()
+
+ validate_func = mock_get_execution_context()["validate"]
+ validate_func.assert_not_called()
diff --git a/test/tasks/test_masssive_redeploy_base_config.py b/test/tasks/test_massive_redeploy_base_config.py
similarity index 98%
rename from test/tasks/test_masssive_redeploy_base_config.py
rename to test/tasks/test_massive_redeploy_base_config.py
index 394b2ca21f2d01139f57d49563f317bd26a7b34d..690c90f1151acb0829663b9f05ff54af391b2e84 100644
--- a/test/tasks/test_masssive_redeploy_base_config.py
+++ b/test/tasks/test_massive_redeploy_base_config.py
@@ -133,7 +133,7 @@ def test_timeout_and_validation_and_unexpected(
expected_failed = {
"t1.example.com": "Timed out waiting for workflow to complete",
"t2.example.com": f"Validation error: {validation_exc}",
- "t3.example.com": "Unexpected error: boom",
+ "t3.example.com": "Unexpected error: boom, router_fqdn: t3.example.com",
}
expected_payload = {"successful_wfs": {}, "failed_wfs": expected_failed}
diff --git a/test/utils/types/__init__.py b/test/utils/types/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/test/utils/types/test_multi_exit_discriminator.py b/test/utils/types/test_multi_exit_discriminator.py
new file mode 100644
index 0000000000000000000000000000000000000000..e24ffba7bb8e54f37de1ea7e2075f9c19f763ff5
--- /dev/null
+++ b/test/utils/types/test_multi_exit_discriminator.py
@@ -0,0 +1,30 @@
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from gso.utils.types.multi_exit_discriminator import MultiExitDiscriminator
+
+
+class BGPDiscriminator(BaseModel):
+ bgp_med: MultiExitDiscriminator
+
+
+@pytest.mark.parametrize(
+ ("input_value", "is_valid"),
+ [
+ ("igp", True),
+ ("min-igp", True),
+ ("40", True),
+ ("0", True),
+ (43, True),
+ ("-74", False),
+ ("45.6", False),
+ (-91, False),
+ ("abc", False),
+ ],
+)
+def test_multi_exit_discriminator(input_value, is_valid):
+ if is_valid:
+ assert BGPDiscriminator(bgp_med=input_value).bgp_med == str(input_value)
+ else:
+ with pytest.raises(ValidationError):
+ BGPDiscriminator(bgp_med=input_value)
diff --git a/test/workflows/edge_port/test_create_edge_port.py b/test/workflows/edge_port/test_create_edge_port.py
index 39ac848fb8e7647bc7b64896ceebc6895d672efb..ca49dd9ef67f4e8ff60d862c479d332f50d73ac5 100644
--- a/test/workflows/edge_port/test_create_edge_port.py
+++ b/test/workflows/edge_port/test_create_edge_port.py
@@ -64,13 +64,13 @@ def input_form_wizard_data(request, router_subscription_factory, partner_factory
"ga_id": "GA-12345",
}
create_edge_port_interface_step = {
- "name": faker.nokia_lag_interface_name(),
+ "name": faker.unique.nokia_lag_interface_name(),
"description": faker.sentence(),
"ae_members": [
{
- "interface_name": faker.juniper_physical_interface_name()
+ "interface_name": faker.unique.juniper_physical_interface_name()
if vendor == Vendor.JUNIPER
- else faker.nokia_physical_interface_name(),
+ else faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
diff --git a/test/workflows/edge_port/test_create_imported_edge_port.py b/test/workflows/edge_port/test_create_imported_edge_port.py
index d13614eb1e0b5328e55c506875ac507e290ebf84..66b279f59e3369c15ffe408b24f4df5c50f24e04 100644
--- a/test/workflows/edge_port/test_create_imported_edge_port.py
+++ b/test/workflows/edge_port/test_create_imported_edge_port.py
@@ -15,7 +15,7 @@ def imported_edge_port_creation_input_form_data(router_subscription_factory, par
"service_type": EdgePortType.CUSTOMER,
"speed": PhysicalPortCapacity.TEN_GIGABIT_PER_SECOND,
"encapsulation": EncapsulationType.DOT1Q,
- "name": faker.nokia_lag_interface_name(),
+ "name": faker.unique.nokia_lag_interface_name(),
"minimum_links": 2,
"ga_id": faker.imported_ga_id(),
"mac_address": faker.mac_address(),
@@ -24,11 +24,11 @@ def imported_edge_port_creation_input_form_data(router_subscription_factory, par
"ignore_if_down": False,
"ae_members": [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
},
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
},
],
diff --git a/test/workflows/edge_port/test_migrate_edge_port.py b/test/workflows/edge_port/test_migrate_edge_port.py
index d81536c6c9d6c848e82f85ebd11dc6677e928880..a235a8f66f1e93ffff2cc8485d4da6db23aa6689 100644
--- a/test/workflows/edge_port/test_migrate_edge_port.py
+++ b/test/workflows/edge_port/test_migrate_edge_port.py
@@ -54,11 +54,11 @@ def input_form_wizard_data(request, router_subscription_factory, partner, faker)
"node": str(router_subscription_factory(vendor=Vendor.NOKIA).subscription_id),
}
create_edge_port_interface_step = {
- "name": faker.nokia_lag_interface_name(),
+ "name": faker.unique.nokia_lag_interface_name(),
"description": faker.sentence(),
"ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
diff --git a/test/workflows/edge_port/test_modify_edge_port.py b/test/workflows/edge_port/test_modify_edge_port.py
index aa915148c95dba7390bffbb9138f0333f1cf2294..1d7e4aa04edb3299a4425d4517ef8bdbeb25f74c 100644
--- a/test/workflows/edge_port/test_modify_edge_port.py
+++ b/test/workflows/edge_port/test_modify_edge_port.py
@@ -36,9 +36,9 @@ def input_form_wizard_data(
"description": faker.sentence(),
"ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name()
+ "interface_name": faker.unique.nokia_physical_interface_name()
if vendor == Vendor.NOKIA
- else faker.juniper_physical_interface_name(),
+ else faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
],
diff --git a/test/workflows/iptrunk/test_create_imported_iptrunk.py b/test/workflows/iptrunk/test_create_imported_iptrunk.py
index 7f83775bb0d8ba1309bc008a8440f90b541e0f53..9373e8f8502b2c28a567fa34063d451e6d993fd3 100644
--- a/test/workflows/iptrunk/test_create_imported_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_imported_iptrunk.py
@@ -24,17 +24,17 @@ def workflow_input_data(faker, router_subscription_factory):
"iptrunk_isis_metric": 10000,
"iptrunk_description_suffix": faker.word(),
"side_a_node_id": str(router_subscription_factory().subscription_id),
- "side_a_ae_iface": faker.nokia_lag_interface_name(),
+ "side_a_ae_iface": faker.unique.nokia_lag_interface_name(),
"side_a_ga_id": faker.imported_ga_id(),
"side_a_ae_members": [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {"interface_name": faker.unique.nokia_physical_interface_name(), "interface_description": faker.sentence()}
for _ in range(3)
],
"side_b_node_id": str(router_subscription_factory().subscription_id),
- "side_b_ae_iface": faker.nokia_lag_interface_name(),
+ "side_b_ae_iface": faker.unique.nokia_lag_interface_name(),
"side_b_ga_id": faker.imported_ga_id(),
"side_b_ae_members": [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {"interface_name": faker.unique.nokia_physical_interface_name(), "interface_description": faker.sentence()}
for _ in range(3)
],
"iptrunk_ipv4_network": faker.ipv4_network(max_subnet=31),
diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py
index 9e3b297efd94b62581859a7a767d5adaae1dee5d..1341d20f9a5aab053de799f348bde95665ef9b6b 100644
--- a/test/workflows/iptrunk/test_create_iptrunk.py
+++ b/test/workflows/iptrunk/test_create_iptrunk.py
@@ -55,7 +55,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
router_side_b = str(router_subscription_factory(vendor=Vendor.JUNIPER).subscription_id)
side_b_members = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -63,7 +63,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
else:
router_side_b = str(router_subscription_factory().subscription_id)
side_b_members = [
- {"interface_name": faker.nokia_physical_interface_name(), "interface_description": faker.sentence()}
+ {"interface_name": faker.unique.nokia_physical_interface_name(), "interface_description": faker.sentence()}
for _ in range(2)
]
@@ -79,10 +79,10 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
create_ip_trunk_confirm_step = {"iptrunk_minimum_links": 1}
create_ip_trunk_side_a_router_name = {"side_a_node_id": router_side_a}
create_ip_trunk_side_a_step = {
- "side_a_ae_iface": faker.nokia_lag_interface_name(),
+ "side_a_ae_iface": faker.unique.nokia_lag_interface_name(),
"side_a_ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -90,7 +90,7 @@ def input_form_wizard_data(request, router_subscription_factory, faker):
}
create_ip_trunk_side_b_router_name = {"side_b_node_id": router_side_b}
create_ip_trunk_side_b_step = {
- "side_b_ae_iface": faker.nokia_lag_interface_name(),
+ "side_b_ae_iface": faker.unique.nokia_lag_interface_name(),
"side_b_ae_members": side_b_members,
}
summary_view_step = {}
diff --git a/test/workflows/iptrunk/test_migrate_iptrunk.py b/test/workflows/iptrunk/test_migrate_iptrunk.py
index 179788307a3b963858aedc80f7e7d27b2473b89d..05f2544f1330784d0690f30b37539f4d497489ae 100644
--- a/test/workflows/iptrunk/test_migrate_iptrunk.py
+++ b/test/workflows/iptrunk/test_migrate_iptrunk.py
@@ -31,14 +31,14 @@ def migrate_form_input(
use_juniper = getattr(request, "param", UseJuniperSide.NONE)
new_side_ae_members_nokia = [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
]
new_side_ae_members_juniper = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -50,7 +50,7 @@ def migrate_form_input(
new_router = str(router_subscription_factory(vendor=Vendor.JUNIPER, router_access_via_ts=False).subscription_id)
replace_side = str(old_subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.owner_subscription_id)
new_side_ae_members = new_side_ae_members_juniper
- lag_name = faker.juniper_ae_interface_name()
+ lag_name = faker.unique.juniper_ae_interface_name()
elif use_juniper == UseJuniperSide.SIDE_B:
# Juniper -> Nokia
old_side_a_node = router_subscription_factory(vendor=Vendor.JUNIPER)
@@ -81,7 +81,7 @@ def migrate_form_input(
new_router = str(router_subscription_factory().subscription_id)
replace_side = str(old_subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.subscription.subscription_id)
new_side_ae_members = new_side_ae_members_nokia
- lag_name = faker.nokia_lag_interface_name()
+ lag_name = faker.unique.nokia_lag_interface_name()
return [
{"subscription_id": str(old_subscription.subscription_id)},
diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py
index b6117a8c3161a2c19d822f1b5acf026b265b36db..7edfe553261282248aa13aa39ee6f53fbbb19051 100644
--- a/test/workflows/iptrunk/test_modify_trunk_interface.py
+++ b/test/workflows/iptrunk/test_modify_trunk_interface.py
@@ -32,14 +32,14 @@ def input_form_iptrunk_data(
side_b_node = iptrunk_side_subscription_factory()
new_side_a_ae_members = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
]
new_side_b_ae_members = [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -50,14 +50,14 @@ def input_form_iptrunk_data(
side_b_node = iptrunk_side_subscription_factory(iptrunk_side_node=side_node)
new_side_a_ae_members = [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
]
new_side_b_ae_members = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -69,14 +69,14 @@ def input_form_iptrunk_data(
side_b_node = iptrunk_side_subscription_factory(iptrunk_side_node=side_node_2)
new_side_a_ae_members = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
]
new_side_b_ae_members = [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
@@ -86,14 +86,14 @@ def input_form_iptrunk_data(
side_b_node = iptrunk_side_subscription_factory()
new_side_a_ae_members = [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
]
new_side_b_ae_members = [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
diff --git a/test/workflows/l3_core_service/test_create_imported_l3_core_service.py b/test/workflows/l3_core_service/test_create_imported_l3_core_service.py
index 21a00b73bbd76383a94b4e3985f8e6f06388544c..b6984fb90fb4af6b510da1c39a8cde3203857e7f 100644
--- a/test/workflows/l3_core_service/test_create_imported_l3_core_service.py
+++ b/test/workflows/l3_core_service/test_create_imported_l3_core_service.py
@@ -72,6 +72,9 @@ def test_create_imported_l3_core_service_success(faker, partner_factory, edge_po
}
if product_name == ProductName.IAS:
creation_form_input_data["ias_flavor"] = IASFlavor.IASGWS
+ elif product_name in {ProductName.R_AND_E_PEER, ProductName.R_AND_E_LHCONE}:
+ creation_form_input_data["v6_bgp_local_preference"] = 9999
+ creation_form_input_data["v6_bgp_med"] = 8888
result, _, _ = run_workflow(f"{L3_CREAT_IMPORTED_WF_MAP[product_name]}", creation_form_input_data)
state = extract_state(result)
subscription = SubscriptionModel.from_subscription(state["subscription_id"])
@@ -81,3 +84,13 @@ def test_create_imported_l3_core_service_success(faker, partner_factory, edge_po
if product_name == ProductName.IAS:
assert subscription.ias.ias_flavor == IASFlavor.IASGWS
+ elif product_name == ProductName.R_AND_E_PEER:
+ assert subscription.r_and_e_peer.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_peer.v4_bgp_med == "igp"
+ assert subscription.r_and_e_peer.v6_bgp_local_preference == 9999
+ assert subscription.r_and_e_peer.v6_bgp_med == "8888"
+ elif product_name == ProductName.R_AND_E_LHCONE:
+ assert subscription.r_and_e_lhcone.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_lhcone.v4_bgp_med == "igp"
+ assert subscription.r_and_e_lhcone.v6_bgp_local_preference == 9999
+ assert subscription.r_and_e_lhcone.v6_bgp_med == "8888"
diff --git a/test/workflows/l3_core_service/test_create_l3_core_service.py b/test/workflows/l3_core_service/test_create_l3_core_service.py
index a8ee5bc6a27b090acfaf936010999fc8ab0be7fe..6edf078fe4bffb344399ecf5d87cc200cfc2a82f 100644
--- a/test/workflows/l3_core_service/test_create_l3_core_service.py
+++ b/test/workflows/l3_core_service/test_create_l3_core_service.py
@@ -55,7 +55,7 @@ def test_create_l3_core_service_success(
form_input_data = [
{"product": product_id},
- {"tt_number": faker.tt_number(), "partner": partner["partner_id"]},
+ {"tt_number": faker.tt_number(), "partner": partner["partner_id"], "edge_port_partner": partner["partner_id"]},
{"edge_port": {"edge_port": edge_port_a, "ap_type": APType.PRIMARY, "custom_service_name": faker.sentence()}},
{
"is_tagged": faker.boolean(),
@@ -89,6 +89,12 @@ def test_create_l3_core_service_success(
"ias_flavor": IASFlavor.IASGWS,
}
form_input_data.append(extra_ias_data)
+ elif product_name in {ProductName.R_AND_E_PEER, ProductName.R_AND_E_LHCONE}:
+ extra_r_and_e_data = {
+ "v6_bgp_local_preference": 5555,
+ "v6_bgp_med": "min-igp",
+ }
+ form_input_data.append(extra_r_and_e_data)
lso_interaction_count = 7
@@ -117,3 +123,8 @@ def test_create_l3_core_service_success(
if product_name == ProductName.IAS:
assert subscription.ias.ias_flavor == IASFlavor.IASGWS
+ elif product_name == ProductName.R_AND_E_PEER:
+ assert subscription.r_and_e_peer.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_peer.v4_bgp_med == "igp"
+ assert subscription.r_and_e_peer.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_peer.v6_bgp_med == "min-igp"
diff --git a/test/workflows/l3_core_service/test_migrate_l3_core_service.py b/test/workflows/l3_core_service/test_migrate_l3_core_service.py
index eb1b292a90b769ffd091e1d677a91df16518803f..f9960a053f988d455898164fa0f195927719417d 100644
--- a/test/workflows/l3_core_service/test_migrate_l3_core_service.py
+++ b/test/workflows/l3_core_service/test_migrate_l3_core_service.py
@@ -35,6 +35,7 @@ def test_migrate_l3_core_service_success(
subscription = SubscriptionModel.from_subscription(subscription_id)
form_input_data = [
{"subscription_id": subscription_id},
+ {"edge_port_partner": partner["partner_id"]},
{
"tt_number": faker.tt_number(),
"source_edge_port": subscription.l3_core.ap_list[0].sbp.edge_port.owner_subscription_id,
@@ -86,6 +87,7 @@ def test_migrate_l3_core_service_scoped_emission(
form_input_data = [
{"subscription_id": str(subscription.subscription_id)},
+ {"edge_port_partner": partner["partner_id"]},
{
"tt_number": faker.tt_number(),
"source_edge_port": source_edge_port,
diff --git a/test/workflows/l3_core_service/test_modify_l3_core_service.py b/test/workflows/l3_core_service/test_modify_l3_core_service.py
index 93f8680b68dc20273030535499364f8a5a0e121f..e9a7df9d36f5888a9481b34aff3e3e30e606f1fc 100644
--- a/test/workflows/l3_core_service/test_modify_l3_core_service.py
+++ b/test/workflows/l3_core_service/test_modify_l3_core_service.py
@@ -26,6 +26,12 @@ def test_modify_l3_core_service_remove_edge_port_success(faker, l3_core_service_
"ias_flavor": IASFlavor.IASGWS,
}
input_form_data.append(extra_ias_data)
+ elif product_name in {ProductName.R_AND_E_PEER, ProductName.R_AND_E_LHCONE}:
+ extra_r_and_e_data = {
+ "v6_bgp_local_preference": 5555,
+ "v6_bgp_med": "min-igp",
+ }
+ input_form_data.append(extra_r_and_e_data)
result, _, _ = run_workflow(L3_MODIFICATION_WF_MAP[product_name], input_form_data)
@@ -36,6 +42,16 @@ def test_modify_l3_core_service_remove_edge_port_success(faker, l3_core_service_
assert ap_list[0].ap_type == APType.BACKUP
if product_name == ProductName.IAS:
assert subscription.ias.ias_flavor == IASFlavor.IASGWS
+ elif product_name == ProductName.R_AND_E_PEER:
+ assert subscription.r_and_e_peer.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_peer.v4_bgp_med == "igp"
+ assert subscription.r_and_e_peer.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_peer.v6_bgp_med == "min-igp"
+ elif product_name == ProductName.R_AND_E_LHCONE:
+ assert subscription.r_and_e_lhcone.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_lhcone.v4_bgp_med == "igp"
+ assert subscription.r_and_e_lhcone.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_lhcone.v6_bgp_med == "min-igp"
@pytest.mark.parametrize("product_name", L3_PRODUCT_NAMES)
@@ -53,6 +69,7 @@ def test_modify_l3_core_service_add_new_edge_port_success(
input_form_data = [
{"subscription_id": str(subscription.subscription_id)},
{"tt_number": faker.tt_number(), "operation": Operation.ADD},
+ {"edge_port_partner": partner["partner_id"]},
{ # Adding configuration for the new SBP
"edge_port": str(new_edge_port),
"ap_type": APType.BACKUP,
@@ -86,6 +103,12 @@ def test_modify_l3_core_service_add_new_edge_port_success(
"ias_flavor": IASFlavor.IASGWS,
}
input_form_data.append(extra_ias_data)
+ elif product_name in {ProductName.R_AND_E_PEER, ProductName.R_AND_E_LHCONE}:
+ extra_r_and_e_data = {
+ "v6_bgp_local_preference": 5555,
+ "v6_bgp_med": "123123",
+ }
+ input_form_data.append(extra_r_and_e_data)
result, _, _ = run_workflow(L3_MODIFICATION_WF_MAP[product_name], input_form_data)
@@ -94,15 +117,25 @@ def test_modify_l3_core_service_add_new_edge_port_success(
ap_list = subscription.l3_core.ap_list
new_ap = ap_list[-1]
assert new_ap.ap_type == APType.BACKUP
- assert new_ap.sbp.gs_id == input_form_data[2]["gs_id"]
- assert new_ap.sbp.vlan_id == input_form_data[2]["vlan_id"]
- assert str(new_ap.sbp.ipv4_address) == input_form_data[2]["ipv4_address"]
- assert new_ap.sbp.ipv4_mask == input_form_data[2]["ipv4_mask"]
- assert str(new_ap.sbp.ipv6_address) == input_form_data[2]["ipv6_address"]
- assert new_ap.sbp.ipv6_mask == input_form_data[2]["ipv6_mask"]
+ assert new_ap.sbp.gs_id == input_form_data[3]["gs_id"]
+ assert new_ap.sbp.vlan_id == input_form_data[3]["vlan_id"]
+ assert str(new_ap.sbp.ipv4_address) == input_form_data[3]["ipv4_address"]
+ assert new_ap.sbp.ipv4_mask == input_form_data[3]["ipv4_mask"]
+ assert str(new_ap.sbp.ipv6_address) == input_form_data[3]["ipv6_address"]
+ assert new_ap.sbp.ipv6_mask == input_form_data[3]["ipv6_mask"]
assert len(ap_list) == 3
if product_name == ProductName.IAS:
assert subscription.ias.ias_flavor == IASFlavor.IASGWS
+ elif product_name == ProductName.R_AND_E_PEER:
+ assert subscription.r_and_e_peer.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_peer.v4_bgp_med == "igp"
+ assert subscription.r_and_e_peer.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_peer.v6_bgp_med == "123123"
+ elif product_name == ProductName.R_AND_E_LHCONE:
+ assert subscription.r_and_e_lhcone.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_lhcone.v4_bgp_med == "igp"
+ assert subscription.r_and_e_lhcone.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_lhcone.v6_bgp_med == "123123"
@pytest.fixture()
@@ -152,7 +185,7 @@ def sbp_input_form_data(faker):
@pytest.mark.parametrize("product_name", L3_PRODUCT_NAMES)
@pytest.mark.workflow()
-def test_modify_l3_core_service_modify_edge_port_success(
+def test_modify_l3_core_service_modify_edge_port_success( # noqa: PLR0915
faker, l3_core_service_subscription_factory, product_name, sbp_input_form_data
):
subscription = l3_core_service_subscription_factory(product_name=product_name)
@@ -170,6 +203,12 @@ def test_modify_l3_core_service_modify_edge_port_success(
"ias_flavor": IASFlavor.IASGWS,
}
input_form_data.append(extra_ias_data)
+ elif product_name in {ProductName.R_AND_E_PEER, ProductName.R_AND_E_LHCONE}:
+ extra_r_and_e_data = {
+ "v6_bgp_local_preference": 5555,
+ "v6_bgp_med": "min-igp",
+ }
+ input_form_data.append(extra_r_and_e_data)
result, _, _ = run_workflow(L3_MODIFICATION_WF_MAP[product_name], input_form_data)
@@ -222,3 +261,13 @@ def test_modify_l3_core_service_modify_edge_port_success(
if product_name == ProductName.IAS:
assert subscription.ias.ias_flavor == IASFlavor.IASGWS
+ elif product_name == ProductName.R_AND_E_PEER:
+ assert subscription.r_and_e_peer.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_peer.v4_bgp_med == "igp"
+ assert subscription.r_and_e_peer.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_peer.v6_bgp_med == "min-igp"
+ elif product_name == ProductName.R_AND_E_LHCONE:
+ assert subscription.r_and_e_lhcone.v4_bgp_local_preference == 100
+ assert subscription.r_and_e_lhcone.v4_bgp_med == "igp"
+ assert subscription.r_and_e_lhcone.v6_bgp_local_preference == 5555
+ assert subscription.r_and_e_lhcone.v6_bgp_med == "min-igp"
diff --git a/test/workflows/l3_core_service/test_redeploy_l3_core_service.py b/test/workflows/l3_core_service/test_redeploy_l3_core_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..46b7c52b53b6568379aeb8d9737ba25689a84dbc
--- /dev/null
+++ b/test/workflows/l3_core_service/test_redeploy_l3_core_service.py
@@ -0,0 +1,88 @@
+from copy import deepcopy
+
+import pytest
+from orchestrator.domain import SubscriptionModel
+
+from gso.workflows.l3_core_service.shared import L3_PRODUCT_NAMES
+from test.workflows import assert_complete, assert_lso_interaction_success, extract_state, run_workflow
+
+
+@pytest.mark.parametrize("product_name", L3_PRODUCT_NAMES)
+@pytest.mark.workflow()
+def test_redeploy_l3_core_service_success(faker, l3_core_service_subscription_factory, product_name):
+ subscription = l3_core_service_subscription_factory(product_name=product_name)
+ old_subscription: SubscriptionModel = deepcopy(subscription)
+ access_port = subscription.l3_core.ap_list[0]
+ input_form_data = [
+ {"subscription_id": str(subscription.subscription_id)},
+ {"tt_number": faker.tt_number(), "access_port": str(access_port.subscription_instance_id)},
+ ]
+
+ result, process_stat, step_log = run_workflow("redeploy_l3_core_service", input_form_data)
+
+ for _ in range(4):
+ result, step_log = assert_lso_interaction_success(result, process_stat, step_log)
+
+ assert_complete(result)
+ state = extract_state(result)
+ subscription = SubscriptionModel.from_subscription(state["subscription_id"])
+ ap_list = subscription.l3_core.ap_list
+ old_ap_list = old_subscription.l3_core.ap_list
+
+ # Assertions that ensure the subscription is unchanged
+ for old_access_port, access_port in zip(old_ap_list, ap_list, strict=False):
+ assert access_port.sbp.gs_id == old_access_port.sbp.gs_id
+ assert access_port.sbp.is_tagged == old_access_port.sbp.is_tagged
+ assert access_port.sbp.vlan_id == old_access_port.sbp.vlan_id
+ assert str(access_port.sbp.ipv4_address) == str(old_access_port.sbp.ipv4_address)
+ assert access_port.sbp.ipv4_mask == old_access_port.sbp.ipv4_mask
+ assert str(access_port.sbp.ipv6_address) == str(old_access_port.sbp.ipv6_address)
+ assert access_port.sbp.ipv6_mask == old_access_port.sbp.ipv6_mask
+ assert access_port.sbp.custom_firewall_filters == old_access_port.sbp.custom_firewall_filters
+
+ assert access_port.sbp.bgp_session_list[0].bfd_enabled == old_access_port.sbp.bgp_session_list[0].bfd_enabled
+ assert (
+ access_port.sbp.bgp_session_list[0].has_custom_policies
+ == old_access_port.sbp.bgp_session_list[0].has_custom_policies
+ )
+ assert (
+ access_port.sbp.bgp_session_list[0].authentication_key
+ == old_access_port.sbp.bgp_session_list[0].authentication_key
+ )
+ assert (
+ access_port.sbp.bgp_session_list[0].multipath_enabled
+ == old_access_port.sbp.bgp_session_list[0].multipath_enabled
+ )
+ assert (
+ access_port.sbp.bgp_session_list[0].send_default_route
+ == old_access_port.sbp.bgp_session_list[0].send_default_route
+ )
+ assert access_port.sbp.bgp_session_list[0].is_passive == old_access_port.sbp.bgp_session_list[0].is_passive
+
+ assert access_port.sbp.bgp_session_list[1].bfd_enabled == old_access_port.sbp.bgp_session_list[1].bfd_enabled
+ assert (
+ access_port.sbp.bgp_session_list[1].has_custom_policies
+ == old_access_port.sbp.bgp_session_list[1].has_custom_policies
+ )
+ assert (
+ access_port.sbp.bgp_session_list[1].authentication_key
+ == old_access_port.sbp.bgp_session_list[1].authentication_key
+ )
+ assert (
+ access_port.sbp.bgp_session_list[1].multipath_enabled
+ == old_access_port.sbp.bgp_session_list[1].multipath_enabled
+ )
+ assert (
+ access_port.sbp.bgp_session_list[1].send_default_route
+ == old_access_port.sbp.bgp_session_list[1].send_default_route
+ )
+ assert access_port.sbp.bgp_session_list[1].is_passive == old_access_port.sbp.bgp_session_list[1].is_passive
+
+ assert access_port.sbp.v4_bfd_settings.bfd_enabled == old_access_port.sbp.v4_bfd_settings.bfd_enabled
+ assert access_port.sbp.v4_bfd_settings.bfd_interval_rx == old_access_port.sbp.v4_bfd_settings.bfd_interval_rx
+ assert access_port.sbp.v4_bfd_settings.bfd_interval_tx == old_access_port.sbp.v4_bfd_settings.bfd_interval_tx
+ assert access_port.sbp.v4_bfd_settings.bfd_multiplier == old_access_port.sbp.v4_bfd_settings.bfd_multiplier
+ assert access_port.sbp.v6_bfd_settings.bfd_enabled == old_access_port.sbp.v6_bfd_settings.bfd_enabled
+ assert access_port.sbp.v6_bfd_settings.bfd_interval_rx == old_access_port.sbp.v6_bfd_settings.bfd_interval_rx
+ assert access_port.sbp.v6_bfd_settings.bfd_interval_tx == old_access_port.sbp.v6_bfd_settings.bfd_interval_tx
+ assert access_port.sbp.v6_bfd_settings.bfd_multiplier == old_access_port.sbp.v6_bfd_settings.bfd_multiplier
diff --git a/test/workflows/lan_switch_interconnect/test_create_imported_lan_switch_interconnect.py b/test/workflows/lan_switch_interconnect/test_create_imported_lan_switch_interconnect.py
index bce3d1fa01b7bbd3a431e0ed160c2dcf8c60c0de..3b93dc456e42b5a5b9a339028da17a0108048d84 100644
--- a/test/workflows/lan_switch_interconnect/test_create_imported_lan_switch_interconnect.py
+++ b/test/workflows/lan_switch_interconnect/test_create_imported_lan_switch_interconnect.py
@@ -18,20 +18,20 @@ def workflow_input_data(faker, router_subscription_factory, switch_subscription_
"minimum_links": 1,
"router_side": {
"node": str(router_subscription_factory().subscription_id),
- "ae_iface": faker.nokia_lag_interface_name(),
+ "ae_iface": faker.unique.nokia_lag_interface_name(),
"ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
],
},
"switch_side": {
"switch": str(switch_subscription_factory().subscription_id),
- "ae_iface": faker.juniper_ae_interface_name(),
+ "ae_iface": faker.unique.juniper_ae_interface_name(),
"ae_members": [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
],
diff --git a/test/workflows/lan_switch_interconnect/test_create_lan_switch_interconnect.py b/test/workflows/lan_switch_interconnect/test_create_lan_switch_interconnect.py
index f5e423c16a7b8428627518d42f3e1d76c2e4e1f1..614a12c970196878df4fb01476b55e0667d748a8 100644
--- a/test/workflows/lan_switch_interconnect/test_create_lan_switch_interconnect.py
+++ b/test/workflows/lan_switch_interconnect/test_create_lan_switch_interconnect.py
@@ -47,20 +47,20 @@ def input_form_data(faker, router_subscription_factory, switch_subscription_fact
"minimum_link_count": 2,
},
{
- "router_side_iface": faker.nokia_lag_interface_name(),
+ "router_side_iface": faker.unique.nokia_lag_interface_name(),
"router_side_ae_members": [
{
- "interface_name": faker.nokia_physical_interface_name(),
+ "interface_name": faker.unique.nokia_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
],
},
{
- "switch_side_iface": faker.juniper_ae_interface_name(),
+ "switch_side_iface": faker.unique.juniper_ae_interface_name(),
"switch_side_ae_members": [
{
- "interface_name": faker.juniper_physical_interface_name(),
+ "interface_name": faker.unique.juniper_physical_interface_name(),
"interface_description": faker.sentence(),
}
for _ in range(2)
diff --git a/test/workflows/router/test_terminate_router.py b/test/workflows/router/test_terminate_router.py
index a7303181ecbf3ab50106ed9627f539e96b1add6f..2aeaca372b0a97042dc8e9bd9d858bcba5095790 100644
--- a/test/workflows/router/test_terminate_router.py
+++ b/test/workflows/router/test_terminate_router.py
@@ -37,6 +37,7 @@ def test_terminate_pe_router_full_success(
"remove_configuration": remove_configuration,
"update_ibgp_mesh": update_ibgp_mesh,
"update_sdp_mesh": update_sdp_mesh,
+ "remove_loopback_from_ipam": True,
}
lso_interaction_count = 0
if remove_configuration:
@@ -89,6 +90,7 @@ def test_terminate_p_router_full_success(
"tt_number": faker.tt_number(),
"remove_configuration": remove_configuration,
"update_ibgp_mesh": update_ibgp_mesh,
+ "remove_loopback_from_ipam": True,
}
lso_interaction_count = 0
if remove_configuration:
diff --git a/test/workflows/tasks/test_task_validate_products.py b/test/workflows/tasks/test_task_validate_products.py
index 66853d257d838b423cd7595e2a8ee2b015d94ecf..6c7398b920d392fb86b688d105efd49b01006ae1 100644
--- a/test/workflows/tasks/test_task_validate_products.py
+++ b/test/workflows/tasks/test_task_validate_products.py
@@ -4,14 +4,15 @@ from test.workflows import assert_complete, extract_state, run_workflow
@pytest.mark.workflow()
-def test_task_validate_geant_products():
- result, _, _ = run_workflow("task_validate_geant_products", [{}])
+def test_task_validate_products():
+ result, _, _ = run_workflow("task_validate_products", [{}])
assert_complete(result)
state = extract_state(result)
assert state["check_all_workflows_are_in_db"]
assert state["check_workflows_for_matching_targets_and_descriptions"]
- # assert state["check_that_products_have_at_least_one_workflow"] FIXME: Uncomment when the task is reverted again
+ assert state["check_that_products_have_at_least_one_workflow"]
+ assert state["check_that_active_products_have_a_modify_note"]
assert state["check_db_fixed_input_config"]
assert state["check_that_products_have_create_modify_and_terminate_workflows"]
assert state["check_subscription_models"]