Skip to content

Commit b1e7da2

Browse files
committed
Merge branch 'stable' into 'release-1-5' with resolved conflicts
2 parents 5998cb0 + 0b1a94a commit b1e7da2

39 files changed

+604
-187
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang
1111

1212
<!-- towncrier release notes start -->
1313

14+
## [Infrahub - v1.4.12](https://github.com/opsmill/infrahub/tree/infrahub-v1.4.12) - 2025-10-23
15+
16+
### Added
17+
18+
- - Schema Visualizer now displays `on_delete` settings for relationships
19+
- Fixed display of common_parent settings in relationships.
20+
21+
([#7431](https://github.com/opsmill/infrahub/issues/7431))
22+
23+
### Fixed
24+
25+
- Loosen requirements for upsert mutations in the GraphQL schema so that required fields can be supplied by a template. ([#7398](https://github.com/opsmill/infrahub/issues/7398))
26+
- Fix a bug that could cause duplicated attributes to be created when updating a generic schema with a new attribute. Includes a migration to fix any existing duplicated attributes created by this bug. ([#7407](https://github.com/opsmill/infrahub/issues/7407))
27+
- Fix bug in logic to create an object from a template that would prevent existing objects in relationships of sub-templates from being correctly linked to the created object. ([#7430](https://github.com/opsmill/infrahub/issues/7430))
28+
- The artifact count has been removed from the Proposed Changes list view.
29+
1430
## [Infrahub - v1.4.11](https://github.com/opsmill/infrahub/tree/infrahub-v1.4.11) - 2025-10-17
1531

1632
### Added

backend/infrahub/core/node/create.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,25 @@ async def extract_peer_data(
6262

6363
for rel in template_peer.get_schema().relationship_names:
6464
rel_manager: RelationshipManager = getattr(template_peer, rel)
65-
if (
66-
rel_manager.schema.kind not in [RelationshipKind.COMPONENT, RelationshipKind.PARENT]
67-
or rel_manager.schema.name not in obj_peer_schema.relationship_names
68-
):
65+
66+
if rel_manager.schema.name not in obj_peer_schema.relationship_names:
6967
continue
7068

71-
if list(await rel_manager.get_peers(db=db)) == [current_template.id]:
69+
peers_map = await rel_manager.get_peers(db=db)
70+
if rel_manager.schema.kind in [RelationshipKind.COMPONENT, RelationshipKind.PARENT] and list(
71+
peers_map.keys()
72+
) == [current_template.id]:
7273
obj_peer_data[rel] = {"id": parent_obj.id}
74+
continue
75+
76+
rel_peer_ids = []
77+
for peer_id, peer_object in peers_map.items():
78+
# deeper templates are handled in the next level of recursion
79+
if peer_object.get_schema().is_template_schema:
80+
continue
81+
rel_peer_ids.append({"id": peer_id})
82+
83+
obj_peer_data[rel] = rel_peer_ids
7384

7485
return obj_peer_data
7586

backend/infrahub/git/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,10 @@ async def _raise_enriched_error(self, error: GitCommandError, branch_name: str |
941941
def _raise_enriched_error_static(
942942
error: GitCommandError, name: str, location: str, branch_name: str | None = None
943943
) -> NoReturn:
944-
if "Repository not found" in error.stderr or "does not appear to be a git" in error.stderr:
944+
if any(
945+
err in error.stderr
946+
for err in ("Repository not found", "does not appear to be a git", "Failed to connect to")
947+
):
945948
raise RepositoryConnectionError(identifier=name) from error
946949

947950
if "error: pathspec" in error.stderr:

backend/infrahub/git/tasks.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from infrahub_sdk import InfrahubClient
24
from infrahub_sdk.protocols import (
35
CoreArtifact,
@@ -14,7 +16,12 @@
1416

1517
from infrahub import lock
1618
from infrahub.context import InfrahubContext
17-
from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus, ValidatorConclusion
19+
from infrahub.core.constants import (
20+
InfrahubKind,
21+
RepositoryInternalStatus,
22+
RepositoryOperationalStatus,
23+
ValidatorConclusion,
24+
)
1825
from infrahub.core.manager import NodeManager
1926
from infrahub.core.registry import registry
2027
from infrahub.exceptions import CheckError, RepositoryError
@@ -152,6 +159,39 @@ async def create_branch(branch: str, branch_id: str) -> None:
152159
pass
153160

154161

162+
@flow(name="sync-git-repo-with-origin", flow_run_name="Sync git repo with origin")
163+
async def sync_git_repo_with_origin_and_tag_on_failure(
164+
client: InfrahubClient,
165+
repository_id: str,
166+
repository_name: str,
167+
repository_location: str,
168+
internal_status: str,
169+
default_branch_name: str,
170+
operational_status: str,
171+
staging_branch: str | None = None,
172+
infrahub_branch: str | None = None,
173+
) -> None:
174+
repo = await InfrahubRepository.init(
175+
id=repository_id,
176+
name=repository_name,
177+
location=repository_location,
178+
client=client,
179+
internal_status=internal_status,
180+
default_branch_name=default_branch_name,
181+
)
182+
183+
try:
184+
await repo.sync(staging_branch=staging_branch)
185+
except RepositoryError:
186+
if operational_status == RepositoryOperationalStatus.ONLINE.value:
187+
params: dict[str, Any] = {
188+
"branches": [infrahub_branch] if infrahub_branch else [],
189+
"nodes": [str(repository_id)],
190+
}
191+
await add_tags(**params)
192+
raise
193+
194+
155195
@flow(name="git_repositories_sync", flow_run_name="Sync Git Repositories")
156196
async def sync_remote_repositories() -> None:
157197
log = get_run_logger()
@@ -204,7 +244,17 @@ async def sync_remote_repositories() -> None:
204244
continue
205245

206246
try:
207-
await repo.sync(staging_branch=staging_branch)
247+
await sync_git_repo_with_origin_and_tag_on_failure(
248+
client=client,
249+
repository_id=repository_data.repository.id,
250+
repository_name=repository_data.repository.name.value,
251+
repository_location=repository_data.repository.location.value,
252+
internal_status=active_internal_status,
253+
default_branch_name=repository_data.repository.default_branch.value,
254+
operational_status=repository_data.repository.operational_status.value,
255+
staging_branch=staging_branch,
256+
infrahub_branch=infrahub_branch,
257+
)
208258
# Tell workers to fetch to stay in sync
209259
message = messages.RefreshGitFetch(
210260
meta=Meta(initiator_id=WORKER_IDENTITY, request_id=get_log_data().get("request_id", "")),

backend/infrahub/graphql/manager.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -780,25 +780,20 @@ class StatusUpsertInput(InputObjectType):
780780
attr_kind = get_attr_kind(schema, attr)
781781
attr_type = get_attribute_type(kind=attr_kind).get_graphql_update()
782782

783-
# A Field is not required if explicitly indicated or if a default value has been provided
784-
required = not attr.optional if not attr.default_value else False
785-
786-
attrs[attr.name] = graphene.InputField(attr_type, required=required, description=attr.description)
783+
attrs[attr.name] = graphene.InputField(attr_type, description=attr.description)
787784

788785
for rel in schema.relationships:
789786
if rel.internal_peer or rel.read_only:
790787
continue
791788

792789
input_type = self._get_related_input_type(relationship=rel)
793790

794-
required = not rel.optional
795791
if rel.cardinality == RelationshipCardinality.ONE:
796-
attrs[rel.name] = graphene.InputField(input_type, required=required, description=rel.description)
792+
attrs[rel.name] = graphene.InputField(input_type, description=rel.description)
797793

798794
elif rel.cardinality == RelationshipCardinality.MANY:
799-
attrs[rel.name] = graphene.InputField(
800-
graphene.List(input_type), required=required, description=rel.description
801-
)
795+
attrs[rel.name] = graphene.InputField(graphene.List(input_type), description=rel.description)
796+
802797
input_name = f"{schema.kind}UpsertInput"
803798
md5hash = hashlib.md5(usedforsecurity=False)
804799
md5hash.update(f"{input_name}{schema.get_hash()}".encode())

backend/tests/helpers/schema/tshirt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
default_filter="name__value",
1212
display_label="{{ name__value }} {{ color__name__value }}",
1313
uniqueness_constraints=[["name__value"]],
14+
generate_template=True,
1415
attributes=[
1516
AttributeSchema(name="name", kind="Text"),
1617
AttributeSchema(

backend/tests/unit/graphql/test_mutation_create.py

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from infrahub import config
44
from infrahub.core import registry
55
from infrahub.core.branch.models import Branch
6-
from infrahub.core.constants import InfrahubKind, SchemaPathType
6+
from infrahub.core.constants import InfrahubKind, RelationshipKind, SchemaPathType
77
from infrahub.core.initialization import create_branch
88
from infrahub.core.manager import NodeManager
99
from infrahub.core.migrations.schema.node_kind_update import NodeKindUpdateMigration
@@ -17,7 +17,7 @@
1717
from infrahub.graphql.initialization import prepare_graphql_params
1818
from tests.constants import TestKind
1919
from tests.helpers.graphql import graphql
20-
from tests.helpers.schema import DEVICE_SCHEMA
20+
from tests.helpers.schema import CAR_SCHEMA, DEVICE_SCHEMA
2121

2222

2323
async def test_create_simple_object(db: InfrahubDatabase, default_branch: Branch, car_person_schema: None) -> None:
@@ -1523,6 +1523,100 @@ async def test_create_with_object_template(
15231523
assert sfp.part_number.source_id is None
15241524

15251525

1526+
async def test_create_with_object_template_and_real_object(
1527+
db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch, branch: Branch
1528+
):
1529+
"""
1530+
Test that relationships on sub-templates will correctly link the created sub-object to an existing object on non-component relationships
1531+
"""
1532+
updated_car_schema = CAR_SCHEMA.duplicate()
1533+
manufacturer_schema = updated_car_schema.get(name=TestKind.MANUFACTURER)
1534+
manufacturer_schema.generate_template = True
1535+
cars_rel = manufacturer_schema.get_relationship(name="cars")
1536+
cars_rel.kind = RelationshipKind.COMPONENT
1537+
person_schema = updated_car_schema.get(name=TestKind.PERSON)
1538+
person_schema.generate_template = True
1539+
car_schema = updated_car_schema.get(name=TestKind.CAR)
1540+
car_schema.generate_template = True
1541+
manufacturer_rel = car_schema.get_relationship(name="manufacturer")
1542+
manufacturer_rel.kind = RelationshipKind.PARENT
1543+
registry.schema.register_schema(schema=updated_car_schema, branch=branch.name)
1544+
1545+
manufacturer_object = await Node.init(schema=TestKind.MANUFACTURER, db=db, branch=branch)
1546+
await manufacturer_object.new(db=db, name="Hark Motors")
1547+
await manufacturer_object.save(db=db)
1548+
1549+
person_object = await Node.init(schema=TestKind.PERSON, db=db, branch=branch)
1550+
await person_object.new(db=db, name="John", height=180)
1551+
await person_object.save(db=db)
1552+
1553+
car_object = await Node.init(schema=TestKind.CAR, db=db, branch=branch)
1554+
await car_object.new(db=db, name="Accord", manufacturer=manufacturer_object, owner=person_object, color="blurple")
1555+
await car_object.save(db=db)
1556+
1557+
manufacturer_template: Node = await Node.init(schema=f"Template{TestKind.MANUFACTURER}", db=db, branch=branch)
1558+
await manufacturer_template.new(db=db, template_name="m_template", customers=[person_object])
1559+
await manufacturer_template.save(db=db)
1560+
1561+
car_template_with_person_object = await Node.init(schema=f"Template{TestKind.CAR}", db=db, branch=branch)
1562+
await car_template_with_person_object.new(
1563+
db=db,
1564+
template_name="c_template",
1565+
name="Civic",
1566+
color="blurple",
1567+
manufacturer=manufacturer_template,
1568+
owner=person_object,
1569+
)
1570+
await car_template_with_person_object.save(db=db)
1571+
1572+
create_manufacturer_with_template_query = """
1573+
mutation CreateManufacturerWithTemplate($manufacturer_name: String!, $template_id: String!) {
1574+
TestingManufacturerCreate(data: {
1575+
name: {value: $manufacturer_name}
1576+
object_template: {id: $template_id}
1577+
}) {
1578+
ok
1579+
object {
1580+
id
1581+
}
1582+
}
1583+
}
1584+
"""
1585+
gql_params = await prepare_graphql_params(db=db, branch=branch)
1586+
result = await graphql(
1587+
schema=gql_params.schema,
1588+
source=create_manufacturer_with_template_query,
1589+
context_value=gql_params.context,
1590+
variable_values={"manufacturer_name": "Fresh Motors", "template_id": manufacturer_template.id},
1591+
)
1592+
assert not result.errors
1593+
new_manufacturer = await NodeManager.get_one(
1594+
db=db,
1595+
kind=TestKind.MANUFACTURER,
1596+
branch=branch,
1597+
id=result.data[f"{TestKind.MANUFACTURER}Create"]["object"]["id"],
1598+
)
1599+
assert new_manufacturer
1600+
assert new_manufacturer.name.value == "Fresh Motors"
1601+
customers_peers = await new_manufacturer.customers.get_peers(db=db)
1602+
assert len(customers_peers) == 1
1603+
customers_by_name = {person.name.value: person for person in customers_peers.values()}
1604+
# check non-template person
1605+
non_template_person = customers_by_name["John"]
1606+
assert non_template_person.id == person_object.id
1607+
1608+
cars_peers = await new_manufacturer.cars.get_peers(db=db)
1609+
assert len(cars_peers) == 1
1610+
cars_by_name = {car.name.value: car for car in cars_peers.values()}
1611+
# check car template with person object
1612+
car_template_with_person_object = cars_by_name["Civic"]
1613+
assert car_template_with_person_object.color.value == "blurple"
1614+
car_manufacturer = await car_template_with_person_object.manufacturer.get_peer(db=db)
1615+
assert car_manufacturer.id == new_manufacturer.id
1616+
car_owner = await car_template_with_person_object.owner.get_peer(db=db)
1617+
assert car_owner.id == person_object.id
1618+
1619+
15261620
async def test_create_without_object_template(
15271621
db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch, branch: Branch
15281622
):

backend/tests/unit/graphql/test_mutation_upsert.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from tests.adapters.event import MemoryInfrahubEvent
1313
from tests.constants import TestKind
1414
from tests.helpers.graphql import graphql
15-
from tests.helpers.schema import TICKET
15+
from tests.helpers.schema import COLOR, TICKET, TSHIRT
1616
from tests.node_creation import create_and_save
1717

1818

@@ -691,3 +691,82 @@ async def test_upsert_node_on_branch_with_hfid_on_default(db: InfrahubDatabase,
691691
in result.errors[0].message
692692
)
693693
assert f"Please rebase this branch to access {person.id} / TestPerson" in result.errors[0].message
694+
695+
696+
async def test_upsert_with_required_relationship_from_template(
697+
db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None
698+
) -> None:
699+
"""Validate that we can use a template to populate required relationships in upsert mutations.
700+
701+
Steps:
702+
- Create a color node and a Tshirt template node.
703+
- Try to upsert a Tshirt without specifying color or template (should fail).
704+
- Upsert a Tshirt specifying the template (should succeed and apply the color from the template).
705+
"""
706+
registry.schema.register_schema(schema=SchemaRoot(nodes=[TSHIRT, COLOR]), branch=default_branch.name)
707+
708+
# Create a color node
709+
color_node = await Node.init(db=db, schema="TestingColor", branch=default_branch)
710+
await color_node.new(db=db, name="Red", description="Bright Red Color")
711+
await color_node.save(db=db)
712+
713+
# Create a Tshirt template node with the color relationship set
714+
template_node = await Node.init(db=db, schema="TemplateTestingTShirt", branch=default_branch)
715+
await template_node.new(db=db, template_name="Basic Red Tshirt", color=color_node)
716+
await template_node.save(db=db)
717+
718+
# Try to upsert a TShirt without specifying color or template (should fail)
719+
query_missing_required = """
720+
mutation {
721+
TestingTShirtUpsert(data: {name: {value: "My Shirt"} }) {
722+
ok
723+
object {
724+
id
725+
name { value }
726+
color { node { id name { value } } }
727+
}
728+
}
729+
}
730+
"""
731+
gql_params = await prepare_graphql_params(db=db, include_subscription=False, branch=default_branch)
732+
result_missing = await graphql(
733+
schema=gql_params.schema,
734+
source=query_missing_required,
735+
context_value=gql_params.context,
736+
root_value=None,
737+
variable_values={},
738+
)
739+
assert result_missing.errors
740+
assert "color is mandatory for TestingTShirt at color" in str(result_missing.errors)
741+
742+
# Upsert a Tshirt specifying the template (should succeed and apply the color from the template)
743+
query_with_template = """
744+
mutation UpsertTShirt($template_id: String!) {
745+
TestingTShirtUpsert(data: {
746+
name: {value: "My Tshirt"},
747+
object_template: {id: $template_id}
748+
}) {
749+
ok
750+
object {
751+
id
752+
name { value }
753+
color { node { id name { value } } }
754+
}
755+
}
756+
}
757+
"""
758+
759+
result_with_template = await graphql(
760+
schema=gql_params.schema,
761+
source=query_with_template,
762+
context_value=gql_params.context,
763+
root_value=None,
764+
variable_values={"template_id": template_node.id},
765+
)
766+
assert result_with_template.errors is None
767+
assert result_with_template.data
768+
assert result_with_template.data["TestingTShirtUpsert"]["ok"] is True
769+
tshirt_obj = result_with_template.data["TestingTShirtUpsert"]["object"]
770+
assert tshirt_obj["name"]["value"] == "My Tshirt"
771+
assert tshirt_obj["color"]["node"]["id"] == color_node.id
772+
assert tshirt_obj["color"]["node"]["name"]["value"] == "Red"

changelog/+artifact.fixed.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

changelog/+checks.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Resolved a problem that caused generator checks to fail when retrying requests

0 commit comments

Comments
 (0)