infrahub-server 1.2.5__py3-none-any.whl → 1.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. infrahub/cli/db.py +2 -0
  2. infrahub/cli/patch.py +153 -0
  3. infrahub/computed_attribute/models.py +81 -1
  4. infrahub/computed_attribute/tasks.py +34 -53
  5. infrahub/core/manager.py +15 -2
  6. infrahub/core/node/__init__.py +4 -1
  7. infrahub/core/query/ipam.py +7 -5
  8. infrahub/core/registry.py +2 -3
  9. infrahub/core/schema/schema_branch.py +34 -37
  10. infrahub/database/__init__.py +2 -0
  11. infrahub/graphql/manager.py +10 -0
  12. infrahub/graphql/mutations/main.py +4 -5
  13. infrahub/graphql/mutations/resource_manager.py +3 -3
  14. infrahub/patch/__init__.py +0 -0
  15. infrahub/patch/constants.py +13 -0
  16. infrahub/patch/edge_adder.py +64 -0
  17. infrahub/patch/edge_deleter.py +33 -0
  18. infrahub/patch/edge_updater.py +28 -0
  19. infrahub/patch/models.py +98 -0
  20. infrahub/patch/plan_reader.py +107 -0
  21. infrahub/patch/plan_writer.py +92 -0
  22. infrahub/patch/queries/__init__.py +0 -0
  23. infrahub/patch/queries/base.py +17 -0
  24. infrahub/patch/runner.py +254 -0
  25. infrahub/patch/vertex_adder.py +61 -0
  26. infrahub/patch/vertex_deleter.py +33 -0
  27. infrahub/patch/vertex_updater.py +28 -0
  28. infrahub/tasks/registry.py +4 -1
  29. infrahub_sdk/checks.py +1 -1
  30. infrahub_sdk/ctl/cli_commands.py +2 -2
  31. infrahub_sdk/ctl/menu.py +56 -13
  32. infrahub_sdk/ctl/object.py +55 -5
  33. infrahub_sdk/ctl/utils.py +22 -1
  34. infrahub_sdk/exceptions.py +19 -1
  35. infrahub_sdk/node.py +42 -26
  36. infrahub_sdk/protocols_generator/__init__.py +0 -0
  37. infrahub_sdk/protocols_generator/constants.py +28 -0
  38. infrahub_sdk/{code_generator.py → protocols_generator/generator.py} +47 -34
  39. infrahub_sdk/protocols_generator/template.j2 +114 -0
  40. infrahub_sdk/schema/__init__.py +110 -74
  41. infrahub_sdk/schema/main.py +36 -2
  42. infrahub_sdk/schema/repository.py +2 -0
  43. infrahub_sdk/spec/menu.py +3 -3
  44. infrahub_sdk/spec/object.py +522 -41
  45. infrahub_sdk/testing/docker.py +4 -5
  46. infrahub_sdk/testing/schemas/animal.py +7 -0
  47. infrahub_sdk/yaml.py +63 -7
  48. {infrahub_server-1.2.5.dist-info → infrahub_server-1.2.7.dist-info}/METADATA +1 -1
  49. {infrahub_server-1.2.5.dist-info → infrahub_server-1.2.7.dist-info}/RECORD +56 -39
  50. infrahub_testcontainers/container.py +52 -2
  51. infrahub_testcontainers/docker-compose.test.yml +27 -0
  52. infrahub_testcontainers/performance_test.py +1 -1
  53. infrahub_testcontainers/plugin.py +1 -1
  54. infrahub_sdk/ctl/constants.py +0 -115
  55. {infrahub_server-1.2.5.dist-info → infrahub_server-1.2.7.dist-info}/LICENSE.txt +0 -0
  56. {infrahub_server-1.2.5.dist-info → infrahub_server-1.2.7.dist-info}/WHEEL +0 -0
  57. {infrahub_server-1.2.5.dist-info → infrahub_server-1.2.7.dist-info}/entry_points.txt +0 -0
infrahub/cli/db.py CHANGED
@@ -54,12 +54,14 @@ from infrahub.services.adapters.message_bus.local import BusSimulator
54
54
  from infrahub.services.adapters.workflow.local import WorkflowLocalExecution
55
55
 
56
56
  from .constants import ERROR_BADGE, FAILED_BADGE, SUCCESS_BADGE
57
+ from .patch import patch_app
57
58
 
58
59
  if TYPE_CHECKING:
59
60
  from infrahub.cli.context import CliContext
60
61
  from infrahub.database import InfrahubDatabase
61
62
 
62
63
  app = AsyncTyper()
