Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
467b007
fix(frontend): filter relationship selectors by common_parent (#10039)
pa-lem Jul 28, 2026
0d089bb
fix: skip merged branches during git repository sync (#9938)
minitriga Jul 29, 2026
c910c1d
Remove resolved violations that were previously ignored
ogenstad Jul 29, 2026
c14d299
docs: add local development and evaluation sizing to hardware require…
iddocohen Jul 29, 2026
f56c188
docs: document Community single-core Neo4j limitation (#10072)
iddocohen Jul 29, 2026
609c042
docs: link hardware requirements from Community install page (#10073)
iddocohen Jul 29, 2026
2396182
Merge pull request #10077 from opsmill/pog-ty-remove-resolved-violations
ogenstad Jul 30, 2026
e0341de
refactor(frontend): replace Apollo Client with urql for the GraphQL t…
bilalabbad Jul 30, 2026
c06fc6e
test(merge-recompute): assert cross-relationship HFID refresh via ser…
gmazoyer Jul 30, 2026
dfdbf12
ci: dispatch Infrahub releases to infrahub-demo-sp (#10076)
petercrocker Jul 30, 2026
9306bef
Refresh a reader's derived values when its read peer is deleted (#9845)
gmazoyer Jul 30, 2026
ccc94ca
Fix Jinja2 comparison during repository imports
ogenstad Jul 30, 2026
07e549b
fix: render Jinja2 computed attributes sourced from an unallocated nu…
polmichel Jul 30, 2026
4420031
Merge pull request #10087 from opsmill/pog-compare-jinja2-during-repo…
ogenstad Jul 30, 2026
d197ed9
perf(graphql): skip peer hydration for id-only single relationships (…
claude[bot] Jul 30, 2026
e17fea5
final uniqueness-constraint related touchups (#10082)
ajtmccarty Jul 30, 2026
afe3503
run pnpm codegen:graphql (#10091)
ajtmccarty Jul 30, 2026
913a48d
docs: polish 1.8.0 and 1.9.0 release notes (voice, clarity, links) (#…
yjouffrault Jul 31, 2026
1e696cb
Merge stable into release-1.11
pa-lem Jul 31, 2026
a949fd7
Merge stable into release-1.11 (supersedes #10078) (#10100)
pa-lem Jul 31, 2026
42101de
Add maskless `IPAddress` attribute kind (#10090)
ajtmccarty Jul 31, 2026
3ea1eba
docs(specs): record the selective-regen known limitations tracked und…
polmichel Jul 31, 2026
ed60894
feat(frontend): allow users to customize the sorting of proposed chan…
bilalabbad Jul 31, 2026
3410471
feat(schema): report the schema fields the load endpoint does not app…
dgarros Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/repository-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
# Either a literal path, or the name of a secret...
repo:
- "opsmill/infrahub-demo-dc"
- "opsmill/infrahub-demo-sp"
- "INFRAHUB_PACKER_REPOSITORY"
- "INFRAHUB_ENTERPRISE_REPOSITORY"
- "INFRAHUB_CUSTOMER1_REPOSITORY"
Expand Down
1 change: 1 addition & 0 deletions .vale/styles/spelling-exceptions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ namespaces
nats
Nautobot
Neo4j
netmask
NGINX
Netbox
Netutils
Expand Down
59 changes: 51 additions & 8 deletions backend/infrahub/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
TemplateSchemaRead,
)
from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite
from infrahub_sdk.schema.validate import SchemaValidationWarningDetail
from infrahub_sdk.schema.validate import validate_schema as validate_write_schema
from pydantic import (
BaseModel,
Field,
PrivateAttr,
ValidatorFunctionWrapHandler,
computed_field,
create_model,
model_validator,
Expand Down Expand Up @@ -45,6 +47,8 @@
ProfileSchema,
SchemaRoot,
SchemaWarning,
SchemaWarningKind,
SchemaWarningType,
TemplateSchema,
)
from infrahub.core.schema.constants import SchemaNamespace # noqa: TC001
Expand Down Expand Up @@ -99,18 +103,51 @@ class SchemaReadAPI(BaseModel):
namespaces: list[SchemaNamespace] = Field(default_factory=list)


def read_only_field_warnings(details: list[SchemaValidationWarningDetail]) -> list[SchemaWarning]:
"""Aggregate read-only field findings into one warning per field name.

A payload read back from the schema API repeats the same read-only field on every node and
attribute it contains, so grouping by field name keeps the response proportional to the number
of distinct offending fields rather than to the size of the schema.
"""
grouped: dict[str, list[SchemaWarningKind]] = {}
for detail in details:
kinds = grouped.setdefault(detail.name, [])
if detail.kind is None:
continue
kind = SchemaWarningKind(kind=detail.kind, field=detail.element)
if kind not in kinds:
kinds.append(kind)

return [
SchemaWarning(
type=SchemaWarningType.DEPRECATION,
kinds=kinds,
message=f"'{name}' is a read-only field, the submitted value is ignored",
)
for name, kinds in grouped.items()
]


class SchemaLoadAPI(InfrahubSchemaWrite):
_internal_schema: SchemaRoot = PrivateAttr()
_contract_warnings: list[SchemaWarning] = PrivateAttr(default_factory=list)

@model_validator(mode="before")
@model_validator(mode="wrap")
@classmethod
def validate_write_contract(cls, data: Any) -> Any:
def validate_write_contract(cls, data: Any, handler: ValidatorFunctionWrapHandler) -> Self:
# Wrapped rather than run before validation so the warnings, which are only visible on the
# raw payload, can be carried on the instance the handler returns.
result = validate_write_schema(schema=data) if isinstance(data, dict) else None
# Raising here turns the field-level messages into a single request-validation error.
if isinstance(data, dict):
result = validate_write_schema(schema=data)
if not result.valid:
raise ValueError("; ".join(result.messages))
return data
if result is not None and not result.valid:
raise ValueError("; ".join(result.messages))

instance: Self = handler(data)

if result is not None:
instance._contract_warnings = read_only_field_warnings(details=result.warnings)
return instance

@model_validator(mode="after")
def build_internal_schema(self) -> Self:
Expand All @@ -122,6 +159,10 @@ def build_internal_schema(self) -> Self:
def internal_schema(self) -> SchemaRoot:
return self._internal_schema

@property
def contract_warnings(self) -> list[SchemaWarning]:
return self._contract_warnings


class SchemasLoadAPI(BaseModel):
schemas: list[SchemaLoadAPI]
Expand Down Expand Up @@ -367,6 +408,7 @@ async def load_schema(
candidate_schemas.append(internal_schema)
errors += internal_schema.validate_reserved_names()
warnings += internal_schema.gather_warnings()
warnings += schema.contract_warnings

if errors:
raise SchemaNotValidError(message=", ".join(errors))
Expand All @@ -380,7 +422,7 @@ async def load_schema(
)

if not result.diff.all:
return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff)
return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff, warnings=warnings)

# ----------------------------------------------------------
# Validate if the new schema is valid with the content of the database
Expand Down Expand Up @@ -460,6 +502,7 @@ async def check_schema(
candidate_schemas.append(internal_schema)
errors += internal_schema.validate_reserved_names()
warnings += internal_schema.gather_warnings()
warnings += schema.contract_warnings

if errors:
raise SchemaNotValidError(message=", ".join(errors))
Expand Down
8 changes: 8 additions & 0 deletions backend/infrahub/computed_attribute/jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,11 @@ def __init__(
filters={**FILTERS, **(filters or {})},
client=client,
)

def get_referenced_root_fields(self) -> set[str]:
"""Root schema field names the template depends on (the segment before ``__``).

A variable such as ``owner__name__value`` resolves to the root field ``owner``; ``__`` is
the schema-path separator, so a field name never contains it.
"""
return {variable.split("__")[0] for variable in self.get_variables()}
103 changes: 98 additions & 5 deletions backend/infrahub/core/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,13 @@ def deserialize_value(self, data: AttributeFromDB) -> Any:
"""Deserialize the value coming from the database."""
return data.value

def _normalize_value(self, value: Any) -> Any:
"""Return the canonical form of a value."""
@classmethod
def _normalize_value(cls, value: Any) -> Any:
"""Return the canonical form of a value.

Exposed on the class so that a value can be checked for canonicality without building an
attribute instance.
"""
return value

async def save(
Expand Down Expand Up @@ -1007,7 +1012,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None
except ValueError as exc:
raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc

def _normalize_value(self, value: Any) -> str:
@classmethod
def _normalize_value(cls, value: Any) -> str:
return ipaddress.ip_network(value).with_prefixlen

def get_db_node_type(self) -> AttributeDBNodeType:
Expand Down Expand Up @@ -1147,7 +1153,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None
except ValueError as exc:
raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc

def _normalize_value(self, value: Any) -> str:
@classmethod
def _normalize_value(cls, value: Any) -> str:
return ipaddress.ip_interface(value).with_prefixlen

def get_db_node_type(self) -> AttributeDBNodeType:
Expand All @@ -1170,6 +1177,91 @@ class IPHostOptional(IPHost):
value: str | None


class IPAddress(BaseAttribute):
type = str
value: str

@staticmethod
def get_allowed_property_in_path() -> list[str]:
return ["binary_address", "value", "version"]

@property
def obj(self) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
"""Return the bare ip address without a prefix or subnet mask.

Raises:
ValueError: When the IP address value has not been defined.

"""
if not self.value:
raise ValueError("value for IPAddress must be defined")
return ipaddress.ip_address(str(self.value))

@property
def version(self) -> int | None:
"""Return the IP version of the ip address."""
if not self.value:
return None
return self.obj.version

@property
def ip_integer(self) -> int:
"""Return the ip address as an integer."""
return int(self.obj)

@property
def ip_binary(self) -> str:
"""Return the ip address in binary format."""
return convert_ip_to_binary_str(obj=self.obj)

@classmethod
def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None:
"""Validate the format of the attribute.

A bare address is required, so any prefix length or netmask suffix is rejected.

Args:
value (Any): value to validate
name (str): name of the attribute to include in a potential error message
schema (AttributeSchema): schema for this attribute

Raises:
ValidationError: Format of the attribute value is not valid

"""
super().validate_format(value=value, name=name, schema=schema)

try:
ipaddress.ip_address(value)
except ValueError as exc:
raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc

@classmethod
def _normalize_value(cls, value: Any) -> str:
return str(ipaddress.ip_address(value))

def get_db_node_type(self) -> AttributeDBNodeType:
if self.value is not None:
return AttributeDBNodeType.IPHOST
return super().get_db_node_type()

def to_db(self) -> dict[str, Any]:
data = super().to_db()

if self.value is not None:
data["version"] = self.version
data["binary_address"] = self.ip_binary
# The shared AttributeIPHost vertex requires prefixlen, and neo4j refuses to MERGE on a
# null property. A bare address is a single host, so its prefix length is the maximum.
data["prefixlen"] = self.obj.max_prefixlen

return data


class IPAddressOptional(IPAddress):
value: str | None


class MacAddress(BaseAttribute):
type = str
value: str
Expand Down Expand Up @@ -1273,7 +1365,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None
if not netaddr.valid_mac(addr=str(value)):
raise ValidationError({name: f"{value} is not a valid {schema.kind}"})

def _normalize_value(self, value: Any) -> str:
@classmethod
def _normalize_value(cls, value: Any) -> str:
return netaddr.EUI(addr=value).format(dialect=netaddr.mac_unix_expanded).upper()

def serialize_value(self) -> str:
Expand Down
10 changes: 5 additions & 5 deletions backend/infrahub/core/branch/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,19 +192,19 @@ async def rebase_branch(branch: str, context: InfrahubContext, send_events: bool
)

candidate_schema = schema_analyzer.get_candidate_schema()
determiner = build_constraint_validator_determiner(
db=db, branch=user_branch, schema_branch=candidate_schema, at=rebase_at
determiner = build_constraint_validator_determiner(db=db, branch=user_branch, at=rebase_at)
data_diff_constraints = await determiner.get_constraints(
schema_branch=candidate_schema, node_diffs=node_diff_field_summaries
)
data_diff_constraints = await determiner.get_constraints(node_diffs=node_diff_field_summaries)

# If there are some changes related to the schema between this branch and main, we need to
# - Run all the validations to ensure everything is correct before rebasing the branch
# - Run all the migrations after the rebase
schema_diff_constraints: list[SchemaUpdateConstraintInfo] = []
if user_branch.has_schema_changes:
schema_diff_constraints = await schema_analyzer.calculate_validations(target_schema=candidate_schema)
merger = build_constraint_info_merger(schema_branch=candidate_schema)
constraints = merger.merge(data_diff_constraints, schema_diff_constraints)
merger = build_constraint_info_merger()
constraints = merger.merge(candidate_schema, data_diff_constraints, schema_diff_constraints)
if constraints:
responses = await schema_validate_migrations(
message=SchemaValidateMigrationData(
Expand Down
4 changes: 3 additions & 1 deletion backend/infrahub/core/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,9 @@ async def _enrich_one_node_with_relationships(
direction=rel_schema.direction,
peer_id=peer_id,
)
peer_with_metadata = PeerWithRelationshipMetadata(peer=peer)
peer_with_metadata = PeerWithRelationshipMetadata(
peer=peer, peer_kind=grouped_peer_nodes.get_peer_kind(peer_id=peer_id)
)
if not metadata_map:
rel_peers_with_metadata.append(peer_with_metadata)
continue
Expand Down
5 changes: 4 additions & 1 deletion backend/infrahub/core/merge/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from infrahub.core.registry import registry
from infrahub.core.rollback import GraphRollbacker
from infrahub.core.schema.update_coordinator import SchemaUpdateCoordinator
from infrahub.core.validators.constraint_merge import build_constraint_info_merger
from infrahub.core.validators.determiner import build_constraint_validator_determiner
from infrahub.core.validators.tasks import schema_validate_migrations
from infrahub.dependencies.registry import get_component_registry
from infrahub.workers.dependencies import get_cache, get_event_service, get_workflow
Expand Down Expand Up @@ -65,9 +67,10 @@ async def build_branch_merge_orchestrator(
schema_manager=registry.schema,
)
constraint_validator = MergeConstraintValidator(
db=db,
branch=source_branch,
diff_repository=diff_repository,
determiner=build_constraint_validator_determiner(db=db, branch=source_branch),
constraint_info_merger=build_constraint_info_merger(),
migration_validator=schema_validate_migrations,
)
graph_merger = GraphMerger(
Expand Down
Loading
Loading