Skip to content

Commit b528160

Browse files
committed
Fix computed_attribute_setup_[both] to only update each computed attribute once
1 parent 34cce75 commit b528160

File tree

9 files changed

+112
-38
lines changed

9 files changed

+112
-38
lines changed

backend/infrahub/computed_attribute/tasks.py

+33-12
Original file line numberDiff line numberDiff line change
@@ -302,15 +302,24 @@ async def computed_attribute_setup_jinja2(
302302

303303
triggers = await gather_trigger_computed_attribute_jinja2()
304304

305-
for trigger in triggers:
306-
if event_name != BranchDeletedEvent.event_name and trigger.branch == branch_name:
305+
# Since we can have multiple trigger per NodeKind
306+
# we need to extract the list of unique node that should be processed
307+
# also
308+
# Because the automation in Prefect doesn't capture all information about the computed attribute
309+
# we can't tell right now if a given computed attribute has changed and need to be updated
310+
unique_nodes: set[tuple[str, str, str]] = {
311+
(trigger.branch, trigger.computed_attribute.kind, trigger.computed_attribute.attribute.name) # type: ignore[attr-defined]
312+
for trigger in triggers
313+
}
314+
for branch, kind, attribute_name in unique_nodes:
315+
if event_name != BranchDeletedEvent.event_name and branch == branch_name:
307316
await service.workflow.submit_workflow(
308317
workflow=TRIGGER_UPDATE_JINJA_COMPUTED_ATTRIBUTES,
309318
context=context,
310319
parameters={
311-
"branch_name": trigger.branch,
312-
"computed_attribute_name": trigger.computed_attribute.attribute.name,
313-
"computed_attribute_kind": trigger.computed_attribute.kind,
320+
"branch_name": branch,
321+
"computed_attribute_name": attribute_name,
322+
"computed_attribute_kind": kind,
314323
},
315324
)
316325

@@ -320,6 +329,7 @@ async def computed_attribute_setup_jinja2(
320329
client=prefect_client,
321330
triggers=triggers,
322331
trigger_type=TriggerType.COMPUTED_ATTR_JINJA2,
332+
force_update=False,
323333
) # type: ignore[misc]
324334

325335
log.info(f"{len(triggers)} Computed Attribute for Jinja2 automation configuration completed")
@@ -347,18 +357,29 @@ async def computed_attribute_setup_python(
347357

348358
triggers_python, triggers_python_query = await gather_trigger_computed_attribute_python(db=db)
349359

350-
for trigger in triggers_python:
351-
if event_name != BranchDeletedEvent.event_name and trigger.branch == branch_name:
352-
log.info(
353-
f"Triggering update for {trigger.computed_attribute.computed_attribute.attribute.name} on {branch_name}"
354-
)
360+
# Since we can have multiple trigger per NodeKind
361+
# we need to extract the list of unique node that should be processed
362+
# also
363+
# Because the automation in Prefect doesn't capture all information about the computed attribute
364+
# we can't tell right now if a given computed attribute has changed and need to be updated
365+
unique_nodes: set[tuple[str, str, str]] = {
366+
(
367+
trigger.branch, # type: ignore[attr-defined]
368+
trigger.computed_attribute.computed_attribute.kind, # type: ignore[attr-defined]
369+
trigger.computed_attribute.computed_attribute.attribute.name, # type: ignore[attr-defined]
370+
)
371+
for trigger in triggers_python
372+
}
373+
for branch, kind, attribute_name in unique_nodes:
374+
if event_name != BranchDeletedEvent.event_name and branch == branch_name:
375+
log.info(f"Triggering update for {kind}.{attribute_name} on {branch}")
355376
await service.workflow.submit_workflow(
356377
workflow=TRIGGER_UPDATE_PYTHON_COMPUTED_ATTRIBUTES,
357378
context=context,
358379
parameters={
359380
"branch_name": branch_name,
360-
"computed_attribute_name": trigger.computed_attribute.computed_attribute.attribute.name,
361-
"computed_attribute_kind": trigger.computed_attribute.computed_attribute.kind,
381+
"computed_attribute_name": attribute_name,
382+
"computed_attribute_kind": kind,
362383
},
363384
)
364385

backend/infrahub/trigger/models.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
from typing import TYPE_CHECKING, Any
66

77
from prefect.events.actions import RunDeployment
8+
from prefect.events.schemas.automations import (
9+
Automation, # noqa: TC002
10+
Posture,
11+
)
812
from prefect.events.schemas.automations import EventTrigger as PrefectEventTrigger
9-
from prefect.events.schemas.automations import Posture
1013
from prefect.events.schemas.events import ResourceSpecification
1114
from pydantic import BaseModel, Field
1215

@@ -19,6 +22,13 @@
1922
from uuid import UUID
2023

2124

25+
class TriggerSetupReport(BaseModel):
26+
created: list[TriggerDefinition] = Field(default_factory=list)
27+
updated: list[TriggerDefinition] = Field(default_factory=list)
28+
deleted: list[Automation] = Field(default_factory=list)
29+
unchanged: list[TriggerDefinition] = Field(default_factory=list)
30+
31+
2232
class TriggerType(str, Enum):
2333
BUILTIN = "builtin"
2434
WEBHOOK = "webhook"

backend/infrahub/trigger/setup.py

+38-13
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,39 @@
55
from prefect.cache_policies import NONE
66
from prefect.client.orchestration import PrefectClient
77
from prefect.client.schemas.filters import DeploymentFilter, DeploymentFilterName
8+
from prefect.events.schemas.automations import Automation
89

910
from infrahub.trigger.models import TriggerDefinition
1011

11-
from .models import TriggerType
12+
from .models import TriggerSetupReport, TriggerType
1213

1314
if TYPE_CHECKING:
1415
from uuid import UUID
1516

1617

18+
def compare_automations(target: AutomationCore, existing: Automation) -> bool:
19+
"""Compare an AutomationCore with an existing Automation object to identify if they are identical or not
20+
21+
Return True if the target is identical to the existing automatino
22+
"""
23+
24+
target_dump = target.model_dump(exclude_defaults=True, exclude_none=True)
25+
existing_dump = existing.model_dump(exclude_defaults=True, exclude_none=True, exclude={"id"})
26+
27+
return target_dump == existing_dump
28+
29+
1730
@task(name="trigger-setup", task_run_name="Setup triggers", cache_policy=NONE) # type: ignore[arg-type]
1831
async def setup_triggers(
1932
client: PrefectClient,
2033
triggers: list[TriggerDefinition],
2134
trigger_type: TriggerType | None = None,
22-
) -> None:
35+
force_update: bool = False,
36+
) -> TriggerSetupReport:
2337
log = get_run_logger()
2438

39+
report = TriggerSetupReport()
40+
2541
if trigger_type:
2642
log.info(f"Setting up triggers of type {trigger_type.value}")
2743
else:
@@ -38,23 +54,24 @@ async def setup_triggers(
3854
)
3955
}
4056
deployments_mapping: dict[str, UUID] = {name: item.id for name, item in deployments.items()}
41-
existing_automations = {item.name: item for item in await client.read_automations()}
4257