64
+ app.add_typer(patch_app, name="patch")
63
65
 
64
66
  PERMISSIONS_AVAILABLE = ["read", "write", "admin"]
65
67
 
infrahub/cli/patch.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import inspect
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ import typer
10
+ from infrahub_sdk.async_typer import AsyncTyper
11
+ from rich import print as rprint
12
+
13
+ from infrahub import config
14
+ from infrahub.patch.edge_adder import PatchPlanEdgeAdder
15
+ from infrahub.patch.edge_deleter import PatchPlanEdgeDeleter
16
+ from infrahub.patch.edge_updater import PatchPlanEdgeUpdater
17
+ from infrahub.patch.plan_reader import PatchPlanReader
18
+ from infrahub.patch.plan_writer import PatchPlanWriter
19
+ from infrahub.patch.queries.base import PatchQuery
20
+ from infrahub.patch.runner import (
21
+ PatchPlanEdgeDbIdTranslator,
22
+ PatchRunner,
23
+ )
24
+ from infrahub.patch.vertex_adder import PatchPlanVertexAdder
25
+ from infrahub.patch.vertex_deleter import PatchPlanVertexDeleter
26
+ from infrahub.patch.vertex_updater import PatchPlanVertexUpdater
27
+
28
+ from .constants import ERROR_BADGE, SUCCESS_BADGE
29
+
30
+ if TYPE_CHECKING:
31
+ from infrahub.cli.context import CliContext
32
+ from infrahub.database import InfrahubDatabase
33
+
34
+
35
+ patch_app = AsyncTyper(help="Commands for planning, applying, and reverting database patches")
36
+
37
+
38
+ def get_patch_runner(db: InfrahubDatabase) -> PatchRunner:
39
+ return PatchRunner(
40
+ plan_writer=PatchPlanWriter(),
41
+ plan_reader=PatchPlanReader(),
42
+ edge_db_id_translator=PatchPlanEdgeDbIdTranslator(),
43
+ vertex_adder=PatchPlanVertexAdder(db=db),
44
+ vertex_deleter=PatchPlanVertexDeleter(db=db),
45
+ vertex_updater=PatchPlanVertexUpdater(db=db),
46
+ edge_adder=PatchPlanEdgeAdder(db=db),
47
+ edge_deleter=PatchPlanEdgeDeleter(db=db),
48
+ edge_updater=PatchPlanEdgeUpdater(db=db),
49
+ )
50
+
51
+
52
+ @patch_app.command(name="plan")
53
+ async def plan_patch_cmd(
54
+ ctx: typer.Context,
55
+ patch_path: str = typer.Argument(
56
+ help="Path to the file containing the PatchQuery instance to run. Use Python-style dot paths, such as infrahub.cli.patch.queries.base"
57
+ ),
58
+ patch_plans_dir: Path = typer.Option(Path("infrahub-patches"), help="Path to patch plans directory"), # noqa: B008
59
+ apply: bool = typer.Option(False, help="Apply the patch immediately after creating it"),
60
+ config_file: str = typer.Argument("infrahub.toml", envvar="INFRAHUB_CONFIG"),
61
+ ) -> None:
62
+ """Create a plan for a given patch and save it in the patch plans directory to be applied/reverted"""
63
+ logging.getLogger("infrahub").setLevel(logging.WARNING)
64
+ logging.getLogger("neo4j").setLevel(logging.ERROR)
65
+ logging.getLogger("prefect").setLevel(logging.ERROR)
66
+
67
+ patch_module = importlib.import_module(patch_path)
68
+ patch_query_class = None
69
+ patch_query_class_count = 0
70
+ for _, cls in inspect.getmembers(patch_module, inspect.isclass):
71
+ if issubclass(cls, PatchQuery) and cls is not PatchQuery:
72
+ patch_query_class = cls
73
+ patch_query_class_count += 1
74
+
75
+ patch_query_path = f"{PatchQuery.__module__}.{PatchQuery.__name__}"
76
+ if patch_query_class is None:
77
+ rprint(f"{ERROR_BADGE} No subclass of {patch_query_path} found in {patch_path}")
78
+ raise typer.Exit(1)
79
+ if patch_query_class_count > 1:
80
+ rprint(
81
+ f"{ERROR_BADGE} Multiple subclasses of {patch_query_path} found in {patch_path}. Please only define one per file."
82
+ )
83
+ raise typer.Exit(1)
84
+
85
+ config.load_and_exit(config_file_name=config_file)
86
+
87
+ context: CliContext = ctx.obj
88
+ dbdriver = await context.init_db(retry=1)
89
+
90
+ patch_query_instance = patch_query_class(db=dbdriver)
91
+ async with dbdriver.start_session() as db:
92
+ patch_runner = get_patch_runner(db=db)
93
+ patch_plan_dir = await patch_runner.prepare_plan(patch_query_instance, directory=Path(patch_plans_dir))
94
+ rprint(f"{SUCCESS_BADGE} Patch plan created at {patch_plan_dir}")
95
+ if apply:
96
+ await patch_runner.apply(patch_plan_directory=patch_plan_dir)
97
+ rprint(f"{SUCCESS_BADGE} Patch plan successfully applied")
98
+
99
+ await dbdriver.close()
100
+
101
+
102
+ @patch_app.command(name="apply")
103
+ async def apply_patch_cmd(
104
+ ctx: typer.Context,
105
+ patch_plan_dir: Path = typer.Argument(help="Path to the directory containing a patch plan"),
106
+ config_file: str = typer.Argument("infrahub.toml", envvar="INFRAHUB_CONFIG"),
107
+ ) -> None:
108
+ """Apply a given patch plan"""
109
+ logging.getLogger("infrahub").setLevel(logging.WARNING)
110
+ logging.getLogger("neo4j").setLevel(logging.ERROR)
111
+ logging.getLogger("prefect").setLevel(logging.ERROR)
112
+
113
+ config.load_and_exit(config_file_name=config_file)
114
+
115
+ context: CliContext = ctx.obj
116
+ dbdriver = await context.init_db(retry=1)
117
+
118
+ if not patch_plan_dir.exists() or not patch_plan_dir.is_dir():
119
+ rprint(f"{ERROR_BADGE} patch_plan_dir must be an existing directory")
120
+ raise typer.Exit(1)
121
+
122
+ async with dbdriver.start_session() as db:
123
+ patch_runner = get_patch_runner(db=db)
124
+ await patch_runner.apply(patch_plan_directory=patch_plan_dir)
125
+ rprint(f"{SUCCESS_BADGE} Patch plan successfully applied")
126
+
127
+ await dbdriver.close()
128
+
129
+
130
+ @patch_app.command(name="revert")
131
+ async def revert_patch_cmd(
132
+ ctx: typer.Context,
133
+ patch_plan_dir: Path = typer.Argument(help="Path to the directory containing a patch plan"),
134
+ config_file: str = typer.Argument("infrahub.toml", envvar="INFRAHUB_CONFIG"),
135
+ ) -> None:
136
+ """Revert a given patch plan"""
137
+ logging.getLogger("infrahub").setLevel(logging.WARNING)
138
+ logging.getLogger("neo4j").setLevel(logging.ERROR)
139
+ logging.getLogger("prefect").setLevel(logging.ERROR)
140
+ config.load_and_exit(config_file_name=config_file)
141
+
142
+ context: CliContext = ctx.obj
143
+ db = await context.init_db(retry=1)
144
+
145
+ if not patch_plan_dir.exists() or not patch_plan_dir.is_dir():
146
+ rprint(f"{ERROR_BADGE} patch_plan_dir must be an existing directory")
147
+ raise typer.Exit(1)
148
+
149
+ patch_runner = get_patch_runner(db=db)
150
+ await patch_runner.revert(patch_plan_directory=patch_plan_dir)
151
+ rprint(f"{SUCCESS_BADGE} Patch plan successfully reverted")
152
+
153
+ await db.close()
@@ -2,13 +2,16 @@ from __future__ import annotations
2
2
 
