feat(dataflow): govern reusable definition updates
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+351
-2
@@ -25,12 +25,16 @@ from govoplan_dataflow.backend.schemas import (
|
||||
DataflowTriggerSchedule,
|
||||
PipelineCreateRequest,
|
||||
PipelineDeriveRequest,
|
||||
PipelineRebaseRequest,
|
||||
PipelineUpdateRequest,
|
||||
)
|
||||
from govoplan_dataflow.backend.service import (
|
||||
DataflowConflictError,
|
||||
DataflowValidationError,
|
||||
create_pipeline,
|
||||
derive_pipeline,
|
||||
pipeline_response,
|
||||
rebase_pipeline,
|
||||
start_pipeline_run,
|
||||
update_pipeline,
|
||||
)
|
||||
@@ -46,10 +50,87 @@ POLICY_CAPABILITY = "policy.definitionGovernance"
|
||||
AUTOMATION_CAPABILITY = "auth.automationPrincipalProvider"
|
||||
|
||||
|
||||
def sample_graph():
|
||||
def sample_graph(*, minimum: int = 10):
|
||||
from test_service import sample_graph as build_graph
|
||||
|
||||
return build_graph()
|
||||
return build_graph(minimum=minimum)
|
||||
|
||||
|
||||
def reusable_graph(*, minimum: int = 10):
|
||||
graph = sample_graph(minimum=minimum)
|
||||
return graph.model_copy(
|
||||
update={
|
||||
"nodes": [
|
||||
(
|
||||
node.model_copy(
|
||||
update={
|
||||
"config": {
|
||||
**node.config,
|
||||
"input_binding": True,
|
||||
}
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
if node.id == "source"
|
||||
else node
|
||||
)
|
||||
for node in graph.nodes
|
||||
]
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
|
||||
|
||||
def referencing_graph(
|
||||
source_ref: str,
|
||||
source_revision: int,
|
||||
*,
|
||||
omit_amount: bool = False,
|
||||
input_binding: bool = False,
|
||||
):
|
||||
graph = sample_graph()
|
||||
return graph.model_copy(
|
||||
update={
|
||||
"nodes": [
|
||||
(
|
||||
node.model_copy(
|
||||
update={
|
||||
"config": {
|
||||
**node.config,
|
||||
"rows": (
|
||||
[{"id": 1}]
|
||||
if omit_amount
|
||||
else node.config["rows"]
|
||||
),
|
||||
"input_binding": input_binding,
|
||||
}
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
if node.id == "source"
|
||||
else node.model_copy(
|
||||
update={
|
||||
"type": "subflow",
|
||||
"label": "Governed reusable flow",
|
||||
"config": {
|
||||
"template_ref": source_ref,
|
||||
"template_version": str(source_revision),
|
||||
"parameters": {},
|
||||
"graph": sample_graph(
|
||||
minimum=999
|
||||
).model_dump(mode="json"),
|
||||
},
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
if node.id == "filter"
|
||||
else node
|
||||
)
|
||||
for node in graph.nodes
|
||||
]
|
||||
},
|
||||
deep=True,
|
||||
)
|
||||
|
||||
|
||||
def principal() -> ApiPrincipal:
|
||||
@@ -482,6 +563,152 @@ class DataflowTriggerTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_reusable_reference_is_policy_resolved_with_typed_contracts(
|
||||
self,
|
||||
) -> None:
|
||||
template = create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="template-author",
|
||||
payload=PipelineCreateRequest(
|
||||
name="Typed reusable filter",
|
||||
graph=reusable_graph(minimum=10),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
consumer = create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineCreateRequest(
|
||||
name="Resolved consumer",
|
||||
graph=referencing_graph(f"pipeline:{template.id}", 1),
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
stored = consumer.revisions[0].graph
|
||||
subflow = next(
|
||||
node for node in stored["nodes"] if node["id"] == "filter"
|
||||
)
|
||||
self.assertEqual(template.revisions[0].content_hash, subflow["config"]["template_hash"])
|
||||
self.assertEqual(
|
||||
10,
|
||||
next(
|
||||
node
|
||||
for node in subflow["config"]["graph"]["nodes"]
|
||||
if node["id"] == "filter"
|
||||
)["config"]["value"],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"id", "amount"},
|
||||
{
|
||||
field["name"]
|
||||
for field in subflow["config"]["input_schema"]
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
{"id", "amount"},
|
||||
{
|
||||
field["name"]
|
||||
for field in subflow["config"]["output_schema"]
|
||||
},
|
||||
)
|
||||
self.assertTrue(
|
||||
subflow["config"]["reference_provenance"][
|
||||
"policy_decision"
|
||||
]["allowed"]
|
||||
)
|
||||
|
||||
with self.assertRaises(DataflowValidationError):
|
||||
create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineCreateRequest(
|
||||
name="Incompatible consumer",
|
||||
graph=referencing_graph(
|
||||
f"pipeline:{template.id}",
|
||||
1,
|
||||
omit_amount=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_reusable_reference_cycles_are_rejected_across_revisions(
|
||||
self,
|
||||
) -> None:
|
||||
left = create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=PipelineCreateRequest(
|
||||
name="Left template",
|
||||
graph=reusable_graph(),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
right = create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=PipelineCreateRequest(
|
||||
name="Right template",
|
||||
graph=reusable_graph(),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
self.session.flush()
|
||||
update_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
pipeline_id=left.id,
|
||||
actor_id="account-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineUpdateRequest(
|
||||
name=left.name,
|
||||
graph=referencing_graph(
|
||||
f"pipeline:{right.id}",
|
||||
1,
|
||||
input_binding=True,
|
||||
),
|
||||
status="draft",
|
||||
expected_revision=1,
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(DataflowConflictError, "cannot reference itself"):
|
||||
update_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
pipeline_id=right.id,
|
||||
actor_id="account-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineUpdateRequest(
|
||||
name=right.name,
|
||||
graph=referencing_graph(
|
||||
f"pipeline:{left.id}",
|
||||
2,
|
||||
input_binding=True,
|
||||
),
|
||||
status="draft",
|
||||
expected_revision=1,
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
|
||||
def test_derived_limits_cannot_be_broadened_transitively(self) -> None:
|
||||
template = create_pipeline(
|
||||
self.session,
|
||||
@@ -549,6 +776,128 @@ class DataflowTriggerTests(unittest.TestCase):
|
||||
self.assertFalse(grandchild.allow_automation)
|
||||
self.assertFalse(grandchild.inherit_to_lower_scopes)
|
||||
|
||||
def test_source_update_is_detected_and_rebased_as_reviewed_revision(
|
||||
self,
|
||||
) -> None:
|
||||
template = create_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=PipelineCreateRequest(
|
||||
name="Reusable import",
|
||||
graph=sample_graph(),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
derived = derive_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
source_pipeline_id=template.id,
|
||||
payload=PipelineDeriveRequest(
|
||||
name="Tenant import",
|
||||
allow_run=True,
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
before = pipeline_response(
|
||||
self.session,
|
||||
derived,
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
)
|
||||
self.assertTrue(before.governance.source_available)
|
||||
self.assertFalse(before.governance.update_available)
|
||||
original_child_hash = derived.revisions[0].content_hash
|
||||
|
||||
update_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
pipeline_id=template.id,
|
||||
actor_id="template-author",
|
||||
payload=PipelineUpdateRequest(
|
||||
name=template.name,
|
||||
graph=sample_graph(minimum=20),
|
||||
status="draft",
|
||||
expected_revision=1,
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
source_hash = template.revisions[-1].content_hash
|
||||
|
||||
available = pipeline_response(
|
||||
self.session,
|
||||
derived,
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
)
|
||||
self.assertTrue(available.governance.update_available)
|
||||
self.assertEqual(2, available.governance.source_current_revision)
|
||||
self.assertEqual(source_hash, available.governance.source_current_hash)
|
||||
self.assertEqual(original_child_hash, derived.revisions[0].content_hash)
|
||||
|
||||
with self.assertRaisesRegex(DataflowConflictError, "source hash"):
|
||||
rebase_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
pipeline_id=derived.id,
|
||||
actor_id="reviewer-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineRebaseRequest(
|
||||
expected_revision=1,
|
||||
source_revision=2,
|
||||
source_hash="0" * 64,
|
||||
reason="Reviewed the changed filter threshold.",
|
||||
),
|
||||
)
|
||||
|
||||
rebased = rebase_pipeline(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
pipeline_id=derived.id,
|
||||
actor_id="reviewer-1",
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
payload=PipelineRebaseRequest(
|
||||
expected_revision=1,
|
||||
source_revision=2,
|
||||
source_hash=source_hash,
|
||||
reason="Reviewed the changed filter threshold.",
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(2, rebased.current_revision)
|
||||
self.assertEqual("draft", rebased.status)
|
||||
self.assertEqual(2, rebased.derived_from_revision)
|
||||
self.assertEqual(source_hash, rebased.derived_from_hash)
|
||||
self.assertEqual(source_hash, rebased.revisions[-1].content_hash)
|
||||
self.assertEqual(original_child_hash, rebased.revisions[0].content_hash)
|
||||
history = rebased.derivation_provenance["rebase_history"]
|
||||
self.assertEqual(1, len(history))
|
||||
self.assertEqual("reviewer-1", history[0]["rebased_by"])
|
||||
self.assertEqual(
|
||||
"Reviewed the changed filter threshold.",
|
||||
history[0]["reason"],
|
||||
)
|
||||
current = pipeline_response(
|
||||
self.session,
|
||||
rebased,
|
||||
principal=self.actor,
|
||||
registry=self.registry,
|
||||
)
|
||||
self.assertFalse(current.governance.update_available)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user