4358
# If a trigger type is provided, narrow down the list of existing triggers to know which one to delete
59+
existing_automations: dict[str, Automation] = {}
4460
if trigger_type:
45-
trigger_automations = [
46-
item.name for item in await client.read_automations() if item.name.startswith(trigger_type.value)
47-
]
61+
existing_automations = {
62+
item.name: item for item in await client.read_automations() if item.name.startswith(trigger_type.value)
63+
}
4864
else:
49-
trigger_automations = [item.name for item in await client.read_automations()]
65+
existing_automations = {item.name: item for item in await client.read_automations()}
5066

5167
trigger_names = [trigger.generate_name() for trigger in triggers]
68+
automation_names = list(existing_automations.keys())
5269

53-
log.debug(f"{len(trigger_automations)} existing triggers ({trigger_automations})")
54-
log.debug(f"{len(trigger_names)} triggers to configure ({trigger_names})")
70+
log.debug(f"{len(automation_names)} existing triggers ({automation_names})")
71+
log.debug(f"{len(trigger_names)} triggers to configure ({trigger_names})")
5572

56-
to_delete = set(trigger_automations) - set(trigger_names)
57-
log.debug(f"{len(trigger_names)} triggers to delete ({to_delete})")
73+
to_delete = set(automation_names) - set(trigger_names)
74+
log.debug(f"{len(to_delete)} triggers to delete ({to_delete})")
5875