3
3
  from collections import defaultdict
4
4
  from dataclasses import dataclass, field
5
- from typing import TYPE_CHECKING
5
+ from typing import TYPE_CHECKING, Any
6
6
 
7
+ from infrahub_sdk.graphql import Query
7
8
  from prefect.events.schemas.automations import Automation # noqa: TC002
8
9
  from pydantic import BaseModel, ConfigDict, Field, computed_field
9
10
  from typing_extensions import Self
10
11
 
11
12
  from infrahub.core import registry
13
+ from infrahub.core.constants import RelationshipCardinality
14
+ from infrahub.core.schema import AttributeSchema, NodeSchema # noqa: TC001
12
15
  from infrahub.core.schema.schema_branch_computed import ( # noqa: TC001
13
16
  ComputedAttributeTarget,
14
17
  ComputedAttributeTriggerNode,
@@ -309,3 +312,80 @@ class ComputedAttrPythonQueryTriggerDefinition(TriggerBranchDefinition):
309
312
  )
310
313
 
311
314
  return definition
315
+
316
+
317
+ class ComputedAttrJinja2GraphQLResponse(BaseModel):
318
+ node_id: str
319
+ computed_attribute_value: str | None
320
+ variables: dict[str, Any] = Field(default_factory=dict)
321
+
322
+
323
+ class ComputedAttrJinja2GraphQL(BaseModel):
324
+ node_schema: NodeSchema = Field(..., description="The node kind where the computed attribute is defined")
325
+ attribute_schema: AttributeSchema = Field(..., description="The computed attribute")
326
+ variables: list[str] = Field(..., description="The list of variable names used within the computed attribute")
327
+
328
+ def render_graphql_query(self, query_filter: str, filter_id: str) -> str:
329
+ query_fields = self.query_fields
330
+ query_fields["id"] = None
331
+ query_fields[self.attribute_schema.name] = {"value": None}
332
+ query = Query(
333
+ name="ComputedAttributeFilter",
334
+ query={
335
+ self.node_schema.kind: {
336
+ "@filters": {query_filter: filter_id},
337
+ "edges": {"node": query_fields},
338
+ }
339
+ },
340
+ )
341
+
342
+ return query.render()
343
+
344
+ @property
345
+ def query_fields(self) -> dict[str, Any]:
346
+ output: dict[str, Any] = {}
347
+ for variable in self.variables:
348
+ field_name, remainder = variable.split("__", maxsplit=1)
349
+ if field_name in self.node_schema.attribute_names:
350
+ output[field_name] = {remainder: None}
351
+ elif field_name in self.node_schema.relationship_names:
352
+ related_attribute, related_value = remainder.split("__", maxsplit=1)
353
+ relationship = self.node_schema.get_relationship(name=field_name)
354
+ if relationship.cardinality == RelationshipCardinality.ONE:
355
+ if field_name not in output:
356
+ output[field_name] = {"node": {}}
357
+ output[field_name]["node"][related_attribute] = {related_value: None}
358
+ return output
359
+
360
+ def parse_response(self, response: dict[str, Any]) -> list[ComputedAttrJinja2GraphQLResponse]:
361
+ rendered_response: list[ComputedAttrJinja2GraphQLResponse] = []
362
+ if kind_payload := response.get(self.node_schema.kind):
363
+ edges = kind_payload.get("edges", [])
364
+ for node in edges:
365
+ if node_response := self.to_node_response(node_dict=node):
366
+ rendered_response.append(node_response)
367
+ return rendered_response
368
+
369
+ def to_node_response(self, node_dict: dict[str, Any]) -> ComputedAttrJinja2GraphQLResponse | None:
370
+ if node := node_dict.get("node"):
371
+ node_id = node.get("id")
372
+ else:
373
+ return None
374
+
375
+ computed_attribute = node.get(self.attribute_schema.name, {}).get("value")
376
+ response = ComputedAttrJinja2GraphQLResponse(node_id=node_id, computed_attribute_value=computed_attribute)
377
+ for variable in self.variables:
378
+ field_name, remainder = variable.split("__", maxsplit=1)
379
+ response.variables[variable] = None
380
+ if field_content := node.get(field_name):
381
+ if field_name in self.node_schema.attribute_names:
382
+ response.variables[variable] = field_content.get(remainder)
383
+ elif field_name in self.node_schema.relationship_names:
384
+ relationship = self.node_schema.get_relationship(name=field_name)
385
+ if relationship.cardinality == RelationshipCardinality.ONE:
386
+ related_attribute, related_value = remainder.split("__", maxsplit=1)
387
+ node_content = field_content.get("node") or {}
388
+ related_attribute_content = node_content.get(related_attribute) or {}
389
+ response.variables[variable] = related_attribute_content.get(related_value)
390
+
391
+ return response
@@ -2,10 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from infrahub_sdk.protocols import (
6
- CoreNode, # noqa: TC002
7
- CoreTransformPython,
8
- )
5
+ from infrahub_sdk.protocols import CoreTransformPython
9
6
  from infrahub_sdk.template import Jinja2Template
