diff --git a/.devcontainer/onCreateCommand.sh b/.devcontainer/onCreateCommand.sh index cbbc87a8e1..1a61fc2582 100755 --- a/.devcontainer/onCreateCommand.sh +++ b/.devcontainer/onCreateCommand.sh @@ -8,4 +8,4 @@ poetry install --no-interaction --no-ansi git submodule update --init -invoke demo.pull +poetry run invoke demo.pull diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index a574523175..be741d5d0b 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -2,4 +2,4 @@ git pull git submodule update -invoke demo.start --wait +poetry run invoke demo.start --wait diff --git a/.devcontainer/updateContentCommand.sh b/.devcontainer/updateContentCommand.sh index eb522f6425..9a5d83499d 100755 --- a/.devcontainer/updateContentCommand.sh +++ b/.devcontainer/updateContentCommand.sh @@ -1,12 +1,12 @@ #!/bin/bash export WEB_CONCURRENCY=2 -invoke demo.start +poetry run invoke demo.start sleep 120 docker logs infrahub-server-1 -invoke demo.load-infra-schema +poetry run invoke demo.load-infra-schema docker logs infrahub-server-1 sleep 90 docker logs infrahub-server-1 -invoke demo.load-infra-data -invoke demo.stop +poetry run invoke demo.load-infra-data +poetry run invoke demo.stop diff --git a/CHANGELOG.md b/CHANGELOG.md index e8023c2a5c..01bab77a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [Infrahub - v1.4.12](https://github.com/opsmill/infrahub/tree/infrahub-v1.4.12) - 2025-10-23 + +### Added + +- - Schema Visualizer now displays `on_delete` settings for relationships + - Fixed display of common_parent settings in relationships. + + ([#7431](https://github.com/opsmill/infrahub/issues/7431)) + +### Fixed + +- 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)) +- 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)) +- 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)) +- The artifact count has been removed from the Proposed Changes list view. + ## [Infrahub - v1.4.11](https://github.com/opsmill/infrahub/tree/infrahub-v1.4.11) - 2025-10-17 ### Added diff --git a/backend/infrahub/core/node/create.py b/backend/infrahub/core/node/create.py index d7bef7ade1..91a68721b4 100644 --- a/backend/infrahub/core/node/create.py +++ b/backend/infrahub/core/node/create.py @@ -57,14 +57,25 @@ async def extract_peer_data( for rel in template_peer.get_schema().relationship_names: rel_manager: RelationshipManager = getattr(template_peer, rel) - if ( - rel_manager.schema.kind not in [RelationshipKind.COMPONENT, RelationshipKind.PARENT] - or rel_manager.schema.name not in obj_peer_schema.relationship_names - ): + + if rel_manager.schema.name not in obj_peer_schema.relationship_names: continue - if list(await rel_manager.get_peers(db=db)) == [current_template.id]: + peers_map = await rel_manager.get_peers(db=db) + if rel_manager.schema.kind in [RelationshipKind.COMPONENT, RelationshipKind.PARENT] and list( + peers_map.keys() + ) == [current_template.id]: obj_peer_data[rel] = {"id": parent_obj.id} + continue + + rel_peer_ids = [] + for peer_id, peer_object in peers_map.items(): + # deeper templates are handled in the next level of recursion + if peer_object.get_schema().is_template_schema: + continue + rel_peer_ids.append({"id": peer_id}) + + obj_peer_data[rel] = rel_peer_ids return obj_peer_data diff --git a/backend/infrahub/graphql/manager.py b/backend/infrahub/graphql/manager.py index 0544a2b323..b19f6ae42d 100644 --- a/backend/infrahub/graphql/manager.py +++ b/backend/infrahub/graphql/manager.py @@ -787,10 +787,7 @@ class StatusUpsertInput(InputObjectType): attr_kind = get_attr_kind(schema, attr) attr_type = get_attribute_type(kind=attr_kind).get_graphql_update() - # A Field is not required if explicitly indicated or if a default value has been provided - required = not attr.optional if not attr.default_value else False - - attrs[attr.name] = graphene.InputField(attr_type, required=required, description=attr.description) + attrs[attr.name] = graphene.InputField(attr_type, description=attr.description) for rel in schema.relationships: if rel.internal_peer or rel.read_only: @@ -798,14 +795,11 @@ class StatusUpsertInput(InputObjectType): input_type = self._get_related_input_type(relationship=rel) - required = not rel.optional if rel.cardinality == RelationshipCardinality.ONE: - attrs[rel.name] = graphene.InputField(input_type, required=required, description=rel.description) + attrs[rel.name] = graphene.InputField(input_type, description=rel.description) elif rel.cardinality == RelationshipCardinality.MANY: - attrs[rel.name] = graphene.InputField( - graphene.List(input_type), required=required, description=rel.description - ) + attrs[rel.name] = graphene.InputField(graphene.List(input_type), description=rel.description) return type(f"{schema.kind}UpsertInput", (graphene.InputObjectType,), attrs) diff --git a/backend/tests/helpers/schema/tshirt.py b/backend/tests/helpers/schema/tshirt.py index 05fc2c1afc..3b184b685a 100644 --- a/backend/tests/helpers/schema/tshirt.py +++ b/backend/tests/helpers/schema/tshirt.py @@ -11,6 +11,7 @@ default_filter="name__value", display_labels=["name__value"], uniqueness_constraints=[["name__value"]], + generate_template=True, attributes=[ AttributeSchema(name="name", kind="Text"), AttributeSchema( diff --git a/backend/tests/unit/graphql/test_mutation_create.py b/backend/tests/unit/graphql/test_mutation_create.py index 597e103315..763b9a687b 100644 --- a/backend/tests/unit/graphql/test_mutation_create.py +++ b/backend/tests/unit/graphql/test_mutation_create.py @@ -3,7 +3,7 @@ from infrahub import config from infrahub.core import registry from infrahub.core.branch.models import Branch -from infrahub.core.constants import InfrahubKind, SchemaPathType +from infrahub.core.constants import InfrahubKind, RelationshipKind, SchemaPathType from infrahub.core.initialization import create_branch from infrahub.core.manager import NodeManager from infrahub.core.migrations.schema.node_kind_update import NodeKindUpdateMigration @@ -17,7 +17,7 @@ from infrahub.graphql.initialization import prepare_graphql_params from tests.constants import TestKind from tests.helpers.graphql import graphql -from tests.helpers.schema import DEVICE_SCHEMA +from tests.helpers.schema import CAR_SCHEMA, DEVICE_SCHEMA async def test_create_simple_object(db: InfrahubDatabase, default_branch, car_person_schema): @@ -1462,6 +1462,100 @@ async def test_create_with_object_template( assert sfp.part_number.source_id is None +async def test_create_with_object_template_and_real_object( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch, branch: Branch +): + """ + Test that relationships on sub-templates will correctly link the created sub-object to an existing object on non-component relationships + """ + updated_car_schema = CAR_SCHEMA.duplicate() + manufacturer_schema = updated_car_schema.get(name=TestKind.MANUFACTURER) + manufacturer_schema.generate_template = True + cars_rel = manufacturer_schema.get_relationship(name="cars") + cars_rel.kind = RelationshipKind.COMPONENT + person_schema = updated_car_schema.get(name=TestKind.PERSON) + person_schema.generate_template = True + car_schema = updated_car_schema.get(name=TestKind.CAR) + car_schema.generate_template = True + manufacturer_rel = car_schema.get_relationship(name="manufacturer") + manufacturer_rel.kind = RelationshipKind.PARENT + registry.schema.register_schema(schema=updated_car_schema, branch=branch.name) + + manufacturer_object = await Node.init(schema=TestKind.MANUFACTURER, db=db, branch=branch) + await manufacturer_object.new(db=db, name="Hark Motors") + await manufacturer_object.save(db=db) + + person_object = await Node.init(schema=TestKind.PERSON, db=db, branch=branch) + await person_object.new(db=db, name="John", height=180) + await person_object.save(db=db) + + car_object = await Node.init(schema=TestKind.CAR, db=db, branch=branch) + await car_object.new(db=db, name="Accord", manufacturer=manufacturer_object, owner=person_object, color="blurple") + await car_object.save(db=db) + + manufacturer_template: Node = await Node.init(schema=f"Template{TestKind.MANUFACTURER}", db=db, branch=branch) + await manufacturer_template.new(db=db, template_name="m_template", customers=[person_object]) + await manufacturer_template.save(db=db) + + car_template_with_person_object = await Node.init(schema=f"Template{TestKind.CAR}", db=db, branch=branch) + await car_template_with_person_object.new( + db=db, + template_name="c_template", + name="Civic", + color="blurple", + manufacturer=manufacturer_template, + owner=person_object, + ) + await car_template_with_person_object.save(db=db) + + create_manufacturer_with_template_query = """ + mutation CreateManufacturerWithTemplate($manufacturer_name: String!, $template_id: String!) { + TestingManufacturerCreate(data: { + name: {value: $manufacturer_name} + object_template: {id: $template_id} + }) { + ok + object { + id + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=branch) + result = await graphql( + schema=gql_params.schema, + source=create_manufacturer_with_template_query, + context_value=gql_params.context, + variable_values={"manufacturer_name": "Fresh Motors", "template_id": manufacturer_template.id}, + ) + assert not result.errors + new_manufacturer = await NodeManager.get_one( + db=db, + kind=TestKind.MANUFACTURER, + branch=branch, + id=result.data[f"{TestKind.MANUFACTURER}Create"]["object"]["id"], + ) + assert new_manufacturer + assert new_manufacturer.name.value == "Fresh Motors" + customers_peers = await new_manufacturer.customers.get_peers(db=db) + assert len(customers_peers) == 1 + customers_by_name = {person.name.value: person for person in customers_peers.values()} + # check non-template person + non_template_person = customers_by_name["John"] + assert non_template_person.id == person_object.id + + cars_peers = await new_manufacturer.cars.get_peers(db=db) + assert len(cars_peers) == 1 + cars_by_name = {car.name.value: car for car in cars_peers.values()} + # check car template with person object + car_template_with_person_object = cars_by_name["Civic"] + assert car_template_with_person_object.color.value == "blurple" + car_manufacturer = await car_template_with_person_object.manufacturer.get_peer(db=db) + assert car_manufacturer.id == new_manufacturer.id + car_owner = await car_template_with_person_object.owner.get_peer(db=db) + assert car_owner.id == person_object.id + + async def test_create_without_object_template( db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch, branch: Branch ): diff --git a/backend/tests/unit/graphql/test_mutation_upsert.py b/backend/tests/unit/graphql/test_mutation_upsert.py index 0c56781100..6047af9750 100644 --- a/backend/tests/unit/graphql/test_mutation_upsert.py +++ b/backend/tests/unit/graphql/test_mutation_upsert.py @@ -12,7 +12,7 @@ from tests.adapters.event import MemoryInfrahubEvent from tests.constants import TestKind from tests.helpers.graphql import graphql -from tests.helpers.schema import TICKET +from tests.helpers.schema import COLOR, TICKET, TSHIRT from tests.node_creation import create_and_save @@ -673,3 +673,82 @@ async def test_upsert_node_on_branch_with_hfid_on_default(db: InfrahubDatabase, in result.errors[0].message ) assert f"Please rebase this branch to access {person.id} / TestPerson" in result.errors[0].message + + +async def test_upsert_with_required_relationship_from_template( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """Validate that we can use a template to populate required relationships in upsert mutations. + + Steps: + - Create a color node and a Tshirt template node. + - Try to upsert a Tshirt without specifying color or template (should fail). + - Upsert a Tshirt specifying the template (should succeed and apply the color from the template). + """ + registry.schema.register_schema(schema=SchemaRoot(nodes=[TSHIRT, COLOR]), branch=default_branch.name) + + # Create a color node + color_node = await Node.init(db=db, schema="TestingColor", branch=default_branch) + await color_node.new(db=db, name="Red", description="Bright Red Color") + await color_node.save(db=db) + + # Create a Tshirt template node with the color relationship set + template_node = await Node.init(db=db, schema="TemplateTestingTShirt", branch=default_branch) + await template_node.new(db=db, template_name="Basic Red Tshirt", color=color_node) + await template_node.save(db=db) + + # Try to upsert a TShirt without specifying color or template (should fail) + query_missing_required = """ + mutation { + TestingTShirtUpsert(data: {name: {value: "My Shirt"} }) { + ok + object { + id + name { value } + color { node { id name { value } } } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, include_subscription=False, branch=default_branch) + result_missing = await graphql( + schema=gql_params.schema, + source=query_missing_required, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result_missing.errors + assert "color is mandatory for TestingTShirt at color" in str(result_missing.errors) + + # Upsert a Tshirt specifying the template (should succeed and apply the color from the template) + query_with_template = """ + mutation UpsertTShirt($template_id: String!) { + TestingTShirtUpsert(data: { + name: {value: "My Tshirt"}, + object_template: {id: $template_id} + }) { + ok + object { + id + name { value } + color { node { id name { value } } } + } + } + } + """ + + result_with_template = await graphql( + schema=gql_params.schema, + source=query_with_template, + context_value=gql_params.context, + root_value=None, + variable_values={"template_id": template_node.id}, + ) + assert result_with_template.errors is None + assert result_with_template.data + assert result_with_template.data["TestingTShirtUpsert"]["ok"] is True + tshirt_obj = result_with_template.data["TestingTShirtUpsert"]["object"] + assert tshirt_obj["name"]["value"] == "My Tshirt" + assert tshirt_obj["color"]["node"]["id"] == color_node.id + assert tshirt_obj["color"]["node"]["name"]["value"] == "Red" diff --git a/changelog/+artifact.fixed.md b/changelog/+artifact.fixed.md deleted file mode 100644 index baefaa8cbb..0000000000 --- a/changelog/+artifact.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The artifact count has been removed from the Proposed Changes list view. diff --git a/changelog/+checks.fixed.md b/changelog/+checks.fixed.md new file mode 100644 index 0000000000..ba06df4153 --- /dev/null +++ b/changelog/+checks.fixed.md @@ -0,0 +1 @@ +Resolved a problem that caused generator checks to fail when retrying requests \ No newline at end of file diff --git a/changelog/7407.fixed.md b/changelog/7407.fixed.md deleted file mode 100644 index 5ba7373deb..0000000000 --- a/changelog/7407.fixed.md +++ /dev/null @@ -1 +0,0 @@ -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. \ No newline at end of file diff --git a/changelog/7431.added.md b/changelog/7431.added.md deleted file mode 100644 index b907bc7cd0..0000000000 --- a/changelog/7431.added.md +++ /dev/null @@ -1,2 +0,0 @@ -- Schema Visualizer now displays `on_delete` settings for relationships -- Fixed display of common_parent settings in relationships. diff --git a/docker-compose.yml b/docker-compose.yml index aaf6c990e8..50ba8e8fc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -199,7 +199,7 @@ services: - 6362:6362 task-manager: - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.11}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.12}" command: uvicorn --host 0.0.0.0 --port 4200 --factory infrahub.prefect_server.app:create_infrahub_prefect restart: unless-stopped depends_on: @@ -232,7 +232,7 @@ services: retries: 5 infrahub-server: - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.11}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.12}" restart: unless-stopped command: > gunicorn --config backend/infrahub/serve/gunicorn_config.py @@ -278,7 +278,7 @@ services: deploy: mode: replicated replicas: 2 - image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.11}" + image: "${INFRAHUB_DOCKER_IMAGE:-registry.opsmill.io/opsmill/infrahub}:${VERSION:-1.4.12}" command: prefect worker start --type infrahubasync --pool infrahub-worker --with-healthcheck restart: unless-stopped depends_on: diff --git a/docs/docs/guides/change-approval-workflow.mdx b/docs/docs/guides/change-approval-workflow.mdx index 18b480cba0..64d52c4a41 100644 --- a/docs/docs/guides/change-approval-workflow.mdx +++ b/docs/docs/guides/change-approval-workflow.mdx @@ -10,6 +10,12 @@ This guide walks you through implementing a change approval workflow in Infrahub Some features in this guide require the Enterprise Edition of Infrahub. If you are using the Community Edition, the enforcement mechanisms of the change approval workflow will not be available, though you can still implement a process-based approach. ::: +:::success Change Management Workflow Blog Post + +Want to see how branches can be used in a change management workflow? Read our blog post on [Infrahub’s Change Management Workflow Is Built for Infrastructure Data](https://opsmill.com/blog/infrastructure-change-management-workflow/). + +::: + ## What you'll build By the end of this guide, you'll have a complete governance system for infrastructure changes that includes: @@ -296,3 +302,4 @@ Users with `Super Administrator` permission can: - [Managing users and permissions](../topics/permissions-roles) - [Working with branches](../topics/version-control) - [Configuring Git repositories](../guides/repository) +- [Change Management Workflow Blog Post](https://opsmill.com/blog/infrastructure-change-management-workflow/) diff --git a/docs/docs/release-notes/infrahub/release-1_4_12.mdx b/docs/docs/release-notes/infrahub/release-1_4_12.mdx new file mode 100644 index 0000000000..cf416abe6e --- /dev/null +++ b/docs/docs/release-notes/infrahub/release-1_4_12.mdx @@ -0,0 +1,35 @@ +--- +title: Release 1.4.12 +--- +
| Release Number | +1.4.12 | +
|---|---|
| Release Date | +October 23rd, 2025 | +
| Tag | +[infrahub-v1.4.12](https://github.com/opsmill/infrahub/releases/tag/infrahub-v1.4.12) | +