5976
# -------------------------------------------------------------
6077
# Create or Update all triggers
@@ -71,11 +88,16 @@ async def setup_triggers(
7188
existing_automation = existing_automations.get(trigger.generate_name(), None)
7289

7390
if existing_automation:
74-
await client.update_automation(automation_id=existing_automation.id, automation=automation)
75-
log.info(f"{trigger.generate_name()} Updated")
91+
if force_update or not compare_automations(target=automation, existing=existing_automation):
92+
await client.update_automation(automation_id=existing_automation.id, automation=automation)
93+
log.info(f"{trigger.generate_name()} Updated")
94+
report.updated.append(trigger)
95+
else:
96+
report.unchanged.append(trigger)
7697
else:
7798
await client.create_automation(automation=automation)
7899
log.info(f"{trigger.generate_name()} Created")
100+
report.created.append(trigger)
79101

80102
# -------------------------------------------------------------
81103
# Delete Triggers that shouldn't be there
@@ -86,5 +108,8 @@ async def setup_triggers(
86108
if not existing_automation:
87109
continue
88110

111+
report.deleted.append(existing_automation)
89112
await client.delete_automation(automation_id=existing_automation.id)
90113
log.info(f"{item_to_delete} Deleted")
114+
115+
return report

backend/infrahub/trigger/tasks.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,4 @@ async def trigger_configure_all(service: InfrahubServices) -> None:
3131
)
3232

3333
async with get_client(sync_client=False) as prefect_client:
34-
await setup_triggers(
35-
client=prefect_client,
36-
triggers=triggers,
37-
)
34+
await setup_triggers(client=prefect_client, triggers=triggers, force_update=True)

backend/infrahub/workflows/initialization.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,5 @@ async def setup_task_manager() -> None:
7171
await setup_worker_pools(client=client)
7272
await setup_deployments(client=client)
7373
await setup_triggers(
74-
client=client,
75-
triggers=builtin_triggers,
76-
trigger_type=TriggerType.BUILTIN,
74+
client=client, triggers=builtin_triggers, trigger_type=TriggerType.BUILTIN, force_update=True
7775
)

backend/tests/unit/trigger/test_tasks.py

+27-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from prefect.client.orchestration import PrefectClient, get_client
33

44
from infrahub.trigger.catalogue import builtin_triggers
5-
from infrahub.trigger.models import TriggerType
5+
from infrahub.trigger.models import EventTrigger, TriggerType
66
from infrahub.trigger.setup import setup_triggers
77
from infrahub.workflows.initialization import setup_deployments, setup_worker_pools
88

@@ -27,12 +27,35 @@ async def init_prefect(prefect_client: PrefectClient) -> None:
2727

2828

2929
async def test_setup_triggers(prefect_client: PrefectClient, init_prefect, cleanup_automation):
30-
await setup_triggers(client=prefect_client, triggers=builtin_triggers, trigger_type=TriggerType.BUILTIN)
30+
report = await setup_triggers(client=prefect_client, triggers=builtin_triggers, trigger_type=TriggerType.BUILTIN)
31+
32+
assert len(report.deleted) == 0
33+
assert len(report.updated) == 0
34+
assert len(report.unchanged) == 0
35+
assert len(report.created) == len(builtin_triggers)
3136

3237
automations = await prefect_client.read_automations()
3338
assert len(automations) == len(builtin_triggers)
3439

35-
# Remove 2 Triggers and ensure that setup_triggers is working as expected
36-
await setup_triggers(client=prefect_client, triggers=builtin_triggers[:-2], trigger_type=TriggerType.BUILTIN)
40+
# Update 1 Trigger and remove 2 to ensure that setup_triggers is working as expected
41+
builtin_triggers[0].trigger = EventTrigger(events={"new.event.name"})
42+
report_after = await setup_triggers(
43+
client=prefect_client, triggers=builtin_triggers[:-2], trigger_type=TriggerType.BUILTIN
44+
)
45+
46+
assert len(report_after.deleted) == 2
47+
assert len(report_after.updated) == 1
48+
assert len(report_after.unchanged) == len(builtin_triggers) - 3
49+
assert len(report_after.created) == 0
50+
3751
automations = await prefect_client.read_automations()
3852
assert len(automations) == len(builtin_triggers[:-2])
53+
54+
# Ensure force_update is working properly
55+
report_force = await setup_triggers(
56+
client=prefect_client, triggers=builtin_triggers[:-2], trigger_type=TriggerType.BUILTIN, force_update=True
57+
)
58+
assert len(report_force.deleted) == 0
59+
assert len(report_force.updated) == len(builtin_triggers) - 2
60+
assert len(report_force.unchanged) == 0
61+
assert len(report_force.created) == 0
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed issue with computed attribute that would trigger multiple updates after a schema change if the attribute reference multiple kind of nodes.

changelog/+removeschematextfields.md

Whitespace-only changes.

changelog/+upsertdefaultfilterandhfid.md

-1
This file was deleted.

0 commit comments

Comments
 (0)