From b5ef4ae8b345284ca7a50894694a985c379a79d9 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 8 May 2025 10:34:16 +0200
Subject: [PATCH 1/4] Update workflow name of prefix list validation workflow
 in email notification gatherer

---
 gso/workflows/tasks/send_email_notifications.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/gso/workflows/tasks/send_email_notifications.py b/gso/workflows/tasks/send_email_notifications.py
index cf87864bb..6db502a79 100644
--- a/gso/workflows/tasks/send_email_notifications.py
+++ b/gso/workflows/tasks/send_email_notifications.py
@@ -18,7 +18,7 @@ from gso.settings import load_oss_params
 @step("Gather all tasks that have failed")
 def gather_failed_tasks() -> State:
     """Gather all tasks that have failed."""
-    failed_prefix_list_tasks = get_suspended_tasks_by_workflow_name("validate_prefix_list")
+    failed_prefix_list_tasks = get_suspended_tasks_by_workflow_name("validate_geant_ip_prefix_list")
     all_other_tasks = list(set(get_failed_tasks()) - set(failed_prefix_list_tasks))
 
     return {
-- 
GitLab


From f9c1f9c66d50658d757c0819734a0b2f4abcd2b5 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 8 May 2025 11:33:22 +0200
Subject: [PATCH 2/4] Improve insync handling of prefix list validation
 workflow

Always run the workflow, even if out of sync. Only return subscription to in sync if it already was when before running this validation
---
 .../l3_core_service/geant_ip/validate_prefix_list.py     | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/gso/workflows/l3_core_service/geant_ip/validate_prefix_list.py b/gso/workflows/l3_core_service/geant_ip/validate_prefix_list.py
index ec335abd7..8dc838d20 100644
--- a/gso/workflows/l3_core_service/geant_ip/validate_prefix_list.py
+++ b/gso/workflows/l3_core_service/geant_ip/validate_prefix_list.py
@@ -7,7 +7,7 @@ from orchestrator.domain import SubscriptionModel
 from orchestrator.forms import SubmitFormPage
 from orchestrator.targets import Target
 from orchestrator.workflow import StepList, begin, conditional, done, inputstep, step, workflow
-from orchestrator.workflows.steps import resync, store_process_subscription, unsync
+from orchestrator.workflows.steps import resync, store_process_subscription, unsync_unchecked
 from orchestrator.workflows.utils import wrap_modify_initial_input_form
 from pydantic import Field
 from pydantic_forms.types import FormGenerator, State, UUIDstr
@@ -26,7 +26,7 @@ def build_fqdn_list(subscription_id: UUIDstr) -> State:
     ap_fqdn_list = [
         ap.sbp.edge_port.node.router_fqdn for ap in ap_list if ap.sbp.edge_port.node.vendor != Vendor.JUNIPER
     ]
-    return {"ap_fqdn_list": ap_fqdn_list, "subscription": subscription}
+    return {"ap_fqdn_list": ap_fqdn_list, "subscription": subscription, "subscription_was_in_sync": subscription.insync}
 
 
 @step("[DRY RUN] Validate Prefix-Lists")
@@ -115,14 +115,15 @@ def validate_geant_ip_prefix_list() -> StepList:
     """Validate prefix-lists for an existing GÉANT IP subscription."""
     fqdn_list_is_empty = conditional(lambda state: state["ap_fqdn_list"] == [])
     prefix_list_has_drifted = conditional(lambda state: bool(state["prefix_list_drift"]))
+    subscription_was_in_sync = conditional(lambda state: bool(state["subscription_was_in_sync"]))
 
     redeploy_prefix_list_steps = (
         begin
-        >> unsync
+        >> unsync_unchecked
         >> await_operator
         >> lso_interaction(deploy_prefix_lists_dry)
         >> lso_interaction(deploy_prefix_lists_real)
-        >> resync
+        >> subscription_was_in_sync(resync)
     )
     prefix_list_validation_steps = (
         begin
-- 
GitLab


From 271c4778ba3d624384e664e6ada09d487da9ae74 Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 8 May 2025 11:34:42 +0200
Subject: [PATCH 3/4] Fix when to run product validation workflow

---
 gso/schedules/validate_products.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/gso/schedules/validate_products.py b/gso/schedules/validate_products.py
index f51f5c22b..efc441ad2 100644
--- a/gso/schedules/validate_products.py
+++ b/gso/schedules/validate_products.py
@@ -11,5 +11,5 @@ from gso.services.processes import count_incomplete_validate_products
 @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:
+    if count_incomplete_validate_products() == 0:
         start_process("task_validate_geant_products")
-- 
GitLab


From 3db329d5be650cde5fe9eace84b7105e7fcd484d Mon Sep 17 00:00:00 2001
From: Karel van Klink <karel.vanklink@geant.org>
Date: Thu, 8 May 2025 12:06:31 +0200
Subject: [PATCH 4/4] Fix running validation workflows on all active
 subscriptions

Allow for running a validation workflow on a subscription that is out of sync, if the workflow is marked as able to run on one
Allow for running multiple validation workflows on one subscription
---
 gso/schedules/validate_subscriptions.py | 36 +++++++++++++++----------
 gso/services/subscriptions.py           |  6 ++---
 test/schedules/test_scheduling.py       | 20 +++++++-------
 3 files changed, 35 insertions(+), 27 deletions(-)

diff --git a/gso/schedules/validate_subscriptions.py b/gso/schedules/validate_subscriptions.py
index 0ac4e36f6..7323b6086 100644
--- a/gso/schedules/validate_subscriptions.py
+++ b/gso/schedules/validate_subscriptions.py
@@ -10,11 +10,11 @@ From this list, each workflow is selected that meets the following:
 import structlog
 from celery import shared_task
 from orchestrator.services.processes import get_execution_context
-from orchestrator.services.subscriptions import TARGET_DEFAULT_USABLE_MAP
+from orchestrator.services.subscriptions import TARGET_DEFAULT_USABLE_MAP, WF_USABLE_WHILE_OUT_OF_SYNC
 from orchestrator.targets import Target
 
 from gso.schedules.scheduling import CronScheduleConfig, scheduler
-from gso.services.subscriptions import get_active_insync_subscriptions
+from gso.services.subscriptions import get_active_subscriptions
 
 logger = structlog.get_logger(__name__)
 
@@ -22,8 +22,16 @@ logger = structlog.get_logger(__name__)
 @shared_task
 @scheduler(CronScheduleConfig(name="Subscriptions Validator", minute="10", hour="3"))
 def validate_subscriptions() -> None:
-    """Validate all subscriptions using their corresponding validation workflow."""
-    subscriptions = get_active_insync_subscriptions()
+    """Validate all subscriptions using their corresponding validation workflow.
+
+    Validation workflows only run on subscriptions that are active, even when they could be run on provisioning
+    subscriptions. E.g. for routers, they can manually be validated when provisioning, but are not included in this
+    schedule.
+
+    Validation workflows will, in principle, only run on subscriptions that are in sync. Except when a specific
+    validation workflow is marked as usable while out of sync in the list `WF_USABLE_WHILE_OUT_OF_SYNC`.
+    """
+    subscriptions = get_active_subscriptions()
     if not subscriptions:
         logger.info("No subscriptions to validate")
         return
@@ -35,18 +43,18 @@ def validate_subscriptions() -> None:
             if workflow.target == Target.SYSTEM and workflow.name.startswith("validate_"):
                 validation_workflow = workflow.name
 
-        if validation_workflow:
-            #  Validation workflows only run on subscriptions that are active, even when they could be run on
-            #  provisioning subscriptions. E.g. for routers, they can manually be validated when provisioning, but are
-            #  not included in this schedule.
-            usable_when = TARGET_DEFAULT_USABLE_MAP[Target.SYSTEM]
+            if validation_workflow:
+                validation_workflow_usable = (subscription.status in TARGET_DEFAULT_USABLE_MAP[Target.SYSTEM]) and (
+                    subscription.insync or (workflow in WF_USABLE_WHILE_OUT_OF_SYNC)
+                )
+
+                if validation_workflow_usable:
+                    json = [{"subscription_id": str(subscription.subscription_id)}]
 
-            if subscription.status in usable_when:
-                json = [{"subscription_id": str(subscription.subscription_id)}]
+                    validate_func = get_execution_context()["validate"]
+                    validate_func(validation_workflow, json=json)
 
-                validate_func = get_execution_context()["validate"]
-                validate_func(validation_workflow, json=json)
-        else:
+        if not validation_workflow:
             logger.warning(
                 "SubscriptionTable has no validation workflow",
                 subscription=subscription,
diff --git a/gso/services/subscriptions.py b/gso/services/subscriptions.py
index 6ae731410..93e849d89 100644
--- a/gso/services/subscriptions.py
+++ b/gso/services/subscriptions.py
@@ -343,11 +343,11 @@ def get_subscription_by_process_id(process_id: str) -> SubscriptionModel | None:
     return SubscriptionModel.from_subscription(subscription_table.subscription_id) if subscription_table else None
 
 
-def get_active_insync_subscriptions() -> list[SubscriptionTable]:
-    """Retrieve all subscriptions that are currently active and in sync."""
+def get_active_subscriptions() -> list[SubscriptionTable]:
+    """Retrieve all subscriptions that are currently active."""
     return (
         SubscriptionTable.query.join(ProductTable)
-        .filter(SubscriptionTable.insync.is_(True), SubscriptionTable.status == SubscriptionLifecycle.ACTIVE.value)
+        .filter(SubscriptionTable.status == SubscriptionLifecycle.ACTIVE)
         .all()
     )
 
diff --git a/test/schedules/test_scheduling.py b/test/schedules/test_scheduling.py
index 93f1b5d33..a1eb56b48 100644
--- a/test/schedules/test_scheduling.py
+++ b/test/schedules/test_scheduling.py
@@ -14,8 +14,8 @@ def validate_subscriptions():
 
 
 @pytest.fixture()
-def mock_get_active_insync_subscriptions():
-    with patch("gso.schedules.validate_subscriptions.get_active_insync_subscriptions") as mock:
+def mock_get_active_subscriptions():
+    with patch("gso.schedules.validate_subscriptions.get_active_subscriptions") as mock:
         yield mock
 
 
@@ -82,24 +82,24 @@ def test_scheduled_task_still_works():
     assert result == "task result"
 
 
-def test_no_subscriptions(mock_get_active_insync_subscriptions, mock_logger, validate_subscriptions):
-    mock_get_active_insync_subscriptions.return_value = []
+def test_no_subscriptions(mock_get_active_subscriptions, mock_logger, validate_subscriptions):
+    mock_get_active_subscriptions.return_value = []
     validate_subscriptions()
     mock_logger.info.assert_called_once_with("No subscriptions to validate")
 
 
 def test_subscriptions_without_system_target_workflow(
-    mock_get_active_insync_subscriptions,
+    mock_get_active_subscriptions,
     mock_logger,
     validate_subscriptions,
 ):
-    mock_get_active_insync_subscriptions.return_value = [MagicMock(product=MagicMock(workflows=[]))]
+    mock_get_active_subscriptions.return_value = [MagicMock(product=MagicMock(workflows=[]))]
     validate_subscriptions()
     mock_logger.warning.assert_called_once()
 
 
 def test_subscription_status_not_usable(
-    mock_get_active_insync_subscriptions,
+    mock_get_active_subscriptions,
     mock_get_execution_context,
     validate_subscriptions,
 ):
@@ -107,7 +107,7 @@ def test_subscription_status_not_usable(
     subscription_mock.product.workflows = [MagicMock(target=Target.SYSTEM, name="workflow_name")]
     subscription_mock.status = "Not Usable Status"
 
-    mock_get_active_insync_subscriptions.return_value = [subscription_mock]
+    mock_get_active_subscriptions.return_value = [subscription_mock]
     validate_subscriptions()
 
     validate_func = mock_get_execution_context()["validate"]
@@ -115,7 +115,7 @@ def test_subscription_status_not_usable(
 
 
 def test_valid_subscriptions_for_validation(
-    mock_get_active_insync_subscriptions,
+    mock_get_active_subscriptions,
     mock_get_execution_context,
     validate_subscriptions,
 ):
@@ -123,7 +123,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"
-    mock_get_active_insync_subscriptions.return_value = [subscription_mock]
+    mock_get_active_subscriptions.return_value = [subscription_mock]
     validate_subscriptions()
     validate_func = mock_get_execution_context()["validate"]
     validate_func.assert_called_once_with(
-- 
GitLab