Skip to content
Merged
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.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,11 @@ Notable changes to Cloudfall. The format follows

### Fixed

- The operator proposal schema refuses `approval.via` on an autonomous
(policy) approval. Such a receipt used to pass validation and lose the
field on load; it now fails schema validation like any other invalid
receipt. Cloudfall never wrote one, so only a hand-edited receipt is
affected (#46)
- A decision record that is not JSON or does not match its schema answers
`RECORD_INVALID` (exit 86, code `decision_record_invalid`) naming the file
and the failing field, from `decisions show`, `decisions list`,
Expand Down
17 changes: 17 additions & 0 deletions config/schemas/v1/operator-proposal.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@
"required": [
"mode"
],
"if": {
"properties": {
"mode": {
"const": "autonomous"
}
},
"required": [
"mode"
]
},
"then": {
"not": {
"required": [
"via"
]
}
},
"additionalProperties": false
},
"outcome": {
Expand Down
54 changes: 54 additions & 0 deletions sdk/tests/test_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
watch,
)
from cloudfall.validation import SchemaCatalog, validate_config
from jsonschema.exceptions import ValidationError
from test_decision import _Terminal
from treaty import CommandPath

Expand Down Expand Up @@ -715,6 +716,59 @@ def test_autonomy_executes_once_history_is_earned(tmp_path: Path) -> None:
assert receipt.approval.policy.value == "production-operator"


def _autonomous_receipt(tmp_path: Path) -> tuple[ProposalStore, Path]:
from datetime import UTC, datetime # noqa: PLC0415 - test-local.

from cloudfall.operator import autonomous_pass # noqa: PLC0415

store = _store(tmp_path)
_seed_verified_drift(store, "packages.required[git]")
_seed_verified_drift(store, "packages.required[rsync]")
drift_pass(
lambda: _audit_report(("packages.required[curl]", AuditStatus.DRIFT)),
store,
)
report = autonomous_pass(
store,
_policied_inventory(),
lambda _proposal: None,
lambda _proposal: lambda _p: True,
_FAST_APPROVE,
now=datetime(2026, 9, 10, 12, 0, tzinfo=UTC),
)
proposal_id, _status = report.executed[0]
return store, tmp_path / "proposals" / f"{proposal_id}.json"


def test_an_autonomous_approval_is_written_and_loaded_without_a_channel(
tmp_path: Path,
) -> None:
store, path = _autonomous_receipt(tmp_path)

document = json.loads(path.read_text(encoding="utf-8"))
loaded = store.load(ResourceId.from_boundary(path.stem))

assert "via" not in document["spec"]["approval"]
assert loaded.approval is not None
assert (loaded.approval.mode, loaded.approval.via) == ("autonomous", None)


@pytest.mark.parametrize("via", ["terminal", "unknown"])
def test_an_autonomous_approval_with_a_channel_is_refused_on_load(
tmp_path: Path, via: str
) -> None:
"""A policy's approval has no channel; a receipt claiming one does not load."""
store, path = _autonomous_receipt(tmp_path)
document = json.loads(path.read_text(encoding="utf-8"))
document["spec"]["approval"]["via"] = via
path.write_text(json.dumps(document), encoding="utf-8")

with pytest.raises(ValidationError) as caught:
store.load(ResourceId.from_boundary(path.stem))

assert list(caught.value.absolute_path) == ["spec", "approval"]


def test_autonomy_respects_quiet_hours(tmp_path: Path) -> None:
from datetime import UTC, datetime # noqa: PLC0415 - test-local.

Expand Down
Loading