10
7
  from prefect import flow
11
8
  from prefect.client.orchestration import get_client
@@ -28,9 +25,7 @@ from infrahub.workflows.catalogue import (
28
25
  from infrahub.workflows.utils import add_tags, wait_for_schema_to_converge
29
26
 
30
27
  from .gather import gather_trigger_computed_attribute_jinja2, gather_trigger_computed_attribute_python
31
- from .models import (
32
- PythonTransformTarget,
33
- )
28
+ from .models import ComputedAttrJinja2GraphQL, ComputedAttrJinja2GraphQLResponse, PythonTransformTarget
34
29
 
35
30
  if TYPE_CHECKING:
36
31
  from infrahub.core.schema.computed_attribute import ComputedAttribute
@@ -167,49 +162,33 @@ async def trigger_update_python_computed_attributes(
167
162
  flow_run_name="Update value for computed attribute {attribute_name}",
168
163
  )
169
164
  async def update_computed_attribute_value_jinja2(
170
- branch_name: str, obj: CoreNode, attribute_name: str, template_value: str, service: InfrahubServices
165
+ branch_name: str,
166
+ obj: ComputedAttrJinja2GraphQLResponse,
167
+ node_kind: str,
168
+ attribute_name: str,
169
+ template: Jinja2Template,
170
+ service: InfrahubServices,
171
171
  ) -> None:
172
172
  log = get_run_logger()
173
173
 
174
- await add_tags(branches=[branch_name], nodes=[obj.id], db_change=True)
175
-
176
- jinja_template = Jinja2Template(template=template_value)
177
- variables = {}
178
- for variable in jinja_template.get_variables():
179
- components = variable.split("__")
180
- if len(components) == 2:
181
- property_name = components[0]
182
- property_value = components[1]
183
- attribute_property = getattr(obj, property_name)
184
- variables[variable] = getattr(attribute_property, property_value)
185
- elif len(components) == 3:
186
- relationship_name = components[0]
187
- property_name = components[1]
188
- property_value = components[2]
189
- relationship = getattr(obj, relationship_name)
190
- try:
191
- attribute_property = getattr(relationship.peer, property_name)
192
- variables[variable] = getattr(attribute_property, property_value)
193
- except ValueError:
194
- variables[variable] = ""
195
-
196
- value = await jinja_template.render(variables=variables)
197
- existing_value = getattr(obj, attribute_name).value
198
- if value == existing_value:
174
+ await add_tags(branches=[branch_name], nodes=[obj.node_id], db_change=True)
175
+
176
+ value = await template.render(variables=obj.variables)
177
+ if value == obj.computed_attribute_value:
199
178
  log.debug(f"Ignoring to update {obj} with existing value on {attribute_name}={value}")
200
179
  return
201
180
 
202
181
  await service.client.execute_graphql(
203
182
  query=UPDATE_ATTRIBUTE,
204
183
  variables={
205
- "id": obj.id,
206
- "kind": obj.get_kind(),
184
+ "id": obj.node_id,
185
+ "kind": node_kind,
207
186
  "attribute": attribute_name,
208
187
  "value": value,
209
188
  },
210
189
  branch_name=branch_name,
211
190
  )
212
- log.info(f"Updating computed attribute {obj.get_kind()}.{attribute_name}='{value}' ({obj.id})")
191
+ log.info(f"Updating computed attribute {node_kind}.{attribute_name}='{value}' ({obj.node_id})")
213
192
 
214
193
 
215
194
  @flow(
@@ -235,41 +214,43 @@ async def process_jinja2(
235
214
  branch_name if branch_name in registry.get_altered_schema_branches() else registry.default_branch
236
215
  )
237
216
  schema_branch = registry.schema.get_schema_branch(name=target_branch_schema)
238
- await service.client.schema.all(branch=branch_name, refresh=True, schema_hash=schema_branch.get_hash())
239
-
217
+ node_schema = schema_branch.get_node(name=computed_attribute_kind, duplicate=False)
240
218
  computed_macros = [
241
219
  attrib
242
220
  for attrib in schema_branch.computed_attributes.get_impacted_jinja2_targets(kind=node_kind, updates=updates)
243
221
  if attrib.kind == computed_attribute_kind and attrib.attribute.name == computed_attribute_name
244
222
  ]
245
223
  for computed_macro in computed_macros:
246
- found: list[CoreNode] = []
224
+ found: list[ComputedAttrJinja2GraphQLResponse] = []
225
+ template_string = "n/a"
226
+ if computed_macro.attribute.computed_attribute and computed_macro.attribute.computed_attribute.jinja2_template:
227
+ template_string = computed_macro.attribute.computed_attribute.jinja2_template
228
+
229
+ jinja_template = Jinja2Template(template=template_string)
230
+ variables = jinja_template.get_variables()
231
+
232
+ attribute_graphql = ComputedAttrJinja2GraphQL(
233
+ node_schema=node_schema, attribute_schema=computed_macro.attribute, variables=variables
234
+ )
235
+
247
236
  for id_filter in computed_macro.node_filters:
248
- filters = {id_filter: object_id}
249
- nodes: list[CoreNode] = await service.client.filters(
250
- kind=computed_macro.kind,
251
- branch=branch_name,
252
- prefetch_relationships=True,
253
- populate_store=True,
254
- **filters,
255
- )
256
- found.extend(nodes)
237
+ query = attribute_graphql.render_graphql_query(query_filter=id_filter, filter_id=object_id)
238
+ response = await service.client.execute_graphql(query=query, branch_name=branch_name)
239
+ output = attribute_graphql.parse_response(response=response)
240
+ found.extend(output)
257
241
 
258
242
  if not found:
259
243
  log.debug("No nodes found that requires updates")
260
244
 
261
- template_string = "n/a"
262
- if computed_macro.attribute.computed_attribute and computed_macro.attribute.computed_attribute.jinja2_template:
263
- template_string = computed_macro.attribute.computed_attribute.jinja2_template
264
-
265
245
  batch = await service.client.create_batch()
266
246
  for node in found:
267
247
  batch.add(
268
248
  task=update_computed_attribute_value_jinja2,
269
249
  branch_name=branch_name,
270
250
  obj=node,
251
+ node_kind=node_schema.kind,
271
252
  attribute_name=computed_macro.attribute.name,
272
- template_value=template_string,
253
+ template=jinja_template,
273
254
  service=service,
274
255
  )
275
256
 
infrahub/core/manager.py CHANGED
@@ -803,8 +803,21 @@ class NodeManager:
803
803
 
804
804
  hfid_str = " :: ".join(hfid)
805
805
 
806
- if not node_schema.human_friendly_id or len(node_schema.human_friendly_id) != len(hfid):
807
- raise NodeNotFoundError(branch_name=branch.name, node_type=kind_str, identifier=hfid_str)
806
+ if not node_schema.human_friendly_id:
807
+ raise NodeNotFoundError(
808
+ branch_name=branch.name,
809
+ node_type=kind_str,
810
+ identifier=hfid_str,
811
+ message=f"Unable to lookup node by HFID, schema '{node_schema.kind}' does not have a HFID defined.",
812
+ )
813
+
814
+ if len(node_schema.human_friendly_id) != len(hfid):
815
+ raise NodeNotFoundError(
816
+ branch_name=branch.name,
817
+ node_type=kind_str,
818
+ identifier=hfid_str,
819
+ message=f"Unable to lookup node by HFID, schema '{node_schema.kind}' HFID does not contain the same number of elements as {hfid}",
820
+ )
808
821
 
809
822
  filters = {}
810
823
  for key, item in zip(node_schema.human_friendly_id, hfid, strict=False):
@@ -29,6 +29,7 @@ from infrahub.types import ATTRIBUTE_TYPES
29
29
 
30
30
  from ...graphql.constants import KIND_GRAPHQL_FIELD_NAME
31
31
  from ...graphql.models import OrderModel
32
+ from ...log import get_logger
32
33
  from ..query.relationship import RelationshipDeleteAllQuery
33
34
  from ..relationship import RelationshipManager
34
35
  from ..utils import update_relationships_to
@@ -53,6 +54,8 @@ SchemaProtocol = TypeVar("SchemaProtocol")
53
54
  # -
54
55
  # ---------------------------------------------------------------------------------------
55
56
 
57
+ log = get_logger()
58
+
56
59
 
57
60
  class Node(BaseNode, metaclass=BaseNodeMeta):
58
61
  @classmethod
@@ -348,7 +351,7 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
348
351
  fields.pop("updated_at")
349
352
  for field_name in fields.keys():
350
353
  if field_name not in self._schema.valid_input_names:
351
- errors.append(ValidationError({field_name: f"{field_name} is not a valid input for {self.get_kind()}"}))
354
+ log.error(f"{field_name} is not a valid input for {self.get_kind()}")
352
355
 
353
356
  # Backfill fields with the ones from the template if there's one
354
357
  await self.handle_object_template(fields=fields, db=db, errors=errors)
@@ -367,9 +367,11 @@ class IPPrefixReconcileQuery(Query):
367
367
  possible_prefix = tmp_prefix.ljust(self.ip_value.max_prefixlen, "0")
368
368
  if possible_prefix not in possible_prefix_map:
369
369
  possible_prefix_map[possible_prefix] = max_prefix_len
370
- self.params["possible_prefix_and_length_list"] = [
371
- [possible_prefix, max_length] for possible_prefix, max_length in possible_prefix_map.items()
372
- ]
370
+ self.params["possible_prefix_and_length_list"] = []
371
+ self.params["possible_prefix_list"] = []
372
+ for possible_prefix, max_length in possible_prefix_map.items():
373
+ self.params["possible_prefix_and_length_list"].append([possible_prefix, max_length])
374
+ self.params["possible_prefix_list"].append(possible_prefix)
373
375
 
374
376
  namespace_query = """
375
377
  // ------------------
@@ -386,8 +388,7 @@ class IPPrefixReconcileQuery(Query):
386
388
  // ------------------
387
389
  // Get IP Prefix node by UUID
388
390
  // ------------------
389
- MATCH (ip_node {uuid: $node_uuid})
390
- WHERE "%(ip_kind)s" IN labels(ip_node)
391
+ MATCH (ip_node:%(ip_kind)s {uuid: $node_uuid})
391
392
  """ % {
392
393
  "ip_kind": InfrahubKind.IPADDRESS
393
394
  if isinstance(self.ip_value, IPAddressType)
@@ -487,6 +488,7 @@ class IPPrefixReconcileQuery(Query):
487
488
  -[hvr:HAS_VALUE]->(av:%(ip_prefix_attribute_kind)s)
488
489
  WHERE all(r IN relationships(parent_path) WHERE (%(branch_filter)s))
489
490
  AND av.version = $ip_version
491
+ AND av.binary_address IN $possible_prefix_list
490
492
  AND any(prefix_and_length IN $possible_prefix_and_length_list WHERE av.binary_address = prefix_and_length[0] AND av.prefixlen <= prefix_and_length[1])
491
493
  WITH
492
494
  maybe_new_parent,
infrahub/core/registry.py CHANGED
@@ -222,7 +222,7 @@ class Registry:
222
222
 
223
223
  async def purge_inactive_branches(
224
224
  self, db: InfrahubDatabase, active_branches: list[Branch] | None = None
225
- ) -> list[str]:
225
+ ) -> set[str]:
226
226
  """Return a list of branches that were purged from the registry."""
227
227
  active_branches = active_branches or await self.branch_object.get_list(db=db)
228
228
  active_branch_names = [branch.name for branch in active_branches]
@@ -235,8 +235,7 @@ class Registry:
235
235
 
236
236
  purged_branches.update(self.schema.purge_inactive_branches(active_branches=active_branch_names))
237
237
  purged_branches.update(db.purge_inactive_schemas(active_branches=active_branch_names))
238
-
239
- return sorted(purged_branches)
238
+ return purged_branches
240
239
 
241
240
 
242
241
  registry = Registry()
@@ -1909,10 +1909,8 @@ class SchemaBranch:
1909
1909
 
1910
1910
  self.set(name=node_name, schema=node_schema)
1911
1911
 
1912
- def add_relationships_to_template(self, node: NodeSchema) -> None:
1912
+ def add_relationships_to_template(self, node: NodeSchema | GenericSchema) -> None:
1913
1913
  template_schema = self.get(name=self._get_object_template_kind(node_kind=node.kind), duplicate=False)
1914
- if template_schema.is_generic_schema:
1915
- return
1916
1914
 
1917
1915
  # Remove previous relationships to account for new ones
1918
1916
  template_schema.relationships = [
@@ -1954,6 +1952,7 @@ class SchemaBranch:
1954
1952
  label=f"{relationship.name} template".title()
1955
1953
  if relationship.kind in [RelationshipKind.COMPONENT, RelationshipKind.PARENT]
1956
1954
  else relationship.name.title(),
1955
+ inherited=relationship.inherited,
1957
1956
  )
1958
1957
  )
1959
1958
 
@@ -1983,9 +1982,6 @@ class SchemaBranch:
1983
1982
  need_template_kinds = [n.kind for n in need_templates]
1984
1983
 
1985
1984
  if node.is_generic_schema:
1986
- # When needing a template for a generic, we generate an empty shell mostly to make sure that schemas (including the GraphQL one) will
1987
- # look right. We don't really care about applying inheritance of fields as it was already processed and actual templates will have the
1988
- # correct attributes and relationships
1989
1985
  template = GenericSchema(
1990
1986
  name=node.kind,
1991
1987
  namespace="Template",
@@ -1994,43 +1990,44 @@ class SchemaBranch:
1994
1990
  generate_profile=False,
1995
1991
  branch=node.branch,
1996
1992
  include_in_menu=False,
1993
+ display_labels=["template_name__value"],
1994
+ human_friendly_id=["template_name__value"],
1995
+ uniqueness_constraints=[["template_name__value"]],
1997
1996
  attributes=[template_name_attr],
1998
1997
  )
1999
1998
 
2000
1999
  for used in node.used_by:
2001
2000
  if used in need_template_kinds:
2002
2001
  template.used_by.append(self._get_object_template_kind(node_kind=used))
2002
+ else:
2003
+ template = TemplateSchema(
2004
+ name=node.kind,
2005
+ namespace="Template",
2006
+ label=f"Object template {node.label}",
2007
+ description=f"Object template for {node.kind}",
2008
+ branch=node.branch,
2009
+ include_in_menu=False,
2010
+ display_labels=["template_name__value"],
2011
+ human_friendly_id=["template_name__value"],
2012
+ uniqueness_constraints=[["template_name__value"]],
2013
+ inherit_from=[InfrahubKind.LINEAGESOURCE, InfrahubKind.NODE, core_template_schema.kind],
2014
+ default_filter="template_name__value",
2015
+ attributes=[template_name_attr],
2016
+ relationships=[
2017
+ RelationshipSchema(
2018
+ name="related_nodes",
2019
+ identifier="node__objecttemplate",
2020
+ peer=node.kind,
2021
+ kind=RelationshipKind.TEMPLATE,
2022
+ cardinality=RelationshipCardinality.MANY,
2023
+ branch=BranchSupportType.AWARE,
2024
+ )
2025
+ ],
2026
+ )
2003
2027
 
2004
- return template
2005
-
2006
- template = TemplateSchema(
2007
- name=node.kind,
2008
- namespace="Template",
2009
- label=f"Object template {node.label}",
2010
- description=f"Object template for {node.kind}",
2011
- branch=node.branch,
2012
- include_in_menu=False,
2013
- display_labels=["template_name__value"],
2014
- human_friendly_id=["template_name__value"],
2015
- uniqueness_constraints=[["template_name__value"]],
2016
- inherit_from=[InfrahubKind.LINEAGESOURCE, InfrahubKind.NODE, core_template_schema.kind],
2017
- default_filter="template_name__value",
2018
- attributes=[template_name_attr],
2019
- relationships=[
2020
- RelationshipSchema(
2021
- name="related_nodes",
2022
- identifier="node__objecttemplate",
2023
- peer=node.kind,
2024
- kind=RelationshipKind.TEMPLATE,
2025
- cardinality=RelationshipCardinality.MANY,
2026
- branch=BranchSupportType.AWARE,
2027
- )
2028
- ],
2029
- )
2030
-
2031
- for inherited in node.inherit_from:
2032
- if inherited in need_template_kinds:
2033
- template.inherit_from.append(self._get_object_template_kind(node_kind=inherited))
2028
+ for inherited in node.inherit_from:
2029
+ if inherited in need_template_kinds:
2030
+ template.inherit_from.append(self._get_object_template_kind(node_kind=inherited))
2034
2031
 
2035
2032
  for node_attr in node.attributes:
2036
2033
  if node_attr.unique or node_attr.read_only:
@@ -2038,7 +2035,7 @@ class SchemaBranch:
2038
2035
 
2039
2036
  attr = AttributeSchema(
2040
2037
  optional=node_attr.optional if is_autogenerated_subtemplate else True,
2041
- **node_attr.model_dump(exclude=["id", "unique", "optional", "read_only", "inherited"]),
2038
+ **node_attr.model_dump(exclude=["id", "unique", "optional", "read_only"]),
2042
2039
  )
2043
2040
  template.attributes.append(attr)
2044
2041
 
@@ -394,8 +394,10 @@ class InfrahubDatabase:
394
394
  with QUERY_EXECUTION_METRICS.labels(**labels).time():
395
395
  response = await self.run_query(query=query, params=params, name=name)
396
396
  if response is None:
397
+ span.set_attribute("rows", "empty")
397
398
  return [], {}
398
399
  results = [item async for item in response]
400
+ span.set_attribute("rows", len(results))
399
401
  return results, response._metadata or {}
400
402
 
401
403
  async def run_query(
@@ -113,6 +113,16 @@ class GraphQLSchemaManager:
113
113
  def clear_cache(cls) -> None:
114
114
  cls._branch_details_by_name = {}
115
115
 
116
+ @classmethod
117
+ def purge_inactive(cls, active_branches: list[str]) -> set[str]:
118
+ """Return inactive branches that were purged"""
119
+ inactive_branches: set[str] = set()
120
+ for branch_name in list(cls._branch_details_by_name):
121
+ if branch_name not in active_branches:
122
+ inactive_branches.add(branch_name)
123
+ del cls._branch_details_by_name[branch_name]
124
+ return inactive_branches
125
+
116
126
  @classmethod
117
127
  def _cache_branch(
118
128
  cls, branch: Branch, schema_branch: SchemaBranch, schema_hash: str | None = None