Make governed release source closure explicit so a pushed governed candidate
can enter the authoritative Git/release closure without manual runtime
overwrite or fixed-recipe bypass.
- Gitea machine transport: add _safe_branch() and exact OID/branch verification.
- release_source_admission.py (new): admit a governed source by verifying remote
branch -> exact OID, fetching only to refs/aios/release-sources/<OID>,
validating authoritative main ancestry + tree OID, and writing a policy-bound,
HMAC-authenticated immutable admission record (source_admission_hmac).
- trusted candidate builder: read-only admission verifier, remains
PrivateNetwork / read-only; missing admission surfaces SOURCE_ADMISSION_REQUIRED.
- Model API / release saga: consume admission records and return machine-actionable
SOURCE_ADMISSION_REQUIRED (not NOT_FOUND) when a non-fixed source is unadmitted.
- systemd: add aios-release-source-admission.service (oneshot, root/gitops,
AIOS_GITOPS_WRITES_ENABLED=false, independent source_admission_hmac credential);
wire source_admission_hmac into trusted builder; update staging unit.
- Fixed V2R2C2/V2R2C3 recipes are not overridden by admission.
- Tests: new release_source_admission + adjusted builder/model-api/live-executor/
readiness tests.
Verified: 13-file after_sha256 byte-exact; py_compile PASS;
Linux 326 targeted passed / 0 failed; acceptance 165; regression 1401;
systemd-analyze verify 3/3 PASS.
Base: a320ea3 (agent/baseline-1.1-principal-recovery-20260908).
241 lines
9.0 KiB
Python
241 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import aios_gitops_job_executor as executor_module
|
|
from aios_authority_git_executor import AuthorityGitError, AuthorityGitExecutor
|
|
import aios_gitea_machine_transport as gitea_transport_module
|
|
from aios_gitea_machine_transport import GiteaMachineTransport
|
|
from aios_gitea_machine_broker import CampaignCapabilityReceipt, GiteaMachineBroker, MachineCredentialProjection
|
|
from aios_gitops_job_controller import GitOpsJobController, JobError, JobStore, JobState
|
|
from aios_gitops_writer_worker import handle_request
|
|
|
|
|
|
OLD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
|
NEW = "3fbbc18fb2131bbbd9e511004a59ee95cf3e86b7"
|
|
|
|
|
|
class FakeAuthority:
|
|
def __init__(self, descendant=True):
|
|
self.head = OLD
|
|
self.descendant = descendant
|
|
self.cas_calls = []
|
|
|
|
def read_ref(self, ref):
|
|
return self.head
|
|
|
|
def ancestry_check(self, older, newer):
|
|
return self.descendant and older == OLD and newer == NEW
|
|
|
|
def advance_task_branch_CAS(self, branch, expected, new):
|
|
if self.head != expected:
|
|
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
|
self.cas_calls.append((branch, expected, new))
|
|
self.head = new
|
|
return new
|
|
|
|
|
|
class FakeBroker:
|
|
def __init__(self):
|
|
self.remote = None
|
|
self.push_calls = []
|
|
|
|
def verify_identity(self):
|
|
return {"login": "aios-autonomy-agent", "repository": "aios/aios"}
|
|
|
|
def readback(self):
|
|
return self.remote
|
|
|
|
def push_branch(self, new):
|
|
self.push_calls.append(new)
|
|
self.remote = new
|
|
return new
|
|
|
|
def create_pr(self, *args):
|
|
return {"number": 1}
|
|
|
|
def status(self, *args):
|
|
return {"status": "success"}
|
|
|
|
def merge(self, *args):
|
|
return {"merged": True}
|
|
|
|
|
|
def _request(**extra):
|
|
value = {
|
|
"campaign_id": executor_module.CAMPAIGN_ID,
|
|
"repository": executor_module.REPOSITORY,
|
|
"operation": "ADVANCE_EXISTING_CAMPAIGN_BRANCH",
|
|
"branch": executor_module.BRANCH,
|
|
"expected_branch_oid": OLD,
|
|
"payload_digest": "0" * 64,
|
|
"task_id": executor_module.TASK_ID,
|
|
"new_branch_oid": NEW,
|
|
"authorization_recovery_digest": executor_module.AUTHORIZATION_DIGEST,
|
|
}
|
|
value.update(extra)
|
|
return value
|
|
|
|
|
|
def _executor(temp, authority=None, broker=None):
|
|
controller = GitOpsJobController(JobStore(temp))
|
|
job = controller.submit(_request())
|
|
executor = executor_module.DurableJobExecutor(controller, authority or FakeAuthority(), broker or FakeBroker(), Path(temp) / "recovery.json")
|
|
return controller, job, executor
|
|
|
|
|
|
def _recovery():
|
|
return {
|
|
"digest": executor_module.AUTHORIZATION_DIGEST,
|
|
"campaign_id": executor_module.CAMPAIGN_ID,
|
|
"task_id": executor_module.TASK_ID,
|
|
"repository": executor_module.REPOSITORY,
|
|
"branch": executor_module.BRANCH,
|
|
"expected_branch_oid": OLD,
|
|
}
|
|
|
|
|
|
def test_advance_existing_branch_success_and_duplicate_execute(monkeypatch):
|
|
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: _recovery())
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
controller, job, executor = _executor(temp)
|
|
first = executor.execute(job.job_id)
|
|
second = executor.execute(job.job_id)
|
|
assert first["state"] == JobState.COMPLETED.value
|
|
assert second["state"] == JobState.COMPLETED.value
|
|
assert executor.authority.cas_calls == [(f"refs/heads/{executor_module.BRANCH}", OLD, NEW)]
|
|
assert executor.broker.push_calls == [NEW]
|
|
|
|
|
|
def test_authorization_digest_required(monkeypatch):
|
|
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: _recovery())
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
controller = GitOpsJobController(JobStore(temp))
|
|
job = controller.submit(_request(authorization_recovery_digest="bad"))
|
|
executor = executor_module.DurableJobExecutor(controller, FakeAuthority(), FakeBroker(), Path(temp) / "recovery.json")
|
|
with pytest.raises(executor_module.JobExecutionError, match="AUTHORIZATION_RECOVERY_DIGEST_MISMATCH"):
|
|
executor.dry_run(job.job_id)
|
|
|
|
|
|
def test_wrong_branch_rejected(monkeypatch):
|
|
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: _recovery())
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
controller = GitOpsJobController(JobStore(temp))
|
|
job = controller.submit(_request(branch="agent/not-the-campaign"))
|
|
executor = executor_module.DurableJobExecutor(controller, FakeAuthority(), FakeBroker(), Path(temp) / "recovery.json")
|
|
with pytest.raises(executor_module.JobExecutionError, match="BRANCH_MISMATCH"):
|
|
executor.dry_run(job.job_id)
|
|
|
|
|
|
def test_non_descendant_new_oid_rejected(monkeypatch):
|
|
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: _recovery())
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
authority = FakeAuthority(descendant=False)
|
|
controller, job, executor = _executor(temp, authority=authority)
|
|
with pytest.raises(executor_module.JobExecutionError, match="NEW_OID_NOT_DESCENDANT"):
|
|
executor.dry_run(job.job_id)
|
|
|
|
|
|
def test_worker_execute_action_consumes_job():
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
controller = GitOpsJobController(JobStore(temp))
|
|
job = controller.submit(_request())
|
|
|
|
class Stub:
|
|
def execute(self, job_id, *, dry_run=False):
|
|
assert job_id == job.job_id
|
|
assert dry_run is True
|
|
return {"decision": "SAFE_TO_ADVANCE"}
|
|
|
|
result = handle_request(controller, {"action": "execute", "job_id": job.job_id, "dry_run": True}, Stub())
|
|
assert result["job"]["decision"] == "SAFE_TO_ADVANCE"
|
|
|
|
|
|
def test_machine_transport_branch_cas_is_not_force_push(monkeypatch):
|
|
transport = GiteaMachineTransport("http://127.0.0.1:3000/api/v1", "aios/aios", "https://g.cnnetmkt.top/git/aios/aios.git", Path("/tmp/repo.git"))
|
|
calls = []
|
|
def read_branch(_self, _branch):
|
|
return NEW if calls else OLD
|
|
monkeypatch.setattr(GiteaMachineTransport, "read_branch", read_branch)
|
|
monkeypatch.setattr(GiteaMachineTransport, "_push_branch_fast_forward", lambda _self, branch, new: calls.append((branch, new)))
|
|
assert transport.compare_and_swap_branch(executor_module.BRANCH, OLD, NEW) == NEW
|
|
assert calls == [(executor_module.BRANCH, NEW)]
|
|
|
|
|
|
def test_machine_transport_fetches_exact_branch_tip_into_quarantine_ref(monkeypatch, tmp_path):
|
|
transport = GiteaMachineTransport(
|
|
"http://127.0.0.1:3000/api/v1",
|
|
"aios/aios",
|
|
"https://g.cnnetmkt.top/git/aios/aios.git",
|
|
tmp_path / "repo.git",
|
|
)
|
|
reads = iter([NEW, NEW])
|
|
monkeypatch.setattr(
|
|
GiteaMachineTransport,
|
|
"read_branch",
|
|
lambda _self, _branch: next(reads),
|
|
)
|
|
askpass = tmp_path / "askpass"
|
|
askpass.write_text("#!/bin/sh\n", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
GiteaMachineTransport,
|
|
"_askpass",
|
|
lambda _self: (askpass, {}),
|
|
)
|
|
calls = []
|
|
|
|
def run(argv, **kwargs):
|
|
calls.append(list(argv))
|
|
if "fetch" in argv:
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
if "rev-parse" in argv:
|
|
# Before fetch the quarantine ref is absent; after fetch it resolves.
|
|
seen_fetch = any("fetch" in call for call in calls[:-1])
|
|
return SimpleNamespace(
|
|
returncode=0 if seen_fetch else 1,
|
|
stdout=(NEW + "\n") if seen_fetch else "",
|
|
stderr="",
|
|
)
|
|
if "update-ref" in argv:
|
|
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
raise AssertionError(argv)
|
|
|
|
monkeypatch.setattr(gitea_transport_module.subprocess, "run", run)
|
|
|
|
assert transport.fetch_branch_exact(executor_module.BRANCH, NEW) == NEW
|
|
fetch = next(call for call in calls if "fetch" in call)
|
|
assert "--no-tags" in fetch
|
|
assert "--no-write-fetch-head" in fetch
|
|
assert f"refs/heads/{executor_module.BRANCH}:refs/aios/release-sources/{NEW}" in fetch
|
|
assert not any("--force" in item or item == "-f" for item in fetch)
|
|
|
|
|
|
def test_machine_transport_does_not_fetch_when_remote_tip_moved(monkeypatch, tmp_path):
|
|
transport = GiteaMachineTransport(
|
|
"http://127.0.0.1:3000/api/v1",
|
|
"aios/aios",
|
|
"https://g.cnnetmkt.top/git/aios/aios.git",
|
|
tmp_path / "repo.git",
|
|
)
|
|
monkeypatch.setattr(
|
|
GiteaMachineTransport,
|
|
"read_branch",
|
|
lambda _self, _branch: OLD,
|
|
)
|
|
monkeypatch.setattr(
|
|
gitea_transport_module.subprocess,
|
|
"run",
|
|
lambda *args, **kwargs: (_ for _ in ()).throw(
|
|
AssertionError("git must not run after remote mismatch")
|
|
),
|
|
)
|
|
with pytest.raises(
|
|
gitea_transport_module.GiteaTransportError,
|
|
match="GITEA_SOURCE_REF_MISMATCH",
|
|
):
|
|
transport.fetch_branch_exact(executor_module.BRANCH, NEW)
|