Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions changelog/1248.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Potentially breaking change:** setting a read-only attribute now raises `ReadOnlyAttributeError` instead of silently discarding the value.

Previously, assigning to a read-only attribute (for example `node.some_readonly_attr.value = "x"`) was accepted and then quietly dropped from the mutation at save time, so the change never reached the server and no error was reported. That assignment now raises `ReadOnlyAttributeError` immediately. Any existing workflow that writes to a read-only attribute and relies on the old silent no-op will now fail and needs to stop setting that attribute.

Reading and re-querying read-only values are unaffected: values loaded from the API continue to populate normally. Relatedly, `infrahubctl object update` now rejects a read-only field passed via `--set` with a clear error instead of silently ignoring it.
5 changes: 5 additions & 0 deletions infrahub_sdk/ctl/object/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ async def _update_with_set_args(
rel_names = schema.relationship_names
validate_set_fields(data, attr_names, rel_names)

read_only_keys = sorted(key for key in data if key in attr_names and schema.get_attribute(key).read_only)
if read_only_keys:
console.print(f"[red]Error: cannot update read-only field(s): {', '.join(read_only_keys)}.")
raise typer.Exit(code=1)

node = await resolve_node(client, kind, identifier, schema=schema, branch=branch)

prepared = prepare_relationship_data(data, schema)
Expand Down
9 changes: 9 additions & 0 deletions infrahub_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ def __init__(self, message: str | None = None) -> None:
super().__init__(self.message)


class ReadOnlyAttributeError(Error):
"""Raised when attempting to modify the value of a read-only attribute."""

def __init__(self, name: str, message: str | None = None) -> None:
self.name = name
self.message = message or f"The attribute '{name}' is read-only and cannot be modified."
super().__init__(self.message)


class ResourceNotDefinedError(Error):
"""Raised when trying to access a resource that hasn't been defined."""

Expand Down
11 changes: 9 additions & 2 deletions infrahub_sdk/node/attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, NamedTuple, get_args

from ..exceptions import ReadOnlyAttributeError
from ..uuidt import UUIDT
from .constants import (
ATTRIBUTE_METADATA_OBJECT,
Expand Down Expand Up @@ -99,8 +100,6 @@ def __init__(self, name: str, schema: AttributeSchemaAPI, data: Any | dict) -> N
self._properties_object = PROPERTIES_OBJECT
self._properties = self._properties_flag + self._properties_object

self._read_only = ["updated_at", "is_inherited"]

self.id: str | None = data.get("id")

self._value: Any | None = data.get("value")
Expand Down Expand Up @@ -140,6 +139,14 @@ def value(self) -> Any:

@value.setter
def value(self, value: Any) -> None:
# Read-only attributes are populated from the API (via the constructor and
# _set_value), but users must not change them; loading and re-querying keep working.
if self._schema.read_only:
raise ReadOnlyAttributeError(name=self.name)
self._set_value(value)

def _set_value(self, value: Any) -> None:
"""Set the value bypassing the read-only guard, for internal population."""
self._value = value
self.value_has_been_mutated = True

Expand Down
4 changes: 2 additions & 2 deletions infrahub_sdk/node/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -1606,7 +1606,7 @@ async def _process_mutation_result(
continue

# Process allocated resource from a pool and update attribute
attr.value = object_response[attr_name]["value"]
attr._set_value(object_response[attr_name]["value"])

for rel_name in self._relationships:
rel = getattr(self, rel_name)
Expand Down Expand Up @@ -2837,7 +2837,7 @@ def _process_mutation_result(
continue

# Process allocated resource from a pool and update attribute
attr.value = object_response[attr_name]["value"]
attr._set_value(object_response[attr_name]["value"])

for rel_name in self._relationships:
rel = getattr(self, rel_name)
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/ctl/object/test_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -39,6 +40,7 @@ def test_update_with_set_args() -> None:
mock_schema = MagicMock()
mock_schema.attribute_names = ["name", "description"]
mock_schema.relationship_names = []
mock_schema.get_attribute = lambda _name: SimpleNamespace(read_only=False)

mock_attr = MagicMock()
mock_attr.value = "old-name"
Expand Down Expand Up @@ -81,6 +83,7 @@ def test_update_with_set_args_attribute_applied() -> None:
mock_schema = MagicMock()
mock_schema.attribute_names = ["description"]
mock_schema.relationship_names = []
mock_schema.get_attribute = lambda _name: SimpleNamespace(read_only=False)

mock_attr = MagicMock()
mock_attr.value = "old description"
Expand Down Expand Up @@ -118,6 +121,7 @@ def test_update_with_set_args_and_branch() -> None:
mock_schema = MagicMock()
mock_schema.attribute_names = ["name"]
mock_schema.relationship_names = []
mock_schema.get_attribute = lambda _name: SimpleNamespace(read_only=False)

mock_attr = MagicMock()
mock_attr.value = "old"
Expand Down Expand Up @@ -168,6 +172,31 @@ def test_update_invalid_field() -> None:
assert result.exit_code != 0


def test_update_read_only_field_rejected() -> None:
"""Using --set on a read-only attribute exits non-zero before resolving the node."""
mock_schema = MagicMock()
mock_schema.attribute_names = ["computed_address"]
mock_schema.relationship_names = []
mock_schema.get_attribute = lambda _name: SimpleNamespace(read_only=True)

mock_client = MagicMock()
mock_client.schema = MagicMock()
mock_client.schema.get = AsyncMock(return_value=mock_schema)

mock_resolve = AsyncMock()
with (
patch("infrahub_sdk.ctl.object.update.initialize_client", return_value=mock_client),
patch("infrahub_sdk.ctl.object.update.resolve_node", mock_resolve),
):
result = runner.invoke(app, ["object", "update", "InfraDevice", "abc-123", "--set", "computed_address=x"])

assert result.exit_code != 0
assert "read-only" in result.stdout
assert "computed_address" in result.stdout
# rejected during validation, before the node is fetched or mutated
mock_resolve.assert_not_awaited()


def test_update_with_file() -> None:
"""``object update`` with --file delegates to ObjectFile and prints a confirmation."""
mock_file = MagicMock()
Expand Down Expand Up @@ -256,6 +285,7 @@ def test_update_with_set_args_attribute_noop() -> None:
mock_schema = MagicMock()
mock_schema.attribute_names = ["description"]
mock_schema.relationship_names = []
mock_schema.get_attribute = lambda _name: SimpleNamespace(read_only=False)

mock_attr = MagicMock()
mock_attr.value = "same value"
Expand Down
Loading