Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9adec87c4b | ||
|
|
a320ea391e | ||
|
|
6ffd7fe19c | ||
|
|
0136a53426 | ||
|
|
1f9f65d8c7 | ||
|
|
591884bf5f | ||
|
|
5cf599e1d1 | ||
|
|
b3cee6ae48 | ||
|
|
c8c25fa672 | ||
|
|
1735371e06 | ||
|
|
1aee083564 | ||
|
|
1efa7724c1 |
@@ -0,0 +1,122 @@
|
||||
"""Staging HTTP overlay for the exact Task lineage supersession capability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import ThreadingHTTPServer
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_task_lineage_supersession import (
|
||||
DECISION_SCHEMA,
|
||||
TaskLineageSupersessionController,
|
||||
TaskLineageSupersessionError,
|
||||
)
|
||||
|
||||
SUPERSESSION_ROUTE = "/v1/control/task-lineage-supersession"
|
||||
|
||||
|
||||
def _status(error: TaskLineageSupersessionError) -> int:
|
||||
return error.http_status
|
||||
|
||||
|
||||
class LineageSupersessionEndpoint:
|
||||
"""Framework-neutral endpoint; authentication must precede all parsing/mutation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
verifier,
|
||||
authenticate_control: Callable[[Mapping[str, str]], Any],
|
||||
*,
|
||||
clock=None,
|
||||
identity_digest=None,
|
||||
) -> None:
|
||||
self.authenticate_control = authenticate_control
|
||||
self.controller = TaskLineageSupersessionController(
|
||||
db_path, verifier, clock=clock, identity_digest=identity_digest
|
||||
)
|
||||
|
||||
def post(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
self.authenticate_control(headers)
|
||||
try:
|
||||
result = self.controller.supersede(body)
|
||||
except TaskLineageSupersessionError as exc:
|
||||
return _status(exc), {"error": exc.code}
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
|
||||
|
||||
def build_runtime():
|
||||
"""Bind to the already-active recovery API and its formal Owner verifier lazily."""
|
||||
try:
|
||||
from aios_agent_task_api import APIConfig
|
||||
from aios_agent_task_api_p0_failed_lease_recovery import (
|
||||
P0FailedLeaseRecoveryHandler,
|
||||
P0FailedLeaseRecoveryTaskApplication,
|
||||
)
|
||||
from aios_agent_task_protocol import TaskProtocolError
|
||||
from aios_owner_approval_verifier import (
|
||||
GrantRootOwnerApprovalVerifier,
|
||||
OwnerApprovalVerifierError,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("LINEAGE_SUPERSESSION_RUNTIME_OVERLAY_UNAVAILABLE") from exc
|
||||
|
||||
# The active entrypoint layers failed- and active-lease recovery on top of
|
||||
# expired recovery. Inherit that current layer so adding lineage does not
|
||||
# silently remove either already-deployed recovery route.
|
||||
class Application(P0FailedLeaseRecoveryTaskApplication):
|
||||
def __init__(self, config: Any) -> None:
|
||||
super().__init__(config)
|
||||
try:
|
||||
verifier = GrantRootOwnerApprovalVerifier()
|
||||
except OwnerApprovalVerifierError as exc:
|
||||
raise TaskProtocolError(str(exc), 503) from exc
|
||||
self.lineage_supersession = LineageSupersessionEndpoint(
|
||||
config.db_path, verifier, self.authenticate_control
|
||||
)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
value = dict(super().health())
|
||||
value.update({
|
||||
"task_lineage_supersession": True,
|
||||
"task_lineage_supersession_schema": DECISION_SCHEMA,
|
||||
"task_lineage_supersession_exact_scope": True,
|
||||
"task_lineage_supersession_exact_cas": True,
|
||||
"task_lineage_supersession_append_only": True,
|
||||
"task_lineage_supersession_caller_payload": False,
|
||||
})
|
||||
return value
|
||||
|
||||
class Handler(P0FailedLeaseRecoveryHandler):
|
||||
app: Application | None = None
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != SUPERSESSION_ROUTE:
|
||||
super().do_POST()
|
||||
return
|
||||
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
status, result = self.app.lineage_supersession.post(
|
||||
self._headers(), self._body()
|
||||
)
|
||||
if "error" in result:
|
||||
raise TaskProtocolError(str(result["error"]), status)
|
||||
self._json(result, status)
|
||||
|
||||
self._run(action)
|
||||
|
||||
return APIConfig, Application, Handler
|
||||
|
||||
|
||||
def serve(config=None) -> None:
|
||||
APIConfig, Application, Handler = build_runtime()
|
||||
config = config or APIConfig.from_env()
|
||||
application = Application(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -389,6 +389,8 @@ class CanonicalBridgeInitialExecutionResolver:
|
||||
class InitialExecutionProjectionStore:
|
||||
"""Apply the exact initial projection through one durable CAS transaction."""
|
||||
|
||||
allows_supersession_child = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
@@ -406,6 +408,32 @@ class InitialExecutionProjectionStore:
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _validate_projection_entrypoint(
|
||||
self, connection: sqlite3.Connection, task_id: str
|
||||
) -> None:
|
||||
"""Fence formal lineage children from the legacy projection entrypoint.
|
||||
|
||||
A supersession child carries additional append-only lineage evidence that
|
||||
must be committed with its initial projection. The legacy endpoint has
|
||||
no such contract, so it must not be able to project one accidentally.
|
||||
"""
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
if "task_lineage_supersessions" not in tables:
|
||||
return
|
||||
lineage = connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersessions WHERE child_task_id=?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if lineage is not None and not self.allows_supersession_child:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_ENDPOINT_REQUIRED", 409
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
||||
return {
|
||||
@@ -622,6 +650,7 @@ class InitialExecutionProjectionStore:
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
self._validate_projection_entrypoint(connection, task_id)
|
||||
if (
|
||||
str(task["status"]) != request.expected_status
|
||||
or int(task["revision"]) != request.expected_revision
|
||||
@@ -771,6 +800,29 @@ class InitialExecutionProjectionStore:
|
||||
"trace_id": str(task["task_id"]),
|
||||
}
|
||||
|
||||
def _projection_event_extension(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
request: InitialExecutionProjectionRequest,
|
||||
execution: Mapping[str, Any],
|
||||
applied_revision: int,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Return server-derived fields for a specialized projection event."""
|
||||
return {}
|
||||
|
||||
def _insert_projection_extension(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
task_id: str,
|
||||
request: InitialExecutionProjectionRequest,
|
||||
execution: Mapping[str, Any],
|
||||
event_payload: Mapping[str, Any],
|
||||
applied_revision: int,
|
||||
) -> None:
|
||||
"""Persist specialized evidence in the caller's projection transaction."""
|
||||
return None
|
||||
|
||||
def project(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -782,6 +834,7 @@ class InitialExecutionProjectionStore:
|
||||
request_sha = _sha(request.public())
|
||||
|
||||
with self._connect() as connection:
|
||||
self._validate_projection_entrypoint(connection, task_id)
|
||||
self._ensure_receipt_table(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM task_initial_execution_projections "
|
||||
@@ -915,6 +968,20 @@ class InitialExecutionProjectionStore:
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
}
|
||||
extension = dict(
|
||||
self._projection_event_extension(
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
applied_revision,
|
||||
)
|
||||
)
|
||||
if set(extension).intersection(event_payload):
|
||||
raise InitialExecutionProjectionError(
|
||||
"INITIAL_EXECUTION_PROJECTION_EVENT_EXTENSION_CONFLICT", 500
|
||||
)
|
||||
event_payload.update(extension)
|
||||
self._insert_event(connection, task_id, event_payload)
|
||||
connection.execute(
|
||||
"""
|
||||
@@ -938,6 +1005,14 @@ class InitialExecutionProjectionStore:
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
self._insert_projection_extension(
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
event_payload,
|
||||
applied_revision,
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Implementation Baseline 1.1 integrations for existing AIOS authorities."""
|
||||
BASELINE_VERSION = '1.1'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
"""Executable validation profile and thin Action client.
|
||||
|
||||
This supplied ZIP does not establish the online Task authority. Consequently
|
||||
the runnable profile creates an explicitly isolated validation authority. Online
|
||||
activation requires the authority mapping gates documented in this package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,Ed25519PublicKey
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from aios_gitops_job_controller import JobStore
|
||||
from .authorization import AuthorizationService,sign_owner_delegation_grant
|
||||
from .host import SystemdHost
|
||||
from .lineage import LineageLedger
|
||||
from .operations import OperationRegistry
|
||||
from .runtime_contract import validate_contract
|
||||
from .service import BaselineService,Identity
|
||||
from .worker_policy import WorkerProfile
|
||||
|
||||
|
||||
def _unique_object(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
if key in result: raise ValueError('DUPLICATE_JSON_KEY')
|
||||
result[key]=value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(_safe_read(Path(path).absolute()).decode('utf-8'),object_pairs_hook=_unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError('NON_FINITE_JSON')))
|
||||
|
||||
|
||||
def _path(value):
|
||||
if not isinstance(value,str): raise ValueError('PATH_INVALID')
|
||||
prefix='${CREDENTIALS_DIRECTORY}/'
|
||||
if value.startswith(prefix):
|
||||
directory=os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
name=value[len(prefix):]
|
||||
if not directory or '/' in name or name in ('','.','..'): raise ValueError('CREDENTIAL_PATH_INVALID')
|
||||
value=str(Path(directory)/name)
|
||||
if '$' in value or not Path(value).is_absolute() or '..' in Path(value).parts:
|
||||
raise ValueError('PATH_MUST_BE_EXPLICIT_ABSOLUTE')
|
||||
return Path(value)
|
||||
|
||||
|
||||
def _safe_read(path,*,private=False):
|
||||
path=_path(str(path))
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
forbidden=0o077 if private else 0o022
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid not in {0,os.geteuid()} or info.st_mode & forbidden or info.st_size>65536:
|
||||
raise ValueError('CONFIG_OR_SECRET_FILE_PERMISSIONS_INVALID')
|
||||
with os.fdopen(fd,'rb',closefd=False) as stream:
|
||||
data=stream.read(65537)
|
||||
if len(data)>65536: raise ValueError('CONFIG_OR_SECRET_FILE_TOO_LARGE')
|
||||
return data
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def secret(path):
|
||||
return _safe_read(path,private=True)
|
||||
|
||||
|
||||
def load_configuration(path):
|
||||
config=read_json(path)
|
||||
required={'schema','profile','state_dir','listen','machine_key_id','machine_key_file',
|
||||
'owner_public_key_files','owner_key_scopes','identities','workers','contracts','operations','host_bindings'}
|
||||
if type(config) is not dict or set(config)-{'receipt_public_key_files'}!=required or config['schema']!='aios.baseline-server-config.v1':
|
||||
raise ValueError('SERVER_CONFIG_FIELDS_INVALID')
|
||||
if config['profile']!='isolated-validation': raise ValueError('PROFILE_REQUIRES_VERIFIED_AUTHORITY_ADAPTER')
|
||||
listen=config['listen']
|
||||
if type(listen) is not dict or set(listen)!={'host','port'} or listen['host']!='127.0.0.1' or type(listen['port']) is not int or not 1024<=listen['port']<=65535:
|
||||
raise ValueError('LOOPBACK_LISTENER_REQUIRED')
|
||||
state=_path(config['state_dir'])
|
||||
if state.is_symlink() or state==Path('/') or state.name in {'aios','active-runtime','app'}:
|
||||
raise ValueError('ISOLATED_STATE_PATH_REQUIRED')
|
||||
if not isinstance(config['machine_key_id'],str) or not config['machine_key_id']: raise ValueError('MACHINE_ID_REQUIRED')
|
||||
machine=serialization.load_pem_private_key(secret(config['machine_key_file']),password=None)
|
||||
if not isinstance(machine,Ed25519PrivateKey): raise ValueError('ED25519_MACHINE_KEY_REQUIRED')
|
||||
keys={}
|
||||
for key_id,filename in config['owner_public_key_files'].items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_OWNER_KEY_REQUIRED')
|
||||
keys[key_id]=public
|
||||
if not keys: raise ValueError('OWNER_TRUST_ROOT_REQUIRED')
|
||||
receipt_keys={}
|
||||
for key_id,filename in config.get('receipt_public_key_files',{}).items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_RECEIPT_KEY_REQUIRED')
|
||||
receipt_keys[key_id]=public
|
||||
tokens={}
|
||||
for entry in config['identities']:
|
||||
if type(entry) is not dict or set(entry)-{'token_file','tenant_id','subject_id','role','worker_id'}:
|
||||
raise ValueError('IDENTITY_FIELDS_INVALID')
|
||||
token=secret(entry['token_file']).decode().strip()
|
||||
if len(token)<32 or len(token)>512 or any(x.isspace() for x in token) or token in tokens: raise ValueError('TOKEN_INVALID_OR_DUPLICATE')
|
||||
identity=Identity(**{k:v for k,v in entry.items() if k!='token_file'})
|
||||
if identity.role not in {'user','owner','worker'} or any(not isinstance(v,str) or not v for v in (identity.tenant_id,identity.subject_id)):
|
||||
raise ValueError('IDENTITY_INVALID')
|
||||
if (identity.role=='worker') != bool(identity.worker_id): raise ValueError('WORKER_IDENTITY_INVALID')
|
||||
tokens[token]=identity
|
||||
workers={}
|
||||
for entry in config['workers']:
|
||||
worker=WorkerProfile.from_dict(entry)
|
||||
if worker.worker_id in workers: raise ValueError('WORKER_DUPLICATE')
|
||||
workers[worker.worker_id]=worker
|
||||
for identity in tokens.values():
|
||||
if identity.role=='worker':
|
||||
worker=workers.get(identity.worker_id)
|
||||
if not worker or worker.identity!=identity.subject_id or identity.tenant_id not in worker.tenant_scope:
|
||||
raise ValueError('WORKER_IDENTITY_SCOPE_MISMATCH')
|
||||
contracts=[validate_contract(c) for c in config['contracts']]
|
||||
operations=OperationRegistry(config['operations'])
|
||||
host=SystemdHost(config['host_bindings'])
|
||||
config['_resolved']={'state':state,'machine':machine,'owner_keys':keys,'workers':workers,
|
||||
'contracts':contracts,'operations':operations,'host':host,'tokens':tokens,'receipt_keys':receipt_keys}
|
||||
return config,tokens
|
||||
|
||||
|
||||
def build_service(config):
|
||||
resolved=config['_resolved']
|
||||
state=resolved['state']
|
||||
marker=state/'ISOLATED_VALIDATION_AUTHORITY'
|
||||
if state.exists() and not marker.is_file() and any(state.iterdir()):
|
||||
raise ValueError('EXISTING_STATE_AUTHORITY_NOT_IDENTIFIED')
|
||||
if marker.exists() and _safe_read(marker)!=b'aios.baseline.isolated-validation.v1\n':
|
||||
raise ValueError('STATE_AUTHORITY_MARKER_INVALID')
|
||||
state.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if state.stat().st_mode & 0o077: raise ValueError('STATE_DIRECTORY_PERMISSIONS_INVALID')
|
||||
if not marker.exists(): marker.write_text('aios.baseline.isolated-validation.v1\n')
|
||||
auth=AuthorizationService(state/'authorization.sqlite3',resolved['owner_keys'],resolved['machine'],config['machine_key_id'],
|
||||
owner_key_scopes=config['owner_key_scopes'],
|
||||
control_context_verifier=lambda ctx:isinstance(ctx,Identity) and ctx.role=='owner' and any(ctx is known for known in resolved['tokens'].values()))
|
||||
store=TaskPlaneStore(str(state/'tasks.sqlite3'),high_assurance=True)
|
||||
return BaselineService(store,state/'jobs',auth,resolved['contracts'],resolved['operations'],resolved['workers'],
|
||||
resolved['host'],LineageLedger(state/'lineage.sqlite3',store,JobStore(state/'jobs').read),resolved['machine'],
|
||||
machine_key_id=config['machine_key_id'],trusted_receipt_keys=resolved['receipt_keys'])
|
||||
|
||||
|
||||
def _write_json(path,value,mode=0o600):
|
||||
data=(json.dumps(value,ensure_ascii=False,sort_keys=True,indent=2,allow_nan=False)+'\n').encode()
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,mode)
|
||||
with os.fdopen(fd,'wb') as out: out.write(data)
|
||||
|
||||
|
||||
def initialize_validation(directory,*,unit):
|
||||
"""Create explicit local-only example identities, not production trust roots."""
|
||||
directory=Path(directory).absolute()
|
||||
directory.mkdir(mode=0o700) # Never overwrite an existing installation.
|
||||
# Validate unit syntax before writing any identity material.
|
||||
SystemdHost({'validation:staging:sample':unit})
|
||||
owner=Ed25519PrivateKey.generate()
|
||||
machine=Ed25519PrivateKey.generate()
|
||||
for name,key in [('owner-private.pem',owner),('machine-private.pem',machine)]:
|
||||
path=directory/name
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
out.write(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||
(directory/'owner-public.pem').write_bytes(owner.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
for name in ('user.token','worker.token'):
|
||||
fd=os.open(directory/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'w') as out: out.write(secrets.token_urlsafe(48)+'\n')
|
||||
operation={'operation_id':'service-status','version':1,'effect_class':'READ_ONLY','handler_ref':'host.service_status',
|
||||
'input_schema':'aios.operation.service-status.input.v1','required_permissions':['service-status'],'timeout_seconds':10,
|
||||
'readback':{'mode':'NONE','required':False},'retry':{'mode':'SAFE','max_attempts':2},'reconcile':{'mode':'NONE'},
|
||||
'rollback':{'mode':'NONE'},'owner_boundary':{'mode':'WITHIN_GRANT'}}
|
||||
contract={'contract_schema':'aios.runtime-contract.v1','contract_revision':1,'component_id':'sample','environment':'staging',
|
||||
'tenant_scope':'validation','lifecycle':'active','entrypoints':{'status':{'component_id':'sample','environment':'staging','resolver_ref':'service://sample/status'}},
|
||||
'bindings':{},'resource_refs':{},'credential_refs':{},'operations':['service-status']}
|
||||
config={'schema':'aios.baseline-server-config.v1','profile':'isolated-validation','state_dir':str(directory/'state'),
|
||||
'listen':{'host':'127.0.0.1','port':8769},'machine_key_id':'validation-machine','machine_key_file':str(directory/'machine-private.pem'),
|
||||
'owner_public_key_files':{'validation-owner':str(directory/'owner-public.pem')},
|
||||
'owner_key_scopes':{'validation-owner':{'tenant_ids':['validation'],'subject_ids':['owner']}},
|
||||
'identities':[{'token_file':str(directory/'user.token'),'tenant_id':'validation','subject_id':'owner','role':'owner'},
|
||||
{'token_file':str(directory/'worker.token'),'tenant_id':'validation','subject_id':'machine-worker','role':'worker','worker_id':'diagnostic-worker'}],
|
||||
'workers':[{'worker_id':'diagnostic-worker','identity':'machine-worker','tenant_scope':['validation'],'subject_scope':['owner'],
|
||||
'capabilities':['service-status'],'priority':10,'fallback_executor':False,'heartbeat_seconds':15,'max_concurrency':1}],
|
||||
'contracts':[contract],'operations':[operation],'host_bindings':{'validation:staging:sample':unit}}
|
||||
_write_json(directory/'server.json',config)
|
||||
_write_json(directory/'worker.json',{'schema':'aios.baseline-worker-config.v1','base_url':'http://127.0.0.1:8769',
|
||||
'token_file':str(directory/'worker.token'),'poll_seconds':2})
|
||||
grant=sign_owner_delegation_grant(owner,'validation-owner',grant_id='validation-grant',generation=1,
|
||||
tenant_id='validation',subject_id='owner',mission_ids=('validation-mission',),task_ids=('*',),operations=('service-status',),
|
||||
component_ids=('sample',),environments=('staging',),issued_at=int(time.time()),validity_mode='UNTIL_REVOKED',max_effects=20)
|
||||
_write_json(directory/'grant.json',grant)
|
||||
_write_json(directory/'goal.json',{'mission_id':'validation-mission','grant_id':'validation-grant','operation':'service-status',
|
||||
'component_id':'sample','environment':'staging','inputs':{},'intent':'检查已绑定 staging 服务并生成诊断结果'})
|
||||
return directory
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
sub=parser.add_subparsers(dest='command',required=True)
|
||||
init=sub.add_parser('init-validation');init.add_argument('--directory',required=True);init.add_argument('--service-unit',required=True)
|
||||
for name in ('preflight','server','grant-import','grant-revoke'):
|
||||
item=sub.add_parser(name);item.add_argument('--config',required=True)
|
||||
if name=='grant-import': item.add_argument('--grant',required=True)
|
||||
if name=='grant-revoke':
|
||||
item.add_argument('--grant',required=True);item.add_argument('--owner-token-file',required=True)
|
||||
worker=sub.add_parser('worker');worker.add_argument('--config',required=True);worker.add_argument('--once',action='store_true')
|
||||
for name in ('goal-submit','goal-explain','task-get','task-cancel'):
|
||||
item=sub.add_parser(name);item.add_argument('--base-url',default='http://127.0.0.1:8769');item.add_argument('--token-file',required=True)
|
||||
if name=='goal-submit': item.add_argument('--request',required=True);item.add_argument('--idempotency-key',required=True)
|
||||
elif name=='goal-explain': item.add_argument('--mission-id',required=True)
|
||||
else: item.add_argument('--task-id',required=True)
|
||||
args=parser.parse_args(argv)
|
||||
try:
|
||||
if args.command=='init-validation':
|
||||
print(json.dumps({'profile':'isolated-validation','directory':str(initialize_validation(args.directory,unit=args.service_unit))}));return 0
|
||||
if args.command in {'preflight','server','grant-import','grant-revoke'}:
|
||||
config,tokens=load_configuration(args.config)
|
||||
if args.command=='preflight':
|
||||
print(json.dumps({'status':'CONFIG_VALID','profile':config['profile'],'online_authority':'NOT_VERIFIED',
|
||||
'production_activation':'BLOCKED','registered_operations':len(config['operations'])}));return 0
|
||||
service=build_service(config)
|
||||
if args.command=='grant-import':
|
||||
grant=service.authorization.register_grant(read_json(args.grant))
|
||||
print(json.dumps({'grant_id':grant['payload']['grant_id'],'registered':True}));return 0
|
||||
if args.command=='grant-revoke':
|
||||
context=tokens.get(secret(args.owner_token_file).decode().strip())
|
||||
grant=read_json(args.grant)
|
||||
payload=grant['payload']
|
||||
if context is None or context.role!='owner' or payload['tenant_id']!=context.tenant_id or payload['subject_id']!=context.subject_id:
|
||||
raise ValueError('OWNER_SCOPE_DENIED')
|
||||
# Register verifies signature and ID before a target can be revoked.
|
||||
service.authorization.register_grant(grant)
|
||||
generation=service.authorization.revoke_grant(payload['grant_id'],control_context=context)
|
||||
print(json.dumps({'grant_id':payload['grant_id'],'revocation_generation':generation}));return 0
|
||||
from .transport import serve
|
||||
server=serve(service,tokens,**config['listen'])
|
||||
stop=threading.Event()
|
||||
def recover_loop():
|
||||
while not stop.wait(5):
|
||||
try: service.recover()
|
||||
except Exception: service.storage_blocked=True
|
||||
recovery=threading.Thread(target=recover_loop,daemon=True);recovery.start()
|
||||
def shutdown(_signal,_frame):
|
||||
stop.set();threading.Thread(target=server.shutdown,daemon=True).start()
|
||||
signal.signal(signal.SIGTERM,shutdown);signal.signal(signal.SIGINT,shutdown)
|
||||
print(json.dumps({'status':'LISTENING','host':config['listen']['host'],'port':config['listen']['port'],'profile':config['profile']}),flush=True)
|
||||
try: server.serve_forever(poll_interval=0.5)
|
||||
finally: stop.set();server.server_close()
|
||||
return 0
|
||||
from .transport import HttpTransport
|
||||
if args.command=='worker':
|
||||
from .worker import OperationWorker
|
||||
config=read_json(args.config)
|
||||
if type(config) is not dict or set(config)!={'schema','base_url','token_file','poll_seconds'} or config['schema']!='aios.baseline-worker-config.v1': raise ValueError('WORKER_CONFIG_INVALID')
|
||||
transport=HttpTransport(config['base_url'],secret(config['token_file']).decode().strip(),timeout_seconds=20)
|
||||
worker=OperationWorker(transport,poll_seconds=config['poll_seconds'])
|
||||
if args.once: print(json.dumps(worker.run_once(),ensure_ascii=False));return 0
|
||||
worker.run_forever();return 0
|
||||
transport=HttpTransport(args.base_url,secret(args.token_file).decode().strip(),timeout_seconds=20)
|
||||
if args.command=='goal-submit': result=transport.submit_goal(read_json(args.request),idempotency_key=args.idempotency_key)
|
||||
elif args.command=='goal-explain': result=transport.explain(args.mission_id)
|
||||
elif args.command=='task-get': result=transport.get_task(args.task_id)
|
||||
else: result=transport.cancel_task(args.task_id)
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2,allow_nan=False));return 0
|
||||
except (Exception,KeyboardInterrupt) as exc:
|
||||
# Avoid dumping request bodies, credential material or Python tracebacks.
|
||||
text=str(exc)
|
||||
code=text if re.fullmatch(r'[A-Z][A-Z0-9_]{2,100}',text) else getattr(exc,'code',type(exc).__name__)
|
||||
print(json.dumps({'status':'ERROR','error_type':type(exc).__name__,'code':code,'message':'See local configuration and acceptance runbook; no action was reported successful.'}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Canonical, domain-separated machine receipts, distinct from Owner grants."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class ReceiptSigner:
|
||||
def __init__(self, private_key, *, key_id='baseline-machine', trusted_public_keys=None):
|
||||
self.private_key=private_key
|
||||
self.public_key=private_key.public_key()
|
||||
self.key_id=key_id
|
||||
self.trusted_public_keys=dict(trusted_public_keys or {})
|
||||
self.trusted_public_keys[key_id]=self.public_key
|
||||
|
||||
def sign(self,payload):
|
||||
raw=canonical({'domain':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload})
|
||||
return {'schema':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload,
|
||||
'signature':base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
|
||||
def verify(self,envelope):
|
||||
try:
|
||||
if type(envelope) is not dict or set(envelope)!={'schema','key_id','payload','signature'} or envelope['schema']!='aios.operation-receipt.v1' or type(envelope['payload']) is not dict:
|
||||
return False
|
||||
public=self.trusted_public_keys.get(envelope['key_id'])
|
||||
if public is None: return False
|
||||
raw=canonical({'domain':envelope['schema'],'key_id':envelope['key_id'],'payload':envelope['payload']})
|
||||
public.verify(base64.b64decode(envelope['signature'],validate=True),raw)
|
||||
return True
|
||||
except (ValueError,TypeError,InvalidSignature,KeyError):
|
||||
return False
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Small registered host diagnostics; unit bindings come from trusted configuration."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SystemdHost:
|
||||
PROPERTIES = ('Id', 'ActiveState', 'SubState', 'MainPID', 'InvocationID')
|
||||
|
||||
def __init__(self, bindings: dict[str, str], *, executable: str = '/usr/bin/systemctl',
|
||||
timeout_seconds: float = 10):
|
||||
if type(timeout_seconds) not in (int,float) or not 0 < timeout_seconds <= 60:
|
||||
raise HostError('HOST_TIMEOUT_INVALID')
|
||||
self.bindings = dict(bindings)
|
||||
for binding,unit in self.bindings.items():
|
||||
if not isinstance(binding,str) or len(binding.split(':'))!=3 or any(not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in binding.split(':')):
|
||||
raise HostError('HOST_TENANT_BINDING_INVALID')
|
||||
if not isinstance(unit, str) or not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.service', unit):
|
||||
raise HostError('HOST_UNIT_INVALID')
|
||||
if not Path(executable).is_absolute() or not Path(executable).is_file() or Path(executable).is_symlink():
|
||||
raise HostError('HOST_EXECUTABLE_UNAVAILABLE')
|
||||
info=Path(executable).stat()
|
||||
if info.st_uid not in {0,os.geteuid()} or info.st_mode & 0o022:
|
||||
raise HostError('HOST_EXECUTABLE_UNTRUSTED')
|
||||
self._executable_identity=(info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)
|
||||
self.executable = executable
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def status(self, component_id: str, environment: str, *, tenant_id: str, deadline=None) -> dict:
|
||||
if any(not isinstance(part,str) or not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in (tenant_id,environment,component_id)):
|
||||
raise HostError('HOST_SCOPE_INVALID')
|
||||
unit = self.bindings.get(f'{tenant_id}:{environment}:{component_id}')
|
||||
if not unit:
|
||||
raise HostError('HOST_BINDING_NOT_FOUND')
|
||||
remaining=self.timeout_seconds if deadline is None else min(self.timeout_seconds,deadline-time.time())
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
stop_at=time.monotonic()+remaining
|
||||
executable_fd=os.open(self.executable,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(executable_fd)
|
||||
if not stat.S_ISREG(info.st_mode) or (info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)!=self._executable_identity:
|
||||
raise HostError('HOST_EXECUTABLE_IDENTITY_CHANGED')
|
||||
executable_path=f'/proc/self/fd/{executable_fd}'
|
||||
if not Path(executable_path).exists(): raise HostError('HOST_EXECUTOR_PLATFORM_UNSUPPORTED')
|
||||
argv=[executable_path,'show','--no-pager','--property='+','.join(self.PROPERTIES),'--',unit]
|
||||
process=subprocess.Popen(argv,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,
|
||||
env={'PATH':'/usr/bin:/bin','LANG':'C'},start_new_session=True,pass_fds=(executable_fd,))
|
||||
finally: os.close(executable_fd)
|
||||
output=bytearray()
|
||||
byte_count=0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout,selectors.EVENT_READ,'stdout')
|
||||
selector.register(process.stderr,selectors.EVENT_READ,'stderr')
|
||||
while selector.get_map():
|
||||
remaining=stop_at-time.monotonic()
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
for event,_mask in selector.select(remaining):
|
||||
chunk=os.read(event.fileobj.fileno(),4096)
|
||||
if not chunk:
|
||||
selector.unregister(event.fileobj)
|
||||
continue
|
||||
byte_count+=len(chunk)
|
||||
if byte_count>65536: raise HostError('HOST_RESPONSE_TOO_LARGE')
|
||||
if event.data=='stdout': output.extend(chunk)
|
||||
process.wait(timeout=max(0.001,stop_at-time.monotonic()))
|
||||
except (HostError,subprocess.TimeoutExpired) as exc:
|
||||
try: os.killpg(process.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
process.wait()
|
||||
if isinstance(exc,HostError): raise
|
||||
raise HostError('HOST_OPERATION_TIMEOUT') from exc
|
||||
finally:
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
if process.returncode != 0:
|
||||
raise HostError('HOST_STATUS_UNAVAILABLE')
|
||||
try: out=output.decode('utf-8')
|
||||
except UnicodeDecodeError as exc: raise HostError('HOST_OBSERVATION_INVALID') from exc
|
||||
properties = {}
|
||||
for line in out.splitlines():
|
||||
key, sep, value = line.partition('=')
|
||||
if sep and key in self.PROPERTIES:
|
||||
if key in properties:
|
||||
raise HostError('HOST_DUPLICATE_PROPERTY')
|
||||
properties[key] = value
|
||||
if set(properties) != set(self.PROPERTIES) or properties['Id'] != unit:
|
||||
raise HostError('HOST_OBSERVATION_INCOMPLETE')
|
||||
if not properties['MainPID'].isdigit():
|
||||
raise HostError('HOST_OBSERVATION_INVALID')
|
||||
return {'operation':'service-status', 'tenant_id':tenant_id, 'component_id':component_id, 'environment':environment,
|
||||
'unit':unit, 'healthy':properties['ActiveState'] == 'active',
|
||||
'properties':properties, 'observed_at':time.time(), 'source':'systemd.show'}
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Persistent correlation relationships for the AIOS baseline.
|
||||
|
||||
This is deliberately a relationship authority. It does not create or advance
|
||||
Tasks, Jobs, Releases, or Deployments. In particular, a Task is admitted only
|
||||
after the configured Task authority returns the real Task and its authenticated
|
||||
tenant/subject binding has been checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
class LineageError(RuntimeError):
|
||||
"""Base class for persistent lineage failures."""
|
||||
|
||||
|
||||
class LineageConflict(LineageError):
|
||||
"""An opaque identity was already bound to different lineage."""
|
||||
|
||||
|
||||
class LineageVerificationError(LineageError):
|
||||
"""An external authority did not verify the requested relationship."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedAcceptanceEvidence:
|
||||
"""Typed output of a configured acceptance evidence verifier."""
|
||||
|
||||
acceptance_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
accepted: bool
|
||||
evidence_id: str
|
||||
observed: Mapping[str, Any] | None = None
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
_SELF_HASH_FIELDS = {
|
||||
"manifest_sha256",
|
||||
"manifest_hash",
|
||||
"outer_manifest_hash",
|
||||
"outer_manifest_sha256",
|
||||
"self_hash",
|
||||
"self_sha256",
|
||||
}
|
||||
|
||||
|
||||
def canonical_artifact(artifact: Any) -> str:
|
||||
"""Return an exact artifact identity without permitting outer self-hashes."""
|
||||
|
||||
if not isinstance(artifact, (str, Mapping)):
|
||||
raise ValueError("artifact must be a string or object")
|
||||
if isinstance(artifact, str):
|
||||
_required(artifact, "artifact")
|
||||
else:
|
||||
if not artifact:
|
||||
raise ValueError("artifact must not be empty")
|
||||
if any(not isinstance(key,str) for key in artifact):
|
||||
raise ValueError("artifact object keys must be strings")
|
||||
if _SELF_HASH_FIELDS.intersection(key.lower() for key in artifact):
|
||||
raise ValueError("outer artifact manifest must not contain a self hash")
|
||||
try:
|
||||
return _json(artifact)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("artifact must be canonical JSON") from exc
|
||||
|
||||
|
||||
def decode_artifact(value: str) -> Any:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
converter = getattr(value, "to_dict", None)
|
||||
if not callable(converter):
|
||||
converter = getattr(value, "as_dict", None)
|
||||
if callable(converter):
|
||||
try:
|
||||
converted = converter(redact_payload=False)
|
||||
except TypeError:
|
||||
converted = converter()
|
||||
if isinstance(converted, Mapping):
|
||||
return converted
|
||||
raise LineageVerificationError("task_authority returned an invalid Task")
|
||||
|
||||
|
||||
def _task_plan(task: Any) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
|
||||
raw = _mapping(task)
|
||||
plan: Any = raw.get("plan") or raw.get("task_plan")
|
||||
if plan is None and hasattr(task, "task_plan"):
|
||||
plan = getattr(task, "task_plan")
|
||||
if plan is None:
|
||||
plan = raw
|
||||
plan_map = _mapping(plan)
|
||||
return raw, plan_map
|
||||
|
||||
|
||||
class LineageLedger:
|
||||
"""SQLite-backed authority for cross-domain identity relationships."""
|
||||
|
||||
def __init__(self, path: str | Path, task_authority: Any, job_authority: Any):
|
||||
if task_authority is None:
|
||||
raise ValueError("task_authority is required")
|
||||
if job_authority is None:
|
||||
raise ValueError("job_authority is required")
|
||||
self.path = str(path)
|
||||
self.task_authority = task_authority
|
||||
self.job_authority = job_authority
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "lineage_tasks" in tables and "lineage_schema_meta" not in tables:
|
||||
raise LineageConflict("unversioned candidate lineage schema requires explicit migration")
|
||||
if "lineage_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM lineage_schema_meta WHERE domain='lineage'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise LineageConflict("unsupported lineage schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS lineage_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_tasks (
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
source_task_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, task_id),
|
||||
UNIQUE (tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_effects (
|
||||
tenant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
source_effect_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, job_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_releases (
|
||||
tenant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, release_id),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_deployments (
|
||||
tenant_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, deployment_id),
|
||||
UNIQUE (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
REFERENCES lineage_releases(tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_acceptances (
|
||||
tenant_id TEXT NOT NULL,
|
||||
acceptance_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL CHECK (accepted IN (0,1)),
|
||||
evidence_id TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, acceptance_id),
|
||||
FOREIGN KEY (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json)
|
||||
REFERENCES lineage_deployments(
|
||||
tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO lineage_schema_meta(domain,version) VALUES('lineage',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorized_task(self, tenant_id: str, subject_id: str, task_id: str) -> Any:
|
||||
authority = self.task_authority
|
||||
try:
|
||||
if hasattr(authority, "get_task"):
|
||||
task = authority.get_task(task_id)
|
||||
elif callable(authority):
|
||||
try:
|
||||
task = authority(tenant_id, subject_id, task_id)
|
||||
except TypeError:
|
||||
task = authority(task_id)
|
||||
else:
|
||||
raise TypeError("authority has no task lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("task_authority did not confirm task") from exc
|
||||
raw, plan = _task_plan(task)
|
||||
returned_id = raw.get("task_id") or plan.get("task_id") or getattr(task, "task_id", None)
|
||||
if str(returned_id or "") != task_id:
|
||||
raise LineageVerificationError("task_authority returned wrong task_id")
|
||||
issuer = plan.get("issuer") if isinstance(plan.get("issuer"), Mapping) else {}
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
tenant_values = [value for value in (issuer.get("tenant_id"), metadata.get("tenant_id"), baseline.get("tenant_id")) if value is not None]
|
||||
subject_values = [
|
||||
value for value in (issuer.get("subject_id"), metadata.get("subject_id"), baseline.get("subject_id")) if value is not None
|
||||
]
|
||||
# issuer.id is accepted as subject identity only when no explicit subject_id exists.
|
||||
if not subject_values and issuer.get("id") is not None:
|
||||
subject_values.append(issuer["id"])
|
||||
if not tenant_values or any(str(value) != tenant_id for value in tenant_values):
|
||||
raise LineageVerificationError("task_authority tenant mismatch")
|
||||
if not subject_values or any(str(value) != subject_id for value in subject_values):
|
||||
raise LineageVerificationError("task_authority subject mismatch")
|
||||
return task
|
||||
|
||||
def _authorized_job(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, grant_id: str,
|
||||
task_id: str, effect_id: str, job_id: str,
|
||||
) -> None:
|
||||
authority=self.job_authority
|
||||
try:
|
||||
if hasattr(authority,"get_job"):
|
||||
job=authority.get_job(job_id)
|
||||
elif hasattr(authority,"read"):
|
||||
job=authority.read(job_id)
|
||||
elif callable(authority):
|
||||
job=authority(job_id)
|
||||
else:
|
||||
raise TypeError("authority has no Job lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("job_authority did not confirm Job") from exc
|
||||
raw=_mapping(job)
|
||||
request=raw.get("request") or raw.get("binding")
|
||||
if request is None and isinstance(raw.get("request_json"),str):
|
||||
try:
|
||||
request=json.loads(raw["request_json"])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LineageVerificationError("job_authority returned invalid request") from exc
|
||||
if not isinstance(request,Mapping):
|
||||
request=raw
|
||||
if isinstance(request.get("binding"),Mapping):
|
||||
request=request["binding"]
|
||||
bindings={
|
||||
"job_id":raw.get("job_id") or request.get("job_id"),
|
||||
"tenant_id":request.get("tenant_id"),"subject_id":request.get("subject_id"),
|
||||
"mission_id":request.get("mission_id"),"grant_id":request.get("grant_id"),
|
||||
"task_id":request.get("task_id"),"effect_id":request.get("effect_id"),
|
||||
}
|
||||
expected={"job_id":job_id,"tenant_id":tenant_id,"subject_id":subject_id,
|
||||
"mission_id":mission_id,"grant_id":grant_id,"task_id":task_id,"effect_id":effect_id}
|
||||
if any(str(bindings[key] or "") != value for key,value in expected.items()):
|
||||
raise LineageVerificationError("job_authority binding mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _check_task_context(task: Any, mission_id: str, grant_id: str) -> None:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
mission_values = [value for value in (metadata.get("mission_id"), baseline.get("mission_id")) if value is not None]
|
||||
grant_values = [value for value in (metadata.get("grant_id"), baseline.get("grant_id")) if value is not None]
|
||||
if any(str(value) != mission_id for value in mission_values):
|
||||
raise LineageVerificationError("task_authority mission mismatch")
|
||||
if any(str(value) != grant_id for value in grant_values):
|
||||
raise LineageVerificationError("task_authority grant mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _task_component(task: Any, requested: str | None) -> str:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
values=[value for value in (metadata.get("component_id"),baseline.get("component_id")) if value is not None]
|
||||
if requested is not None:
|
||||
requested=_required(requested,"component_id")
|
||||
if any(str(value) != requested for value in values):
|
||||
raise LineageVerificationError("task_authority component mismatch")
|
||||
return requested
|
||||
if not values or any(str(value) != str(values[0]) for value in values):
|
||||
raise LineageVerificationError("task_authority component missing or inconsistent")
|
||||
return _required(values[0],"component_id")
|
||||
|
||||
def link_task(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
mission_id: str,
|
||||
grant_id: str,
|
||||
task_id: str,
|
||||
*,
|
||||
component_id: str | None = None,
|
||||
source_task_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, grant_id, task_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(mission_id, "mission_id"), _required(grant_id, "grant_id"),
|
||||
_required(task_id, "task_id"),
|
||||
)
|
||||
task = self._authorized_task(tenant_id, subject_id, task_id)
|
||||
self._check_task_context(task, mission_id, grant_id)
|
||||
component_id=self._task_component(task,component_id)
|
||||
source_task_id = _required(source_task_id, "source_task_id") if source_task_id else None
|
||||
raw_task, task_plan = _task_plan(task)
|
||||
authoritative_source = task_plan.get("parent_task_id") or raw_task.get("parent_task_id")
|
||||
if authoritative_source is not None:
|
||||
authoritative_source = _required(authoritative_source, "authoritative source_task_id")
|
||||
if source_task_id is not None and source_task_id != authoritative_source:
|
||||
raise LineageVerificationError("source_task_id does not match Task authority")
|
||||
source_task_id = authoritative_source
|
||||
metadata_json = _json(dict(metadata or {}))
|
||||
values = (subject_id, mission_id, component_id, grant_id, source_task_id)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
if existing:
|
||||
if tuple(existing[key] for key in ("subject_id", "mission_id", "component_id", "grant_id", "source_task_id")) != values:
|
||||
raise LineageConflict("task_id already has different lineage")
|
||||
return self._task_row(existing)
|
||||
if source_task_id:
|
||||
self._require_row(connection, "lineage_tasks", "task_id", tenant_id, source_task_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_tasks VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id, task_id, subject_id, mission_id, component_id, grant_id, source_task_id, metadata_json, _now()),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
return self._task_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _require_row(connection, table, id_column, tenant_id, object_id, subject_id, mission_id, component_id=None):
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {table} WHERE tenant_id=? AND {id_column}=?", (tenant_id, object_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LineageConflict(f"{id_column} has no linked authority record")
|
||||
if row["subject_id"] != subject_id or row["mission_id"] != mission_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different subject or mission")
|
||||
if component_id is not None and row["component_id"] != component_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different component")
|
||||
return row
|
||||
|
||||
def link_effect(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, task_id: str,
|
||||
effect_id: str, job_id: str, *, source_effect_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, task_id, effect_id, job_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(task_id,"task_id"),(effect_id,"effect_id"),(job_id,"job_id")),
|
||||
)
|
||||
source_effect_id = _required(source_effect_id, "source_effect_id") if source_effect_id else None
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
task=self._require_row(connection, "lineage_tasks", "task_id", tenant_id, task_id, subject_id, mission_id)
|
||||
component_id=task["component_id"]
|
||||
grant_id=task["grant_id"]
|
||||
self._authorized_job(tenant_id,subject_id,mission_id,grant_id,task_id,effect_id,job_id)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?", (tenant_id, effect_id)
|
||||
).fetchone()
|
||||
expected = (subject_id, mission_id, component_id, grant_id, task_id, job_id, source_effect_id)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","task_id","job_id","source_effect_id"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("effect_id already has different task/job identity")
|
||||
return self._effect_row(existing)
|
||||
job = connection.execute(
|
||||
"SELECT effect_id,task_id,subject_id FROM lineage_effects WHERE tenant_id=? AND job_id=?",
|
||||
(tenant_id, job_id),
|
||||
).fetchone()
|
||||
if job:
|
||||
raise LineageConflict("job_id already belongs to exactly one Task/effect")
|
||||
if source_effect_id:
|
||||
self._require_row(connection, "lineage_effects", "effect_id", tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_effects VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,effect_id,subject_id,mission_id,component_id,grant_id,task_id,job_id,source_effect_id,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row = connection.execute("SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
return self._effect_row(row)
|
||||
|
||||
def link_release(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, effect_id: str,
|
||||
release_id: str, artifact: Any, *, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, effect_id, release_id = map(
|
||||
lambda pair: _required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(effect_id,"effect_id"),(release_id,"release_id")),
|
||||
)
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
effect = self._require_row(connection,"lineage_effects","effect_id",tenant_id,effect_id,subject_id,mission_id)
|
||||
existing = connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
expected = (subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","effect_id","source_task_id","source_effect_id","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("release_id already has different source lineage")
|
||||
return self._release_row(existing)
|
||||
producer = connection.execute("SELECT release_id FROM lineage_releases WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
if producer:
|
||||
raise LineageConflict("release-producing effect already points to a unique release")
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,release_id,subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
return self._release_row(row)
|
||||
|
||||
def link_deployment(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, release_id: str,
|
||||
deployment_id: str, component_id: str, environment: str, target_revision: int, artifact: Any,
|
||||
*, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(component_id,"component_id"),(environment,"environment")),
|
||||
)
|
||||
if not isinstance(target_revision,int) or isinstance(target_revision,bool) or target_revision < 1:
|
||||
raise ValueError("invalid target_revision")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
release=self._require_row(connection,"lineage_releases","release_id",tenant_id,release_id,subject_id,mission_id,component_id)
|
||||
if release["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("deployment artifact does not match release")
|
||||
existing=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","release_id","environment","target_revision","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("deployment_id already has different release identity")
|
||||
return self._deployment_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_deployments VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,deployment_id,subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
return self._deployment_row(row)
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any, verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
if not callable(verifier):
|
||||
raise LineageVerificationError("acceptance verifier is required")
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),
|
||||
(acceptance_id,"acceptance_id"),(deployment_id,"deployment_id"),(release_id,"release_id")),
|
||||
)
|
||||
if type(accepted) is not bool:
|
||||
raise ValueError("accepted must be boolean")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deployment=self._require_row(connection,"lineage_deployments","deployment_id",tenant_id,deployment_id,subject_id,mission_id,component_id)
|
||||
if deployment["release_id"] != release_id:
|
||||
raise LineageConflict("acceptance release_id does not match deployment")
|
||||
if deployment["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("acceptance artifact does not match deployment")
|
||||
expected={"acceptance_id":acceptance_id,"tenant_id":tenant_id,"subject_id":subject_id,"component_id":component_id,
|
||||
"deployment_id":deployment_id,"release_id":release_id,"artifact":decode_artifact(artifact_json),
|
||||
"accepted":accepted}
|
||||
try:
|
||||
verified=verifier(evidence,expected)
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("acceptance evidence was not verified") from exc
|
||||
if not isinstance(verified,VerifiedAcceptanceEvidence):
|
||||
raise LineageVerificationError("verifier must return VerifiedAcceptanceEvidence")
|
||||
bindings=(verified.acceptance_id,verified.tenant_id,verified.subject_id,verified.component_id,verified.deployment_id,verified.release_id,canonical_artifact(verified.artifact),verified.accepted)
|
||||
if bindings != (acceptance_id,tenant_id,subject_id,component_id,deployment_id,release_id,artifact_json,accepted):
|
||||
raise LineageVerificationError("verified acceptance evidence binding mismatch")
|
||||
evidence_id=_required(verified.evidence_id,"evidence_id")
|
||||
evidence_json=_json({
|
||||
"evidence_id":evidence_id,
|
||||
"observed":dict(verified.observed or {}),
|
||||
"observed_at":verified.observed_at,
|
||||
"signed_envelope":dict(evidence) if isinstance(evidence,Mapping) else None,
|
||||
})
|
||||
existing=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
expected_row=(subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","deployment_id","release_id","artifact_json","accepted","evidence_id","evidence_json"))
|
||||
if actual != expected_row:
|
||||
raise LineageConflict("acceptance_id already has different binding")
|
||||
return self._acceptance_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_acceptances VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,acceptance_id,subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json,_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
return self._acceptance_row(row)
|
||||
|
||||
def explain(self, tenant_id: str, subject_id: str, mission_id: str) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id=(_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(mission_id,"mission_id"))
|
||||
specs=(("tasks","lineage_tasks",self._task_row),("effects","lineage_effects",self._effect_row),
|
||||
("releases","lineage_releases",self._release_row),("deployments","lineage_deployments",self._deployment_row),
|
||||
("acceptances","lineage_acceptances",self._acceptance_row))
|
||||
result={"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":mission_id}
|
||||
with self._connect() as connection:
|
||||
for key,table,converter in specs:
|
||||
rows=connection.execute(f"SELECT * FROM {table} WHERE tenant_id=? AND subject_id=? AND mission_id=? ORDER BY created_at",(tenant_id,subject_id,mission_id)).fetchall()
|
||||
result[key]=[converter(row) for row in rows]
|
||||
return result
|
||||
|
||||
def get_release(self, tenant_id: str, subject_id: str, release_id: str) -> dict[str, Any]:
|
||||
"""Resolve an exact tenant/subject release without disclosing other scopes."""
|
||||
|
||||
tenant_id, subject_id, release_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(release_id, "release_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND subject_id=?",
|
||||
(tenant_id, release_id, subject_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
def get_release_for_component(self, tenant_id: str, component_id: str, release_id: str) -> dict[str, Any]:
|
||||
tenant_id,component_id,release_id=(_required(tenant_id,"tenant_id"),_required(component_id,"component_id"),_required(release_id,"release_id"))
|
||||
with self._connect() as connection:
|
||||
row=connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND component_id=?",
|
||||
(tenant_id,release_id,component_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release component lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _common(row):
|
||||
return {key:row[key] for key in row.keys() if not key.endswith("_json")}
|
||||
|
||||
@classmethod
|
||||
def _task_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _effect_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _release_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _deployment_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _acceptance_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["accepted"]=bool(row["accepted"]); value["evidence"]=json.loads(row["evidence_json"]); return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LineageConflict", "LineageError", "LineageLedger", "LineageVerificationError",
|
||||
"VerifiedAcceptanceEvidence", "canonical_artifact", "decode_artifact",
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Declarative, non-executing operation registry and timeout safety rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
EFFECT_CLASSES = frozenset(
|
||||
{"READ_ONLY", "IDEMPOTENT", "CONDITIONAL", "RECONCILABLE", "IRREVERSIBLE"}
|
||||
)
|
||||
_FORBIDDEN_EXECUTION_FIELDS = frozenset({"command", "shell", "argv", "executable", "script"})
|
||||
_HANDLER_REF = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,126}$")
|
||||
|
||||
|
||||
def _invalid(code: str, field: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{field}" if field else code)
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _invalid("field_invalid", field)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _policy(value: object, field: str, allowed_modes: set[str]) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise _invalid("policy_invalid", field)
|
||||
result = deepcopy(dict(value))
|
||||
mode = _text(result.get("mode"), f"{field}.mode").upper()
|
||||
if mode not in allowed_modes:
|
||||
raise _invalid("policy_mode_invalid", f"{field}.mode")
|
||||
result["mode"] = mode
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationDefinition:
|
||||
operation_id: str
|
||||
version: int | str
|
||||
effect_class: str
|
||||
handler_ref: str
|
||||
input_schema: str | Mapping[str, Any]
|
||||
required_permissions: tuple[str, ...]
|
||||
timeout_seconds: float
|
||||
readback: Mapping[str, Any]
|
||||
retry: Mapping[str, Any]
|
||||
reconcile: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
owner_boundary: Mapping[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "OperationDefinition":
|
||||
if not isinstance(data, Mapping):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
forbidden = sorted(_FORBIDDEN_EXECUTION_FIELDS.intersection(data))
|
||||
if forbidden:
|
||||
raise _invalid("arbitrary_execution_forbidden", forbidden[0])
|
||||
operation_id = _text(data.get("operation_id"), "operation_id")
|
||||
version = data.get("version")
|
||||
if (
|
||||
isinstance(version, bool)
|
||||
or not isinstance(version, (int, str))
|
||||
or (isinstance(version, int) and version < 1)
|
||||
or (isinstance(version, str) and not version.strip())
|
||||
):
|
||||
raise _invalid("field_invalid", "version")
|
||||
effect_class = _text(data.get("effect_class"), "effect_class").upper()
|
||||
if effect_class not in EFFECT_CLASSES:
|
||||
raise _invalid("effect_class_invalid", "effect_class")
|
||||
handler_ref = _text(data.get("handler_ref", data.get("handler")), "handler_ref")
|
||||
if not _HANDLER_REF.fullmatch(handler_ref):
|
||||
raise _invalid("handler_ref_invalid", "handler_ref")
|
||||
input_schema = data.get("input_schema")
|
||||
if not isinstance(input_schema, (str, Mapping)) or not input_schema:
|
||||
raise _invalid("field_invalid", "input_schema")
|
||||
input_schema = deepcopy(input_schema)
|
||||
permissions = data.get("required_permissions", data.get("permissions"))
|
||||
if not isinstance(permissions, Sequence) or isinstance(permissions, (str, bytes, bytearray)):
|
||||
raise _invalid("field_invalid", "required_permissions")
|
||||
required_permissions = tuple(
|
||||
_text(permission, f"required_permissions[{index}]")
|
||||
for index, permission in enumerate(permissions)
|
||||
)
|
||||
if len(set(required_permissions)) != len(required_permissions):
|
||||
raise _invalid("duplicate_permission", "required_permissions")
|
||||
timeout_seconds = data.get("timeout_seconds")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise _invalid("field_invalid", "timeout_seconds")
|
||||
|
||||
readback = _policy(data.get("readback"), "readback", {"NONE", "STATE", "EXACT"})
|
||||
if "required" in readback and not isinstance(readback["required"], bool):
|
||||
raise _invalid("field_invalid", "readback.required")
|
||||
retry = _policy(
|
||||
data.get("retry"),
|
||||
"retry",
|
||||
{"NEVER", "SAFE", "AFTER_READBACK", "RECONCILE_FIRST"},
|
||||
)
|
||||
attempts = retry.get("max_attempts", 1)
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "retry.max_attempts")
|
||||
retry["max_attempts"] = attempts
|
||||
reconcile = _policy(
|
||||
data.get("reconcile"), "reconcile", {"NONE", "EXACT_READBACK", "CUSTOM", "REQUIRED"}
|
||||
)
|
||||
rollback = _policy(
|
||||
data.get("rollback"), "rollback", {"NONE", "OPERATION", "COMPENSATE"}
|
||||
)
|
||||
if rollback["mode"] == "OPERATION":
|
||||
_text(rollback.get("operation_id"), "rollback.operation_id")
|
||||
owner_boundary = _policy(
|
||||
data.get("owner_boundary"),
|
||||
"owner_boundary",
|
||||
{"NONE", "WITHIN_GRANT", "EXPLICIT_AUTHORIZATION", "CROSS_BOUNDARY_DENIED"},
|
||||
)
|
||||
|
||||
restart_operation = (
|
||||
"service.restart" in operation_id
|
||||
or "service-restart" in operation_id
|
||||
or handler_ref.endswith("service_restart")
|
||||
)
|
||||
if restart_operation and (
|
||||
readback["mode"] != "EXACT"
|
||||
or not readback.get("required", False)
|
||||
or reconcile["mode"] == "NONE"
|
||||
):
|
||||
raise _invalid("service_restart_exact_readback_required")
|
||||
|
||||
return cls(
|
||||
operation_id=operation_id,
|
||||
version=version,
|
||||
effect_class=effect_class,
|
||||
handler_ref=handler_ref,
|
||||
input_schema=input_schema,
|
||||
required_permissions=required_permissions,
|
||||
timeout_seconds=float(timeout_seconds),
|
||||
readback=readback,
|
||||
retry=retry,
|
||||
reconcile=reconcile,
|
||||
rollback=rollback,
|
||||
owner_boundary=owner_boundary,
|
||||
)
|
||||
|
||||
def timeout_action(self, outcome: str, attempts: int = 1) -> str:
|
||||
return operation_timeout_action(self, outcome=outcome, attempts=attempts)
|
||||
|
||||
@property
|
||||
def permissions(self) -> tuple[str, ...]:
|
||||
return self.required_permissions
|
||||
|
||||
|
||||
class OperationRegistry:
|
||||
"""Lookup-only registry; it deliberately has no execute or shell interface."""
|
||||
|
||||
def __init__(self, definitions: Iterable[OperationDefinition | Mapping[str, Any]]) -> None:
|
||||
self._definitions: dict[str, OperationDefinition] = {}
|
||||
for raw in definitions:
|
||||
definition = raw if isinstance(raw, OperationDefinition) else OperationDefinition.from_dict(raw)
|
||||
if definition.operation_id in self._definitions:
|
||||
raise _invalid("duplicate_operation_id", definition.operation_id)
|
||||
self._definitions[definition.operation_id] = definition
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition:
|
||||
return self._definitions[_text(operation_id, "operation_id")]
|
||||
|
||||
@property
|
||||
def definitions(self) -> tuple[OperationDefinition, ...]:
|
||||
return tuple(self._definitions.values())
|
||||
|
||||
|
||||
def operation_timeout_action(
|
||||
definition: OperationDefinition,
|
||||
outcome: str = "AMBIGUOUS",
|
||||
attempts: int = 1,
|
||||
) -> str:
|
||||
"""Return ``RETRY``, ``RECONCILE`` or ``STOP`` after a timeout.
|
||||
|
||||
An unknown response from a mutating sink never authorizes a blind repeat.
|
||||
Exact readback/reconciliation must first establish whether the effect happened.
|
||||
"""
|
||||
|
||||
if not isinstance(definition, OperationDefinition):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
normalized_outcome = _text(outcome, "outcome").upper()
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "attempts")
|
||||
if definition.effect_class == "IRREVERSIBLE":
|
||||
return "STOP"
|
||||
if definition.effect_class != "READ_ONLY" and normalized_outcome in {
|
||||
"AMBIGUOUS",
|
||||
"UNKNOWN",
|
||||
"MAY_HAVE_SUCCEEDED",
|
||||
}:
|
||||
if definition.reconcile["mode"] != "NONE" or definition.readback.get("required", False):
|
||||
return "RECONCILE"
|
||||
return "STOP"
|
||||
retry_mode = definition.retry["mode"]
|
||||
if attempts >= definition.retry["max_attempts"] or retry_mode == "NEVER":
|
||||
return "STOP"
|
||||
if retry_mode in {"AFTER_READBACK", "RECONCILE_FIRST"} and normalized_outcome not in {
|
||||
"NOT_OBSERVED",
|
||||
"PROVEN_ABSENT",
|
||||
}:
|
||||
return "RECONCILE" if definition.reconcile["mode"] != "NONE" else "STOP"
|
||||
return "RETRY"
|
||||
|
||||
|
||||
def _timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def verify_service_restart(
|
||||
before: Mapping[str, Any], after: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a restart with instance identity and start-time evidence.
|
||||
|
||||
Merely observing an active unit proves current activity, not that the requested
|
||||
restart occurred.
|
||||
"""
|
||||
|
||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_observation_invalid"]}
|
||||
if not isinstance(after.get("active"), bool):
|
||||
return {"status": "UNKNOWN", "reasons": ["service_active_invalid"]}
|
||||
if after["active"] is False:
|
||||
return {"status": "DRIFT", "reasons": ["service_not_active"]}
|
||||
before_id = before.get("instance_id")
|
||||
after_id = after.get("instance_id")
|
||||
before_started = _timestamp(before.get("started_at"))
|
||||
after_started = _timestamp(after.get("started_at"))
|
||||
if (
|
||||
not isinstance(before_id, str)
|
||||
or not before_id
|
||||
or not isinstance(after_id, str)
|
||||
or not after_id
|
||||
or before_started is None
|
||||
or after_started is None
|
||||
):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_identity_missing"]}
|
||||
if before_id == after_id or after_started <= before_started:
|
||||
return {"status": "DRIFT", "reasons": ["restart_not_observed"]}
|
||||
return {"status": "VERIFIED", "reasons": []}
|
||||
@@ -0,0 +1,866 @@
|
||||
"""Bounded, task-only optimization proposals for Baseline 1.1.
|
||||
|
||||
This module deliberately has no configuration or policy write adapter. Its only
|
||||
effect boundary is ``submit_task``, supplied by the authenticated root service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
ENVELOPE_SCHEMA = "aios.optimization-envelope.v1"
|
||||
PROPOSAL_SCHEMA = "aios.optimization-proposal.v1"
|
||||
TASK_REQUEST_SCHEMA = "aios.optimization-task-request.v1"
|
||||
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
|
||||
# An envelope may narrow these limits but cannot introduce another path or
|
||||
# expand a range. Every entry describes a low-risk operational tuning knob.
|
||||
_HARD_PARAMETER_LIMITS: dict[str, tuple[float, float, bool]] = {
|
||||
"retry.max_attempts": (1, 10, True),
|
||||
"retry.backoff_seconds": (0, 60, False),
|
||||
"backoff.initial_seconds": (0, 60, False),
|
||||
"backoff.maximum_seconds": (0.1, 600, False),
|
||||
"routing.candidate_weight_percent": (0, 25, False),
|
||||
"routing.weight_percent": (0, 25, False),
|
||||
"evidence_reuse.max_age_seconds": (0, 3600, True),
|
||||
"evidence_reuse.ttl_seconds": (0, 3600, True),
|
||||
"timeout.operation_seconds": (0.1, 300, False),
|
||||
"timeout.seconds": (0.1, 300, False),
|
||||
"poll.interval_seconds": (0.1, 300, False),
|
||||
"notification_dedup.window_seconds": (0, 3600, True),
|
||||
"test_selection.optional_sample_percent": (1, 100, False),
|
||||
"test_selection.optional_percentage": (1, 100, False),
|
||||
}
|
||||
_NON_PRODUCTION_ENVIRONMENTS = frozenset({"development", "test", "staging", "canary"})
|
||||
|
||||
|
||||
class OptimizationError(RuntimeError):
|
||||
"""A fail-closed optimization boundary error with a stable code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OptimizationError("OPTIMIZATION_DOCUMENT_NOT_CANONICAL") from exc
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _number(value: Any, code: str, *, minimum: float | None = None,
|
||||
maximum: float | None = None) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise OptimizationError(code)
|
||||
if not math.isfinite(float(value)):
|
||||
raise OptimizationError(code)
|
||||
if minimum is not None and value < minimum:
|
||||
raise OptimizationError(code)
|
||||
if maximum is not None and value > maximum:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: Any, code: str) -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _unique_identifiers(value: Any, code: str, *, maximum: int = 256) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) > maximum:
|
||||
raise OptimizationError(code)
|
||||
items = tuple(_identifier(item, code) for item in value)
|
||||
if len(set(items)) != len(items):
|
||||
raise OptimizationError(code)
|
||||
return items
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterRange:
|
||||
minimum: int | float
|
||||
maximum: int | float
|
||||
integer: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityFloor:
|
||||
direction: str
|
||||
threshold: int | float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionRules:
|
||||
objective_metric: str
|
||||
minimum_improvement: int | float
|
||||
mandatory_tests: tuple[str, ...]
|
||||
mandatory_quality_gates: tuple[str, ...]
|
||||
required_receipt_schema: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptimizationEnvelope:
|
||||
schema: str
|
||||
envelope_id: str
|
||||
tenant_id: str
|
||||
allowed_parameters: Mapping[str, ParameterRange]
|
||||
allowed_components: tuple[str, ...]
|
||||
allowed_environments: tuple[str, ...]
|
||||
max_change_size: int
|
||||
max_compute_budget: int | float
|
||||
quality_floor: Mapping[str, QualityFloor]
|
||||
rollback_requirement: bool
|
||||
promotion_rules: PromotionRules
|
||||
source_config_digest: str
|
||||
key_id: str
|
||||
signature: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
verify_envelope: Callable[[str, bytes, str], bool] | None,
|
||||
) -> "OptimizationEnvelope":
|
||||
"""Parse a trusted-config envelope after authenticating its exact bytes.
|
||||
|
||||
``verify_envelope`` is intentionally mandatory. The caller cannot mark
|
||||
a document trusted with a boolean or construct an unrestricted envelope.
|
||||
"""
|
||||
if verify_envelope is None or not callable(verify_envelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_VERIFIER_REQUIRED")
|
||||
if not isinstance(data, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_INVALID")
|
||||
required = {
|
||||
"schema", "envelope_id", "tenant_id", "allowed_parameters",
|
||||
"allowed_components", "allowed_environments", "max_change_size",
|
||||
"max_compute_budget", "quality_floor", "rollback_requirement",
|
||||
"promotion_rules", "source_config_digest", "key_id", "signature",
|
||||
}
|
||||
if set(data) != required:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_FIELDS_INVALID")
|
||||
unsigned = dict(data)
|
||||
signature = unsigned.pop("signature")
|
||||
key_id = data.get("key_id")
|
||||
if not isinstance(signature, str) or not signature or not isinstance(key_id, str):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = verify_envelope(key_id, canonical_json_bytes(unsigned), signature)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID") from exc
|
||||
if verified is not True:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
|
||||
if data["schema"] != ENVELOPE_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SCHEMA_INVALID")
|
||||
envelope_id = _identifier(data["envelope_id"], "OPTIMIZATION_ENVELOPE_ID_INVALID")
|
||||
tenant_id = _identifier(data["tenant_id"], "OPTIMIZATION_TENANT_INVALID")
|
||||
if not _DIGEST.fullmatch(str(data["source_config_digest"])):
|
||||
raise OptimizationError("OPTIMIZATION_SOURCE_CONFIG_DIGEST_INVALID")
|
||||
key_id = _identifier(key_id, "OPTIMIZATION_ENVELOPE_KEY_ID_INVALID")
|
||||
|
||||
raw_parameters = data["allowed_parameters"]
|
||||
if not isinstance(raw_parameters, Mapping) or not raw_parameters or len(raw_parameters) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_ALLOWED_PARAMETERS_INVALID")
|
||||
parameters: dict[str, ParameterRange] = {}
|
||||
for path, raw_rule in raw_parameters.items():
|
||||
if path not in _HARD_PARAMETER_LIMITS:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if not isinstance(raw_rule, Mapping) or not {"minimum", "maximum"} <= set(raw_rule):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if set(raw_rule) - {"minimum", "maximum", "integer"}:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
hard_min, hard_max, hard_integer = _HARD_PARAMETER_LIMITS[path]
|
||||
minimum = _number(raw_rule["minimum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
maximum = _number(raw_rule["maximum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
integer = raw_rule.get("integer", hard_integer)
|
||||
if type(integer) is not bool or minimum > maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if minimum < hard_min or maximum > hard_max or (hard_integer and not integer):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_TOO_BROAD")
|
||||
if integer and (type(minimum) is not int or type(maximum) is not int):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
parameters[path] = ParameterRange(minimum, maximum, integer)
|
||||
|
||||
components = _unique_identifiers(
|
||||
data["allowed_components"], "OPTIMIZATION_COMPONENTS_INVALID", maximum=32
|
||||
)
|
||||
environments = _unique_identifiers(
|
||||
data["allowed_environments"], "OPTIMIZATION_ENVIRONMENTS_INVALID", maximum=4
|
||||
)
|
||||
if not components or not environments or not set(environments) <= _NON_PRODUCTION_ENVIRONMENTS:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENTS_INVALID")
|
||||
max_change_size = _number(
|
||||
data["max_change_size"], "OPTIMIZATION_CHANGE_SIZE_INVALID", minimum=1, maximum=16
|
||||
)
|
||||
if type(max_change_size) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_INVALID")
|
||||
max_compute_budget = _number(
|
||||
data["max_compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_INVALID",
|
||||
minimum=0.001,
|
||||
maximum=10_000,
|
||||
)
|
||||
|
||||
raw_quality = data["quality_floor"]
|
||||
if not isinstance(raw_quality, Mapping) or not raw_quality or len(raw_quality) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
quality: dict[str, QualityFloor] = {}
|
||||
for metric, raw_rule in raw_quality.items():
|
||||
metric = _identifier(metric, "OPTIMIZATION_QUALITY_METRIC_INVALID")
|
||||
if not isinstance(raw_rule, Mapping) or set(raw_rule) != {"direction", "threshold"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
if raw_rule["direction"] not in {"higher", "lower"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_DIRECTION_INVALID")
|
||||
threshold = _number(
|
||||
raw_rule["threshold"], "OPTIMIZATION_QUALITY_FLOOR_INVALID",
|
||||
minimum=-1e12, maximum=1e12,
|
||||
)
|
||||
quality[metric] = QualityFloor(raw_rule["direction"], threshold)
|
||||
|
||||
if type(data["rollback_requirement"]) is not bool:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_REQUIREMENT_INVALID")
|
||||
raw_promotion = data["promotion_rules"]
|
||||
promotion_fields = {
|
||||
"objective_metric", "minimum_improvement", "mandatory_tests",
|
||||
"mandatory_quality_gates", "required_receipt_schema",
|
||||
}
|
||||
if not isinstance(raw_promotion, Mapping) or set(raw_promotion) != promotion_fields:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
objective = _identifier(
|
||||
raw_promotion["objective_metric"], "OPTIMIZATION_OBJECTIVE_METRIC_INVALID"
|
||||
)
|
||||
if objective not in quality:
|
||||
raise OptimizationError("OPTIMIZATION_OBJECTIVE_METRIC_INVALID")
|
||||
minimum_improvement = _number(
|
||||
raw_promotion["minimum_improvement"],
|
||||
"OPTIMIZATION_MINIMUM_IMPROVEMENT_INVALID",
|
||||
minimum=0,
|
||||
maximum=1e12,
|
||||
)
|
||||
mandatory_tests = _unique_identifiers(
|
||||
raw_promotion["mandatory_tests"], "OPTIMIZATION_MANDATORY_TESTS_INVALID"
|
||||
)
|
||||
mandatory_gates = _unique_identifiers(
|
||||
raw_promotion["mandatory_quality_gates"], "OPTIMIZATION_MANDATORY_GATES_INVALID"
|
||||
)
|
||||
if not mandatory_tests or not mandatory_gates:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
receipt_schema = _identifier(
|
||||
raw_promotion["required_receipt_schema"], "OPTIMIZATION_RECEIPT_SCHEMA_INVALID"
|
||||
)
|
||||
if not receipt_schema.startswith("aios."):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_INVALID")
|
||||
promotion = PromotionRules(
|
||||
objective, minimum_improvement, mandatory_tests, mandatory_gates, receipt_schema
|
||||
)
|
||||
return cls(
|
||||
ENVELOPE_SCHEMA, envelope_id, tenant_id, parameters, components,
|
||||
environments, max_change_size, max_compute_budget, quality,
|
||||
data["rollback_requirement"], promotion, data["source_config_digest"],
|
||||
key_id, signature,
|
||||
)
|
||||
|
||||
|
||||
_PROPOSAL_REQUIRED = {
|
||||
"schema", "proposal_id", "envelope_id", "tenant_id", "component",
|
||||
"environment", "changes", "compute_budget", "tests", "quality_gates",
|
||||
"baseline_metrics", "rollback_plan",
|
||||
}
|
||||
|
||||
|
||||
def validate_proposal(envelope: OptimizationEnvelope, proposal: Mapping[str, Any]) -> dict:
|
||||
"""Return a canonical candidate constrained by ``envelope``."""
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not isinstance(proposal, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_INVALID")
|
||||
allowed_fields = _PROPOSAL_REQUIRED | {"evidence_reuse"}
|
||||
if set(proposal) - allowed_fields or not _PROPOSAL_REQUIRED <= set(proposal):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_FIELDS_INVALID")
|
||||
if proposal["schema"] != PROPOSAL_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_SCHEMA_INVALID")
|
||||
proposal_id = _identifier(proposal["proposal_id"], "OPTIMIZATION_PROPOSAL_ID_INVALID")
|
||||
if proposal["envelope_id"] != envelope.envelope_id:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_BINDING_MISMATCH")
|
||||
if proposal["tenant_id"] != envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TENANT_MISMATCH")
|
||||
component = _identifier(proposal["component"], "OPTIMIZATION_COMPONENT_INVALID")
|
||||
if component not in envelope.allowed_components:
|
||||
raise OptimizationError("OPTIMIZATION_COMPONENT_FORBIDDEN")
|
||||
environment = _identifier(proposal["environment"], "OPTIMIZATION_ENVIRONMENT_INVALID")
|
||||
if environment not in envelope.allowed_environments:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENT_FORBIDDEN")
|
||||
|
||||
raw_changes = proposal["changes"]
|
||||
if not isinstance(raw_changes, list) or not raw_changes or len(raw_changes) > envelope.max_change_size:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_EXCEEDED")
|
||||
changes: list[dict[str, Any]] = []
|
||||
paths: set[str] = set()
|
||||
for change in raw_changes:
|
||||
if not isinstance(change, Mapping) or set(change) != {"path", "value"}:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_INVALID")
|
||||
path = change["path"]
|
||||
if not isinstance(path, str) or path not in envelope.allowed_parameters:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if path in paths:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_DUPLICATE")
|
||||
paths.add(path)
|
||||
rule = envelope.allowed_parameters[path]
|
||||
value = _number(change["value"], "OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if rule.integer and type(value) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if value < rule.minimum or value > rule.maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_OUT_OF_RANGE")
|
||||
changes.append({"path": path, "value": value})
|
||||
|
||||
compute_budget = _number(
|
||||
proposal["compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_EXCEEDED",
|
||||
minimum=0.001,
|
||||
maximum=float(envelope.max_compute_budget),
|
||||
)
|
||||
tests = _unique_identifiers(proposal["tests"], "OPTIMIZATION_TESTS_INVALID")
|
||||
gates = _unique_identifiers(proposal["quality_gates"], "OPTIMIZATION_QUALITY_GATES_INVALID")
|
||||
for mandatory in envelope.promotion_rules.mandatory_tests:
|
||||
if mandatory not in tests:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_TEST_MISSING")
|
||||
for mandatory in envelope.promotion_rules.mandatory_quality_gates:
|
||||
if mandatory not in gates:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_GATE_MISSING")
|
||||
|
||||
raw_baseline = proposal["baseline_metrics"]
|
||||
if not isinstance(raw_baseline, Mapping) or set(raw_baseline) != set(envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
baseline = {
|
||||
metric: _number(raw_baseline[metric], "OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
for metric in sorted(raw_baseline)
|
||||
}
|
||||
|
||||
rollback_plan = proposal["rollback_plan"]
|
||||
if envelope.rollback_requirement:
|
||||
if (
|
||||
not isinstance(rollback_plan, Mapping)
|
||||
or set(rollback_plan) != {"strategy", "reference"}
|
||||
or rollback_plan.get("strategy") != "restore_previous_values"
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_REQUIRED")
|
||||
_identifier(rollback_plan.get("reference"), "OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback: dict[str, str] | None = {
|
||||
"strategy": "restore_previous_values",
|
||||
"reference": rollback_plan["reference"],
|
||||
}
|
||||
else:
|
||||
if rollback_plan is not None:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback = None
|
||||
|
||||
normalized_evidence = None
|
||||
if "evidence_reuse" in proposal:
|
||||
evidence = proposal["evidence_reuse"]
|
||||
if not isinstance(evidence, Mapping) or set(evidence) != {"scope", "evidence_ids"}:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_REUSE_INVALID")
|
||||
expected_scope = {
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
}
|
||||
if evidence["scope"] != expected_scope:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_SCOPE_MISMATCH")
|
||||
evidence_ids = _unique_identifiers(
|
||||
evidence["evidence_ids"], "OPTIMIZATION_EVIDENCE_REUSE_INVALID", maximum=128
|
||||
)
|
||||
normalized_evidence = {"scope": expected_scope, "evidence_ids": list(evidence_ids)}
|
||||
|
||||
normalized = {
|
||||
"schema": PROPOSAL_SCHEMA,
|
||||
"proposal_id": proposal_id,
|
||||
"envelope_id": envelope.envelope_id,
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
"changes": changes,
|
||||
"compute_budget": float(compute_budget),
|
||||
"tests": list(tests),
|
||||
"quality_gates": list(gates),
|
||||
"baseline_metrics": baseline,
|
||||
"rollback_plan": normalized_rollback,
|
||||
}
|
||||
if normalized_evidence is not None:
|
||||
normalized["evidence_reuse"] = normalized_evidence
|
||||
if len(canonical_json_bytes(normalized)) > 65_536:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TOO_LARGE")
|
||||
return normalized
|
||||
|
||||
|
||||
class OptimizationController:
|
||||
"""Durable proposal state machine whose only external effect is task admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
envelope: OptimizationEnvelope,
|
||||
submit_task: Callable[[Mapping[str, Any]], Mapping[str, Any]],
|
||||
*,
|
||||
verify_receipt: Callable[[Mapping[str, Any]], bool],
|
||||
):
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not callable(submit_task):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMITTER_REQUIRED")
|
||||
if not callable(verify_receipt):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_VERIFIER_REQUIRED")
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.envelope = envelope
|
||||
self.submit_task = submit_task
|
||||
self.verify_receipt = verify_receipt
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS optimization_proposals (
|
||||
proposal_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
envelope_id TEXT NOT NULL,
|
||||
proposal_digest TEXT NOT NULL,
|
||||
proposal_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
evaluation_task_id TEXT,
|
||||
promotion_task_id TEXT,
|
||||
baseline_json TEXT NOT NULL,
|
||||
candidate_json TEXT,
|
||||
effect_json TEXT,
|
||||
evaluation_json TEXT,
|
||||
receipt_json TEXT,
|
||||
error_code TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _context(self, value: Mapping[str, Any]) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED")
|
||||
fields = ("tenant_id", "subject_id", "grant_id", "mission_id", "service_identity")
|
||||
try:
|
||||
context = {
|
||||
field: _identifier(value[field], "OPTIMIZATION_AUTHENTICATED_CONTEXT_INVALID")
|
||||
for field in fields
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED") from exc
|
||||
if context["service_identity"] != "aios-root-service":
|
||||
raise OptimizationError("OPTIMIZATION_ROOT_SERVICE_REQUIRED")
|
||||
if context["tenant_id"] != self.envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
return context
|
||||
|
||||
def _row(self, proposal_id: str) -> sqlite3.Row:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_NOT_FOUND")
|
||||
return row
|
||||
|
||||
def _authorized_row(self, proposal_id: str, context: Mapping[str, Any]) -> sqlite3.Row:
|
||||
checked = self._context(context)
|
||||
row = self._row(_identifier(proposal_id, "OPTIMIZATION_PROPOSAL_ID_INVALID"))
|
||||
if row["tenant_id"] != checked["tenant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
if row["subject_id"] != checked["subject_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_SUBJECT_MISMATCH")
|
||||
if row["grant_id"] != checked["grant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_GRANT_MISMATCH")
|
||||
if row["mission_id"] != checked["mission_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_MISSION_MISMATCH")
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _decode(value: str | None) -> Any:
|
||||
return None if value is None else json.loads(value)
|
||||
|
||||
def _record(self, row: sqlite3.Row) -> dict:
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
result = dict(proposal)
|
||||
result.update(
|
||||
{
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"status": row["status"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"promotion_task_id": row["promotion_task_id"],
|
||||
"baseline_metrics": self._decode(row["baseline_json"]),
|
||||
"candidate_metrics": self._decode(row["candidate_json"]),
|
||||
"effect_comparison": self._decode(row["effect_json"]),
|
||||
"error_code": row["error_code"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def get_proposal(self, proposal_id: str, *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
return self._record(self._authorized_row(proposal_id, authenticated_context))
|
||||
|
||||
def _persist_invalid(self, proposal: Mapping[str, Any], context: Mapping[str, str],
|
||||
error: OptimizationError) -> None:
|
||||
proposal_id = proposal.get("proposal_id")
|
||||
tenant_id = proposal.get("tenant_id")
|
||||
if (
|
||||
not isinstance(proposal_id, str)
|
||||
or _IDENTIFIER.fullmatch(proposal_id) is None
|
||||
or tenant_id != self.envelope.tenant_id
|
||||
):
|
||||
return
|
||||
try:
|
||||
encoded = canonical_json_bytes(proposal)
|
||||
except OptimizationError:
|
||||
return
|
||||
if len(encoded) > 65_536:
|
||||
return
|
||||
digest = "sha256:" + hashlib.sha256(encoded).hexdigest()
|
||||
baseline = proposal.get("baseline_metrics")
|
||||
if not isinstance(baseline, Mapping):
|
||||
baseline = {}
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,error_code,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, self.envelope.tenant_id, context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
digest, encoded.decode("utf-8"), "STOPPED_INVALID",
|
||||
canonical_json_bytes(baseline).decode("utf-8"), error.code, now, now,
|
||||
),
|
||||
)
|
||||
|
||||
def propose(self, proposal: Mapping[str, Any], *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
try:
|
||||
normalized = validate_proposal(self.envelope, proposal)
|
||||
except OptimizationError as exc:
|
||||
if isinstance(proposal, Mapping):
|
||||
self._persist_invalid(proposal, context, exc)
|
||||
raise
|
||||
proposal_digest = _digest(normalized)
|
||||
proposal_id = normalized["proposal_id"]
|
||||
now = time.time()
|
||||
encoded = canonical_json_bytes(normalized).decode("utf-8")
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["proposal_digest"] != proposal_digest:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_ID_CONFLICT")
|
||||
if (
|
||||
existing["tenant_id"] != context["tenant_id"]
|
||||
or existing["subject_id"] != context["subject_id"]
|
||||
or existing["grant_id"] != context["grant_id"]
|
||||
or existing["mission_id"] != context["mission_id"]
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_BINDING_MISMATCH")
|
||||
if existing["status"] != "PROPOSAL_RECORDED":
|
||||
return self._record(existing)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, context["tenant_id"], context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
proposal_digest, encoded, "PROPOSAL_RECORDED",
|
||||
canonical_json_bytes(normalized["baseline_metrics"]).decode("utf-8"),
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
requested_task_id = f"optimization-evaluate-{proposal_id}"
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": requested_task_id,
|
||||
"action": "evaluate",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": proposal_id,
|
||||
"proposal_digest": proposal_digest,
|
||||
"component": normalized["component"],
|
||||
"environment": normalized["environment"],
|
||||
"compute_budget": normalized["compute_budget"],
|
||||
}
|
||||
try:
|
||||
receipt = self.submit_task(task_request)
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
receipt.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("STOPPED_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,evaluation_task_id=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("EVALUATION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
def _trusted_receipt(self, receipt: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
try:
|
||||
trusted = self.verify_receipt(receipt)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED") from exc
|
||||
if trusted is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED")
|
||||
return receipt
|
||||
|
||||
def record_evaluation(
|
||||
self,
|
||||
proposal_id: str,
|
||||
evaluation: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
row = self._authorized_row(proposal_id, authenticated_context)
|
||||
if row["status"] != "EVALUATION_SUBMITTED":
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_STATE_INVALID")
|
||||
if not isinstance(evaluation, Mapping) or set(evaluation) != {
|
||||
"candidate_metrics", "compute_used", "tests_passed",
|
||||
"quality_gates_passed", "receipt",
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_INVALID")
|
||||
result_body = {key: evaluation[key] for key in evaluation if key != "receipt"}
|
||||
receipt = self._trusted_receipt(evaluation["receipt"])
|
||||
required_receipt = {
|
||||
"schema", "task_id", "proposal_id", "proposal_digest", "action",
|
||||
"success", "result_digest", "key_id", "signature",
|
||||
}
|
||||
if set(receipt) != required_receipt:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
if receipt["schema"] != self.envelope.promotion_rules.required_receipt_schema:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_MISMATCH")
|
||||
if receipt["task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_TASK_MISMATCH")
|
||||
if receipt["proposal_id"] != row["proposal_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_MISMATCH")
|
||||
if receipt["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_DIGEST_MISMATCH")
|
||||
if receipt["action"] != "evaluate" or receipt["success"] is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_INVALID")
|
||||
if receipt["result_digest"] != _digest(result_body):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_DIGEST_MISMATCH")
|
||||
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
compute_used = _number(
|
||||
evaluation["compute_used"], "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED", minimum=0
|
||||
)
|
||||
if compute_used > proposal["compute_budget"] or compute_used > self.envelope.max_compute_budget:
|
||||
status = "STOPPED_BUDGET_EXCEEDED"
|
||||
error_code = "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED"
|
||||
return self._store_evaluation(row, evaluation, receipt, {}, status, error_code)
|
||||
tests_passed = _unique_identifiers(
|
||||
evaluation["tests_passed"], "OPTIMIZATION_EVALUATION_TESTS_INVALID"
|
||||
)
|
||||
gates_passed = _unique_identifiers(
|
||||
evaluation["quality_gates_passed"], "OPTIMIZATION_EVALUATION_GATES_INVALID"
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_tests) <= set(tests_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_TEST_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_TEST_FAILED",
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_quality_gates) <= set(gates_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_GATE_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_GATE_FAILED",
|
||||
)
|
||||
raw_candidate = evaluation["candidate_metrics"]
|
||||
if not isinstance(raw_candidate, Mapping) or set(raw_candidate) != set(self.envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
candidate = {
|
||||
metric: _number(raw_candidate[metric], "OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
for metric in sorted(raw_candidate)
|
||||
}
|
||||
baseline = self._decode(row["baseline_json"])
|
||||
effect: dict[str, float] = {}
|
||||
quality_regression = False
|
||||
for metric in sorted(self.envelope.quality_floor):
|
||||
rule = self.envelope.quality_floor[metric]
|
||||
if rule.direction == "higher":
|
||||
delta = float(candidate[metric]) - float(baseline[metric])
|
||||
below_floor = candidate[metric] < rule.threshold
|
||||
else:
|
||||
delta = float(baseline[metric]) - float(candidate[metric])
|
||||
below_floor = candidate[metric] > rule.threshold
|
||||
effect[metric] = round(delta, 12)
|
||||
quality_regression = quality_regression or below_floor or delta < -1e-12
|
||||
objective = self.envelope.promotion_rules.objective_metric
|
||||
if quality_regression:
|
||||
status, error_code = "STOPPED_QUALITY_REGRESSION", "OPTIMIZATION_QUALITY_REGRESSION"
|
||||
elif effect[objective] + 1e-12 < self.envelope.promotion_rules.minimum_improvement:
|
||||
status, error_code = "STOPPED_NO_IMPROVEMENT", "OPTIMIZATION_NO_IMPROVEMENT"
|
||||
else:
|
||||
status, error_code = "ELIGIBLE", None
|
||||
return self._store_evaluation(row, evaluation, receipt, effect, status, error_code, candidate)
|
||||
|
||||
def _store_evaluation(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
evaluation: Mapping[str, Any],
|
||||
receipt: Mapping[str, Any],
|
||||
effect: Mapping[str, Any],
|
||||
status: str,
|
||||
error_code: str | None,
|
||||
candidate: Mapping[str, Any] | None = None,
|
||||
) -> dict:
|
||||
candidate = dict(candidate or {})
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE optimization_proposals
|
||||
SET status=?,candidate_json=?,effect_json=?,evaluation_json=?,receipt_json=?,
|
||||
error_code=?,updated_at=?
|
||||
WHERE proposal_id=?
|
||||
""",
|
||||
(
|
||||
status, canonical_json_bytes(candidate).decode("utf-8"),
|
||||
canonical_json_bytes(effect).decode("utf-8"),
|
||||
canonical_json_bytes({k: evaluation[k] for k in evaluation if k != "receipt"}).decode("utf-8"),
|
||||
canonical_json_bytes(receipt).decode("utf-8"), error_code, time.time(),
|
||||
row["proposal_id"],
|
||||
),
|
||||
)
|
||||
return self._record(self._row(row["proposal_id"]))
|
||||
|
||||
def promote(
|
||||
self,
|
||||
proposal_id: str,
|
||||
promotion: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
row = self._authorized_row(proposal_id, context)
|
||||
if not isinstance(promotion, Mapping) or set(promotion) != {
|
||||
"evaluation_task_id", "proposal_digest"
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_REQUEST_INVALID")
|
||||
if promotion["evaluation_task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_TASK_MISMATCH")
|
||||
if promotion["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_PROPOSAL_MISMATCH")
|
||||
if row["status"] == "PROMOTION_SUBMITTED":
|
||||
return self._record(row)
|
||||
if row["status"] != "ELIGIBLE":
|
||||
raise OptimizationError("OPTIMIZATION_NOT_PROMOTABLE")
|
||||
stored_receipt = self._trusted_receipt(self._decode(row["receipt_json"]))
|
||||
if (
|
||||
stored_receipt.get("task_id") != row["evaluation_task_id"]
|
||||
or stored_receipt.get("proposal_digest") != row["proposal_digest"]
|
||||
or stored_receipt.get("action") != "evaluate"
|
||||
or stored_receipt.get("success") is not True
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RECEIPT_BINDING_INVALID")
|
||||
evaluation = self._decode(row["evaluation_json"])
|
||||
if stored_receipt.get("result_digest") != _digest(evaluation):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_QUALITY_RESULT_INVALID")
|
||||
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": f"optimization-promote-{proposal_id}",
|
||||
"action": "promote",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"evaluation_result_digest": stored_receipt["result_digest"],
|
||||
}
|
||||
try:
|
||||
submission = self.submit_task(task_request)
|
||||
if not isinstance(submission, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
submission.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? WHERE proposal_id=?",
|
||||
("STOPPED_PROMOTION_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,promotion_task_id=?,error_code=NULL,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='ELIGIBLE'",
|
||||
("PROMOTION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OptimizationController", "OptimizationEnvelope", "OptimizationError",
|
||||
"canonical_json_bytes", "validate_proposal",
|
||||
]
|
||||
@@ -0,0 +1,742 @@
|
||||
"""Release-owned deployment targets and a receipt-gated persistent saga.
|
||||
|
||||
The module persists intent before invoking a governed operation adapter. It
|
||||
does not contain a shell runner, production signer, or success simulator. A
|
||||
configured verifier must translate an adapter/sink receipt into a typed,
|
||||
operation-bound :class:`VerifiedReceipt` before a stage can advance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from .lineage import (
|
||||
LineageLedger,
|
||||
VerifiedAcceptanceEvidence,
|
||||
canonical_artifact,
|
||||
decode_artifact,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseConflict(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseVerificationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseAuthorizationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseReconciliationRequired(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedReceipt:
|
||||
"""Trusted verifier output, fully bound to one persisted stage effect."""
|
||||
|
||||
receipt_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
task_id: str
|
||||
source_task_id: str
|
||||
source_effect_id: str
|
||||
grant_id: str
|
||||
job_id: str
|
||||
effect_id: str
|
||||
operation_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
observed: Mapping[str, Any]
|
||||
outcome: str = "SUCCEEDED"
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentDeploymentTarget:
|
||||
tenant_id: str
|
||||
component_id: str
|
||||
environment: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
revision: int
|
||||
stage: str
|
||||
source_revision: str | None
|
||||
observed_main_revision: str | None
|
||||
previous_release_id: str | None
|
||||
previous_artifact: Any | None
|
||||
rollback_release_id: str | None
|
||||
rollback_artifact: Any | None
|
||||
rollback_compatible: bool
|
||||
updated_by_subject_id: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _recipe_digest(stages: Sequence[Mapping[str, Any]]) -> str:
|
||||
return hashlib.sha256(_json(list(stages)).encode()).hexdigest()
|
||||
|
||||
|
||||
class ReleaseLedger:
|
||||
"""The release domain authority for dynamic targets and deployment sagas."""
|
||||
|
||||
TARGET_STAGES = {"STAGED", "ACTIVE", "ROLLBACK_STAGED"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
lineage: LineageLedger,
|
||||
receipt_verifier: Callable[[Any, Mapping[str, Any]], VerifiedReceipt],
|
||||
target_authorizer: Callable[[str,str,str,str,str], bool],
|
||||
):
|
||||
if not isinstance(lineage, LineageLedger):
|
||||
raise ValueError("lineage ledger is required")
|
||||
if not callable(receipt_verifier):
|
||||
raise ValueError("receipt_verifier is required")
|
||||
if not callable(target_authorizer):
|
||||
raise ValueError("target_authorizer is required")
|
||||
self.path = str(path)
|
||||
self.lineage = lineage
|
||||
self.receipt_verifier = receipt_verifier
|
||||
self.target_authorizer = target_authorizer
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "release_targets" in tables and "release_schema_meta" not in tables:
|
||||
raise ReleaseConflict("unversioned candidate release schema requires explicit migration")
|
||||
if "release_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM release_schema_meta WHERE domain='release'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise ReleaseConflict("unsupported release schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS release_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
stages_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_targets (
|
||||
tenant_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
source_revision TEXT,
|
||||
observed_main_revision TEXT,
|
||||
previous_release_id TEXT,
|
||||
previous_artifact_json TEXT,
|
||||
rollback_release_id TEXT,
|
||||
rollback_artifact_json TEXT,
|
||||
rollback_compatible INTEGER NOT NULL CHECK (rollback_compatible IN (0,1)),
|
||||
updated_by_subject_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, component_id, environment)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_sagas (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
recipe_id TEXT NOT NULL,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id),
|
||||
UNIQUE (tenant_id, deployment_id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES release_recipes(recipe_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_effect_intents (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
state TEXT NOT NULL,
|
||||
receipt_id TEXT,
|
||||
receipt_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id, stage_index),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_deployment_facts (
|
||||
tenant_id TEXT NOT NULL,
|
||||
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
receipt_id TEXT NOT NULL,
|
||||
observed_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, effect_id, receipt_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO release_schema_meta(domain,version) VALUES('release',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorize(self, tenant_id: str, subject_id: str, component_id: str, environment: str, action: str) -> None:
|
||||
try:
|
||||
authorized=self.target_authorizer(tenant_id,subject_id,component_id,environment,action)
|
||||
except Exception as exc:
|
||||
raise ReleaseAuthorizationError("deployment target authorization unavailable") from exc
|
||||
if authorized is not True:
|
||||
raise ReleaseAuthorizationError("deployment target authorization denied")
|
||||
|
||||
def register_recipe(self, recipe_id: str, stages: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
recipe_id = _required(recipe_id, "recipe_id")
|
||||
if not isinstance(stages, Sequence) or isinstance(stages, (str, bytes)) or not stages:
|
||||
raise ValueError("recipe stages must be a non-empty sequence")
|
||||
normalized = []
|
||||
stage_ids = set()
|
||||
for item in stages:
|
||||
if not isinstance(item, Mapping) or set(item) - {"stage_id", "operation_id", "parameters"}:
|
||||
raise ValueError("invalid recipe stage")
|
||||
stage_id = _required(item.get("stage_id"), "stage_id")
|
||||
operation_id = _required(item.get("operation_id"), "operation_id")
|
||||
if stage_id in stage_ids:
|
||||
raise ValueError("duplicate recipe stage_id")
|
||||
stage_ids.add(stage_id)
|
||||
parameters = item.get("parameters", {})
|
||||
if not isinstance(parameters, Mapping):
|
||||
raise ValueError("stage parameters must be an object")
|
||||
normalized.append({"stage_id": stage_id, "operation_id": operation_id, "parameters": dict(parameters)})
|
||||
stages_json = _json(normalized)
|
||||
digest = _recipe_digest(normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?", (recipe_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["recipe_digest"] != digest:
|
||||
raise ReleaseConflict("recipe_id already has different immutable stages")
|
||||
else:
|
||||
connection.execute("INSERT INTO release_recipes VALUES(?,?,?,?)", (recipe_id, digest, stages_json, _now()))
|
||||
return {"recipe_id": recipe_id, "recipe_digest": digest, "stages": normalized}
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
release_id: str,
|
||||
artifact: Any,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
stage: str = "STAGED",
|
||||
source_revision: str | None = None,
|
||||
observed_main_revision: str | None = None,
|
||||
rollback_release_id: str | None = None,
|
||||
rollback_artifact: Any | None = None,
|
||||
rollback_compatible: bool = False,
|
||||
) -> EnvironmentDeploymentTarget:
|
||||
tenant_id, subject_id, component_id, environment, release_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(component_id,"component_id"),(environment,"environment"),(release_id,"release_id")),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"write")
|
||||
if stage not in self.TARGET_STAGES:
|
||||
raise ValueError("invalid target stage")
|
||||
if expected_revision is not None and (not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0):
|
||||
raise ValueError("invalid expected_revision")
|
||||
if type(rollback_compatible) is not bool:
|
||||
raise ValueError("rollback_compatible must be boolean")
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
try:
|
||||
linked_release = self.lineage.get_release_for_component(tenant_id, component_id, release_id)
|
||||
except KeyError as exc:
|
||||
raise ReleaseConflict("deployment target release has no lineage") from exc
|
||||
if canonical_artifact(linked_release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("deployment target artifact does not match release")
|
||||
rollback_artifact_json = canonical_artifact(rollback_artifact) if rollback_artifact is not None else None
|
||||
source_revision = _required(source_revision, "source_revision") if source_revision else None
|
||||
observed_main_revision = _required(observed_main_revision, "observed_main_revision") if observed_main_revision else None
|
||||
rollback_release_id = _required(rollback_release_id, "rollback_release_id") if rollback_release_id else None
|
||||
if (rollback_release_id is None) != (rollback_artifact_json is None):
|
||||
raise ValueError("rollback release and artifact must be supplied together")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id, component_id, environment),
|
||||
).fetchone()
|
||||
if current is None:
|
||||
if expected_revision is not None:
|
||||
raise ReleaseConflict("expected revision does not match absent target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
raise ReleaseConflict("rollback requires a current target")
|
||||
revision = 1
|
||||
previous_release_id = previous_artifact_json = None
|
||||
else:
|
||||
if expected_revision != current["revision"]:
|
||||
raise ReleaseConflict("expected revision does not match target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
if not current["rollback_compatible"]:
|
||||
raise ReleaseConflict("current target has no compatible rollback")
|
||||
if release_id != current["rollback_release_id"] or artifact_json != current["rollback_artifact_json"]:
|
||||
raise ReleaseConflict("rollback target does not match compatible rollback binding")
|
||||
revision = current["revision"] + 1
|
||||
previous_release_id = current["release_id"]
|
||||
previous_artifact_json = current["artifact_json"]
|
||||
if rollback_compatible and rollback_release_id is None:
|
||||
rollback_release_id = previous_release_id
|
||||
rollback_artifact_json = previous_artifact_json
|
||||
if rollback_compatible and current is not None and rollback_release_id is None:
|
||||
raise ReleaseConflict("compatible rollback requires a previous target")
|
||||
now = _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO release_targets VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(tenant_id,component_id,environment) DO UPDATE SET
|
||||
release_id=excluded.release_id, artifact_json=excluded.artifact_json,
|
||||
revision=excluded.revision, stage=excluded.stage,
|
||||
source_revision=excluded.source_revision,
|
||||
observed_main_revision=excluded.observed_main_revision,
|
||||
previous_release_id=excluded.previous_release_id,
|
||||
previous_artifact_json=excluded.previous_artifact_json,
|
||||
rollback_release_id=excluded.rollback_release_id,
|
||||
rollback_artifact_json=excluded.rollback_artifact_json,
|
||||
rollback_compatible=excluded.rollback_compatible,
|
||||
updated_by_subject_id=excluded.updated_by_subject_id,
|
||||
updated_at=excluded.updated_at""",
|
||||
(tenant_id,component_id,environment,release_id,artifact_json,revision,stage,
|
||||
source_revision,observed_main_revision,previous_release_id,previous_artifact_json,
|
||||
rollback_release_id,rollback_artifact_json,int(rollback_compatible),subject_id,now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
return self._target(row)
|
||||
|
||||
def get_target(self, tenant_id: str, subject_id: str, component_id: str, environment: str) -> EnvironmentDeploymentTarget:
|
||||
tenant_id,subject_id,component_id,environment=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(environment,"environment"),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"read")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("deployment target not found")
|
||||
return self._target(row)
|
||||
|
||||
@staticmethod
|
||||
def _target(row: sqlite3.Row) -> EnvironmentDeploymentTarget:
|
||||
return EnvironmentDeploymentTarget(
|
||||
tenant_id=row["tenant_id"], component_id=row["component_id"], environment=row["environment"],
|
||||
release_id=row["release_id"], artifact=decode_artifact(row["artifact_json"]), revision=row["revision"],
|
||||
stage=row["stage"], source_revision=row["source_revision"], observed_main_revision=row["observed_main_revision"],
|
||||
previous_release_id=row["previous_release_id"],
|
||||
previous_artifact=decode_artifact(row["previous_artifact_json"]) if row["previous_artifact_json"] else None,
|
||||
rollback_release_id=row["rollback_release_id"],
|
||||
rollback_artifact=decode_artifact(row["rollback_artifact_json"]) if row["rollback_artifact_json"] else None,
|
||||
rollback_compatible=bool(row["rollback_compatible"]),
|
||||
updated_by_subject_id=row["updated_by_subject_id"], updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def start_saga(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, saga_id: str,
|
||||
release_id: str, deployment_id: str, environment: str, artifact: Any,
|
||||
recipe_id: str, *, expected_target_revision: int,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,component_id,saga_id,release_id,deployment_id,environment,recipe_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),(saga_id,"saga_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(environment,"environment"),(recipe_id,"recipe_id")),
|
||||
)
|
||||
if not isinstance(expected_target_revision,int) or isinstance(expected_target_revision,bool) or expected_target_revision < 1:
|
||||
raise ValueError("invalid expected_target_revision")
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"deploy")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
target=self.get_target(tenant_id,subject_id,component_id,environment)
|
||||
if target.revision != expected_target_revision:
|
||||
raise ReleaseConflict("expected target revision does not match")
|
||||
if target.stage not in {"STAGED","ROLLBACK_STAGED"}:
|
||||
raise ReleaseConflict("deployment target is not staged")
|
||||
if target.release_id != release_id or canonical_artifact(target.artifact) != artifact_json:
|
||||
raise ReleaseConflict("saga does not match deployment target")
|
||||
graph=self.lineage.explain(tenant_id,subject_id,mission_id)
|
||||
release=next((item for item in graph["releases"] if item["release_id"] == release_id),None)
|
||||
if release is None or release["component_id"] != component_id or canonical_artifact(release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("release has no exact source lineage")
|
||||
source_task=next((item for item in graph["tasks"] if item["task_id"] == release["source_task_id"]),None)
|
||||
if source_task is None or source_task["component_id"] != component_id:
|
||||
raise ReleaseConflict("release source Task component lineage missing")
|
||||
with self._connect() as connection:
|
||||
recipe=connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if recipe is None:
|
||||
raise ReleaseConflict("recipe not found")
|
||||
# The relationship is durable before saga admission and is idempotent on retry.
|
||||
self.lineage.link_deployment(
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment,
|
||||
expected_target_revision,decode_artifact(artifact_json),
|
||||
metadata={"saga_id":saga_id,"recipe_id":recipe_id,"recipe_digest":recipe["recipe_digest"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,recipe_id,recipe["recipe_digest"],expected_target_revision)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","source_task_id","source_effect_id","grant_id","release_id","deployment_id","environment","artifact_json","recipe_id","recipe_digest","target_revision"))
|
||||
if actual != expected:
|
||||
raise ReleaseConflict("saga_id already has different immutable binding")
|
||||
return self._saga(connection,existing)
|
||||
collision=connection.execute("SELECT saga_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
if collision:
|
||||
raise ReleaseConflict("deployment_id already has a different saga")
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_sagas VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,
|
||||
recipe_id,recipe["recipe_digest"],expected_target_revision,0,"RUNNING",0,now,now),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
def get_saga(self, tenant_id: str, subject_id: str, saga_id: str) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(_required(tenant_id,"tenant_id"),_required(saga_id,"saga_id"))).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release saga not found")
|
||||
if row["subject_id"] != _required(subject_id,"subject_id"):
|
||||
raise ReleaseAuthorizationError("release saga subject denied")
|
||||
return self._saga(connection,row)
|
||||
|
||||
@staticmethod
|
||||
def _stages(connection: sqlite3.Connection, recipe_id: str) -> list[dict[str, Any]]:
|
||||
row=connection.execute("SELECT stages_json FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ReleaseConflict("persisted recipe missing")
|
||||
return json.loads(row["stages_json"])
|
||||
|
||||
def _saga(self, connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
|
||||
stages=self._stages(connection,row["recipe_id"])
|
||||
index=row["stage_index"]
|
||||
return {
|
||||
"tenant_id":row["tenant_id"],"subject_id":row["subject_id"],"mission_id":row["mission_id"],
|
||||
"component_id":row["component_id"],"source_task_id":row["source_task_id"],
|
||||
"source_effect_id":row["source_effect_id"],"grant_id":row["grant_id"],
|
||||
"saga_id":row["saga_id"],"release_id":row["release_id"],"deployment_id":row["deployment_id"],
|
||||
"environment":row["environment"],"artifact":decode_artifact(row["artifact_json"]),
|
||||
"recipe_id":row["recipe_id"],"recipe_digest":row["recipe_digest"],
|
||||
"target_revision":row["target_revision"],"stage_index":index,
|
||||
"stage_id":stages[index]["stage_id"] if index < len(stages) else None,
|
||||
"state":row["state"],"revision":row["revision"],"created_at":row["created_at"],"updated_at":row["updated_at"],
|
||||
}
|
||||
|
||||
def advance_saga(
|
||||
self, tenant_id: str, subject_id: str, saga_id: str, expected_revision: int,
|
||||
operation_adapter: Any,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,saga_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(saga_id,"saga_id")
|
||||
)
|
||||
if not isinstance(expected_revision,int) or isinstance(expected_revision,bool) or expected_revision < 0:
|
||||
raise ValueError("invalid expected_revision")
|
||||
prepare=getattr(operation_adapter,"prepare",None)
|
||||
execute=getattr(operation_adapter,"execute",None)
|
||||
if not callable(prepare) or not callable(execute):
|
||||
raise ValueError("governed operation adapter requires prepare and execute")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
saga=connection.execute(
|
||||
"SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if saga is None:
|
||||
raise KeyError("release saga not found")
|
||||
if saga["subject_id"] != subject_id:
|
||||
raise ReleaseAuthorizationError("saga belongs to different subject")
|
||||
self._authorize(tenant_id,subject_id,saga["component_id"],saga["environment"],"deploy")
|
||||
if saga["revision"] != expected_revision:
|
||||
raise ReleaseConflict("expected revision does not match saga")
|
||||
if saga["state"] == "COMPLETED":
|
||||
return self._saga(connection,saga)
|
||||
stages=self._stages(connection,saga["recipe_id"])
|
||||
stage=stages[saga["stage_index"]]
|
||||
effect_id=f"release:{saga_id}:{saga['stage_index']}:{stage['stage_id']}"
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent is None:
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_effect_intents VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,saga["stage_index"],stage["stage_id"],stage["operation_id"],effect_id,
|
||||
None,"PENDING_JOB",None,None,now,now),
|
||||
)
|
||||
elif (intent["effect_id"],intent["stage_id"],intent["operation_id"]) != (
|
||||
effect_id,stage["stage_id"],stage["operation_id"]
|
||||
):
|
||||
raise ReleaseConflict("persisted stage effect identity mismatch")
|
||||
operation={
|
||||
"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":saga["mission_id"],
|
||||
"component_id":saga["component_id"],"task_id":saga["source_task_id"],
|
||||
"source_task_id":saga["source_task_id"],"source_effect_id":saga["source_effect_id"],
|
||||
"grant_id":saga["grant_id"],"saga_id":saga_id,
|
||||
"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"environment":saga["environment"],"artifact":decode_artifact(saga["artifact_json"]),
|
||||
"target_revision":saga["target_revision"],"stage_index":saga["stage_index"],
|
||||
"stage_id":stage["stage_id"],"operation_id":stage["operation_id"],"effect_id":effect_id,
|
||||
"parameters":stage.get("parameters",{}),
|
||||
}
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
job_id=intent["job_id"]
|
||||
if job_id is None:
|
||||
prepared=prepare(dict(operation))
|
||||
job_id=prepared.get("job_id") if isinstance(prepared,Mapping) else prepared
|
||||
job_id=_required(job_id,"prepared job_id")
|
||||
# This is the first point where the relationship graph may name a Job:
|
||||
# link_effect independently resolves the configured real Job authority.
|
||||
self.lineage.link_effect(
|
||||
tenant_id,subject_id,saga["mission_id"],saga["source_task_id"],effect_id,job_id,
|
||||
source_effect_id=saga["source_effect_id"],
|
||||
metadata={"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"component_id":saga["component_id"],"stage_id":stage["stage_id"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",
|
||||
(tenant_id,saga_id),
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before Job binding")
|
||||
bound=connection.execute(
|
||||
"SELECT job_id FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if bound["job_id"] not in (None,job_id):
|
||||
raise ReleaseConflict("stage effect already bound to different Job")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET job_id=?,state='JOB_LINKED',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(job_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
operation["job_id"]=job_id
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent["receipt_json"]:
|
||||
raw_receipt=json.loads(intent["receipt_json"])
|
||||
elif intent["state"] == "EFFECT_UNKNOWN":
|
||||
reconcile=getattr(operation_adapter,"reconcile",None)
|
||||
if not callable(reconcile):
|
||||
raise ReleaseReconciliationRequired("RECONCILIATION_REQUIRED: adapter readback unavailable")
|
||||
raw_receipt=reconcile(dict(operation))
|
||||
else:
|
||||
try:
|
||||
raw_receipt=execute(dict(operation))
|
||||
except Exception:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise
|
||||
if not isinstance(raw_receipt,Mapping):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise ReleaseVerificationError("operation adapter receipt must be an object")
|
||||
try:
|
||||
unverified_receipt_json=_json(dict(raw_receipt))
|
||||
except (TypeError,ValueError) as exc:
|
||||
raise ReleaseVerificationError("operation adapter receipt must be canonical JSON") from exc
|
||||
if len(unverified_receipt_json.encode("utf-8")) > 262_144:
|
||||
raise ReleaseVerificationError("operation adapter receipt is too large")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt persistence")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='RECEIPT_UNVERIFIED',receipt_json=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(unverified_receipt_json,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
expected={key:operation[key] for key in (
|
||||
"tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact"
|
||||
)}
|
||||
try:
|
||||
verified=self.receipt_verifier(raw_receipt,expected)
|
||||
except Exception as exc:
|
||||
raise ReleaseVerificationError("configured verifier rejected operation receipt") from exc
|
||||
if not isinstance(verified,VerifiedReceipt):
|
||||
raise ReleaseVerificationError("receipt_verifier must return VerifiedReceipt")
|
||||
if verified.outcome != "SUCCEEDED":
|
||||
raise ReleaseVerificationError("verified receipt outcome is not SUCCEEDED")
|
||||
fields=("tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact")
|
||||
bindings=tuple(canonical_artifact(getattr(verified,name)) if name == "artifact" else getattr(verified,name) for name in fields)
|
||||
expected_bindings=tuple(canonical_artifact(operation[name]) if name == "artifact" else operation[name] for name in fields)
|
||||
if bindings != expected_bindings:
|
||||
wrong=next(name for name,actual,wanted in zip(fields,bindings,expected_bindings) if actual != wanted)
|
||||
raise ReleaseVerificationError(f"verified receipt {wrong} binding mismatch")
|
||||
receipt_id=_required(verified.receipt_id,"receipt_id")
|
||||
if not isinstance(verified.observed,Mapping):
|
||||
raise ReleaseVerificationError("verified receipt observation must be an object")
|
||||
observed_json=_json(dict(verified.observed))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
if current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt commit")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='VERIFIED',receipt_id=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(receipt_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO release_deployment_facts
|
||||
(tenant_id,subject_id,mission_id,component_id,task_id,grant_id,job_id,deployment_id,release_id,artifact_json,saga_id,stage_id,operation_id,effect_id,receipt_id,observed_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(tenant_id,subject_id,saga["mission_id"],saga["component_id"],saga["source_task_id"],saga["grant_id"],job_id,
|
||||
saga["deployment_id"],saga["release_id"],saga["artifact_json"],saga_id,stage["stage_id"],stage["operation_id"],
|
||||
effect_id,receipt_id,observed_json,_now()),
|
||||
)
|
||||
next_index=saga["stage_index"]+1
|
||||
state="COMPLETED" if next_index == len(stages) else "RUNNING"
|
||||
updated=connection.execute(
|
||||
"UPDATE release_sagas SET stage_index=?,state=?,revision=revision+1,updated_at=? WHERE tenant_id=? AND saga_id=? AND revision=?",
|
||||
(next_index,state,_now(),tenant_id,saga_id,expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ReleaseConflict("expected revision changed")
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
|
||||
def deployment_facts(self, tenant_id: str, subject_id: str, component_id: str, deployment_id: str) -> list[dict[str, Any]]:
|
||||
tenant_id,subject_id,component_id,deployment_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(deployment_id,"deployment_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("deployment facts scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"read")
|
||||
rows=connection.execute(
|
||||
"SELECT * FROM release_deployment_facts WHERE tenant_id=? AND subject_id=? AND component_id=? AND deployment_id=? ORDER BY fact_id",
|
||||
(tenant_id,subject_id,component_id,deployment_id),
|
||||
).fetchall()
|
||||
return [{**{key:row[key] for key in row.keys() if not key.endswith("_json")},
|
||||
"artifact":decode_artifact(row["artifact_json"]),"observed":json.loads(row["observed_json"])} for row in rows]
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any,
|
||||
verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("acceptance deployment scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"accept")
|
||||
return self.lineage.record_acceptance(
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id,
|
||||
artifact,accepted,evidence=evidence,verifier=verifier,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EnvironmentDeploymentTarget", "ReleaseAuthorizationError", "ReleaseConflict", "ReleaseError", "ReleaseLedger",
|
||||
"ReleaseReconciliationRequired", "ReleaseVerificationError", "VerifiedReceipt",
|
||||
]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Pure validation and deployment-truth evaluation for runtime contracts.
|
||||
|
||||
Runtime contracts describe stable structure. A release-owned deployment target
|
||||
is supplied separately so that changing a release does not rewrite structural
|
||||
configuration or turn a source branch into runtime evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
CONTRACT_SCHEMA = "aios.runtime-contract.v1"
|
||||
LIFECYCLES = frozenset({"active", "candidate", "legacy", "example"})
|
||||
_DYNAMIC_FIELDS = frozenset(
|
||||
{"desired", "desired_commit", "desired_release", "desired_target", "desired_targets"}
|
||||
)
|
||||
_SECRET_FIELDS = frozenset(
|
||||
{"secret", "secret_bytes", "token", "password", "private_key", "credential_value", "value"}
|
||||
)
|
||||
_DIGEST = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
|
||||
_SAFE_SYMBOLIC_REF = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[/A-Za-z0-9_.:@+-]+$")
|
||||
|
||||
|
||||
def _error(code: str, path: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{path}" if path else code)
|
||||
|
||||
|
||||
def _nonempty_string(value: object, path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _error("field_invalid", path)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _positive_int(value: object, path: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise _error("field_invalid", path)
|
||||
return value
|
||||
|
||||
|
||||
def _reject_dynamic_fields(value: object, path: str = "contract") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise _error("field_name_invalid", path)
|
||||
child_path = f"{path}.{key}"
|
||||
if key.lower() in _DYNAMIC_FIELDS:
|
||||
raise _error("dynamic_field_forbidden", child_path)
|
||||
_reject_dynamic_fields(child, child_path)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for index, child in enumerate(value):
|
||||
_reject_dynamic_fields(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def _safe_path_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or "\\" in text:
|
||||
raise _error("unsafe_path_ref", path)
|
||||
candidate = PurePosixPath(text)
|
||||
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
||||
raise _error("unsafe_path_ref", path)
|
||||
return text
|
||||
|
||||
|
||||
def _symbolic_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or ".." in text or not _SAFE_SYMBOLIC_REF.fullmatch(text):
|
||||
raise _error("resolver_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _credential_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
allowed = (
|
||||
text.startswith("credential://")
|
||||
or text.startswith("secret://")
|
||||
or text.startswith("systemd-credential:")
|
||||
)
|
||||
if not allowed or "\x00" in text or ".." in text or text.startswith("/run/credentials/"):
|
||||
raise _error("credential_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _validate_binding_group(
|
||||
group: object,
|
||||
group_name: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
tenant_scope: str,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(group, Mapping):
|
||||
raise _error("binding_group_invalid", group_name)
|
||||
normalized: dict[str, Any] = {}
|
||||
for name, raw in group.items():
|
||||
item_path = f"{group_name}.{name}"
|
||||
_nonempty_string(name, f"{group_name}.name")
|
||||
if isinstance(raw, str):
|
||||
reference_field = (
|
||||
"credential_ref"
|
||||
if group_name == "credential_refs"
|
||||
else "path_ref"
|
||||
if group_name == "resource_refs" and ":" not in raw
|
||||
else "resolver_ref"
|
||||
)
|
||||
item = {
|
||||
"component_id": component_id,
|
||||
"environment": environment,
|
||||
reference_field: raw,
|
||||
}
|
||||
elif isinstance(raw, Mapping):
|
||||
item = deepcopy(dict(raw))
|
||||
else:
|
||||
raise _error("binding_invalid", item_path)
|
||||
if _nonempty_string(item.get("component_id"), f"{item_path}.component_id") != component_id:
|
||||
raise _error("binding_component_mismatch", item_path)
|
||||
if _nonempty_string(item.get("environment"), f"{item_path}.environment") != environment:
|
||||
raise _error("binding_environment_mismatch", item_path)
|
||||
if "tenant_scope" in item and (
|
||||
_nonempty_string(item["tenant_scope"], f"{item_path}.tenant_scope") != tenant_scope
|
||||
):
|
||||
raise _error("binding_tenant_mismatch", item_path)
|
||||
|
||||
reference_fields = [key for key in ("resolver_ref", "path_ref", "credential_ref") if key in item]
|
||||
if len(reference_fields) != 1:
|
||||
raise _error("binding_reference_invalid", item_path)
|
||||
reference_field = reference_fields[0]
|
||||
reference_path = f"{item_path}.{reference_field}"
|
||||
if group_name == "credential_refs" or reference_field == "credential_ref":
|
||||
for key in item:
|
||||
if key.lower() in _SECRET_FIELDS:
|
||||
raise _error("secret_material_forbidden", f"{item_path}.{key}")
|
||||
item[reference_field] = _credential_ref(item[reference_field], reference_path)
|
||||
elif reference_field == "path_ref":
|
||||
item[reference_field] = _safe_path_ref(item[reference_field], reference_path)
|
||||
else:
|
||||
item[reference_field] = _symbolic_ref(item[reference_field], reference_path)
|
||||
normalized[str(name)] = item
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_contract(document: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and copy a stable ``aios.runtime-contract.v1`` document.
|
||||
|
||||
The returned mapping owns its nested containers, so callers cannot mutate the
|
||||
source after validation. Invalid contracts raise ``ValueError`` with a stable
|
||||
machine-readable code prefix.
|
||||
"""
|
||||
|
||||
if not isinstance(document, Mapping):
|
||||
raise _error("contract_invalid")
|
||||
_reject_dynamic_fields(document)
|
||||
result = deepcopy(dict(document))
|
||||
if result.get("contract_schema") != CONTRACT_SCHEMA:
|
||||
raise _error("contract_schema_invalid", "contract_schema")
|
||||
result["contract_revision"] = _positive_int(result.get("contract_revision"), "contract_revision")
|
||||
component_id = _nonempty_string(result.get("component_id"), "component_id")
|
||||
environment = _nonempty_string(result.get("environment"), "environment")
|
||||
result["component_id"] = component_id
|
||||
result["environment"] = environment
|
||||
tenant_scope = _nonempty_string(result.get("tenant_scope"), "tenant_scope")
|
||||
result["tenant_scope"] = tenant_scope
|
||||
lifecycle = _nonempty_string(result.get("lifecycle"), "lifecycle").lower()
|
||||
if lifecycle not in LIFECYCLES:
|
||||
raise _error("lifecycle_invalid", "lifecycle")
|
||||
result["lifecycle"] = lifecycle
|
||||
|
||||
if "resources" in result and "resource_refs" in result:
|
||||
raise _error("duplicate_resource_group")
|
||||
if "resources" in result:
|
||||
result["resource_refs"] = result.pop("resources")
|
||||
for group_name in ("entrypoints", "bindings", "resource_refs", "credential_refs"):
|
||||
result[group_name] = _validate_binding_group(
|
||||
result.get(group_name), group_name, component_id, environment, tenant_scope
|
||||
)
|
||||
|
||||
operations = result.get("operations")
|
||||
if (
|
||||
not isinstance(operations, Sequence)
|
||||
or isinstance(operations, (str, bytes, bytearray))
|
||||
or not operations
|
||||
):
|
||||
raise _error("operations_invalid", "operations")
|
||||
normalized_operations = [
|
||||
_nonempty_string(operation, f"operations[{index}]")
|
||||
for index, operation in enumerate(operations)
|
||||
]
|
||||
if len(set(normalized_operations)) != len(normalized_operations):
|
||||
raise _error("duplicate_operation", "operations")
|
||||
result["operations"] = normalized_operations
|
||||
return result
|
||||
|
||||
|
||||
def _unknown(*reasons: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"state": "UNKNOWN",
|
||||
"verification": "UNKNOWN",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _drift(reasons: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "DRIFT",
|
||||
"state": "DRIFT",
|
||||
"verification": "FAILED",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _digest_value(document: Mapping[str, Any], *names: str) -> object:
|
||||
for name in names:
|
||||
if name in document:
|
||||
return document[name]
|
||||
return None
|
||||
|
||||
|
||||
def _valid_digest(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(_DIGEST.fullmatch(value))
|
||||
|
||||
|
||||
def evaluate_deployment(
|
||||
contract: Mapping[str, Any],
|
||||
target: Mapping[str, Any] | None,
|
||||
observation: Mapping[str, Any] | None,
|
||||
provenance: Mapping[str, Any] | None,
|
||||
now: datetime | str,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate one target using fresh runtime and provenance evidence.
|
||||
|
||||
Missing or malformed evidence is ``UNKNOWN``. ``DRIFT`` is reserved for a
|
||||
complete evidence set that proves an incompatible target or runtime fact.
|
||||
Repository HEAD is intentionally not an input to this decision.
|
||||
"""
|
||||
|
||||
stable = validate_contract(contract)
|
||||
missing: list[str] = []
|
||||
if target is None:
|
||||
missing.append("target_missing")
|
||||
elif not isinstance(target, Mapping):
|
||||
missing.append("target_invalid")
|
||||
if observation is None:
|
||||
missing.append("observation_missing")
|
||||
elif not isinstance(observation, Mapping):
|
||||
missing.append("observation_invalid")
|
||||
if provenance is None:
|
||||
missing.append("provenance_missing")
|
||||
elif not isinstance(provenance, Mapping):
|
||||
missing.append("provenance_invalid")
|
||||
current_time = _parse_time(now)
|
||||
if current_time is None:
|
||||
missing.append("now_invalid")
|
||||
if missing:
|
||||
return _unknown(*missing)
|
||||
assert target is not None and observation is not None and provenance is not None
|
||||
assert current_time is not None
|
||||
|
||||
target_errors: list[str] = []
|
||||
for field in ("component_id", "environment", "release_id", "deployment_id"):
|
||||
if not isinstance(target.get(field), str) or not str(target[field]).strip():
|
||||
target_errors.append(f"target_{field}_invalid")
|
||||
if "tenant_scope" in target and (
|
||||
not isinstance(target["tenant_scope"], str) or not target["tenant_scope"].strip()
|
||||
):
|
||||
target_errors.append("target_tenant_scope_invalid")
|
||||
target_artifact = target.get("artifact_digest")
|
||||
if not _valid_digest(target_artifact):
|
||||
target_errors.append("target_artifact_digest_invalid")
|
||||
if isinstance(target.get("revision"), bool) or not isinstance(target.get("revision"), int) or target.get("revision", 0) < 1:
|
||||
target_errors.append("target_revision_invalid")
|
||||
|
||||
observed_at = _parse_time(observation.get("observed_at"))
|
||||
max_age = observation.get("max_age_seconds", observation.get("max_age"))
|
||||
observed_artifact = _digest_value(
|
||||
observation, "actual_artifact_digest", "actual_artifact", "artifact_digest"
|
||||
)
|
||||
observation_errors: list[str] = []
|
||||
if observed_at is None:
|
||||
observation_errors.append("observation_observed_at_invalid")
|
||||
if isinstance(max_age, bool) or not isinstance(max_age, (int, float)) or max_age <= 0:
|
||||
observation_errors.append("observation_max_age_invalid")
|
||||
if not isinstance(observation.get("source"), str) or not observation.get("source", "").strip():
|
||||
observation_errors.append("observation_source_invalid")
|
||||
if not _valid_digest(observed_artifact):
|
||||
observation_errors.append("observation_actual_artifact_invalid")
|
||||
if not isinstance(observation.get("healthy"), bool):
|
||||
observation_errors.append("observation_healthy_invalid")
|
||||
for field in ("component_id", "environment", "tenant_scope", "deployment_id"):
|
||||
if field in observation and (
|
||||
not isinstance(observation[field], str) or not observation[field].strip()
|
||||
):
|
||||
observation_errors.append(f"observation_{field}_invalid")
|
||||
if "revision" in observation and (
|
||||
isinstance(observation["revision"], bool)
|
||||
or not isinstance(observation["revision"], int)
|
||||
or observation["revision"] < 1
|
||||
):
|
||||
observation_errors.append("observation_revision_invalid")
|
||||
|
||||
artifact_provenance = _digest_value(provenance, "artifact_digest", "artifact_hash")
|
||||
source_provenance = _digest_value(provenance, "source_digest", "source_hash")
|
||||
builder_provenance = _digest_value(provenance, "builder_digest", "builder_hash")
|
||||
provenance_errors: list[str] = []
|
||||
if not _valid_digest(artifact_provenance):
|
||||
provenance_errors.append("provenance_artifact_digest_invalid")
|
||||
if not _valid_digest(source_provenance):
|
||||
provenance_errors.append("provenance_source_digest_invalid")
|
||||
if not _valid_digest(builder_provenance):
|
||||
provenance_errors.append("provenance_builder_digest_invalid")
|
||||
|
||||
evidence_errors = target_errors + observation_errors + provenance_errors
|
||||
if evidence_errors:
|
||||
return _unknown(*evidence_errors)
|
||||
assert observed_at is not None and isinstance(max_age, (int, float))
|
||||
age = (current_time - observed_at).total_seconds()
|
||||
if age < 0:
|
||||
return _unknown("observation_from_future")
|
||||
if age > max_age:
|
||||
return _unknown("observation_stale")
|
||||
|
||||
drift: list[str] = []
|
||||
if target["component_id"] != stable["component_id"]:
|
||||
drift.append("target_component_id_mismatch")
|
||||
if target["environment"] != stable["environment"]:
|
||||
drift.append("target_environment_mismatch")
|
||||
if "tenant_scope" in target and target["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("target_tenant_scope_mismatch")
|
||||
if "tenant_scope" in observation and observation["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("observation_tenant_scope_mismatch")
|
||||
for field in ("component_id", "environment", "deployment_id", "revision"):
|
||||
if field in observation and observation[field] != target[field]:
|
||||
drift.append(f"observation_{field}_mismatch")
|
||||
if observed_artifact != target_artifact:
|
||||
drift.append("artifact_mismatch")
|
||||
if artifact_provenance != target_artifact:
|
||||
drift.append("provenance_artifact_mismatch")
|
||||
if observation["healthy"] is False:
|
||||
drift.append("observation_unhealthy")
|
||||
if drift:
|
||||
return _drift(drift)
|
||||
return {
|
||||
"status": "VERIFIED",
|
||||
"state": "CONVERGED",
|
||||
"verification": "VERIFIED",
|
||||
"converged": True,
|
||||
"reasons": [],
|
||||
"component_id": target["component_id"],
|
||||
"environment": target["environment"],
|
||||
"release_id": target["release_id"],
|
||||
"deployment_id": target["deployment_id"],
|
||||
"artifact_digest": target_artifact,
|
||||
"revision": target["revision"],
|
||||
"observed_at": observation["observed_at"],
|
||||
"observation_source": observation["source"],
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Thin authenticated application service over existing TaskPlaneStore and JobStore.
|
||||
|
||||
This module owns no second Task lifecycle store. Added task runtime columns live
|
||||
in the supplied Task authority database; the lineage ledger owns only relations.
|
||||
The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
from .runtime_contract import validate_contract
|
||||
from .worker_policy import eligible
|
||||
from .authorization import AuthorizationError
|
||||
from .host import HostError
|
||||
|
||||
|
||||
class ServiceError(RuntimeError):
|
||||
def __init__(self,code,status=409):
|
||||
super().__init__(code)
|
||||
self.code=code
|
||||
self.status=status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
role: str
|
||||
worker_id: str | None=None
|
||||
|
||||
|
||||
class BaselineService:
|
||||
def __init__(self,store,job_root,authorization,contracts,operations,worker_profiles,host,lineage,machine_key,
|
||||
*,machine_key_id='baseline-machine',trusted_receipt_keys=None):
|
||||
self.store=store
|
||||
self.signer=ReceiptSigner(machine_key,key_id=machine_key_id,trusted_public_keys=trusted_receipt_keys)
|
||||
self.jobs=GitOpsJobController(JobStore(Path(job_root)),generic_receipt_verifier=self.signer.verify)
|
||||
self.authorization=authorization
|
||||
self.contracts={}
|
||||
for entry in contracts:
|
||||
value=validate_contract(entry)
|
||||
key=(value['tenant_scope'],value['environment'],value['component_id'])
|
||||
if key in self.contracts: raise ServiceError('CONTRACT_DUPLICATE')
|
||||
self.contracts[key]=value
|
||||
self.operations=operations
|
||||
self.worker_profiles=dict(worker_profiles)
|
||||
self.host=host
|
||||
self.lineage=lineage
|
||||
self.storage_blocked=False
|
||||
self._execute_lock=threading.Lock()
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_task_runtime(
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),ready_at REAL,
|
||||
blocker_code TEXT,job_id TEXT,effect_id TEXT NOT NULL UNIQUE)''')
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
raise ServiceError('USER_IDENTITY_REQUIRED',403)
|
||||
|
||||
def _worker(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role!='worker':
|
||||
raise ServiceError('WORKER_IDENTITY_REQUIRED',403)
|
||||
profile=self.worker_profiles.get(identity.worker_id)
|
||||
if not profile or profile.identity!=identity.subject_id or identity.tenant_id not in profile.tenant_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
return profile
|
||||
|
||||
def _task(self,identity,task_id):
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id')!=identity.tenant_id or meta.get('subject_id')!=identity.subject_id:
|
||||
raise ServiceError('TASK_SCOPE_DENIED',403)
|
||||
return task
|
||||
|
||||
def _scope(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return {**{key:meta[key] for key in ('tenant_id','subject_id','mission_id','operation','component_id','environment')},'task_id':task.task_id}
|
||||
|
||||
def _principal(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return self.authorization.issue_principal(meta['grant_id'],ttl_seconds=60,**self._scope(task))
|
||||
|
||||
def _fresh_write(self):
|
||||
if self.storage_blocked: raise ServiceError('STORAGE_RECOVERY_REQUIRED',503)
|
||||
try:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def submit_goal(self,identity,request,*,idempotency_key):
|
||||
self._user(identity)
|
||||
allowed={'mission_id','grant_id','operation','component_id','environment','inputs','intent'}
|
||||
if type(request) is not dict or set(request)!=allowed or not isinstance(idempotency_key,str) or not 1<=len(idempotency_key)<=128:
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if any(not isinstance(request[key],str) or not request[key] or len(request[key])>2048 for key in allowed-{'inputs'}):
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if request['inputs']!={}: raise ServiceError('OPERATION_INPUT_SCHEMA_INVALID',400)
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',request['mission_id']): raise ServiceError('MISSION_ID_INVALID',400)
|
||||
operation=self.operations.get(request['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY' or operation.required_permissions!=(operation.operation_id,):
|
||||
raise ServiceError('REGISTERED_HANDLER_UNAVAILABLE',503)
|
||||
contract=self.contracts.get((identity.tenant_id,request['environment'],request['component_id']))
|
||||
if not contract or contract['lifecycle']!='active' or request['operation'] not in contract['operations']:
|
||||
raise ServiceError('CONTRACT_OPERATION_UNAVAILABLE',403)
|
||||
host_key=identity.tenant_id+':'+request['environment']+':'+request['component_id']
|
||||
if host_key not in self.host.bindings: raise ServiceError('HOST_BINDING_UNAVAILABLE',503)
|
||||
key=digest({'tenant':identity.tenant_id,'subject':identity.subject_id,'key':idempotency_key})
|
||||
task_id='task-'+key[:40]
|
||||
effect_id='effect-'+digest({'tenant':identity.tenant_id,'task':task_id,'request':request})[:40]
|
||||
meta={**request,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id,'effect_id':effect_id,
|
||||
'operation_version':operation.version,'contract_revision':contract['contract_revision']}
|
||||
plan=TaskPlan.from_dict({'task_id':task_id,'task_type':'host.diagnostic','intent':request['intent'],
|
||||
'issuer':{'id':identity.subject_id,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id},
|
||||
'required_capabilities':[request['operation']],'risk':'low','approval_policy':'governed-grant',
|
||||
'correlation_id':request['mission_id'],'metadata':{'baseline':meta},
|
||||
'budgets':{'attempts':operation.retry['max_attempts'],'time_seconds':30*operation.retry['max_attempts']},'evidence_requirements':['diagnostic']})
|
||||
# Validate business authorization before creating an executable task.
|
||||
self.authorization.issue_principal(request['grant_id'],ttl_seconds=60,tenant_id=identity.tenant_id,
|
||||
subject_id=identity.subject_id,task_id=task_id,**{key:request[key] for key in ('mission_id','operation','component_id','environment')})
|
||||
self._fresh_write()
|
||||
try:
|
||||
with self.store._transaction():
|
||||
task=self.store.create_task_with_event(plan,idempotency_key=key)
|
||||
self.store.conn.execute('INSERT OR IGNORE INTO baseline_task_runtime(task_id,ready_at,effect_id) VALUES(?,?,?)',
|
||||
(task_id,time.time(),effect_id))
|
||||
self.lineage.link_task(identity.tenant_id,identity.subject_id,request['mission_id'],request['grant_id'],task_id)
|
||||
return self.get_task(identity,task_id)
|
||||
except TaskPlaneHTTPError as exc:
|
||||
raise ServiceError('TASK_CONFLICT') from exc
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def get_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT * FROM baseline_task_runtime WHERE task_id=?',(task_id,)).fetchone()
|
||||
result=task.to_dict()
|
||||
if row:
|
||||
result.update({'ready_at':row['ready_at'],'blocker_code':row['blocker_code'],'effect_id':row['effect_id'],'job_id':row['job_id']})
|
||||
if row['job_id']:
|
||||
job=self.jobs.store.read(row['job_id'])
|
||||
result['job_state']=job['state']
|
||||
result['side_effect_state']=job['side_effect_state']
|
||||
envelope=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if self._receipt_valid(task,envelope): result['receipt']=envelope['payload']
|
||||
result['remaining_budget']=self.store.get_task_budget(task_id).to_dict()
|
||||
return result
|
||||
|
||||
def explain(self,identity,mission_id):
|
||||
self._user(identity)
|
||||
result=self.lineage.explain(identity.tenant_id,identity.subject_id,mission_id)
|
||||
result['tasks_runtime']=[self.get_task(identity,t.task_id) for t in self.store.list_tasks()
|
||||
if t.task_plan.metadata.get('baseline',{}).get('mission_id')==mission_id and
|
||||
t.task_plan.issuer.get('tenant_id')==identity.tenant_id and t.task_plan.issuer.get('subject_id')==identity.subject_id]
|
||||
return result
|
||||
|
||||
def register_worker(self,identity):
|
||||
profile=self._worker(identity)
|
||||
try:
|
||||
existing=self.store.get_worker(profile.worker_id)
|
||||
except TaskPlaneHTTPError:
|
||||
existing=None
|
||||
now=time.time()
|
||||
worker=WorkerRegistration.from_dict({'worker_id':profile.worker_id,'worker_type':'baseline-operation-worker',
|
||||
'identity':profile.identity,'capabilities':list(profile.capabilities),'trust_zone':'governed-host',
|
||||
'max_concurrency':profile.max_concurrency,'heartbeat_interval_seconds':profile.heartbeat_seconds,
|
||||
'supported_protocols':['aios-baseline-worker-v1'],'last_heartbeat':now})
|
||||
self.store.register_worker(worker,actor_identity=profile.identity,is_admin=False,
|
||||
expected_revision=existing.registration_revision if existing else None)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('INSERT INTO baseline_worker_policy VALUES(?,?) ON CONFLICT(worker_id) DO UPDATE SET policy_json=excluded.policy_json',
|
||||
(profile.worker_id,canonical(profile.__dict__).decode()))
|
||||
return {'worker_id':profile.worker_id,'registered':True,'fallback_executor':profile.fallback_executor}
|
||||
|
||||
def heartbeat(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
self.store.heartbeat_worker(profile.worker_id,actor_identity=profile.identity,expected_revision=worker.registration_revision)
|
||||
return {'worker_id':profile.worker_id,'alive':True}
|
||||
|
||||
def claim_next(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
if worker.disabled: raise ServiceError('WORKER_DISABLED',403)
|
||||
self._fresh_write()
|
||||
for task in sorted(self.store.list_tasks(),key=lambda t:(t.created_at,t.task_id)):
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if not meta or task.status not in {'CREATED','QUEUED','FAILED_RECOVERABLE'}: continue
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT ready_at FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not row: continue
|
||||
now=time.time()
|
||||
task_scope={**meta,'ready_at':row['ready_at']}
|
||||
if not eligible(profile,task_scope,now=now,last_heartbeat=worker.last_heartbeat): continue
|
||||
# Priority is configuration on ordinary eligible workers; no special main actor.
|
||||
candidates=[]
|
||||
for registered in self.store.list_workers():
|
||||
peer=self.worker_profiles.get(registered.worker_id)
|
||||
if not peer or registered.disabled or not eligible(peer,task_scope,now=now,last_heartbeat=registered.last_heartbeat): continue
|
||||
with self.store._transaction():
|
||||
active=self.store.conn.execute("SELECT count(*) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",(peer.worker_id,now)).fetchone()[0]
|
||||
if active<peer.max_concurrency: candidates.append(peer)
|
||||
if not candidates or sorted(candidates,key=lambda p:(-p.priority,p.worker_id))[0].worker_id!=profile.worker_id: continue
|
||||
try:
|
||||
self._principal(task)
|
||||
with self.store._transaction():
|
||||
self.store.consume_budget(task.task_id,Budget(attempts=1,time_seconds=30))
|
||||
claim=self.store.claim_task(task.task_id,profile.worker_id,profile.identity,lease_seconds=30,
|
||||
expected_revision=task.revision,operation_digest=task.task_plan.operation_digest())
|
||||
except TaskPlaneHTTPError:
|
||||
budget=self.store.get_task_budget(task.task_id)
|
||||
if budget.attempts==0 or budget.time_seconds==0:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('TASK_BUDGET_EXHAUSTED',task.task_id))
|
||||
continue
|
||||
except AuthorizationError:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('AUTHORIZATION_REQUIRED',task.task_id))
|
||||
continue
|
||||
return {'task_id':task.task_id,'claim_id':claim.claim_id,'generation':claim.attempt,
|
||||
'lease_until':claim.lease_until,'operation':meta['operation']}
|
||||
return None
|
||||
|
||||
def renew(self,identity,task_id,claim_id):
|
||||
profile=self._worker(identity)
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_SCOPE_OR_STATE_DENIED',403)
|
||||
self._principal(task)
|
||||
self.store.heartbeat_claim(task_id,claim_id,30)
|
||||
return {'task_id':task_id,'claim_id':claim_id,'renewed':True}
|
||||
|
||||
def _advance(self,job_id,state,**kwargs):
|
||||
row=self.jobs.store.read(job_id)
|
||||
return self.jobs.advance(job_id,state,expected_revision=int(row['revision']),**kwargs)
|
||||
|
||||
@contextmanager
|
||||
def _execution_slot(self,deadline):
|
||||
remaining=deadline-time.time()
|
||||
if remaining<=0 or not self._execute_lock.acquire(timeout=remaining):
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
try:
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
yield
|
||||
finally: self._execute_lock.release()
|
||||
|
||||
def execute_claim(self,identity,task_id,claim_id,*,deadline=None):
|
||||
profile=self._worker(identity)
|
||||
deadline=time.time()+30 if deadline is None else deadline
|
||||
with self._execution_slot(deadline):
|
||||
self._fresh_write()
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
user=Identity(meta['tenant_id'],meta['subject_id'],'user')
|
||||
if task.status=='COMPLETED': return self.get_task(user,task_id)
|
||||
if claim.status!='ACTIVE' or claim.lease_until<=time.time() or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
principal=self._principal(task)
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
operation=self.operations.get(meta['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY':
|
||||
raise ServiceError('HANDLER_NOT_REGISTERED',503)
|
||||
self.authorization.assert_effect_bounds(principal,operation,**self._scope(task))
|
||||
request={'schema':'aios.operation-job.v1','tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],
|
||||
'mission_id':meta['mission_id'],'grant_id':meta['grant_id'],'task_id':task_id,'effect_id':meta['effect_id'],
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']}),'input_digest':digest(meta['inputs'])}
|
||||
try:
|
||||
job=self.jobs.submit(request)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET job_id=? WHERE task_id=?',(job.job_id,task_id))
|
||||
self.lineage.link_task(meta['tenant_id'],meta['subject_id'],meta['mission_id'],meta['grant_id'],task_id)
|
||||
self.lineage.link_effect(meta['tenant_id'],meta['subject_id'],meta['mission_id'],task_id,meta['effect_id'],job.job_id)
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
if row['state']=='COMPLETED':
|
||||
self._close_task(task,claim,json.loads(row['receipt_json']))
|
||||
return self.get_task(user,task_id)
|
||||
if row['state'] not in {'REQUESTED','AUTHORIZED'}:
|
||||
raise ServiceError('JOB_RECONCILIATION_REQUIRED')
|
||||
self.jobs.assert_effect_admission(exclude_job_id=job.job_id)
|
||||
self.authorization.reserve_effect(meta['grant_id'],meta['effect_id'],principal,**self._scope(task))
|
||||
if row['state']=='REQUESTED': self._advance(job.job_id,JobState.AUTHORIZED,side_effect_state='AUTHORIZED')
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
self.jobs.lease_resource_job(job.job_id,lease_id=claim_id,resource_kind=request['resource_kind'],
|
||||
resource_id=request['resource_id'],generation=None,
|
||||
lease_expires_at=datetime.fromtimestamp(min(claim.lease_until,time.time()+30,deadline),timezone.utc).isoformat(),
|
||||
expected_revision=int(row['revision']))
|
||||
effect_lease=self.jobs.get_execution_lease(job.job_id)
|
||||
fence_generation=int(effect_lease['generation'])
|
||||
self._advance(job.job_id,JobState.EXECUTING,side_effect_state='IN_PROGRESS')
|
||||
# Last authorization/claim check directly before the registered host boundary.
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
if self.store.get_task(task_id).status=='CANCELLED' or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
self.jobs.assert_execution_lease(job.job_id,generation=fence_generation,lease_id=claim_id)
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
observation=self.host.status(meta['component_id'],meta['environment'],tenant_id=meta['tenant_id'],deadline=deadline)
|
||||
if time.time()>=deadline or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
envelope=self.signer.sign({'task_id':task_id,'job_id':job.job_id,'effect_id':meta['effect_id'],
|
||||
'tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],'mission_id':meta['mission_id'],
|
||||
'grant_id':meta['grant_id'],'claim_id':claim_id,'generation':fence_generation,
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),
|
||||
'input_digest':request['input_digest'],'resource_kind':request['resource_kind'],
|
||||
'resource_id':request['resource_id'],'observation':observation,'verified':True})
|
||||
self._advance(job.job_id,JobState.LOCAL_VERIFIED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._advance(job.job_id,JobState.COMPLETED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._close_task(task,claim,envelope)
|
||||
return self.get_task(user,task_id)
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('STORAGE_RECOVERY_REQUIRED',503) from exc
|
||||
except HostError as exc:
|
||||
try:
|
||||
self._advance(job.job_id,JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',(str(exc),task_id))
|
||||
except (sqlite3.Error,OSError,JobError): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503) from exc
|
||||
except JobError as exc:
|
||||
if 'UNAVAILABLE' in str(exc) or 'STORAGE' in str(exc): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503 if self.storage_blocked else 409) from exc
|
||||
|
||||
def _receipt_valid(self,task,envelope):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
if not self.signer.verify(envelope): return False
|
||||
payload=envelope['payload']
|
||||
with self.store._transaction():
|
||||
runtime=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not runtime or not runtime['job_id']: return False
|
||||
expected={**{k:meta[k] for k in ('effect_id','tenant_id','subject_id','mission_id','grant_id','operation')},
|
||||
'task_id':task.task_id,'job_id':runtime['job_id'],'operation_version':str(meta['operation_version']),
|
||||
'input_digest':digest(meta['inputs']),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']})}
|
||||
if any(payload.get(k)!=v for k,v in expected.items()) or payload.get('verified') is not True: return False
|
||||
try:
|
||||
lease=self.jobs.get_execution_lease(runtime['job_id'])
|
||||
source_claim=self.store.get_claim(payload.get('claim_id'))
|
||||
if payload.get('claim_id')!=lease['lease_id'] or payload.get('generation')!=lease['generation']: return False
|
||||
if source_claim.task_id!=task.task_id or source_claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
except (JobError,TaskPlaneHTTPError,TypeError): return False
|
||||
observation=payload.get('observation')
|
||||
if type(observation) is not dict: return False
|
||||
for k in ('operation','tenant_id','component_id','environment'):
|
||||
if observation.get(k)!=meta[k]: return False
|
||||
binding=meta['tenant_id']+':'+meta['environment']+':'+meta['component_id']
|
||||
if observation.get('unit')!=self.host.bindings.get(binding) or observation.get('source')!='systemd.show': return False
|
||||
return True
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
self.store.cancel_task_with_event(task_id,task.revision,'cancelled by authenticated subject')
|
||||
return self.get_task(identity,task_id)
|
||||
|
||||
def _verify_probe(self,probe):
|
||||
try:
|
||||
self.signer.public_key.verify(base64.b64decode(probe.signature,validate=True),canonical(probe.digest_payload()))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def recover(self):
|
||||
"""Reconcile expired baseline claims from durable signed receipts; never fake a result."""
|
||||
with self._execute_lock:
|
||||
return self._recover_locked()
|
||||
|
||||
def _recover_locked(self):
|
||||
recovered=[]
|
||||
with self.store._transaction():
|
||||
rows=self.store.conn.execute("SELECT claim_id FROM claims WHERE status='ACTIVE' AND lease_until<?",(time.time(),)).fetchall()
|
||||
for row in rows:
|
||||
claim=self.store.get_claim(row['claim_id'])
|
||||
task=self.store.get_task(claim.task_id)
|
||||
meta=task.task_plan.metadata.get('baseline')
|
||||
if not meta: continue
|
||||
with self.store._transaction():
|
||||
rt=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
outcome=ProbeOutcome.NOT_APPLIED
|
||||
ref='read-only-no-persistent-effect'
|
||||
if rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
env=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if job['state']=='COMPLETED':
|
||||
if self._receipt_valid(task,env):
|
||||
outcome=ProbeOutcome.APPLIED_VERIFIED
|
||||
ref=digest(env)
|
||||
else: outcome=ProbeOutcome.INDETERMINATE
|
||||
elif self.operations.get(meta['operation']).effect_class!='READ_ONLY':
|
||||
outcome=ProbeOutcome.INDETERMINATE
|
||||
now=time.time()
|
||||
probe=ProbeEvidence.create(task_id=task.task_id,claim_id=claim.claim_id,worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,operation_digest=claim.operation_digest,outcome=outcome,
|
||||
observed_at=now,expires_at=now+30,probe_executor_identity='baseline-reconciler',attestation_ref=ref)
|
||||
from dataclasses import replace
|
||||
probe=replace(probe,signature=base64.b64encode(self.signer.private_key.sign(canonical(probe.digest_payload()))).decode())
|
||||
self.store.recover_claim_with_probe_and_event(task.task_id,claim.claim_id,probe)
|
||||
if outcome==ProbeOutcome.NOT_APPLIED and rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
if job['state']!='COMPLETED':
|
||||
self._advance(rt['job_id'],JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
self.jobs.reconcile(rt['job_id'],lambda _:{'state':'REQUESTED','side_effect_state':'READ_ONLY_SAFE_TO_RETRY'})
|
||||
recovered.append({'task_id':task.task_id,'outcome':outcome.value})
|
||||
# A successful writable transaction permits recovery processing, not blind effect replay.
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
try:
|
||||
self.jobs.assert_effect_admission()
|
||||
self.storage_blocked=False
|
||||
except JobError:
|
||||
self.storage_blocked=True
|
||||
return recovered
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Authenticated loopback HTTP transport for the Baseline 1.1 service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from .service import Identity, ServiceError
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 65_536
|
||||
MAX_RESPONSE_BYTES = 1_048_576
|
||||
MAX_DEADLINE_SECONDS = 30
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_IDENTITY_FIELDS = frozenset({"tenant_id", "subject_id", "role", "worker_id", "identity"})
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
def __init__(self, code: str, status: int | None = None):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class _RequestError(RuntimeError):
|
||||
def __init__(self, code: str, status: int):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def _safe_identifier(value: Any, code: str = "HTTP_PATH_INVALID") -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise _RequestError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _has_identity_field(value: Any) -> bool:
|
||||
if isinstance(value, Mapping):
|
||||
if any(key in _IDENTITY_FIELDS for key in value):
|
||||
return True
|
||||
return any(_has_identity_field(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_has_identity_field(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _loopback_host(host: str, code: str) -> str:
|
||||
if not isinstance(host, str):
|
||||
raise TransportError(code)
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError as exc:
|
||||
raise TransportError(code) from exc
|
||||
if not address.is_loopback or address.version != 4:
|
||||
raise TransportError(code)
|
||||
return str(address)
|
||||
|
||||
|
||||
class _BaselineHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address, handler, service, identities):
|
||||
self.baseline_service = service
|
||||
self.trusted_identities = identities
|
||||
self.request_slots=threading.BoundedSemaphore(16)
|
||||
super().__init__(address, handler)
|
||||
|
||||
def get_request(self):
|
||||
connection, address = super().get_request()
|
||||
connection.settimeout(MAX_DEADLINE_SECONDS)
|
||||
return connection, address
|
||||
|
||||
def process_request(self,request,client_address):
|
||||
if not self.request_slots.acquire(blocking=False):
|
||||
try:
|
||||
body=b'{"error":{"code":"HTTP_CAPACITY_EXCEEDED"}}'
|
||||
request.sendall(b'HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: '+str(len(body)).encode()+b'\r\n\r\n'+body)
|
||||
except OSError: pass
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try: super().process_request(request,client_address)
|
||||
except Exception:
|
||||
self.request_slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self,request,client_address):
|
||||
try: super().process_request_thread(request,client_address)
|
||||
finally: self.request_slots.release()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "AIOSBaselineHTTP/1"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
# Request paths can contain tenant-owned identifiers; do not emit them or
|
||||
# bearer-related failure details through the default stderr logger.
|
||||
return
|
||||
|
||||
def send_error(self, code, message=None, explain=None):
|
||||
# BaseHTTPRequestHandler otherwise emits an HTML page which may include
|
||||
# parser details. Keep pre-dispatch protocol failures in the same closed
|
||||
# JSON error format.
|
||||
status = code if isinstance(code, int) and 400 <= code <= 599 else 400
|
||||
self._send(status, {"error": {"code": "HTTP_PROTOCOL_ERROR"}})
|
||||
|
||||
def _send(self, status: int, value: Any) -> None:
|
||||
try:
|
||||
body = json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError):
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_INVALID"}}'
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_TOO_LARGE"}}'
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except OSError:
|
||||
# A peer can disconnect or let its deadline expire while a response
|
||||
# is being written. Do not let the server print a handler traceback.
|
||||
pass
|
||||
self.close_connection = True
|
||||
|
||||
def _error(self, error: _RequestError) -> None:
|
||||
self._send(error.status, {"error": {"code": error.code}})
|
||||
|
||||
def _deadline(self) -> float:
|
||||
values = self.headers.get_all("X-AIOS-Deadline", [])
|
||||
if len(values) != 1:
|
||||
raise _RequestError("HTTP_DEADLINE_REQUIRED", 400)
|
||||
try:
|
||||
deadline = float(values[0])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400) from exc
|
||||
now = time.time()
|
||||
if not math.isfinite(deadline) or deadline > now + MAX_DEADLINE_SECONDS:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400)
|
||||
if deadline <= now:
|
||||
raise _RequestError("HTTP_DEADLINE_EXPIRED", 408)
|
||||
self.connection.settimeout(deadline - now)
|
||||
return deadline
|
||||
|
||||
def _identity(self) -> Identity:
|
||||
values = self.headers.get_all("Authorization", [])
|
||||
if len(values) != 1 or not values[0].startswith("Bearer "):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
token = values[0][7:]
|
||||
if not token or any(ord(char) < 33 or ord(char) > 126 for char in token):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
identity = self.server.trusted_identities.get(token)
|
||||
if identity is None:
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
return identity
|
||||
|
||||
def _json_body(self) -> dict:
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_TRANSFER_ENCODING_FORBIDDEN", 400)
|
||||
content_types = self.headers.get_all("Content-Type", [])
|
||||
if content_types != ["application/json"]:
|
||||
raise _RequestError("HTTP_CONTENT_TYPE_INVALID", 415)
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if len(lengths) != 1 or not lengths[0].isdigit():
|
||||
raise _RequestError("HTTP_CONTENT_LENGTH_INVALID", 411)
|
||||
length = int(lengths[0])
|
||||
if length > MAX_REQUEST_BYTES:
|
||||
raise _RequestError("HTTP_BODY_TOO_LARGE", 413)
|
||||
if length <= 0:
|
||||
raise _RequestError("HTTP_BODY_REQUIRED", 400)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise _RequestError("HTTP_BODY_TRUNCATED", 400)
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise _RequestError("HTTP_JSON_DUPLICATE_KEY", 400)
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
def reject_constant(_value):
|
||||
raise _RequestError("HTTP_JSON_NON_FINITE", 400)
|
||||
|
||||
value = json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=reject_constant,
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise _RequestError("HTTP_JSON_INVALID", 400) from exc
|
||||
if type(value) is not dict:
|
||||
raise _RequestError("HTTP_JSON_OBJECT_REQUIRED", 400)
|
||||
if _has_identity_field(value):
|
||||
raise _RequestError("HTTP_IDENTITY_FIELD_FORBIDDEN", 400)
|
||||
return value
|
||||
|
||||
def _path(self) -> list[str]:
|
||||
parsed = urlsplit(self.path)
|
||||
if parsed.query or parsed.fragment or not parsed.path.startswith("/"):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
raw_segments = parsed.path.split("/")[1:]
|
||||
if any(not segment for segment in raw_segments):
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
segments = [unquote(segment) for segment in raw_segments]
|
||||
if any("/" in segment or "\\" in segment for segment in segments):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
return segments
|
||||
|
||||
@staticmethod
|
||||
def _exact(body: Mapping[str, Any], fields: set[str]) -> None:
|
||||
if set(body) != fields:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
|
||||
def _dispatch(self, method: str, identity: Identity, segments: list[str], body: dict | None):
|
||||
service = self.server.baseline_service
|
||||
if method == "POST" and segments == ["v1", "goals", "submit"]:
|
||||
self._exact(body, {"request", "idempotency_key"})
|
||||
if type(body["request"]) is not dict:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
return 201, service.submit_goal(
|
||||
identity, body["request"], idempotency_key=body["idempotency_key"]
|
||||
)
|
||||
if method == "GET" and len(segments) == 3 and segments[:2] == ["v1", "tasks"]:
|
||||
return 200, service.get_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "POST" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "tasks"] and segments[3] == "cancel"
|
||||
):
|
||||
self._exact(body, set())
|
||||
return 200, service.cancel_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "GET" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "missions"] and segments[3] == "explain"
|
||||
):
|
||||
return 200, service.explain(identity, _safe_identifier(segments[2]))
|
||||
if method == "POST" and segments == ["v1", "workers", "register"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.register_worker(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "heartbeat"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.heartbeat(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "claim"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.claim_next(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "renew"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.renew(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"])
|
||||
)
|
||||
if method == "POST" and segments == ["v1", "workers", "execute"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.execute_claim(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"]),deadline=self.request_deadline
|
||||
)
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
try:
|
||||
self.request_deadline=self._deadline()
|
||||
identity = self._identity()
|
||||
segments = self._path()
|
||||
body = self._json_body() if method == "POST" else None
|
||||
if method == "GET":
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
if len(lengths) > 1 or (lengths and (not lengths[0].isdigit() or int(lengths[0]) != 0)):
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
status, result = self._dispatch(method, identity, segments, body)
|
||||
self._send(status, result)
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
except ServiceError as exc:
|
||||
status = exc.status if isinstance(exc.status, int) and 400 <= exc.status <= 599 else 500
|
||||
self._send(status, {"error": {"code": exc.code}})
|
||||
except TimeoutError:
|
||||
self._send(408, {"error": {"code": "HTTP_DEADLINE_EXPIRED"}})
|
||||
except Exception:
|
||||
# No exception string, traceback, request headers, or token crosses
|
||||
# this boundary.
|
||||
self._send(500, {"error": {"code": "HTTP_INTERNAL_ERROR"}})
|
||||
|
||||
def do_GET(self):
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._handle("POST")
|
||||
|
||||
def _method_not_allowed(self):
|
||||
try:
|
||||
self._deadline()
|
||||
self._identity()
|
||||
self._send(405, {"error": {"code": "HTTP_METHOD_NOT_ALLOWED"}})
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
|
||||
do_PUT = _method_not_allowed
|
||||
do_PATCH = _method_not_allowed
|
||||
do_DELETE = _method_not_allowed
|
||||
do_OPTIONS = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_TRACE = _method_not_allowed
|
||||
do_CONNECT = _method_not_allowed
|
||||
|
||||
|
||||
def serve(
|
||||
service: Any,
|
||||
tokens: Mapping[str, Identity],
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build an unstarted real HTTP server bound only to an IPv4 loopback."""
|
||||
host = _loopback_host(host, "HTTP_LOOPBACK_REQUIRED")
|
||||
if type(port) is not int or not 0 <= port <= 65_535:
|
||||
raise TransportError("HTTP_PORT_INVALID")
|
||||
if not isinstance(tokens, Mapping) or not tokens:
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITIES_REQUIRED")
|
||||
identities: dict[str, Identity] = {}
|
||||
for token, identity in tokens.items():
|
||||
if (
|
||||
not isinstance(token, str) or not 32<=len(token)<=512
|
||||
or any(ord(char) < 33 or ord(char) > 126 for char in token)
|
||||
or not isinstance(identity, Identity)
|
||||
):
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITY_INVALID")
|
||||
identities[token] = identity
|
||||
return _BaselineHTTPServer((host, port), _Handler, service, identities)
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""Small urllib client for the loopback API; redirects and proxies are off."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_seconds: float = 15,
|
||||
max_response_bytes: int = MAX_RESPONSE_BYTES,
|
||||
):
|
||||
try:
|
||||
parsed = urlsplit(base_url)
|
||||
parsed_port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID") from exc
|
||||
if (
|
||||
parsed.scheme != "http" or parsed.username is not None or parsed.password is not None
|
||||
or parsed.query or parsed.fragment or parsed.path not in {"", "/"}
|
||||
or parsed.hostname is None or parsed_port is None
|
||||
):
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID")
|
||||
_loopback_host(parsed.hostname, "TRANSPORT_LOOPBACK_REQUIRED")
|
||||
if not isinstance(token, str) or not 32<=len(token)<=512 or any(
|
||||
ord(char) < 33 or ord(char) > 126 for char in token
|
||||
):
|
||||
raise TransportError("TRANSPORT_TOKEN_INVALID")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(float(timeout_seconds))
|
||||
or not 0.1 <= timeout_seconds <= MAX_DEADLINE_SECONDS
|
||||
):
|
||||
raise TransportError("TRANSPORT_TIMEOUT_INVALID")
|
||||
if type(max_response_bytes) is not int or not 1 <= max_response_bytes <= 4 * 1024 * 1024:
|
||||
raise TransportError("TRANSPORT_RESPONSE_LIMIT_INVALID")
|
||||
self.base_url = f"http://{parsed.hostname}:{parsed_port}"
|
||||
self.token = token
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def _decode(self, response) -> Any:
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
|
||||
if content_type != "application/json":
|
||||
raise TransportError("TRANSPORT_RESPONSE_TYPE_INVALID")
|
||||
declared = response.headers.get("Content-Length")
|
||||
if declared is None or not declared.isdigit() or int(declared) > self.max_response_bytes:
|
||||
raise TransportError("TRANSPORT_RESPONSE_TOO_LARGE")
|
||||
raw = response.read(self.max_response_bytes + 1)
|
||||
if len(raw) > self.max_response_bytes or len(raw) != int(declared):
|
||||
raise TransportError("TRANSPORT_RESPONSE_SIZE_INVALID")
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate")
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
return json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("non-finite")),
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_RESPONSE_JSON_INVALID") from exc
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> Any:
|
||||
if not isinstance(path, str) or not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise TransportError("TRANSPORT_PATH_INVALID")
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.token,
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Deadline": str(time.time() + self.timeout_seconds),
|
||||
}
|
||||
if method == "POST":
|
||||
if not isinstance(body, Mapping):
|
||||
raise TransportError("TRANSPORT_BODY_INVALID")
|
||||
try:
|
||||
data = json.dumps(
|
||||
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_BODY_INVALID") from exc
|
||||
if not data or len(data) > MAX_REQUEST_BYTES:
|
||||
raise TransportError("TRANSPORT_BODY_TOO_LARGE")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self._opener.open(request, timeout=self.timeout_seconds) as response:
|
||||
return self._decode(response)
|
||||
except HTTPError as exc:
|
||||
if 300 <= exc.code < 400:
|
||||
exc.close()
|
||||
raise TransportError("TRANSPORT_REDIRECT_REJECTED", exc.code) from None
|
||||
try:
|
||||
payload = self._decode(exc)
|
||||
except TransportError:
|
||||
payload = None
|
||||
except (TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
finally:
|
||||
exc.close()
|
||||
code = None
|
||||
if isinstance(payload, Mapping) and isinstance(payload.get("error"), Mapping):
|
||||
code = payload["error"].get("code")
|
||||
if not isinstance(code, str) or _IDENTIFIER.fullmatch(code) is None:
|
||||
code = "TRANSPORT_HTTP_ERROR"
|
||||
raise TransportError(code, exc.code) from None
|
||||
except (URLError, TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
|
||||
def submit_goal(self, request: Mapping[str, Any], *, idempotency_key: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/goals/submit",
|
||||
{"request": request, "idempotency_key": idempotency_key},
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
return self._request("GET", "/v1/tasks/" + quote(task_id, safe=""))
|
||||
|
||||
def cancel_task(self, task_id: str) -> dict:
|
||||
return self._request("POST", "/v1/tasks/" + quote(task_id, safe="") + "/cancel", {})
|
||||
|
||||
def explain(self, mission_id: str) -> dict:
|
||||
return self._request("GET", "/v1/missions/" + quote(mission_id, safe="") + "/explain")
|
||||
|
||||
def register_worker(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/register", {})
|
||||
|
||||
def heartbeat(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/heartbeat", {})
|
||||
|
||||
def claim_next(self) -> dict | None:
|
||||
return self._request("POST", "/v1/workers/claim", {})
|
||||
|
||||
def renew(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/renew", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
def execute_claim(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/execute", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
# Goal-oriented names used by thin clients.
|
||||
submit = submit_goal
|
||||
query = get_task
|
||||
cancel = cancel_task
|
||||
|
||||
|
||||
Transport = HttpTransport
|
||||
|
||||
__all__ = ["HttpTransport", "Transport", "TransportError", "serve"]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Verification-only Ed25519 boundaries for release and acceptance evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .lineage import VerifiedAcceptanceEvidence, canonical_artifact
|
||||
from .release import VerifiedReceipt
|
||||
|
||||
|
||||
RELEASE_RECEIPT_DOMAIN = b"aios.release-operation-receipt.v1"
|
||||
ACCEPTANCE_EVIDENCE_DOMAIN = b"aios.release-acceptance-evidence.v1"
|
||||
|
||||
_ENVELOPE_FIELDS = {"schema", "key_id", "payload", "signature"}
|
||||
_RECEIPT_FIELDS = {
|
||||
"receipt_id", "tenant_id", "subject_id", "component_id", "task_id",
|
||||
"source_task_id", "source_effect_id", "grant_id", "job_id", "effect_id", "operation_id",
|
||||
"deployment_id", "release_id", "artifact", "outcome", "observed", "observed_at",
|
||||
}
|
||||
_ACCEPTANCE_FIELDS = {
|
||||
"acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id",
|
||||
"artifact", "accepted", "evidence_id", "observed", "observed_at",
|
||||
}
|
||||
_B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
class SignatureVerificationError(ValueError):
|
||||
"""A configured trust root did not authenticate an exact evidence payload."""
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SignatureVerificationError("signed payload is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _decode_urlsafe(value: Any, name: str, expected_size: int) -> bytes:
|
||||
if not isinstance(value, str) or not value or not _B64URL.fullmatch(value) or "=" in value:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding")
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding") from exc
|
||||
if len(raw) != expected_size:
|
||||
raise SignatureVerificationError(f"invalid {name} size")
|
||||
return raw
|
||||
|
||||
|
||||
def _public_key(value: Any) -> Ed25519PublicKey:
|
||||
if isinstance(value, Ed25519PublicKey):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
elif isinstance(value, str):
|
||||
raw = _decode_urlsafe(value, "trusted public key", 32)
|
||||
else:
|
||||
raise ValueError("trusted public key must be Ed25519PublicKey, raw bytes, or base64url")
|
||||
if len(raw) != 32:
|
||||
raise ValueError("trusted Ed25519 public key must be 32 bytes")
|
||||
return Ed25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
def _trusted_keys(values: Mapping[str, Any]) -> Mapping[str, Ed25519PublicKey]:
|
||||
if not isinstance(values, Mapping) or not values:
|
||||
raise ValueError("trusted_public_keys must be a non-empty mapping")
|
||||
keys: dict[str, Ed25519PublicKey] = {}
|
||||
for key_id, value in values.items():
|
||||
if not isinstance(key_id, str) or not key_id or len(key_id) > 128:
|
||||
raise ValueError("invalid trusted key_id")
|
||||
keys[key_id] = _public_key(value)
|
||||
return MappingProxyType(keys)
|
||||
|
||||
|
||||
def _verified_payload(
|
||||
envelope: Any,
|
||||
keys: Mapping[str, Ed25519PublicKey],
|
||||
*,
|
||||
schema: str,
|
||||
domain: bytes,
|
||||
payload_fields: set[str],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(envelope, Mapping) or set(envelope) != _ENVELOPE_FIELDS:
|
||||
raise SignatureVerificationError("signed envelope fields invalid")
|
||||
if envelope.get("schema") != schema:
|
||||
raise SignatureVerificationError("signed envelope schema mismatch")
|
||||
key_id = envelope.get("key_id")
|
||||
if not isinstance(key_id, str) or key_id not in keys:
|
||||
raise SignatureVerificationError("untrusted signing key_id")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != payload_fields:
|
||||
raise SignatureVerificationError("signed payload fields invalid")
|
||||
signature = _decode_urlsafe(envelope.get("signature"), "signature", 64)
|
||||
try:
|
||||
keys[key_id].verify(signature, domain + b"\x00" + _canonical(dict(payload)))
|
||||
except InvalidSignature as exc:
|
||||
raise SignatureVerificationError("Ed25519 signature invalid") from exc
|
||||
return payload
|
||||
|
||||
|
||||
def _text(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or not value or len(value) > 512:
|
||||
raise SignatureVerificationError(f"signed {name} invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _expected(payload: Mapping[str, Any], expected: Mapping[str, Any], fields: tuple[str, ...]) -> None:
|
||||
for name in fields:
|
||||
if name not in expected:
|
||||
raise SignatureVerificationError(f"verifier expected {name} missing")
|
||||
actual = canonical_artifact(payload[name]) if name == "artifact" else payload[name]
|
||||
wanted = canonical_artifact(expected[name]) if name == "artifact" else expected[name]
|
||||
if actual != wanted:
|
||||
raise SignatureVerificationError(f"signed {name} binding mismatch")
|
||||
|
||||
|
||||
def ed25519_release_receipt_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedReceipt]:
|
||||
"""Build a receipt verifier from local/configured public keys only."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedReceipt:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-operation-receipt-envelope.v1",
|
||||
domain=RELEASE_RECEIPT_DOMAIN,
|
||||
payload_fields=_RECEIPT_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("tenant_id", "subject_id", "component_id", "task_id", "source_task_id", "source_effect_id",
|
||||
"grant_id", "job_id", "effect_id", "operation_id", "deployment_id", "release_id", "artifact"),
|
||||
)
|
||||
if payload.get("outcome") != "SUCCEEDED":
|
||||
raise SignatureVerificationError("signed receipt outcome is not SUCCEEDED")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed receipt observation invalid")
|
||||
return VerifiedReceipt(
|
||||
receipt_id=_text(payload, "receipt_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), effect_id=_text(payload, "effect_id"),
|
||||
component_id=_text(payload,"component_id"), task_id=_text(payload,"task_id"),
|
||||
source_task_id=_text(payload,"source_task_id"), source_effect_id=_text(payload,"source_effect_id"),
|
||||
grant_id=_text(payload,"grant_id"), job_id=_text(payload,"job_id"),
|
||||
operation_id=_text(payload, "operation_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
observed=dict(payload["observed"]), outcome="SUCCEEDED",
|
||||
observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
def ed25519_acceptance_evidence_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence]:
|
||||
"""Build an acceptance verifier in a domain separate from stage receipts."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedAcceptanceEvidence:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-acceptance-evidence-envelope.v1",
|
||||
domain=ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
payload_fields=_ACCEPTANCE_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id", "artifact", "accepted"),
|
||||
)
|
||||
if type(payload.get("accepted")) is not bool:
|
||||
raise SignatureVerificationError("signed accepted decision invalid")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed acceptance observation invalid")
|
||||
return VerifiedAcceptanceEvidence(
|
||||
acceptance_id=_text(payload, "acceptance_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
component_id=_text(payload,"component_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
accepted=payload["accepted"], evidence_id=_text(payload, "evidence_id"),
|
||||
observed=dict(payload["observed"]), observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTANCE_EVIDENCE_DOMAIN", "RELEASE_RECEIPT_DOMAIN",
|
||||
"SignatureVerificationError", "ed25519_acceptance_evidence_verifier",
|
||||
"ed25519_release_receipt_verifier",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Thin resident worker loop over the authenticated HTTP transport.
|
||||
|
||||
Worker policy, operation handlers, effects, receipts, and completion remain in
|
||||
the trusted server. This process only maintains its registered transport
|
||||
identity and asks the service to run an offered claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .transport import TransportError
|
||||
|
||||
|
||||
class WorkerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OperationWorker:
|
||||
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
||||
|
||||
def __init__(self, transport: Any, poll_seconds: float = 1.0, stop_event=None):
|
||||
required = (
|
||||
"register_worker", "heartbeat", "claim_next", "renew", "execute_claim"
|
||||
)
|
||||
if transport is None or any(not callable(getattr(transport, name, None)) for name in required):
|
||||
raise WorkerError("WORKER_TRANSPORT_INVALID")
|
||||
if (
|
||||
isinstance(poll_seconds, bool)
|
||||
or not isinstance(poll_seconds, (int, float))
|
||||
or not math.isfinite(float(poll_seconds))
|
||||
or not 0.001 <= poll_seconds <= 60
|
||||
):
|
||||
raise WorkerError("WORKER_POLL_INTERVAL_INVALID")
|
||||
if stop_event is not None and (
|
||||
not callable(getattr(stop_event, "is_set", None))
|
||||
or not callable(getattr(stop_event, "wait", None))
|
||||
):
|
||||
raise WorkerError("WORKER_STOP_EVENT_INVALID")
|
||||
self.transport = transport
|
||||
self.poll_seconds = float(poll_seconds)
|
||||
self.stop_event = stop_event if stop_event is not None else threading.Event()
|
||||
self.registered = False
|
||||
self.worker_id: str | None = None
|
||||
self._last_heartbeat = 0.0
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _register(self) -> None:
|
||||
result = self.transport.register_worker()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("registered") is not True
|
||||
or not isinstance(result.get("worker_id"), str) or not result["worker_id"]
|
||||
):
|
||||
raise WorkerError("WORKER_REGISTRATION_RESULT_INVALID")
|
||||
self.worker_id = result["worker_id"]
|
||||
self.registered = True
|
||||
|
||||
def _heartbeat(self, *, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and now < self._last_heartbeat + self.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
result = self.transport.heartbeat()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("alive") is not True
|
||||
or result.get("worker_id") != self.worker_id
|
||||
):
|
||||
raise WorkerError("WORKER_HEARTBEAT_RESULT_INVALID")
|
||||
self._last_heartbeat = time.monotonic()
|
||||
|
||||
def run_once(self):
|
||||
"""Perform one register/heartbeat/claim/renew/execute iteration."""
|
||||
if self.stop_event.is_set():
|
||||
return None
|
||||
just_registered = False
|
||||
if not self.registered:
|
||||
self._register()
|
||||
just_registered = True
|
||||
self._heartbeat(force=just_registered)
|
||||
claim = self.transport.claim_next()
|
||||
if claim is None:
|
||||
self.last_error = None
|
||||
return None
|
||||
required = {"task_id", "claim_id", "generation", "lease_until", "operation"}
|
||||
if not isinstance(claim, dict) or set(claim) != required:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
task_id = claim["task_id"]
|
||||
claim_id = claim["claim_id"]
|
||||
if not isinstance(task_id, str) or not task_id or not isinstance(claim_id, str) or not claim_id:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
renewed = self.transport.renew(task_id, claim_id)
|
||||
if (
|
||||
not isinstance(renewed, dict) or renewed.get("renewed") is not True
|
||||
or renewed.get("task_id") != task_id or renewed.get("claim_id") != claim_id
|
||||
):
|
||||
raise WorkerError("WORKER_RENEW_RESULT_INVALID")
|
||||
result = self.transport.execute_claim(task_id, claim_id)
|
||||
if not isinstance(result, dict) or result.get("task_id") != task_id:
|
||||
raise WorkerError("WORKER_EXECUTION_RESULT_INVALID")
|
||||
self.last_error = None
|
||||
return result
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Run until the supplied stop event is set, using interruptible waits."""
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
self.run_once()
|
||||
except TransportError as exc:
|
||||
# Transport failures are observable and retried on the next
|
||||
# bounded poll. Re-register after a server restart; programming
|
||||
# and schema errors still terminate.
|
||||
self.last_error = exc.code
|
||||
self.registered = False
|
||||
self.stop_event.wait(self.poll_seconds)
|
||||
|
||||
|
||||
Worker = OperationWorker
|
||||
|
||||
__all__ = ["OperationWorker", "Worker", "WorkerError"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Registered worker eligibility; fallback is deployment policy, never a special actor."""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerProfile:
|
||||
worker_id: str
|
||||
identity: str
|
||||
tenant_scope: tuple[str, ...]
|
||||
subject_scope: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
priority: int
|
||||
fallback_executor: bool
|
||||
heartbeat_seconds: int
|
||||
max_concurrency: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping):
|
||||
if set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError('WORKER_PROFILE_FIELDS_INVALID')
|
||||
data = dict(value)
|
||||
for name in ('worker_id', 'identity'):
|
||||
if not isinstance(data[name], str) or not data[name] or len(data[name]) > 128:
|
||||
raise ValueError('WORKER_ID_INVALID')
|
||||
for name in ('tenant_scope', 'subject_scope', 'capabilities'):
|
||||
items = data[name]
|
||||
if not isinstance(items, (list, tuple)) or not items or any(not isinstance(x, str) or not x or '*' in x for x in items):
|
||||
raise ValueError('WORKER_SCOPE_INVALID')
|
||||
data[name] = tuple(sorted(set(items)))
|
||||
if type(data['fallback_executor']) is not bool:
|
||||
raise ValueError('WORKER_FALLBACK_INVALID')
|
||||
for name, lower, upper in (('priority', 0, 1000), ('heartbeat_seconds', 1, 300), ('max_concurrency', 1, 32)):
|
||||
if type(data[name]) is not int or not lower <= data[name] <= upper:
|
||||
raise ValueError('WORKER_LIMIT_INVALID')
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def eligible(worker: WorkerProfile, task: Mapping, *, now: float, last_heartbeat: float,
|
||||
fallback_seconds: int = 600) -> bool:
|
||||
ready_at = task.get('ready_at')
|
||||
if any(type(value) not in (int,float) or not math.isfinite(value) for value in (ready_at,now,last_heartbeat)) or ready_at > now:
|
||||
return False
|
||||
if last_heartbeat > now or now > last_heartbeat + worker.heartbeat_seconds * 3:
|
||||
return False
|
||||
if task.get('tenant_id') not in worker.tenant_scope or task.get('subject_id') not in worker.subject_scope:
|
||||
return False
|
||||
if task.get('operation') not in worker.capabilities:
|
||||
return False
|
||||
return not worker.fallback_executor or now >= ready_at + fallback_seconds
|
||||
@@ -24,7 +24,13 @@ from gitea_tag_publication import (
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
try:
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
except ModuleNotFoundError:
|
||||
# The deployed service normally supplies runtime/agent-os on sys.path.
|
||||
# Keep direct, isolated contract execution bound to the repository copy.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "runtime" / "agent-os"))
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
@@ -64,6 +70,11 @@ except ModuleNotFoundError:
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
try:
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -109,6 +120,8 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/repository/tree', '/api/repository/search',
|
||||
'/api/repository/diff-index', '/api/repository/file',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -342,6 +355,77 @@ def required_authoritative_git_dir() -> Path:
|
||||
return Path(str(snapshot["authoritative_git_dir"]))
|
||||
|
||||
|
||||
_DISCOVERY_ACTIONS = {
|
||||
'/api/repository/tree': (
|
||||
'list_repository_tree',
|
||||
{'repository', 'ref', 'limit'},
|
||||
{'prefix', 'depth', 'cursor'},
|
||||
),
|
||||
'/api/repository/search': (
|
||||
'search_repository_source',
|
||||
{'repository', 'ref', 'query', 'max_matches', 'max_bytes'},
|
||||
{'path_globs', 'cursor'},
|
||||
),
|
||||
'/api/repository/diff-index': (
|
||||
'get_diff_index',
|
||||
{'repository', 'from', 'to', 'limit'},
|
||||
{'path_prefix', 'cursor'},
|
||||
),
|
||||
'/api/repository/file': (
|
||||
'read_repository_file',
|
||||
{'repository', 'ref', 'path'},
|
||||
{'max_bytes'},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def repository_discovery_request(
|
||||
path: str, query: dict[str, list[str]]
|
||||
) -> dict[str, Any]:
|
||||
operation, required, optional = _DISCOVERY_ACTIONS[path]
|
||||
if set(query) - required - optional:
|
||||
raise ValueError('unknown repository discovery query field')
|
||||
request: dict[str, Any] = {}
|
||||
for key, values in query.items():
|
||||
if key == 'path_globs':
|
||||
if not values:
|
||||
raise ValueError('empty repository discovery path glob')
|
||||
request[key] = list(values)
|
||||
continue
|
||||
if len(values) != 1:
|
||||
raise ValueError('duplicate repository discovery query field')
|
||||
value: Any = values[0]
|
||||
if key in {'depth', 'limit', 'max_matches', 'max_bytes'}:
|
||||
try:
|
||||
value = int(value, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('invalid repository discovery integer') from exc
|
||||
request[key] = value
|
||||
if required - set(request):
|
||||
raise ValueError('missing repository discovery query field')
|
||||
if not REPOSITORY_IDENTITY:
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE')
|
||||
try:
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY_IDENTITY,),
|
||||
git_dir=required_authoritative_git_dir(),
|
||||
cursor_secret=os.getenv('AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET'),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if isinstance(exc, RepositoryDiscoveryError):
|
||||
raise
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE') from exc
|
||||
public_to_internal = {
|
||||
'ref': 'revision',
|
||||
'path': 'file_path',
|
||||
'path_prefix': 'prefix',
|
||||
}
|
||||
internal_request = {
|
||||
public_to_internal.get(key, key): value for key, value in request.items()
|
||||
}
|
||||
return dict(getattr(discovery, operation)(internal_request))
|
||||
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
publisher = TagPublisher(
|
||||
@@ -441,6 +525,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error': 'method_not_allowed'},405)
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
@@ -456,12 +542,21 @@ class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if len(self.path) > 8192:
|
||||
return self.sendj({'error':'query_too_large'},413)
|
||||
try:
|
||||
q=urllib.parse.parse_qs(
|
||||
u.query, keep_blank_values=True, max_num_fields=64
|
||||
)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'query_invalid'},400)
|
||||
try:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj(repository_discovery_request(path, q))
|
||||
if path in ('/api/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
@@ -648,6 +743,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except FileNotFoundError:
|
||||
return self.sendj({'error':'release candidate not found'},404)
|
||||
except RepositoryDiscoveryError as e:
|
||||
return self.sendj({'error': redact(e)}, getattr(e, 'status_code', 400))
|
||||
except ValueError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
@@ -664,6 +761,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error':'method_not_allowed'},405)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
@@ -960,7 +1059,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
|
||||
@@ -33,6 +33,10 @@ BRIDGE_OPERATIONS = (
|
||||
"getReleaseConsistency",
|
||||
"runReleaseAcceptance",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
)
|
||||
|
||||
TASK_ADMISSION_OPERATIONS = tuple(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS)
|
||||
@@ -55,8 +59,31 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
**TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
"listRepositoryTree": ("repository", "ref", "limit"),
|
||||
"searchRepositorySource": ("repository", "ref", "query", "max_matches", "max_bytes"),
|
||||
"getDiffIndex": ("repository", "from", "to", "limit"),
|
||||
"readRepositoryFile": ("repository", "ref", "path"),
|
||||
}
|
||||
|
||||
OPERATION_OPTIONAL_FIELDS = {
|
||||
"listRepositoryTree": ("prefix", "depth", "cursor"),
|
||||
"searchRepositorySource": ("path_globs", "cursor"),
|
||||
"getDiffIndex": ("path_prefix", "cursor"),
|
||||
"readRepositoryFile": ("max_bytes",),
|
||||
}
|
||||
|
||||
READ_ONLY_OPERATIONS = frozenset(
|
||||
{
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
}
|
||||
)
|
||||
|
||||
_OPERATION_PATHS = {
|
||||
"createGovernedTask": "/v2/governed-task",
|
||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
||||
@@ -70,6 +97,10 @@ _OPERATION_PATHS = {
|
||||
"getReleaseConsistency": "/v2/release/consistency",
|
||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
||||
**{operation: path for path, operation in TASK_ADMISSION_ROUTES.items()},
|
||||
"listRepositoryTree": "/v2/repository/tree",
|
||||
"searchRepositorySource": "/v2/repository/search",
|
||||
"getDiffIndex": "/v2/repository/diff-index",
|
||||
"readRepositoryFile": "/v2/repository/file",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,9 +118,11 @@ def operation_contracts(
|
||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
||||
contracts = {
|
||||
operation: {
|
||||
"method": "POST",
|
||||
"method": "GET" if operation in OPERATION_OPTIONAL_FIELDS else "POST",
|
||||
"path": _OPERATION_PATHS.get(operation),
|
||||
"required_fields": list(fields),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS.get(operation, ())),
|
||||
"read_only": operation in READ_ONLY_OPERATIONS,
|
||||
}
|
||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
||||
if operation not in TASK_ADMISSION_OPERATIONS
|
||||
@@ -124,7 +157,7 @@ def render_openapi_contract_fragment() -> str:
|
||||
" operationId: commitFilesGoverned\n"
|
||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
||||
" security: [{taskPrincipalAuth: []}]\n"
|
||||
" x-aios-task-principal: Bridge verifies a fresh aios.task-principal-assertion.v1 and exact task/job/repository/branch/generation/revision/attempt/execution-digest/capability binding; caller JSON cannot provide a principal and missing verification is denied.\n"
|
||||
" x-aios-task-principal: Bridge resolves a fresh server-side TaskPrincipal from the authoritative admitted+claimed Task and re-verifies its Owner epoch, then checks exact task/job/repository/branch/generation/capability binding; caller JSON cannot provide a principal and missing verification is denied. An explicitly injected legacy bearer verifier may still be used by a trusted host.\n"
|
||||
" requestBody:\n"
|
||||
" required: true\n"
|
||||
" content:\n"
|
||||
@@ -281,6 +314,10 @@ class BridgeV2Adapter:
|
||||
required = OPERATION_REQUIRED_FIELDS[operation]
|
||||
if any(key not in request for key in required):
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_MISSING")
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed = set(required) | set(OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if set(request) - allowed:
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_UNKNOWN")
|
||||
handler = self.handlers.get(operation)
|
||||
if handler is None:
|
||||
raise BridgeV2Error("GOVERNED_HANDLER_UNAVAILABLE")
|
||||
@@ -304,5 +341,9 @@ class BridgeV2Adapter:
|
||||
def getTaskAdmissionEnvelope(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionEnvelope", request)
|
||||
def submitTaskAdmissionAuthorization(self, request: Mapping[str, Any]) -> Any: return self._call("submitTaskAdmissionAuthorization", request)
|
||||
def getTaskAdmissionStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionStatus", request)
|
||||
def listRepositoryTree(self, request: Mapping[str, Any]) -> Any: return self._call("listRepositoryTree", request)
|
||||
def searchRepositorySource(self, request: Mapping[str, Any]) -> Any: return self._call("searchRepositorySource", request)
|
||||
def getDiffIndex(self, request: Mapping[str, Any]) -> Any: return self._call("getDiffIndex", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
|
||||
def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {})
|
||||
|
||||
@@ -11,6 +11,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
@@ -18,11 +19,13 @@ from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import (
|
||||
BridgeV2Adapter,
|
||||
BridgeV2Error,
|
||||
OPERATION_OPTIONAL_FIELDS,
|
||||
OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_OPERATIONS,
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS,
|
||||
TASK_ADMISSION_ROUTES,
|
||||
)
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from gitea_tag_publication import validate_gitea_api_url
|
||||
from aios_continuation_executor import AuthorityGitAdapter, ContinuationCommit, ContinuationExecutor, ContinuationPermissions, ContinuationRuntimeBinding, DetachedGitWorkspace, GiteaContinuationRemote, ServerSideCredentialProjection
|
||||
@@ -35,6 +38,7 @@ from aios_governed_task_bootstrap import (
|
||||
GovernedTaskBootstrap,
|
||||
)
|
||||
from governed_production_release import build_executor_config
|
||||
from aios_baseline.runtime_contract import evaluate_deployment
|
||||
|
||||
MAX_BODY = 2 * 1024 * 1024
|
||||
TaskPrincipalVerifier = Callable[
|
||||
@@ -59,7 +63,13 @@ ROUTES = {
|
||||
"/v2/release/execute": "executeApprovedProductionRelease",
|
||||
"/v2/release/consistency": "getReleaseConsistency",
|
||||
"/v2/release/acceptance": "runReleaseAcceptance",
|
||||
**TASK_ADMISSION_ROUTES,
|
||||
"/v2/task-admission/envelope": "getTaskAdmissionEnvelope",
|
||||
"/v2/task-admission/authorize": "submitTaskAdmissionAuthorization",
|
||||
"/v2/task-admission/status": "getTaskAdmissionStatus",
|
||||
"/v2/repository/tree": "listRepositoryTree",
|
||||
"/v2/repository/search": "searchRepositorySource",
|
||||
"/v2/repository/diff-index": "getDiffIndex",
|
||||
"/v2/repository/file": "readRepositoryFile",
|
||||
}
|
||||
|
||||
# Admission is deliberately separate from a TaskPrincipal. A successful
|
||||
@@ -93,12 +103,84 @@ class RuntimeConfig:
|
||||
baseline_path: Path = Path("/var/lib/aios-gitops-runtime-anchor/baseline/current.json")
|
||||
health_url: str = "http://127.0.0.1:18927/health"
|
||||
release_plan_path: Path | None = None
|
||||
repository_discovery_cursor_secret: str | bytes | None = None
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "runtime"
|
||||
|
||||
|
||||
def _unknown_production() -> dict[str, Any]:
|
||||
return {
|
||||
"baseline_version": None,
|
||||
"target_oid": None,
|
||||
"deployment_state": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def load_production_observation(
|
||||
path: Path,
|
||||
now: datetime | str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Project production fields only from a fresh validated observation bundle."""
|
||||
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_mode & 0o022
|
||||
or metadata.st_size < 2
|
||||
or metadata.st_size > 1024 * 1024
|
||||
):
|
||||
return _unknown_production()
|
||||
|
||||
def strict_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("PRODUCTION_OBSERVATION_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
bundle = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=strict_object)
|
||||
except (OSError, UnicodeError, ValueError, TypeError):
|
||||
return _unknown_production()
|
||||
if not isinstance(bundle, dict) or set(bundle) != {
|
||||
"contract",
|
||||
"target",
|
||||
"observation",
|
||||
"provenance",
|
||||
}:
|
||||
return _unknown_production()
|
||||
try:
|
||||
result = evaluate_deployment(
|
||||
bundle["contract"],
|
||||
bundle["target"],
|
||||
bundle["observation"],
|
||||
bundle["provenance"],
|
||||
now or datetime.now(timezone.utc),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return _unknown_production()
|
||||
target = bundle["target"]
|
||||
release_id = target.get("release_id") if isinstance(target, Mapping) else None
|
||||
target_oid = target.get("target_oid") if isinstance(target, Mapping) else None
|
||||
if (
|
||||
result.get("status") != "VERIFIED"
|
||||
or not isinstance(release_id, str)
|
||||
or not release_id.strip()
|
||||
or not isinstance(target_oid, str)
|
||||
or re.fullmatch(r"[0-9a-f]{40}", target_oid) is None
|
||||
):
|
||||
return _unknown_production()
|
||||
return {
|
||||
"baseline_version": release_id,
|
||||
"target_oid": target_oid,
|
||||
"deployment_state": "ACTIVE",
|
||||
}
|
||||
|
||||
|
||||
class GovernedRuntime:
|
||||
def __init__(self, config: RuntimeConfig) -> None:
|
||||
self.config = config
|
||||
@@ -109,6 +191,7 @@ class GovernedRuntime:
|
||||
self.controllers: dict[str, GitOpsJobController] = {}
|
||||
self.bindings: dict[str, dict[str, Any]] = {}
|
||||
self.release = build_executor_config(config)
|
||||
self._repository_discovery_instance: RepositoryDiscovery | None = None
|
||||
metadata_root = config.state_root / "task-bindings"
|
||||
if metadata_root.is_dir():
|
||||
for metadata in metadata_root.glob("*.json"):
|
||||
@@ -324,6 +407,32 @@ class GovernedRuntime:
|
||||
)
|
||||
result = self.bootstrap.create(request); self.bindings[result["task_id"]] = result; return result
|
||||
|
||||
def _repository_discovery(self) -> RepositoryDiscovery:
|
||||
if self._repository_discovery_instance is None:
|
||||
self._repository_discovery_instance = RepositoryDiscovery(
|
||||
allowed_repositories=(self.config.repository,),
|
||||
git_dir=self.config.authoritative_git_dir,
|
||||
cursor_secret=(
|
||||
self.config.repository_discovery_cursor_secret
|
||||
if self.config.repository_discovery_cursor_secret is not None
|
||||
else os.environ.get("AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET")
|
||||
),
|
||||
)
|
||||
return self._repository_discovery_instance
|
||||
|
||||
def repository_discovery(
|
||||
self, operation: str, request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
method = {
|
||||
"listRepositoryTree": "list_repository_tree",
|
||||
"searchRepositorySource": "search_repository_source",
|
||||
"getDiffIndex": "get_diff_index",
|
||||
"readRepositoryFile": "read_repository_file",
|
||||
}.get(operation)
|
||||
if method is None:
|
||||
raise ValueError("REPOSITORY_DISCOVERY_OPERATION_INVALID")
|
||||
return getattr(self._repository_discovery(), method)(request)
|
||||
|
||||
def acquire_lease(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
replay = self._durable_completed_replay_lease(request)
|
||||
if replay is not None:
|
||||
@@ -882,7 +991,10 @@ class GovernedRuntime:
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
task = self._snapshot_task()
|
||||
runtime = self.runtime_status()
|
||||
return build_control_plane_snapshot(target_version=self.config.target_version, runtime=runtime, repository={"identity": self.config.repository, "authority_git_dir": str(self.config.authoritative_git_dir), "authority_main_oid": self.authority.read_ref("refs/heads/main")}, production={"baseline_version": "V2R1C7", "target_oid": "c7adece1cc8beffe7c718d53c775898b45d6c7f8", "deployment_state": "ACTIVE"}, task=task, capabilities={"legacy_writes_enabled": False, "governed_writes_enabled": runtime["governed_writes_enabled"], "force_push": False, "main_write": False, "tag_write": False, "baseline_write": False, "production_write": False}, credential_broker={"status": "SERVER_SIDE_ONLY", "provider": "FileCredentialProvider"})
|
||||
production = load_production_observation(
|
||||
self.config.state_root / "production-observation.json"
|
||||
)
|
||||
return build_control_plane_snapshot(target_version=self.config.target_version, runtime=runtime, repository={"identity": self.config.repository, "authority_git_dir": str(self.config.authoritative_git_dir), "authority_main_oid": self.authority.read_ref("refs/heads/main")}, production=production, task=task, capabilities={"legacy_writes_enabled": False, "governed_writes_enabled": runtime["governed_writes_enabled"], "force_push": False, "main_write": False, "tag_write": False, "baseline_write": False, "production_write": False}, credential_broker={"status": "SERVER_SIDE_ONLY", "provider": "FileCredentialProvider"})
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -895,6 +1007,18 @@ class GovernedRuntime:
|
||||
"resumeGitOpsJob": lambda request: self.job(request, "resume"),
|
||||
"reconcileGitOpsJob": lambda request: self.job(request, "reconcile"),
|
||||
"getControlPlaneSnapshot": lambda request: self.snapshot(),
|
||||
"listRepositoryTree": lambda request: self.repository_discovery(
|
||||
"listRepositoryTree", request
|
||||
),
|
||||
"searchRepositorySource": lambda request: self.repository_discovery(
|
||||
"searchRepositorySource", request
|
||||
),
|
||||
"getDiffIndex": lambda request: self.repository_discovery(
|
||||
"getDiffIndex", request
|
||||
),
|
||||
"readRepositoryFile": lambda request: self.repository_discovery(
|
||||
"readRepositoryFile", request
|
||||
),
|
||||
"createTagApprovalRequest": self.release.approval_request,
|
||||
"requestOrCreateTag": self.release.publish_tag,
|
||||
"getTagPublicationStatus": self.release.tag_status,
|
||||
@@ -969,11 +1093,15 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
elif operation in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
||||
OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
else:
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | {"schema", "lease_seconds", "create_target_branch", "worktree_mode", "cas_ref_update_required", "expected_branch_oid", "expected_revision", "generation", "branch_mode", "source_lease_epoch"}
|
||||
if operation == "projectOnlineAgentExecution": allowed.add("execution")
|
||||
if operation == "commitFilesGoverned": allowed.add("files")
|
||||
if operation not in TASK_ADMISSION_OPERATIONS:
|
||||
if operation not in TASK_ADMISSION_OPERATIONS and operation not in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed.update({"owner_approval", "execute", "job_id", "historical_lease_head_oid"})
|
||||
if "principal" in body or _PRIVATE_PRINCIPAL in body:
|
||||
raise _HTTPError("CALLER_PRINCIPAL_FORBIDDEN")
|
||||
@@ -1024,12 +1152,47 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
raise _HTTPError("TASK_PRINCIPAL_REJECTED")
|
||||
return {**body, _PRIVATE_PRINCIPAL: dict(principal)}
|
||||
|
||||
def _query_body(self, operation: str) -> dict[str, Any]:
|
||||
parsed = urlsplit(self.path)
|
||||
if len(self.path) > 8192:
|
||||
raise _HTTPError("QUERY_TOO_LARGE")
|
||||
try:
|
||||
pairs = parse_qsl(
|
||||
parsed.query,
|
||||
keep_blank_values=True,
|
||||
strict_parsing=False,
|
||||
max_num_fields=64,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise _HTTPError("QUERY_INVALID") from exc
|
||||
allowed = set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
||||
OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key not in allowed:
|
||||
raise _HTTPError("UNKNOWN_FIELDS")
|
||||
if key == "path_globs":
|
||||
result.setdefault(key, []).append(value)
|
||||
continue
|
||||
if key in result:
|
||||
raise _HTTPError("DUPLICATE_FIELD")
|
||||
if key in {"depth", "limit", "max_matches", "max_bytes"}:
|
||||
try:
|
||||
value = int(value, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _HTTPError("INVALID_INTEGER") from exc
|
||||
result[key] = value
|
||||
if set(OPERATION_REQUIRED_FIELDS[operation]) - set(result):
|
||||
raise _HTTPError("MISSING_FIELDS")
|
||||
return result
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.adapter is None: self._json({"error": "RUNTIME_UNAVAILABLE"}, 503); return
|
||||
if self.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return
|
||||
if self.path == "/v2/capabilities": self._json(self.adapter.getGovernedWriteCapabilities()); return
|
||||
if self.path == "/v2/control-plane-snapshot": self._json(self.adapter.getControlPlaneSnapshot({})); return
|
||||
parsed = urlsplit(self.path)
|
||||
if parsed.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return
|
||||
if parsed.path == "/v2/capabilities": self._json(self.adapter.getGovernedWriteCapabilities()); return
|
||||
if parsed.path == "/v2/control-plane-snapshot": self._json(self.adapter.getControlPlaneSnapshot({})); return
|
||||
if parsed.path == "/v2/task-admission/status":
|
||||
if not callable(self.adapter.handlers.get("getTaskAdmissionStatus")):
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
@@ -1055,10 +1218,30 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
||||
except Exception as exc:
|
||||
self._json({"error": str(exc) or type(exc).__name__}, 503)
|
||||
return
|
||||
operation = ROUTES.get(parsed.path)
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
try:
|
||||
body = self._query_body(operation)
|
||||
self._allowed(operation, body)
|
||||
result = getattr(self.adapter, operation)(body)
|
||||
self._json(result, self._result_status(result))
|
||||
except _HTTPError as exc:
|
||||
status = 413 if str(exc) == "QUERY_TOO_LARGE" else 400
|
||||
self._json({"error": str(exc)}, status)
|
||||
except RepositoryDiscoveryError as exc:
|
||||
self._json({"error": exc.code}, exc.status_code)
|
||||
except (BridgeV2Error, ValueError) as exc:
|
||||
self._json({"error": str(exc)}, 400)
|
||||
except Exception as exc:
|
||||
self._json({"error": str(exc) or type(exc).__name__}, 503)
|
||||
return
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
operation = ROUTES.get(self.path)
|
||||
operation = ROUTES.get(urlsplit(self.path).path)
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
self._json({"error": "METHOD_NOT_ALLOWED"}, 405)
|
||||
return
|
||||
if operation is None or operation in ("getRuntimeStatus", "getGovernedWriteCapabilities") or self.adapter is None: self._json({"error": "NOT_FOUND"}, 404); return
|
||||
if operation in TASK_ADMISSION_OPERATIONS and not callable(self.adapter.handlers.get(operation)):
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
@@ -1096,11 +1279,39 @@ def serve(adapter: BridgeV2Adapter, host: str = "127.0.0.1", port: int = 18892)
|
||||
def main(
|
||||
*,
|
||||
task_admission_handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None,
|
||||
task_principal_verifier: TaskPrincipalVerifier | None = None,
|
||||
) -> None:
|
||||
activation_raw = os.environ.get(
|
||||
"AIOS_TASK_ADMISSION_ACTIVATION_FILE",
|
||||
"/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json",
|
||||
).strip()
|
||||
discovery_secret_file = os.environ.get(
|
||||
"AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET_FILE", ""
|
||||
).strip()
|
||||
if discovery_secret_file:
|
||||
secret_path = Path(discovery_secret_file)
|
||||
try:
|
||||
secret_metadata = secret_path.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(secret_metadata.st_mode)
|
||||
or not stat.S_ISREG(secret_metadata.st_mode)
|
||||
or secret_metadata.st_mode & 0o022
|
||||
or secret_metadata.st_size > 4096
|
||||
):
|
||||
raise RuntimeError("REPOSITORY_DISCOVERY_CURSOR_SECRET_INVALID")
|
||||
discovery_cursor_secret: str | bytes | None = secret_path.read_bytes()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
if str(exc) == "REPOSITORY_DISCOVERY_CURSOR_SECRET_INVALID":
|
||||
raise
|
||||
raise RuntimeError(
|
||||
"REPOSITORY_DISCOVERY_CURSOR_SECRET_UNAVAILABLE"
|
||||
) from exc
|
||||
if not discovery_cursor_secret:
|
||||
raise RuntimeError("REPOSITORY_DISCOVERY_CURSOR_SECRET_EMPTY")
|
||||
else:
|
||||
discovery_cursor_secret = os.environ.get(
|
||||
"AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET"
|
||||
)
|
||||
state_root = Path("/var/lib/aios-gitops/bridge-v2-staging")
|
||||
token_path = Path(os.environ.get("AIOS_GITEA_TOKEN_FILE", "/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token"))
|
||||
ssh_key_path = Path(os.environ.get("AIOS_GITEA_SSH_KEY_PATH", "/home/aiman/.ssh/id_ed25519_codex_tmp"))
|
||||
@@ -1120,29 +1331,36 @@ def main(
|
||||
"AIOS_GITOPS_BASELINE_AUTHORITY",
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
)),
|
||||
repository_discovery_cursor_secret=discovery_cursor_secret,
|
||||
)
|
||||
runtime = GovernedRuntime(config)
|
||||
# A TaskPrincipal verifier is deliberately supplied only by the hosting
|
||||
# service. Without that independently authenticated verifier, all Git
|
||||
# mutation requests fail closed while the admission-only routes remain
|
||||
# available for a configured Owner-approved epoch.
|
||||
BridgeHandler.principal_verifier = None
|
||||
# Prefer a server-side TaskPrincipal provider recovered from the same
|
||||
# verified Task-admission runtime. This removes the caller-side credential
|
||||
# bootstrap loop while keeping every mutation dependent on a currently
|
||||
# claimed Task and a freshly re-verified Owner epoch. Hosts may still
|
||||
# inject an independently authenticated verifier explicitly.
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_handlers_from_environment,
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
task_admission_handlers = build_task_admission_handlers_from_environment(
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_handlers_from_activation_file,
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
task_admission_handlers = build_task_admission_handlers_from_activation_file(
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
BridgeHandler.principal_verifier = admission_runtime.principal_verifier
|
||||
serve(
|
||||
build_bridge_adapter(
|
||||
runtime,
|
||||
|
||||
@@ -24,6 +24,25 @@ def _safe_oid(value: str, field: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _safe_branch(value: str) -> str:
|
||||
branch = str(value or "")
|
||||
if (
|
||||
not branch
|
||||
or len(branch) > 240
|
||||
or branch != branch.strip()
|
||||
or branch.startswith(("/", "-", "."))
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or "\\" in branch
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
||||
):
|
||||
raise GiteaTransportError("BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaMachineTransport:
|
||||
api_base: str
|
||||
@@ -91,6 +110,7 @@ class GiteaMachineTransport:
|
||||
return self.repository_info()
|
||||
|
||||
def read_branch(self, branch: str) -> str | None:
|
||||
branch = _safe_branch(branch)
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
try:
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||
@@ -136,6 +156,99 @@ class GiteaMachineTransport:
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
||||
|
||||
This never updates ``main`` or any caller-visible branch. The remote
|
||||
ref is read before and after the fetch so a concurrent branch move
|
||||
cannot be silently admitted.
|
||||
"""
|
||||
|
||||
branch = _safe_branch(branch)
|
||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
||||
remote_before = self.read_branch(branch)
|
||||
if remote_before != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
||||
|
||||
current = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
self.remote_url,
|
||||
f"refs/heads/{branch}:{target_ref}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
local = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
local_oid = local.stdout.strip().lower()
|
||||
if local.returncode != 0 or local_oid != expected_oid:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"update-ref",
|
||||
"-d",
|
||||
target_ref,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
_safe_oid(expected_oid, "expected_oid")
|
||||
_safe_oid(new_oid, "new_oid")
|
||||
|
||||
@@ -9,13 +9,14 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@@ -61,18 +62,52 @@ _ORDER = {
|
||||
JobState.COMPLETED: 8,
|
||||
}
|
||||
_RECOVERABLE = {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}
|
||||
_RECONCILABLE = _RECOVERABLE | {
|
||||
JobState.RECONCILING,
|
||||
JobState.EXECUTING,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING,
|
||||
JobState.REMOTE_VERIFIED,
|
||||
}
|
||||
_TERMINAL = {
|
||||
JobState.COMPLETED,
|
||||
JobState.FAILED,
|
||||
JobState.CANCELLED,
|
||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
}
|
||||
_GENERIC_TRANSITIONS = {
|
||||
JobState.REQUESTED: {JobState.AUTHORIZED, JobState.CANCELLED},
|
||||
JobState.AUTHORIZED: {JobState.LEASED, JobState.CANCELLED},
|
||||
JobState.LEASED: {JobState.WORKTREE_READY, JobState.EXECUTING, JobState.CANCELLED},
|
||||
JobState.WORKTREE_READY: {JobState.EXECUTING, JobState.CANCELLED},
|
||||
JobState.EXECUTING: {JobState.LOCAL_VERIFIED},
|
||||
JobState.LOCAL_VERIFIED: {JobState.COMPLETED},
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _utc_datetime(value: str | datetime) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise JobError("TIMESTAMP_INVALID") from exc
|
||||
else:
|
||||
raise JobError("TIMESTAMP_INVALID")
|
||||
if parsed.tzinfo is None:
|
||||
raise JobError("TIMESTAMP_TIMEZONE_REQUIRED")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _iso_utc(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||
@@ -273,6 +308,27 @@ class JobStore:
|
||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS resource_leases (
|
||||
lease_id TEXT PRIMARY KEY, job_id TEXT NOT NULL UNIQUE,
|
||||
resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL, lease_expires_at TEXT NOT NULL,
|
||||
state TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS resource_fences (
|
||||
resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL, job_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(resource_kind,resource_id),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reconciliation_claims (
|
||||
job_id TEXT PRIMARY KEY, claim_id TEXT NOT NULL UNIQUE,
|
||||
reconcile_epoch INTEGER NOT NULL, base_revision INTEGER NOT NULL,
|
||||
lease_expires_at TEXT NOT NULL, state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS completed_replay_reconciliations (
|
||||
reconciliation_id TEXT PRIMARY KEY, job_id TEXT NOT NULL,
|
||||
identity_digest TEXT NOT NULL UNIQUE,
|
||||
@@ -579,11 +635,62 @@ class JobStore:
|
||||
|
||||
|
||||
class GitOpsJobController:
|
||||
def __init__(self, store: JobStore) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: JobStore,
|
||||
*,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
max_lease_seconds: int = 300,
|
||||
reconcile_lease_seconds: int = 30,
|
||||
generic_receipt_verifier: Callable[[Mapping[str, Any]], bool] | None = None,
|
||||
) -> None:
|
||||
if max_lease_seconds < 1 or reconcile_lease_seconds < 1:
|
||||
raise JobError("LEASE_CONFIGURATION_INVALID")
|
||||
self.store = store
|
||||
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.max_lease_seconds = max_lease_seconds
|
||||
self.reconcile_lease_seconds = reconcile_lease_seconds
|
||||
self.generic_receipt_verifier = generic_receipt_verifier
|
||||
|
||||
def _now(self) -> datetime:
|
||||
return _utc_datetime(self._clock())
|
||||
|
||||
def _validated_deadline(self, value: str) -> str:
|
||||
deadline = _utc_datetime(value)
|
||||
now = self._now()
|
||||
if deadline <= now:
|
||||
raise JobError("LEASE_EXPIRED")
|
||||
if deadline > now + timedelta(seconds=self.max_lease_seconds):
|
||||
raise JobError("LEASE_DURATION_EXCEEDED")
|
||||
return _iso_utc(deadline)
|
||||
|
||||
@staticmethod
|
||||
def _binding(request: Mapping[str, Any]) -> dict[str, str]:
|
||||
if request.get("schema") == "aios.operation-job.v1":
|
||||
fields = (
|
||||
"schema", "tenant_id", "subject_id", "mission_id", "grant_id",
|
||||
"task_id", "effect_id", "operation", "operation_version",
|
||||
"resource_kind", "resource_id", "input_digest",
|
||||
)
|
||||
if any(not isinstance(request.get(field), str) or not request[field].strip() for field in fields):
|
||||
raise JobError("OPERATION_JOB_BINDING_INVALID")
|
||||
digest = str(request["input_digest"])
|
||||
if len(digest) != 64 or any(ch not in "0123456789abcdef" for ch in digest):
|
||||
raise JobError("INPUT_DIGEST_INVALID")
|
||||
binding = {field: str(request[field]).strip() for field in fields}
|
||||
expected = request.get("expected_resource_revision")
|
||||
if expected is not None:
|
||||
if isinstance(expected, bool) or not isinstance(expected, (str, int)) or not str(expected).strip():
|
||||
raise JobError("EXPECTED_RESOURCE_REVISION_INVALID")
|
||||
binding["expected_resource_revision"] = str(expected).strip()
|
||||
idempotency_key = request.get("idempotency_key")
|
||||
if idempotency_key is not None:
|
||||
if not isinstance(idempotency_key, str) or not idempotency_key.strip():
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
binding["idempotency_key"] = idempotency_key.strip()
|
||||
if any(len(value) > 512 for value in binding.values()):
|
||||
raise JobError("OPERATION_JOB_BINDING_INVALID")
|
||||
return binding
|
||||
fields = ("campaign_id", "repository", "operation", "branch", "expected_branch_oid", "payload_digest")
|
||||
if any(not isinstance(request.get(field), str) or not request[field].strip() for field in fields):
|
||||
raise JobError("JOB_BINDING_INVALID")
|
||||
@@ -634,20 +741,29 @@ class GitOpsJobController:
|
||||
|
||||
def submit(self, request: Mapping[str, Any], *, payload: bytes | None = None) -> JobReceipt:
|
||||
binding = self._binding(request)
|
||||
bound_digest = binding.get("payload_digest", binding.get("input_digest"))
|
||||
assert bound_digest is not None
|
||||
payload_ref = request.get("payload_ref")
|
||||
payload_size = request.get("payload_size")
|
||||
if payload is not None:
|
||||
payload_ref, payload_size = self.store.payloads.put(payload, binding["payload_digest"])
|
||||
if payload_ref is not None and payload_ref != f"sha256/{binding['payload_digest']}":
|
||||
payload_ref, payload_size = self.store.payloads.put(payload, bound_digest)
|
||||
if payload_ref is not None and payload_ref != f"sha256/{bound_digest}":
|
||||
raise JobError("PAYLOAD_REFERENCE_MISMATCH")
|
||||
if payload_ref is not None and (not isinstance(payload_size, int) or payload_size < 0):
|
||||
raise JobError("PAYLOAD_SIZE_INVALID")
|
||||
client_idempotency_key = binding.get("idempotency_key")
|
||||
if client_idempotency_key is not None:
|
||||
if len(client_idempotency_key) > 128 or not all(
|
||||
character.isalnum() or character in "._:-" for character in client_idempotency_key
|
||||
):
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
if client_idempotency_key is not None and (
|
||||
len(client_idempotency_key) > 128
|
||||
or not all(character.isalnum() or character in "._:-" for character in client_idempotency_key)
|
||||
):
|
||||
raise JobError("IDEMPOTENCY_KEY_INVALID")
|
||||
if binding.get("schema") == "aios.operation-job.v1":
|
||||
key = sha256_json({
|
||||
"schema": binding["schema"],
|
||||
"tenant_id": binding["tenant_id"],
|
||||
"effect_id": binding["effect_id"],
|
||||
})
|
||||
elif client_idempotency_key is not None:
|
||||
key = sha256_json({"client_idempotency_key": client_idempotency_key})
|
||||
else:
|
||||
key = sha256_json(binding)
|
||||
@@ -666,9 +782,15 @@ class GitOpsJobController:
|
||||
row = dict(existing)
|
||||
connection.commit()
|
||||
return self.store._receipt(row, duplicate=True)
|
||||
generic = binding.get("schema") == "aios.operation-job.v1"
|
||||
storage_campaign = binding["mission_id"] if generic else binding["campaign_id"]
|
||||
storage_repository = "__operation_job__" if generic else binding["repository"]
|
||||
storage_branch = "__operation_job__" if generic else binding["branch"]
|
||||
storage_oid = "0" * 40 if generic else binding["expected_branch_oid"]
|
||||
storage_digest = binding["input_digest"] if generic else binding["payload_digest"]
|
||||
connection.execute(
|
||||
"INSERT INTO jobs(job_id,idempotency_key,campaign_id,repository,operation,branch,expected_branch_oid,payload_digest,payload_ref,payload_size,state,side_effect_state,revision,request_json,receipt_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, key, binding["campaign_id"], binding["repository"], binding["operation"], binding["branch"], binding["expected_branch_oid"], binding["payload_digest"], payload_ref, payload_size, JobState.REQUESTED.value, "NONE", 0, json.dumps({"binding": binding, **durable_request}, sort_keys=True), None, now, now),
|
||||
(job_id, key, storage_campaign, storage_repository, binding["operation"], storage_branch, storage_oid, storage_digest, payload_ref, payload_size, JobState.REQUESTED.value, "NONE", 0, json.dumps({"binding": binding, **durable_request}, sort_keys=True), None, now, now),
|
||||
)
|
||||
connection.execute("INSERT INTO idempotency_keys(key,job_id,binding_json,created_at) VALUES(?,?,?,?)", (key, job_id, json.dumps(binding, sort_keys=True), now))
|
||||
connection.execute("INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)", (job_id, None, JobState.REQUESTED.value, 0, "NONE", now))
|
||||
@@ -697,8 +819,12 @@ class GitOpsJobController:
|
||||
expected_revision: int,
|
||||
) -> JobReceipt:
|
||||
"""Atomically create a branch lease and move its Job to LEASED."""
|
||||
if generation < 1 or any(len(oid) != 40 for oid in (lineage_anchor_oid, current_head_oid)):
|
||||
if isinstance(generation, bool) or generation < 1 or any(
|
||||
len(oid) != 40 or any(ch not in "0123456789abcdef" for ch in oid)
|
||||
for oid in (lineage_anchor_oid, current_head_oid)
|
||||
):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
deadline = self._validated_deadline(lease_expires_at)
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
@@ -707,10 +833,23 @@ class GitOpsJobController:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
if int(row["revision"]) != expected_revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
if JobState(row["state"]) != JobState.AUTHORIZED:
|
||||
raise JobError("LEASE_JOB_STATE_INVALID")
|
||||
if row["branch"] in {"main", "master"}:
|
||||
raise JobError("PROTECTED_BRANCH_REJECTED")
|
||||
now = utc_now()
|
||||
connection.execute("INSERT INTO branch_leases(lease_id,job_id,branch,lineage_anchor_oid,current_head_oid,generation,lease_expires_at,state,revision) VALUES(?,?,?,?,?,?,?,?,?)", (lease_id, job_id, row["branch"], lineage_anchor_oid, current_head_oid, generation, lease_expires_at, "ACTIVE", 0))
|
||||
existing_lease = connection.execute(
|
||||
"SELECT * FROM branch_leases WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if existing_lease is None:
|
||||
connection.execute("INSERT INTO branch_leases(lease_id,job_id,branch,lineage_anchor_oid,current_head_oid,generation,lease_expires_at,state,revision) VALUES(?,?,?,?,?,?,?,?,?)", (lease_id, job_id, row["branch"], lineage_anchor_oid, current_head_oid, generation, deadline, "ACTIVE", 0))
|
||||
elif existing_lease["state"] == "ACTIVE" and _utc_datetime(existing_lease["lease_expires_at"]) > self._now():
|
||||
raise JobError("LEASE_CONFLICT")
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE branch_leases SET lease_id=?,lineage_anchor_oid=?,current_head_oid=?,generation=?,lease_expires_at=?,state='ACTIVE',revision=revision+1 WHERE job_id=?",
|
||||
(lease_id, lineage_anchor_oid, current_head_oid, generation, deadline, job_id),
|
||||
)
|
||||
next_revision = expected_revision + 1
|
||||
updated = connection.execute("UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?", (JobState.LEASED.value, "LEASED", next_revision, now, job_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
@@ -734,6 +873,13 @@ class GitOpsJobController:
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute("SELECT * FROM branch_leases WHERE lease_id=?", (lease_id,)).fetchone()
|
||||
if lease is None:
|
||||
raise JobError("LEASE_NOT_FOUND")
|
||||
if lease["state"] != "ACTIVE" or _utc_datetime(lease["lease_expires_at"]) <= self._now():
|
||||
raise JobError("LEASE_EXPIRED")
|
||||
if int(lease["generation"]) != generation:
|
||||
raise JobError("LEASE_FENCE_REJECTED")
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
@@ -743,6 +889,182 @@ class GitOpsJobController:
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def lease_resource_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
lease_id: str,
|
||||
resource_kind: str,
|
||||
resource_id: str,
|
||||
generation: int | None = None,
|
||||
lease_expires_at: str,
|
||||
expected_revision: int,
|
||||
) -> JobReceipt:
|
||||
"""Lease a scoped non-Git resource without pretending it is a branch."""
|
||||
if not lease_id or not resource_kind or not resource_id or isinstance(generation, bool):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
deadline = self._validated_deadline(lease_expires_at)
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
request = json.loads(row["request_json"])
|
||||
binding = request.get("binding", request)
|
||||
if binding.get("schema") != "aios.operation-job.v1":
|
||||
raise JobError("RESOURCE_LEASE_PROFILE_REQUIRED")
|
||||
if binding.get("resource_kind") != resource_kind or binding.get("resource_id") != resource_id:
|
||||
raise JobError("RESOURCE_LEASE_BINDING_MISMATCH")
|
||||
if int(row["revision"]) != expected_revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
if JobState(row["state"]) != JobState.AUTHORIZED:
|
||||
raise JobError("LEASE_JOB_STATE_INVALID")
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM resource_leases WHERE resource_kind=? AND resource_id=? AND state='ACTIVE' AND lease_expires_at>?",
|
||||
(resource_kind, resource_id, _iso_utc(self._now())),
|
||||
).fetchone()
|
||||
if active is not None:
|
||||
raise JobError("RESOURCE_LEASE_ACTIVE")
|
||||
fence = connection.execute(
|
||||
"SELECT generation FROM resource_fences WHERE resource_kind=? AND resource_id=?",
|
||||
(resource_kind, resource_id),
|
||||
).fetchone()
|
||||
prior_generation = 0 if fence is None else int(fence["generation"])
|
||||
assigned_generation = prior_generation + 1 if generation is None else generation
|
||||
if assigned_generation <= prior_generation:
|
||||
raise JobError("LEASE_FENCE_REJECTED")
|
||||
old_job_lease = connection.execute(
|
||||
"SELECT * FROM resource_leases WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if old_job_lease is None:
|
||||
connection.execute(
|
||||
"INSERT INTO resource_leases(lease_id,job_id,resource_kind,resource_id,generation,lease_expires_at,state,revision) VALUES(?,?,?,?,?,?,?,0)",
|
||||
(lease_id, job_id, resource_kind, resource_id, assigned_generation, deadline, "ACTIVE"),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE resource_leases SET lease_id=?,resource_kind=?,resource_id=?,generation=?,lease_expires_at=?,state='ACTIVE',revision=revision+1 WHERE job_id=?",
|
||||
(lease_id, resource_kind, resource_id, assigned_generation, deadline, job_id),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO resource_fences(resource_kind,resource_id,generation,job_id,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(resource_kind,resource_id) DO UPDATE SET generation=excluded.generation,job_id=excluded.job_id,updated_at=excluded.updated_at",
|
||||
(resource_kind, resource_id, assigned_generation, job_id, utc_now()),
|
||||
)
|
||||
next_revision = expected_revision + 1
|
||||
now = utc_now()
|
||||
updated = connection.execute(
|
||||
"UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?",
|
||||
(JobState.LEASED.value, "LEASED", next_revision, now, job_id, expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
connection.execute(
|
||||
"INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(job_id, row["state"], JobState.LEASED.value, next_revision, "LEASED", now),
|
||||
)
|
||||
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise JobError("LEASE_CONFLICT") from exc
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
result["request"] = json.loads(result["request_json"])
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
def assert_execution_lease(
|
||||
self, job_id: str, *, generation: int, lease_id: str | None = None
|
||||
) -> Mapping[str, Any]:
|
||||
"""Fence a receiver-side effect by the active lease and generation."""
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
job = connection.execute("SELECT request_json,state FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if job is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
if JobState(job["state"]) not in {
|
||||
JobState.LEASED, JobState.WORKTREE_READY, JobState.EXECUTING,
|
||||
JobState.LOCAL_VERIFIED, JobState.REMOTE_SYNCING,
|
||||
}:
|
||||
raise JobError("LEASE_JOB_STATE_INVALID")
|
||||
binding = json.loads(job["request_json"]).get("binding", {})
|
||||
table = "resource_leases" if binding.get("schema") == "aios.operation-job.v1" else "branch_leases"
|
||||
lease = connection.execute(f"SELECT * FROM {table} WHERE job_id=?", (job_id,)).fetchone()
|
||||
if lease is None:
|
||||
raise JobError("LEASE_NOT_FOUND")
|
||||
if (
|
||||
lease["state"] != "ACTIVE"
|
||||
or _utc_datetime(lease["lease_expires_at"]) <= self._now()
|
||||
or int(lease["generation"]) != generation
|
||||
or (lease_id is not None and lease["lease_id"] != lease_id)
|
||||
):
|
||||
raise JobError("LEASE_FENCE_REJECTED")
|
||||
if table == "resource_leases":
|
||||
fence = connection.execute(
|
||||
"SELECT generation,job_id FROM resource_fences WHERE resource_kind=? AND resource_id=?",
|
||||
(lease["resource_kind"], lease["resource_id"]),
|
||||
).fetchone()
|
||||
if (
|
||||
fence is None
|
||||
or int(fence["generation"]) != int(lease["generation"])
|
||||
or fence["job_id"] != job_id
|
||||
):
|
||||
raise JobError("LEASE_FENCE_REJECTED")
|
||||
return dict(lease)
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def get_execution_lease(self, job_id: str) -> Mapping[str, Any]:
|
||||
"""Return the current typed lease so callers can pass its fence token."""
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
request_row = connection.execute(
|
||||
"SELECT request_json FROM jobs WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if request_row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
binding = json.loads(request_row["request_json"]).get("binding", {})
|
||||
table = "resource_leases" if binding.get("schema") == "aios.operation-job.v1" else "branch_leases"
|
||||
lease = connection.execute(f"SELECT * FROM {table} WHERE job_id=?", (job_id,)).fetchone()
|
||||
if lease is None:
|
||||
raise JobError("LEASE_NOT_FOUND")
|
||||
return dict(lease)
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def assert_effect_admission(self, *, exclude_job_id: str | None = None) -> None:
|
||||
"""Stop new effects while an earlier durable intent needs reconciliation."""
|
||||
in_flight = (
|
||||
JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
JobState.RECONCILING,
|
||||
)
|
||||
placeholders = ",".join("?" for _ in in_flight)
|
||||
values: list[Any] = [state.value for state in in_flight]
|
||||
query = (
|
||||
f"SELECT job_id FROM jobs WHERE (state IN ({placeholders}) "
|
||||
"OR (state=? AND side_effect_state='UNKNOWN'))"
|
||||
)
|
||||
values.append(JobState.RECONCILIATION_REQUIRED.value)
|
||||
if exclude_job_id is not None:
|
||||
query += " AND job_id<>?"
|
||||
values.append(exclude_job_id)
|
||||
query += " LIMIT 1"
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
if connection.execute(query, values).fetchone() is not None:
|
||||
raise JobError("EFFECT_ADMISSION_LATCHED")
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||
return self._advance(
|
||||
job_id,
|
||||
@@ -752,6 +1074,75 @@ class GitOpsJobController:
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def _validate_generic_receipt(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
row: Mapping[str, Any],
|
||||
receipt: Mapping[str, Any] | None,
|
||||
*,
|
||||
recovery: bool,
|
||||
) -> None:
|
||||
if self.generic_receipt_verifier is None:
|
||||
raise JobError("GENERIC_RECEIPT_VERIFIER_REQUIRED")
|
||||
if not isinstance(receipt, Mapping) or set(receipt) != {"schema", "key_id", "payload", "signature"}:
|
||||
raise JobError("GENERIC_RECEIPT_INVALID")
|
||||
if receipt.get("schema") != "aios.operation-receipt.v1":
|
||||
raise JobError("GENERIC_RECEIPT_INVALID")
|
||||
if not isinstance(receipt.get("key_id"), str) or not receipt["key_id"].strip():
|
||||
raise JobError("GENERIC_RECEIPT_INVALID")
|
||||
signature = receipt.get("signature")
|
||||
try:
|
||||
decoded_signature = base64.b64decode(signature, validate=True) if isinstance(signature, str) else b""
|
||||
except (ValueError, TypeError):
|
||||
decoded_signature = b""
|
||||
if len(decoded_signature) != 64:
|
||||
raise JobError("GENERIC_RECEIPT_INVALID")
|
||||
payload = receipt.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("observation"), Mapping):
|
||||
raise JobError("GENERIC_RECEIPT_INVALID")
|
||||
request = json.loads(row["request_json"])
|
||||
binding = request.get("binding", request)
|
||||
required_bindings = (
|
||||
"tenant_id", "subject_id", "mission_id", "grant_id", "task_id",
|
||||
"effect_id", "operation", "operation_version", "resource_kind",
|
||||
"resource_id", "input_digest",
|
||||
)
|
||||
if any(payload.get(field) != binding.get(field) for field in required_bindings):
|
||||
raise JobError("GENERIC_RECEIPT_BINDING_MISMATCH")
|
||||
if payload.get("job_id") != row["job_id"]:
|
||||
raise JobError("GENERIC_RECEIPT_BINDING_MISMATCH")
|
||||
if "expected_resource_revision" in binding and payload.get("expected_resource_revision") != binding["expected_resource_revision"]:
|
||||
raise JobError("GENERIC_RECEIPT_BINDING_MISMATCH")
|
||||
lease = connection.execute(
|
||||
"SELECT * FROM resource_leases WHERE job_id=?", (row["job_id"],)
|
||||
).fetchone()
|
||||
if (
|
||||
lease is None
|
||||
or payload.get("claim_id") != lease["lease_id"]
|
||||
or isinstance(payload.get("generation"), bool)
|
||||
or payload.get("generation") != int(lease["generation"])
|
||||
):
|
||||
raise JobError("GENERIC_RECEIPT_LEASE_MISMATCH")
|
||||
if not recovery:
|
||||
fence = connection.execute(
|
||||
"SELECT generation,job_id FROM resource_fences WHERE resource_kind=? AND resource_id=?",
|
||||
(lease["resource_kind"], lease["resource_id"]),
|
||||
).fetchone()
|
||||
if (
|
||||
lease["state"] != "ACTIVE"
|
||||
or _utc_datetime(lease["lease_expires_at"]) <= self._now()
|
||||
or fence is None
|
||||
or int(fence["generation"]) != int(lease["generation"])
|
||||
or fence["job_id"] != row["job_id"]
|
||||
):
|
||||
raise JobError("GENERIC_RECEIPT_LEASE_MISMATCH")
|
||||
try:
|
||||
verified = self.generic_receipt_verifier(receipt)
|
||||
except Exception as exc:
|
||||
raise JobError("GENERIC_RECEIPT_SIGNATURE_INVALID") from exc
|
||||
if verified is not True:
|
||||
raise JobError("GENERIC_RECEIPT_SIGNATURE_INVALID")
|
||||
|
||||
def _advance(
|
||||
self,
|
||||
job_id: str,
|
||||
@@ -779,7 +1170,17 @@ class GitOpsJobController:
|
||||
)
|
||||
if previous in _TERMINAL and state != previous and not recovery_transition:
|
||||
raise JobError("JOB_TERMINAL")
|
||||
if state not in _RECOVERABLE | {JobState.RECONCILING, JobState.MANUAL_OWNER_REVIEW_REQUIRED}:
|
||||
durable_request = json.loads(row["request_json"])
|
||||
generic = durable_request.get("binding", durable_request).get("schema") == "aios.operation-job.v1"
|
||||
if generic:
|
||||
allowed = _GENERIC_TRANSITIONS.get(previous, set()) | _RECOVERABLE | {
|
||||
JobState.RECONCILIATION_REQUIRED, JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
}
|
||||
if state not in allowed:
|
||||
raise JobError("GENERIC_JOB_TRANSITION_INVALID")
|
||||
if state in {JobState.LOCAL_VERIFIED, JobState.COMPLETED}:
|
||||
self._validate_generic_receipt(connection, row, receipt, recovery=False)
|
||||
if not generic and state not in _RECOVERABLE | {JobState.RECONCILING, JobState.MANUAL_OWNER_REVIEW_REQUIRED}:
|
||||
if previous in _RECOVERABLE:
|
||||
raise JobError("RECONCILE_REQUIRED")
|
||||
if previous != JobState.RECONCILING and _ORDER.get(state, -1) < _ORDER.get(previous, -1):
|
||||
@@ -790,6 +1191,9 @@ class GitOpsJobController:
|
||||
updated = connection.execute("UPDATE jobs SET state=?,side_effect_state=?,revision=?,receipt_json=?,updated_at=? WHERE job_id=? AND revision=?", (state.value, side_effect_state, next_revision, receipt_json, now, job_id, revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
if state in _TERMINAL:
|
||||
connection.execute("UPDATE branch_leases SET state='USED' WHERE job_id=? AND state='ACTIVE'", (job_id,))
|
||||
connection.execute("UPDATE resource_leases SET state='USED' WHERE job_id=? AND state='ACTIVE'", (job_id,))
|
||||
connection.execute("INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)", (job_id, previous.value, state.value, next_revision, side_effect_state, now))
|
||||
if receipt is not None:
|
||||
digest = hashlib.sha256(canonical_json(receipt)).hexdigest()
|
||||
@@ -897,19 +1301,175 @@ class GitOpsJobController:
|
||||
_allow_verified_external_recovery=True,
|
||||
)
|
||||
|
||||
def reconcile(self, job_id: str, observer: Callable[[Mapping[str, Any]], Mapping[str, Any]]) -> JobReceipt:
|
||||
current = self.store.read(job_id)
|
||||
if JobState(current["state"]) not in _RECOVERABLE:
|
||||
return self.store._receipt(current, duplicate=True)
|
||||
self.advance(job_id, JobState.RECONCILING, side_effect_state="IN_PROGRESS")
|
||||
def _claim_reconciliation(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
now = self._now()
|
||||
deadline = _iso_utc(now + timedelta(seconds=self.reconcile_lease_seconds))
|
||||
try:
|
||||
observed = dict(observer(current))
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
state = JobState(row["state"])
|
||||
if state not in _RECONCILABLE:
|
||||
connection.commit()
|
||||
return None
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM reconciliation_claims WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
if (
|
||||
state == JobState.RECONCILING
|
||||
and existing is not None
|
||||
and existing["state"] == "ACTIVE"
|
||||
and _utc_datetime(existing["lease_expires_at"]) > now
|
||||
):
|
||||
raise JobError("RECONCILIATION_CLAIM_ACTIVE")
|
||||
epoch = 1 if existing is None else int(existing["reconcile_epoch"]) + 1
|
||||
claim_id = f"reconcile-{job_id}-{epoch}"
|
||||
revision = int(row["revision"])
|
||||
claimed_revision = revision + 1
|
||||
updated = connection.execute(
|
||||
"UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?",
|
||||
(JobState.RECONCILING.value, "IN_PROGRESS", claimed_revision, utc_now(), job_id, revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
connection.execute(
|
||||
"INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(job_id, state.value, JobState.RECONCILING.value, claimed_revision, "IN_PROGRESS", utc_now()),
|
||||
)
|
||||
if existing is None:
|
||||
connection.execute(
|
||||
"INSERT INTO reconciliation_claims(job_id,claim_id,reconcile_epoch,base_revision,lease_expires_at,state,revision,updated_at) VALUES(?,?,?,?,?,'ACTIVE',0,?)",
|
||||
(job_id, claim_id, epoch, claimed_revision, deadline, utc_now()),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE reconciliation_claims SET claim_id=?,reconcile_epoch=?,base_revision=?,lease_expires_at=?,state='ACTIVE',revision=revision+1,updated_at=? WHERE job_id=?",
|
||||
(claim_id, epoch, claimed_revision, deadline, utc_now(), job_id),
|
||||
)
|
||||
current = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
claim = {
|
||||
"claim_id": claim_id,
|
||||
"reconcile_epoch": epoch,
|
||||
"base_revision": claimed_revision,
|
||||
"effect_id": json.loads(current["request_json"]).get("binding", {}).get("effect_id", job_id),
|
||||
"observed_at": _iso_utc(now),
|
||||
"lease_expires_at": deadline,
|
||||
}
|
||||
return current, claim
|
||||
|
||||
def _finish_reconciliation(
|
||||
self,
|
||||
job_id: str,
|
||||
claim: Mapping[str, Any],
|
||||
state: JobState,
|
||||
*,
|
||||
side_effect_state: str,
|
||||
receipt: Mapping[str, Any] | None,
|
||||
) -> JobReceipt:
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current_claim = connection.execute(
|
||||
"SELECT * FROM reconciliation_claims WHERE job_id=?", (job_id,)
|
||||
).fetchone()
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
if (
|
||||
current_claim is None
|
||||
or current_claim["state"] != "ACTIVE"
|
||||
or current_claim["claim_id"] != claim["claim_id"]
|
||||
or int(current_claim["reconcile_epoch"]) != int(claim["reconcile_epoch"])
|
||||
or int(current_claim["base_revision"]) != int(row["revision"])
|
||||
or _utc_datetime(current_claim["lease_expires_at"]) <= self._now()
|
||||
):
|
||||
raise JobError("RECONCILIATION_CLAIM_LOST")
|
||||
durable_request = json.loads(row["request_json"])
|
||||
generic = durable_request.get("binding", durable_request).get("schema") == "aios.operation-job.v1"
|
||||
if generic and state == JobState.COMPLETED:
|
||||
self._validate_generic_receipt(connection, row, receipt, recovery=True)
|
||||
revision = int(row["revision"])
|
||||
next_revision = revision + 1
|
||||
receipt_payload = None
|
||||
if receipt is not None:
|
||||
receipt_payload = dict(receipt)
|
||||
receipt_payload["reconciliation_claim"] = dict(claim)
|
||||
receipt_json = json.dumps(receipt_payload, sort_keys=True) if receipt_payload is not None else row["receipt_json"]
|
||||
now = utc_now()
|
||||
updated = connection.execute(
|
||||
"UPDATE jobs SET state=?,side_effect_state=?,revision=?,receipt_json=?,updated_at=? WHERE job_id=? AND revision=?",
|
||||
(state.value, side_effect_state, next_revision, receipt_json, now, job_id, revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("RECONCILIATION_CLAIM_LOST")
|
||||
connection.execute("UPDATE branch_leases SET state='RECONCILED' WHERE job_id=? AND state='ACTIVE'", (job_id,))
|
||||
connection.execute("UPDATE resource_leases SET state='RECONCILED' WHERE job_id=? AND state='ACTIVE'", (job_id,))
|
||||
connection.execute(
|
||||
"UPDATE reconciliation_claims SET state='COMPLETED',revision=revision+1,updated_at=? WHERE job_id=? AND claim_id=?",
|
||||
(now, job_id, claim["claim_id"]),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(job_id, row["state"], state.value, next_revision, side_effect_state, now),
|
||||
)
|
||||
if receipt_payload is not None:
|
||||
digest = hashlib.sha256(canonical_json(receipt_payload)).hexdigest()
|
||||
connection.execute(
|
||||
"INSERT INTO receipts(receipt_id,job_id,kind,digest,payload_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(f"receipt-{job_id}-{next_revision}", job_id, f"RECONCILE:{state.value}", digest, json.dumps(receipt_payload, sort_keys=True), now),
|
||||
)
|
||||
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
result["request"] = json.loads(result["request_json"])
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
def reconcile(self, job_id: str, observer: Callable[[Mapping[str, Any]], Mapping[str, Any]]) -> JobReceipt:
|
||||
claimed = self._claim_reconciliation(job_id)
|
||||
if claimed is None:
|
||||
current = self.store.read(job_id)
|
||||
return self.store._receipt(current, duplicate=True)
|
||||
current, claim = claimed
|
||||
observed_input = dict(current)
|
||||
observed_input["reconciliation_claim"] = dict(claim)
|
||||
try:
|
||||
observed = dict(observer(observed_input))
|
||||
stable = JobState(observed.get("state", JobState.REQUESTED.value))
|
||||
if stable in {JobState.RECOVERABLE_FAILURE, JobState.RECONCILING}:
|
||||
raise JobError("RECONCILIATION_UNSTABLE")
|
||||
return self.advance(job_id, stable, side_effect_state=str(observed.get("side_effect_state", "NONE")), receipt=observed.get("receipt"))
|
||||
return self._finish_reconciliation(
|
||||
job_id,
|
||||
claim,
|
||||
stable,
|
||||
side_effect_state=str(observed.get("side_effect_state", "NONE")),
|
||||
receipt=observed.get("receipt"),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.advance(job_id, JobState.RECONCILIATION_REQUIRED, side_effect_state="UNKNOWN", receipt={"error_code": type(exc).__name__})
|
||||
if isinstance(exc, JobError) and str(exc) == "RECONCILIATION_CLAIM_LOST":
|
||||
raise
|
||||
try:
|
||||
self._finish_reconciliation(
|
||||
job_id,
|
||||
claim,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN",
|
||||
receipt={"error_code": type(exc).__name__},
|
||||
)
|
||||
except JobError as finish_exc:
|
||||
if str(finish_exc) == "RECONCILIATION_CLAIM_LOST":
|
||||
raise finish_exc from exc
|
||||
raise
|
||||
raise JobError("RECONCILIATION_REQUIRED") from exc
|
||||
|
||||
def resume(self, job_id: str) -> JobReceipt:
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor, AuthorityGitError
|
||||
from aios_campaign_recovery import RecoveryEvidenceError, load_recovered_authorization
|
||||
from aios_gitea_machine_broker import CampaignCapabilityReceipt, GiteaMachineBroker, MachineBrokerError
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
TASK_ID = "AIOS_AUTONOMOUS_RELEASE_CAMPAIGN_V1"
|
||||
@@ -34,6 +35,17 @@ class DurableJobExecutor:
|
||||
authority: AuthorityGitExecutor
|
||||
broker: GiteaMachineBroker
|
||||
recovery_path: Path
|
||||
clock: Callable[[], datetime] = field(default=lambda: datetime.now(timezone.utc))
|
||||
lease_seconds: int = 60
|
||||
_persistence_halted: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
self.controller.assert_effect_admission()
|
||||
except JobError as exc:
|
||||
if str(exc) != "EFFECT_ADMISSION_LATCHED":
|
||||
raise
|
||||
self._persistence_halted = True
|
||||
|
||||
def _request(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = self.controller.store.read(job_id)
|
||||
@@ -41,7 +53,14 @@ class DurableJobExecutor:
|
||||
request = json.loads(row["request_json"])
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise JobExecutionError("JOB_REQUEST_INVALID") from exc
|
||||
return row, dict(request.get("binding") or {})
|
||||
binding = dict(request.get("binding") or {})
|
||||
# Payload location is derived from and protected by payload_digest, but
|
||||
# is intentionally outside the idempotency binding. Execution still
|
||||
# needs it after a durable round trip.
|
||||
for field_name in ("payload_ref", "payload_size"):
|
||||
if field_name in request:
|
||||
binding[field_name] = request[field_name]
|
||||
return row, binding
|
||||
|
||||
def _validate_campaign(self, request: Mapping[str, Any]) -> str:
|
||||
operation = str(request.get("operation", ""))
|
||||
@@ -89,151 +108,372 @@ class DurableJobExecutor:
|
||||
raise JobExecutionError("NEW_OID_NOT_DESCENDANT")
|
||||
if not self.authority.ancestry_check(EXPECTED_BRANCH_OID, new_oid):
|
||||
raise JobExecutionError("NEW_OID_OUTSIDE_AUTHORIZED_LINEAGE")
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old_main = request.get("expected_main_oid")
|
||||
if (
|
||||
not isinstance(old_main, str)
|
||||
or len(old_main) != 40
|
||||
or any(ch not in "0123456789abcdef" for ch in old_main)
|
||||
):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if not self.authority.ancestry_check(old_main, new_oid):
|
||||
raise JobExecutionError("MAIN_OID_NOT_DESCENDANT")
|
||||
identity = self.broker.verify_identity()
|
||||
if identity.get("repository") != REPOSITORY or identity.get("login") == "aiman":
|
||||
raise JobExecutionError("MACHINE_IDENTITY_SCOPE_INVALID")
|
||||
return new_oid
|
||||
|
||||
def dry_run(self, job_id: str) -> dict[str, Any]:
|
||||
_row, request = self._request(job_id)
|
||||
new_oid = self._validate_campaign(request)
|
||||
remote = self.broker.readback()
|
||||
if remote is None:
|
||||
action = "CREATE_REQUIRED"
|
||||
elif remote == new_oid:
|
||||
action = "ALREADY_IN_SYNC"
|
||||
elif remote == request["expected_branch_oid"]:
|
||||
action = "FAST_FORWARD_REQUIRED"
|
||||
else:
|
||||
action = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
if action == "MANUAL_OWNER_REVIEW_REQUIRED":
|
||||
raise JobExecutionError("GITEA_BRANCH_CONFLICT")
|
||||
return {
|
||||
row, request = self._request(job_id)
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
observation = self._observe_operation(request)
|
||||
result = {
|
||||
"mode": "DRY_RUN",
|
||||
"operation": operation,
|
||||
"decision": "SAFE_TO_ADVANCE",
|
||||
"authority_action": "CAS_REQUIRED",
|
||||
"gitea_action": action,
|
||||
"previous_branch_oid": request["expected_branch_oid"],
|
||||
"final_branch_oid": new_oid,
|
||||
"force_push": False,
|
||||
"effect_performed": False,
|
||||
"readback": observation,
|
||||
}
|
||||
|
||||
def _recover_requested(self, job_id: str) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) in {JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}:
|
||||
self.controller.resume(job_id)
|
||||
row = self.controller.store.read(job_id)
|
||||
return row
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
result.update({
|
||||
"authority_action": "CAS_REQUIRED",
|
||||
"gitea_action": "ALREADY_IN_SYNC" if observation["verification"] == "VERIFIED" else "FAST_FORWARD_REQUIRED",
|
||||
"previous_branch_oid": request["expected_branch_oid"],
|
||||
"final_branch_oid": request["new_branch_oid"],
|
||||
"force_push": False,
|
||||
})
|
||||
return result
|
||||
|
||||
def execute(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return self.dry_run(job_id)
|
||||
if self._persistence_halted:
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED")
|
||||
try:
|
||||
return self._execute_unprotected(job_id, dry_run=dry_run)
|
||||
return self._execute_unprotected(job_id)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state not in {JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED, JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED}:
|
||||
self.controller.advance(job_id, JobState.RECOVERABLE_FAILURE, side_effect_state="UNKNOWN", receipt={"error_code": type(exc).__name__})
|
||||
except Exception:
|
||||
pass
|
||||
if state in {
|
||||
JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN",
|
||||
receipt={"error_code": type(exc).__name__, "verification": "UNKNOWN"},
|
||||
)
|
||||
elif state not in {
|
||||
JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id, JobState.RECOVERABLE_FAILURE,
|
||||
side_effect_state="NONE",
|
||||
receipt={"error_code": type(exc).__name__, "effect_performed": False},
|
||||
)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
|
||||
def _execute_unprotected(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
def _execute_unprotected(self, job_id: str) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
operation = request.get("operation")
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
if dry_run:
|
||||
return self.dry_run(job_id)
|
||||
return self._advance_campaign(job_id)
|
||||
if operation == "COMMIT_FILE":
|
||||
return self._commit_file(job_id, request)
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
new_oid = self._validate_campaign(request)
|
||||
final = self.broker.push_branch(new_oid)
|
||||
return {"operation": operation, "remote_verified": final == new_oid, "final_network_oid": final}
|
||||
if operation == "CREATE_PR":
|
||||
self._validate_campaign(request)
|
||||
return dict(self.broker.create_pr("AIOS autonomous release control plane", "Durable campaign continuation; exact head and recovery evidence are bound in the Job."))
|
||||
if operation == "PUBLISH_CHECK":
|
||||
self._validate_campaign(request)
|
||||
context = request.get("check_context", "aios/durable-release")
|
||||
state = request.get("check_state", "success")
|
||||
description = request.get("check_description", "Durable campaign control-plane check passed")
|
||||
return dict(self.broker.status(str(state), str(description), str(context)))
|
||||
if operation == "PROTECTED_MERGE":
|
||||
self._validate_campaign(request)
|
||||
number = int(request.get("pr_number", "0"))
|
||||
self.broker.transport.expected_merge_head = request["expected_branch_oid"]
|
||||
expected_main = request.get("expected_main_oid")
|
||||
if expected_main is not None:
|
||||
self.broker.transport.expected_merge_base = expected_main
|
||||
return dict(self.broker.merge(number))
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
self._validate_campaign(request)
|
||||
old = request.get("expected_main_oid")
|
||||
new = request.get("new_branch_oid")
|
||||
if not isinstance(old, str) or not isinstance(new, str):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
gitea_main = self.broker.transport.read_branch("main")
|
||||
if gitea_main != new:
|
||||
raise JobExecutionError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
if not self.authority.ancestry_check(old, new):
|
||||
raise JobExecutionError("MAIN_OID_NOT_DESCENDANT")
|
||||
return {"authority_main": self.authority.ff_CAS_main_after_verified_protected_merge(old, new)}
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state in {
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
JobState.RECONCILING, JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
return self.reconcile(job_id)
|
||||
|
||||
def _advance_campaign(self, job_id: str) -> dict[str, Any]:
|
||||
self._recover_requested(job_id)
|
||||
row, request = self._request(job_id)
|
||||
new_oid = self._validate_campaign(request)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
try:
|
||||
self.controller.assert_effect_admission(exclude_job_id=job_id)
|
||||
except JobError as exc:
|
||||
if str(exc) == "EFFECT_ADMISSION_LATCHED":
|
||||
self._persistence_halted = True
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED") from exc
|
||||
raise
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
row = self._prepare_intent(job_id, row, request)
|
||||
generation = int(request.get("generation", "1"))
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
|
||||
before = self._observe_operation(request)
|
||||
if before["verification"] == "VERIFIED":
|
||||
return self._complete(job_id, request, {"reconciled_before_effect": True}, before)
|
||||
if before["verification"] != "ABSENT":
|
||||
return self._record_unknown(job_id, request, before)
|
||||
|
||||
effect = self._perform_effect(job_id, row, request, generation)
|
||||
after = self._observe_operation(request)
|
||||
if after["verification"] != "VERIFIED":
|
||||
return self._record_unknown(job_id, request, after, effect=effect)
|
||||
return self._complete(job_id, request, effect, after)
|
||||
|
||||
def _prepare_intent(
|
||||
self, job_id: str, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
receipt = self.controller.advance(job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED", receipt={"authorization_digest": AUTHORIZATION_DIGEST, "task_id": TASK_ID})
|
||||
self.controller.advance(
|
||||
job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED",
|
||||
receipt={"authorization_digest": AUTHORIZATION_DIGEST, "task_id": TASK_ID},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(job_id, lease_id=f"lease-{job_id}", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=expected_oid, generation=1, lease_expires_at="2099-01-01T00:00:00Z", expected_revision=int(row["revision"]))
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
deadline = self.clock().astimezone(timezone.utc) + timedelta(seconds=self.lease_seconds)
|
||||
self.controller.lease_job(
|
||||
job_id,
|
||||
lease_id=f"lease-{job_id}",
|
||||
lineage_anchor_oid=LINEAGE_ANCHOR,
|
||||
current_head_oid=str(request["expected_branch_oid"]),
|
||||
generation=int(request.get("generation", "1")),
|
||||
lease_expires_at=deadline.isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.LEASED:
|
||||
self.controller.advance(job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY", receipt={"branch": BRANCH, "expected_oid": expected_oid})
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(
|
||||
job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY",
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.WORKTREE_READY:
|
||||
self.controller.advance(job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(
|
||||
job_id, JobState.EXECUTING, side_effect_state="INTENT_COMMITTED",
|
||||
receipt={
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"input_digest": row["payload_digest"],
|
||||
"intent_committed": True,
|
||||
},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
authority_before = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
if authority_before == expected_oid:
|
||||
authority_oid = self.authority.advance_task_branch_CAS(f"refs/heads/{BRANCH}", expected_oid, new_oid)
|
||||
elif authority_before == new_oid:
|
||||
authority_oid = new_oid
|
||||
else:
|
||||
raise JobExecutionError("AUTHORITY_BRANCH_CONFLICT")
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.EXECUTING:
|
||||
self.controller.advance(job_id, JobState.LOCAL_VERIFIED, side_effect_state="LOCAL_VERIFIED", receipt={"authority": {"branch": BRANCH, "previous_oid": expected_oid, "new_oid": new_oid, "CAS_success": True, "readback_oid": authority_oid}})
|
||||
row = self.controller.store.read(job_id)
|
||||
remote_before = self.broker.readback()
|
||||
if remote_before != new_oid:
|
||||
if remote_before not in {None, expected_oid}:
|
||||
raise JobExecutionError("GITEA_BRANCH_CONFLICT")
|
||||
if JobState(row["state"]) == JobState.LOCAL_VERIFIED:
|
||||
if hasattr(self.broker, "receipt"):
|
||||
self.broker.receipt = replace(self.broker.receipt, expected_oid=expected_oid)
|
||||
self.controller.advance(job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS", receipt={"gitea": {"previous_oid": remote_before, "desired_oid": new_oid, "force_push": False}})
|
||||
final_network = self.broker.push_branch(new_oid)
|
||||
else:
|
||||
final_network = new_oid
|
||||
final_api = self.broker.readback()
|
||||
if final_api != new_oid:
|
||||
raise JobExecutionError("GITEA_BRANCH_READBACK_MISMATCH")
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) in {JobState.LOCAL_VERIFIED, JobState.REMOTE_SYNCING}:
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt={"gitea": {"machine_actor": "aios-autonomy-agent", "previous_oid": remote_before, "desired_oid": new_oid, "final_network_oid": final_network, "final_API_oid": final_api, "force_push": False}})
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) == JobState.REMOTE_VERIFIED:
|
||||
self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt={"authority_oid": authority_oid, "network_oid": final_network, "gitea_api_oid": final_api, "force_push": False})
|
||||
return self.controller.store._receipt(self.controller.store.read(job_id), duplicate=True).as_dict()
|
||||
if JobState(row["state"]) != JobState.EXECUTING:
|
||||
raise JobExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
return dict(row)
|
||||
|
||||
def _commit_file(self, job_id: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def _perform_effect(
|
||||
self,
|
||||
job_id: str,
|
||||
row: Mapping[str, Any],
|
||||
request: Mapping[str, Any],
|
||||
generation: int,
|
||||
) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
expected = str(request["expected_branch_oid"])
|
||||
current = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
authority_oid = new_oid if current == new_oid else self.authority.advance_task_branch_CAS(
|
||||
f"refs/heads/{BRANCH}", expected, new_oid
|
||||
)
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
remote = self.broker.readback()
|
||||
if remote != new_oid:
|
||||
if hasattr(self.broker, "receipt"):
|
||||
self.broker.receipt = replace(self.broker.receipt, expected_oid=expected)
|
||||
self.broker.push_branch(new_oid)
|
||||
return {"authority_oid": authority_oid, "network_oid": new_oid, "force_push": False}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
return {"final_network_oid": self.broker.push_branch(new_oid), "force_push": False}
|
||||
if operation == "CREATE_PR":
|
||||
return dict(self.broker.create_pr(
|
||||
"AIOS autonomous release control plane",
|
||||
"Durable campaign continuation; exact head and recovery evidence are bound in the Job.",
|
||||
))
|
||||
if operation == "PUBLISH_CHECK":
|
||||
return dict(self.broker.status(
|
||||
str(request.get("check_state", "success")),
|
||||
str(request.get("check_description", "Durable campaign control-plane check passed")),
|
||||
str(request.get("check_context", "aios/durable-release")),
|
||||
))
|
||||
if operation == "PROTECTED_MERGE":
|
||||
number = int(request.get("pr_number", "0"))
|
||||
self.broker.transport.expected_merge_head = request["expected_branch_oid"]
|
||||
if request.get("expected_main_oid") is not None:
|
||||
self.broker.transport.expected_merge_base = request["expected_main_oid"]
|
||||
return dict(self.broker.merge(number))
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old = request.get("expected_main_oid")
|
||||
if not isinstance(old, str):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if self.broker.transport.read_branch("main") != new_oid:
|
||||
raise JobExecutionError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
return {"authority_main": self.authority.ff_CAS_main_after_verified_protected_merge(old, new_oid)}
|
||||
if operation == "COMMIT_FILE":
|
||||
path, content = self._commit_file_spec(row, request)
|
||||
return {
|
||||
"new_oid": self.authority.commit_file_in_isolated_worktree(
|
||||
base_oid=row["expected_branch_oid"], files={path: content},
|
||||
message="chore(gitops): durable commit-file", worktree_name=job_id,
|
||||
)
|
||||
}
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
|
||||
def _observe_operation(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
authority = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
remote = self.broker.readback()
|
||||
if authority == new_oid and remote == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_oid": authority, "remote_oid": remote}
|
||||
expected = request["expected_branch_oid"]
|
||||
if authority == expected and remote in {None, expected}:
|
||||
return {"verification": "ABSENT", "authority_oid": authority, "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "PARTIAL_OR_CONFLICTING_BRANCH_EFFECT", "authority_oid": authority, "remote_oid": remote}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
remote = self.broker.readback()
|
||||
if remote == new_oid:
|
||||
return {"verification": "VERIFIED", "remote_oid": remote}
|
||||
if remote in {None, EXPECTED_BRANCH_OID}:
|
||||
return {"verification": "ABSENT", "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "REMOTE_BRANCH_CONFLICT", "remote_oid": remote}
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
current = self.authority.read_ref("refs/heads/main")
|
||||
if current == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_main": current}
|
||||
if current == request.get("expected_main_oid"):
|
||||
return {"verification": "ABSENT", "authority_main": current}
|
||||
return {"verification": "UNKNOWN", "blocker": "AUTHORITY_MAIN_CONFLICT", "authority_main": current}
|
||||
if operation == "CREATE_PR" and hasattr(self.broker, "read_pr"):
|
||||
value = self.broker.read_pr(BRANCH, "main", new_oid)
|
||||
return {"verification": "VERIFIED" if value else "ABSENT", "pr": value}
|
||||
if operation == "PUBLISH_CHECK" and hasattr(self.broker, "read_status"):
|
||||
value = self.broker.read_status(
|
||||
new_oid, str(request.get("check_context", "aios/durable-release"))
|
||||
)
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
expected_state = str(request.get("check_state", "success"))
|
||||
return {"verification": "VERIFIED" if value.get("status") == expected_state else "UNKNOWN", "status": dict(value), **({} if value.get("status") == expected_state else {"blocker": "STATUS_READBACK_MISMATCH"})}
|
||||
if operation == "PROTECTED_MERGE" and hasattr(self.broker, "read_merge"):
|
||||
value = self.broker.read_merge(int(request.get("pr_number", "0")))
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
if value.get("merged") is True and value.get("head_oid") == request["expected_branch_oid"]:
|
||||
return {"verification": "VERIFIED", "merge": dict(value)}
|
||||
if value.get("merged") is False:
|
||||
return {"verification": "ABSENT", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "MERGE_READBACK_MISMATCH", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "EXACT_READBACK_UNSUPPORTED"}
|
||||
|
||||
def _complete(
|
||||
self, job_id: str, request: Mapping[str, Any], effect: Mapping[str, Any], observation: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect),
|
||||
"verification": "VERIFIED",
|
||||
"readback": dict(observation),
|
||||
}
|
||||
completed = self.controller.advance(
|
||||
job_id, JobState.COMPLETED, side_effect_state="VERIFIED",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return completed.as_dict()
|
||||
|
||||
def _record_unknown(
|
||||
self,
|
||||
job_id: str,
|
||||
request: Mapping[str, Any],
|
||||
observation: Mapping[str, Any],
|
||||
*,
|
||||
effect: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect) if effect is not None else None,
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": dict(observation),
|
||||
}
|
||||
pending = self.controller.advance(
|
||||
job_id, JobState.RECONCILIATION_REQUIRED, side_effect_state="UNKNOWN",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return pending.as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, Any]:
|
||||
def observer(_current: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
_row, request = self._request(job_id)
|
||||
self._validate_campaign(request)
|
||||
observation = self._observe_operation(request)
|
||||
if observation["verification"] == "VERIFIED":
|
||||
return {
|
||||
"state": JobState.COMPLETED.value,
|
||||
"side_effect_state": "VERIFIED",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"effect": None, "verification": "VERIFIED",
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
if observation["verification"] == "ABSENT":
|
||||
return {
|
||||
"state": JobState.REQUESTED.value,
|
||||
"side_effect_state": "RECONCILED_ABSENT",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "ABSENT", "readback": observation,
|
||||
"reconciled": True,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"state": JobState.RECONCILIATION_REQUIRED.value,
|
||||
"side_effect_state": "UNKNOWN",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = self.controller.reconcile(job_id, observer).as_dict()
|
||||
if result["state"] in {JobState.COMPLETED.value, JobState.REQUESTED.value}:
|
||||
self._persistence_halted = False
|
||||
return result
|
||||
|
||||
def _commit_file_spec(
|
||||
self, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> tuple[str, bytes]:
|
||||
payload_ref = request.get("payload_ref")
|
||||
if not payload_ref:
|
||||
raise JobExecutionError("PAYLOAD_REQUIRED")
|
||||
@@ -244,8 +484,7 @@ class DurableJobExecutor:
|
||||
content = base64.b64decode(spec["content_base64"], validate=True)
|
||||
except Exception as exc:
|
||||
raise JobExecutionError("COMMIT_FILE_PAYLOAD_INVALID") from exc
|
||||
new_oid = self.authority.commit_file_in_isolated_worktree(base_oid=row["expected_branch_oid"], files={path: content}, message="chore(gitops): durable commit-file", worktree_name=job_id)
|
||||
return {"operation": "COMMIT_FILE", "new_oid": new_oid}
|
||||
return path, content
|
||||
|
||||
|
||||
def build_from_environment(job_root: Path) -> DurableJobExecutor:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Collision-resistant storage keys for durable governed Task state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
class JobIdentityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def legacy_task_storage_key(task_id: str) -> str:
|
||||
"""Return the historical lossy key solely to detect migration conflicts."""
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def task_storage_key(task_id: str) -> str:
|
||||
"""Return a canonical collision-resistant key for every Task state sink."""
|
||||
if (
|
||||
not isinstance(task_id, str)
|
||||
or not task_id
|
||||
or task_id != task_id.strip()
|
||||
or len(task_id) > 512
|
||||
):
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
readable = re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:48] or "task"
|
||||
digest = hashlib.sha256(
|
||||
("aios-task-storage-v1\0" + task_id).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"{readable}-{digest}"
|
||||
@@ -4,13 +4,22 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
|
||||
try: # pragma: no cover - exercised on the Linux activation host
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX execution stays fail closed
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
@@ -19,6 +28,29 @@ class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _lexists(path: Path) -> bool:
|
||||
try:
|
||||
path.lstat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _require_runtime_directory(path: Path) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID") from exc
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_mode & 0o022
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedTaskBinding:
|
||||
task_id: str
|
||||
@@ -37,25 +69,95 @@ class GovernedTaskBinding:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||
|
||||
# ===== AIOS Formal Lineage producer-native reservation v12 =====
|
||||
import re as _fl_re
|
||||
|
||||
_FL_PRODUCER_KEY = _fl_re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
def _formal_lineage_reservation_receipt(
|
||||
value,
|
||||
*,
|
||||
request_idempotency_key: str,
|
||||
expected_side_effect_state: str,
|
||||
):
|
||||
if callable(getattr(value, "as_dict", None)):
|
||||
raw=value.as_dict()
|
||||
elif isinstance(value, dict):
|
||||
raw=dict(value)
|
||||
else:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_TYPE_INVALID")
|
||||
|
||||
producer_key=raw.get("idempotency_key")
|
||||
job_id=raw.get("job_id")
|
||||
if not isinstance(job_id,str) or not job_id:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_ID_INVALID")
|
||||
if raw.get("state")!="REQUESTED":
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_STATE_INVALID")
|
||||
if raw.get("side_effect_state")!=expected_side_effect_state:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_SIDE_EFFECT_STATE_INVALID")
|
||||
if not isinstance(producer_key,str) or not _FL_PRODUCER_KEY.fullmatch(producer_key):
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_PRODUCER_IDEMPOTENCY_INVALID")
|
||||
if job_id!="job-"+producer_key[:32]:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_PRODUCER_KEY_CONFLICT")
|
||||
if not isinstance(request_idempotency_key,str) or not request_idempotency_key:
|
||||
raise RuntimeError("GOVERNED_TASK_REQUEST_IDEMPOTENCY_INVALID")
|
||||
|
||||
return {
|
||||
"job_id":job_id,
|
||||
"state":"REQUESTED",
|
||||
"side_effect_state":raw["side_effect_state"],
|
||||
"producer_idempotency_key":producer_key,
|
||||
"request_idempotency_key":request_idempotency_key,
|
||||
}
|
||||
|
||||
def _formal_lineage_attach_receipt(response, receipt):
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RESPONSE_INVALID")
|
||||
if response.get("state")!="BOUND":
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_STATE_INVALID")
|
||||
if response.get("job_id")!=receipt.get("job_id"):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RECEIPT_JOB_CONFLICT")
|
||||
result=dict(response)
|
||||
result["reservation_receipt"]=dict(receipt)
|
||||
return result
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-binding-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.job_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.lock_root.mkdir(parents=True, exist_ok=True, mode=0o700); _require_runtime_directory(self.task_meta_root); _require_runtime_directory(self.job_root); _require_runtime_directory(self.lock_root)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
root = self.job_root / task_storage_key(task_id)
|
||||
legacy = self.job_root / legacy_task_storage_key(task_id)
|
||||
if legacy != root and _lexists(legacy):
|
||||
if _lexists(root):
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED")
|
||||
store = JobStore(root, payload_root=root / "payloads")
|
||||
return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_safe(task_id) + ".json")
|
||||
return self.task_meta_root / (task_storage_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (legacy_task_storage_key(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
legacy = self._legacy_metadata(task_id)
|
||||
if legacy != path and _lexists(legacy):
|
||||
if _lexists(path):
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_MIGRATION_REQUIRED")
|
||||
if not _lexists(path):
|
||||
return None
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise BootstrapError("TASK_BINDING_PATH_INVALID")
|
||||
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_IDENTITY_CONFLICT")
|
||||
return binding
|
||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||
"""Read the exact durable binding; never synthesize missing lineage."""
|
||||
binding = self._read_existing(str(task_id))
|
||||
@@ -96,29 +198,106 @@ class GovernedTaskBootstrap:
|
||||
)
|
||||
|
||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||
path = self._metadata(binding.task_id)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".task-binding-", dir=self.task_meta_root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(json.dumps(asdict(binding), sort_keys=True) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
parent = os.open(self.task_meta_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _task_lock(self, task_id: str) -> int:
|
||||
if fcntl is None:
|
||||
raise BootstrapError("TASK_BINDING_LOCK_UNAVAILABLE")
|
||||
path = self.lock_root / (task_storage_key(task_id) + ".lock")
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
metadata = os.fstat(descriptor)
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_nlink != 1
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_LOCK_INVALID")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_BUSY") from exc
|
||||
return descriptor
|
||||
|
||||
@staticmethod
|
||||
def _unlock_task(descriptor: int) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
return existing.response(idempotent=True)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
return binding.response()
|
||||
descriptor = self._task_lock(values["task_id"])
|
||||
try:
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
expected = str(request.get('expected_branch_oid') or values['base_oid'])
|
||||
payload = json.dumps(values, sort_keys=True, separators=(',', ':')).encode()
|
||||
_, controller = self._store(values['task_id'])
|
||||
_fl_submit_receipt = controller.submit({'idempotency_key': values['idempotency_key'], 'campaign_id': values['task_id'], 'task_id': values['task_id'], 'repository': values['repository'], 'operation': 'GOVERNED_TASK_CREATE', 'branch': values['target_branch'], 'expected_branch_oid': expected, 'payload_digest': hashlib.sha256(payload).hexdigest(), 'lineage_anchor_oid': values['base_oid']}, payload=payload)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
_fl_submit_receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state='SAME_JOB'
|
||||
)
|
||||
_fl_response = existing.response(idempotent=True)
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
expected_job_id = controller.internal_job_id_for_idempotency_key(values["idempotency_key"])
|
||||
with controller.store._connect() as connection:
|
||||
bootstrap_jobs = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE operation='GOVERNED_TASK_CREATE' ORDER BY job_id"
|
||||
).fetchall()
|
||||
if bootstrap_jobs and [str(row[0]) for row in bootstrap_jobs] != [expected_job_id]:
|
||||
raise BootstrapError("TASK_BINDING_RECOVERY_CONFLICT")
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state="QUEUED"
|
||||
)
|
||||
_fl_response = binding.response()
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
finally:
|
||||
self._unlock_task(descriptor)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
@@ -114,6 +115,9 @@ TRUSTED_BUILDER_SOCKET = Path(os.getenv(
|
||||
TRUSTED_BUILDER_PUBLIC_KEYRING = Path(os.getenv(
|
||||
'AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING', ''
|
||||
))
|
||||
TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT = str(os.getenv(
|
||||
'AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT', ''
|
||||
)).strip()
|
||||
TRUSTED_RELEASE_POLICY = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_TRUSTED_POLICY',
|
||||
str(WS / 'config' / 'gitops' / 'silent-release-policy.json'),
|
||||
@@ -253,6 +257,14 @@ class _HostTrustedCandidateBuilderAdapter:
|
||||
nonce=secrets.token_hex(32),
|
||||
)
|
||||
except BuilderError as exc:
|
||||
code = str(exc)
|
||||
if code in {'SOURCE_ADMISSION_REQUIRED', 'RECIPE_UNKNOWN'} or (
|
||||
code == 'REQUEST_RELEASE_BINDING_INVALID'
|
||||
and not intent.get('version')
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
'release source admission required'
|
||||
) from exc
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
'trusted builder request failed'
|
||||
) from exc
|
||||
@@ -290,6 +302,7 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
builder_attestation_verifier = None
|
||||
trusted_policy = None
|
||||
builder_readiness_probe = None
|
||||
source_admission_resolver = None
|
||||
if TRUSTED_BUILDER_ENABLED:
|
||||
# Every path is opt-in. In particular, this API receives its own
|
||||
# client HMAC credential, never the builder's signing private key.
|
||||
@@ -317,6 +330,31 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
client_hmac = (
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY / 'builder_hmac'
|
||||
).read_bytes().strip()
|
||||
if TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT:
|
||||
from release_source_admission import ReleaseSourceAdmissionStore
|
||||
|
||||
try:
|
||||
source_admission_hmac = (
|
||||
TRUSTED_BUILDER_CREDENTIALS_DIRECTORY
|
||||
/ 'source_admission_hmac'
|
||||
).read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
'release source admission credential is unavailable'
|
||||
) from exc
|
||||
source_store = ReleaseSourceAdmissionStore(
|
||||
Path(TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT),
|
||||
repository=builder_config.repository,
|
||||
policy_sha256=builder_config.policy_sha256,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=0,
|
||||
writable=False,
|
||||
)
|
||||
builder_config = replace(
|
||||
builder_config,
|
||||
source_admission_resolver=source_store.resolve_recipe,
|
||||
)
|
||||
source_admission_resolver = source_store.lookup_source
|
||||
client_keyring = BuilderKeyring(builder_hmac=client_hmac)
|
||||
client = UnixTrustedCandidateBuilderClient(
|
||||
builder_config,
|
||||
@@ -365,6 +403,7 @@ def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
builder_attestation_verifier=builder_attestation_verifier,
|
||||
trusted_policy=trusted_policy,
|
||||
builder_readiness_probe=builder_readiness_probe,
|
||||
source_admission_resolver=source_admission_resolver,
|
||||
allow_legacy_candidate_input=False,
|
||||
expected_repository=REPOSITORY_IDENTITY,
|
||||
policy_sha256=policy_sha,
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Owner-final, structured authorization for one governed pull-request merge.
|
||||
|
||||
The Owner approval is the policy decision. Signing and public verification
|
||||
prove that decision but cannot change its repository, pull request, CAS base,
|
||||
technical-gate contract, or lifetime. This module deliberately exposes no
|
||||
generic sign-bytes API and does not publish or alter technical check results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
SignedArtifact,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
POLICY_SCHEMA = "aios.owner-merge-approval-policy.v2"
|
||||
APPROVAL_SCHEMA = "aios.owner-merge-approval.v2"
|
||||
RECEIPT_SCHEMA = "aios.owner-merge-admission-receipt.v2"
|
||||
OPERATION = "merge.pull-request"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,255}$")
|
||||
CHECK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{11,255}$")
|
||||
|
||||
|
||||
class OwnerMergeApprovalError(RuntimeError):
|
||||
"""Stable fail-closed error for the Owner merge approval contract."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _require_oid(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or OID_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _require_digest(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or DIGEST_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeApprovalPolicy:
|
||||
"""Immutable policy input for one exact protected merge approval."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
expected_main_oid: str
|
||||
operation: str
|
||||
required_check_contract_digest: str
|
||||
required_checks: tuple[str, ...]
|
||||
owner_identity: str
|
||||
audit_lineage_digest: str
|
||||
maximum_ttl_seconds: int
|
||||
policy_version: str = "v2"
|
||||
schema: str = POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.schema != POLICY_SCHEMA
|
||||
or self.policy_version != "v2"
|
||||
or not isinstance(self.repository, str)
|
||||
or REPOSITORY_RE.fullmatch(self.repository) is None
|
||||
or type(self.pr_number) is not int
|
||||
or not 1 <= self.pr_number < 2**31
|
||||
or self.operation != OPERATION
|
||||
or not isinstance(self.owner_identity, str)
|
||||
or IDENTITY_RE.fullmatch(self.owner_identity) is None
|
||||
or type(self.maximum_ttl_seconds) is not int
|
||||
or not 60 <= self.maximum_ttl_seconds <= 900
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_INVALID")
|
||||
_require_oid(self.pr_head_oid, "OWNER_MERGE_POLICY_HEAD_INVALID")
|
||||
_require_oid(self.expected_main_oid, "OWNER_MERGE_POLICY_BASE_INVALID")
|
||||
_require_digest(
|
||||
self.required_check_contract_digest,
|
||||
"OWNER_MERGE_POLICY_CHECK_CONTRACT_INVALID",
|
||||
)
|
||||
_require_digest(self.audit_lineage_digest, "OWNER_MERGE_POLICY_LINEAGE_INVALID")
|
||||
if (
|
||||
not isinstance(self.required_checks, tuple)
|
||||
or not self.required_checks
|
||||
or len(set(self.required_checks)) != len(self.required_checks)
|
||||
or any(not isinstance(item, str) or CHECK_RE.fullmatch(item) is None for item in self.required_checks)
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_REQUIRED_CHECKS_INVALID")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
value = asdict(self)
|
||||
value["required_checks"] = list(self.required_checks)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.as_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeLiveState:
|
||||
"""Read-only live evidence; this object has no status publication method."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
main_oid: str
|
||||
required_check_contract_digest: str
|
||||
check_statuses: Mapping[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeOidReadback:
|
||||
authority_main_oid: str
|
||||
gitea_main_oid: str
|
||||
api_main_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeAdmissionReceipt:
|
||||
approval_id: str
|
||||
nonce: str
|
||||
owner_identity: str
|
||||
policy_digest: str
|
||||
proof_path: str
|
||||
merge_oid: str
|
||||
schema: str = RECEIPT_SCHEMA
|
||||
|
||||
|
||||
class ApprovalProofVerifier(Protocol):
|
||||
"""Public proof verifier; it is intentionally unable to issue approvals."""
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
key_id: str,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class GovernedMergeExecutor(Protocol):
|
||||
def execute(self, approval: Mapping[str, object]) -> str:
|
||||
...
|
||||
|
||||
|
||||
class MergeOidReconciler(Protocol):
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
...
|
||||
|
||||
|
||||
APPROVAL_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"approval_id",
|
||||
"nonce",
|
||||
"repository",
|
||||
"pr_number",
|
||||
"pr_head_oid",
|
||||
"expected_main_oid",
|
||||
"operation",
|
||||
"required_check_contract_digest",
|
||||
"required_checks",
|
||||
"owner_identity",
|
||||
"audit_lineage_digest",
|
||||
"policy_version",
|
||||
"policy_digest",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OwnerMergeApprovalAuthority:
|
||||
"""Structured Owner signer for an immutable merge policy.
|
||||
|
||||
``issue`` has no caller-controlled payload parameter. The immutable policy
|
||||
selects the complete authorization scope; only approval IDs and nonces are
|
||||
generated inside the authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] = secrets.token_urlsafe,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id="local-owner-authority",
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
|
||||
def _token(self, prefix: str) -> str:
|
||||
value = prefix + self.token_urlsafe(24)
|
||||
if TOKEN_RE.fullmatch(value) is None:
|
||||
_fail("OWNER_MERGE_AUTHORITY_GENERATED_TOKEN_INVALID")
|
||||
return value
|
||||
|
||||
def issue(self) -> SignedArtifact:
|
||||
now = int(self.clock())
|
||||
approval_id = self._token("owner-merge-approval-")
|
||||
nonce = self._token("owner-merge-nonce-")
|
||||
policy = self.policy
|
||||
payload: dict[str, Any] = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"approval_id": approval_id,
|
||||
"nonce": nonce,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + policy.maximum_ttl_seconds,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.signer.fingerprint,
|
||||
}
|
||||
payload["canonical_payload_digest"] = sha256_json(payload)
|
||||
return self.signer._sign_canonical(payload)
|
||||
|
||||
|
||||
class OwnerMergeApprovalVerifier:
|
||||
"""Verify Owner proof, preferring normal verification then recovery proof.
|
||||
|
||||
The recovery verifier validates a pre-existing approval with the registered
|
||||
public Owner root. It cannot issue an approval and is therefore not a
|
||||
second policy principal.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: ApprovalProofVerifier,
|
||||
recovery: ApprovalProofVerifier | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.recovery = recovery
|
||||
self.clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _artifact(artifact: SignedArtifact | Mapping[str, Any]) -> tuple[dict[str, Any], str, str, str]:
|
||||
if isinstance(artifact, SignedArtifact):
|
||||
return dict(artifact.payload), artifact.signature, artifact.key_id, artifact.namespace
|
||||
if not isinstance(artifact, Mapping) or set(artifact) != {"payload", "signature", "key_id", "namespace"}:
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
signature = artifact.get("signature")
|
||||
key_id = artifact.get("key_id")
|
||||
namespace = artifact.get("namespace")
|
||||
if not isinstance(payload, Mapping) or not all(isinstance(item, str) for item in (signature, key_id, namespace)):
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
return dict(payload), signature, key_id, namespace
|
||||
|
||||
@staticmethod
|
||||
def _validate_scope(payload: Mapping[str, Any], policy: OwnerMergeApprovalPolicy, *, now: int, key_id: str, namespace: str) -> None:
|
||||
if set(payload) != APPROVAL_FIELDS:
|
||||
_fail("OWNER_MERGE_APPROVAL_FIELDS_INVALID")
|
||||
expected = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": key_id,
|
||||
}
|
||||
if payload.get("pr_head_oid") != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_HEAD_BINDING_INVALID")
|
||||
if payload.get("expected_main_oid") != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID")
|
||||
if payload.get("policy_digest") != policy.digest:
|
||||
_fail("OWNER_MERGE_POLICY_DIGEST_DRIFT")
|
||||
if namespace != OWNER_NAMESPACE or any(payload.get(key) != value for key, value in expected.items()):
|
||||
_fail("OWNER_MERGE_APPROVAL_SCOPE_INVALID")
|
||||
for field in ("approval_id", "nonce"):
|
||||
if not isinstance(payload.get(field), str) or TOKEN_RE.fullmatch(payload[field]) is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_TOKEN_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(payload.get(field)) is not int:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
if not payload["not_before"] <= now < payload["expires_at"]:
|
||||
_fail("OWNER_MERGE_APPROVAL_EXPIRED")
|
||||
if payload["issued_at"] > now + 5 or payload["expires_at"] - payload["issued_at"] != policy.maximum_ttl_seconds:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
supplied = payload.get("canonical_payload_digest")
|
||||
body = dict(payload)
|
||||
body.pop("canonical_payload_digest", None)
|
||||
if supplied != sha256_json(body):
|
||||
_fail("OWNER_MERGE_APPROVAL_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact | Mapping[str, Any],
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
policy.validate()
|
||||
payload, signature, key_id, namespace = self._artifact(artifact)
|
||||
canonical = canonical_json_bytes(payload)
|
||||
proof_path: str | None = None
|
||||
for path, verifier in (("primary", self.primary), ("recovery", self.recovery)):
|
||||
if verifier is None:
|
||||
continue
|
||||
try:
|
||||
verified = verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=canonical,
|
||||
signature=signature,
|
||||
key_id=key_id,
|
||||
)
|
||||
except Exception:
|
||||
verified = False
|
||||
if verified is True:
|
||||
proof_path = path
|
||||
break
|
||||
if proof_path is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_SIGNATURE_INVALID")
|
||||
self._validate_scope(payload, policy, now=int(self.clock()), key_id=key_id, namespace=namespace)
|
||||
return payload, proof_path
|
||||
|
||||
|
||||
class SQLiteOwnerMergeNonceLedger:
|
||||
"""Durable one-shot fence. Unknown effects are intentionally not retried."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connection(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_NONCE_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_nonce ("
|
||||
"nonce TEXT PRIMARY KEY, approval_digest TEXT NOT NULL, state TEXT NOT NULL, claimed_at INTEGER NOT NULL)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def claim(self, *, nonce: str, approval_digest: str, now: int) -> None:
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT state FROM owner_merge_nonce WHERE nonce=?", (nonce,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_REPLAY")
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_nonce(nonce,approval_digest,state,claimed_at) VALUES(?,?,?,?)",
|
||||
(nonce, approval_digest, "PENDING", now),
|
||||
)
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def mark(self, *, nonce: str, state: str) -> None:
|
||||
if state not in {"COMPLETED", "UNKNOWN"}:
|
||||
_fail("OWNER_MERGE_NONCE_STATE_INVALID")
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
updated = connection.execute(
|
||||
"UPDATE owner_merge_nonce SET state=? WHERE nonce=? AND state='PENDING'",
|
||||
(state, nonce),
|
||||
).rowcount
|
||||
if updated != 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_STATE_CONFLICT")
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class GovernedOwnerMergeAdmission:
|
||||
"""Admit one protected merge without owning any policy or technical gate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
verifier: OwnerMergeApprovalVerifier,
|
||||
live_state_reader: Callable[[], OwnerMergeLiveState],
|
||||
executor: GovernedMergeExecutor,
|
||||
reconciler: MergeOidReconciler,
|
||||
nonce_ledger: SQLiteOwnerMergeNonceLedger,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.verifier = verifier
|
||||
self.live_state_reader = live_state_reader
|
||||
self.executor = executor
|
||||
self.reconciler = reconciler
|
||||
self.nonce_ledger = nonce_ledger
|
||||
self.clock = clock
|
||||
|
||||
def _validate_live_state(self, live: OwnerMergeLiveState) -> None:
|
||||
if not isinstance(live, OwnerMergeLiveState):
|
||||
_fail("OWNER_MERGE_LIVE_STATE_INVALID")
|
||||
policy = self.policy
|
||||
if live.repository != policy.repository:
|
||||
_fail("OWNER_MERGE_REPOSITORY_DRIFT")
|
||||
if live.pr_number != policy.pr_number:
|
||||
_fail("OWNER_MERGE_PR_BINDING_DRIFT")
|
||||
if live.pr_head_oid != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_HEAD_DRIFT")
|
||||
if live.main_oid != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_BASE_DRIFT")
|
||||
if live.required_check_contract_digest != policy.required_check_contract_digest:
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if not isinstance(live.check_statuses, Mapping) or set(live.check_statuses) != set(policy.required_checks):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if any(value is not True for value in live.check_statuses.values()):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECKS_FAILED")
|
||||
|
||||
def admit(self, artifact: SignedArtifact | Mapping[str, Any]) -> OwnerMergeAdmissionReceipt:
|
||||
approval, proof_path = self.verifier.verify(artifact, self.policy)
|
||||
self._validate_live_state(self.live_state_reader())
|
||||
nonce = str(approval["nonce"])
|
||||
approval_digest = hashlib.sha256(canonical_json_bytes(approval)).hexdigest()
|
||||
self.nonce_ledger.claim(nonce=nonce, approval_digest=approval_digest, now=int(self.clock()))
|
||||
try:
|
||||
merge_oid = self.executor.execute(dict(approval))
|
||||
_require_oid(merge_oid, "OWNER_MERGE_EXECUTOR_RESULT_INVALID")
|
||||
readback = self.reconciler.readback(merge_oid)
|
||||
if (
|
||||
not isinstance(readback, OwnerMergeOidReadback)
|
||||
or readback.authority_main_oid != merge_oid
|
||||
or readback.gitea_main_oid != merge_oid
|
||||
or readback.api_main_oid != merge_oid
|
||||
):
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
_fail("OWNER_MERGE_OID_RECONCILIATION_REQUIRED")
|
||||
except OwnerMergeApprovalError:
|
||||
# Every failure after a durable claim represents an operation that
|
||||
# is either known failed or needs reconciliation. Preserve the
|
||||
# one-shot fence rather than exposing a retryable PENDING state.
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_EXECUTION_UNKNOWN") from exc
|
||||
self.nonce_ledger.mark(nonce=nonce, state="COMPLETED")
|
||||
return OwnerMergeAdmissionReceipt(
|
||||
approval_id=str(approval["approval_id"]),
|
||||
nonce=nonce,
|
||||
owner_identity=str(approval["owner_identity"]),
|
||||
policy_digest=str(approval["policy_digest"]),
|
||||
proof_path=proof_path,
|
||||
merge_oid=merge_oid,
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Restricted issuance service for ``aios.owner-merge-approval.v2``.
|
||||
|
||||
The service has one Owner-only operation. It accepts an empty request payload,
|
||||
loads one immutable policy, generates approval identifiers and nonces itself,
|
||||
and stores one exact issuance response. It deliberately does not expose a
|
||||
generic signing, payload, or policy-selection API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_single_host_authority import RegistryOwnerApprovalVerifier, SignedArtifact
|
||||
|
||||
|
||||
ISSUE_OPERATION = "issue_owner_merge_approval"
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _secure_regular(path: Path, *, require_root_owner: bool) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_FILE_MISSING") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_INVALID")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_WRITABLE")
|
||||
if require_root_owner and metadata.st_uid != 0:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_NOT_ROOT_OWNED")
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool) -> OwnerMergeApprovalPolicy:
|
||||
_secure_regular(path, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
expected = set(OwnerMergeApprovalPolicy.__dataclass_fields__)
|
||||
if set(value) != expected or not isinstance(value.get("required_checks"), list):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
try:
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=value["repository"],
|
||||
pr_number=value["pr_number"],
|
||||
pr_head_oid=value["pr_head_oid"],
|
||||
expected_main_oid=value["expected_main_oid"],
|
||||
operation=value["operation"],
|
||||
required_check_contract_digest=value["required_check_contract_digest"],
|
||||
required_checks=tuple(value["required_checks"]),
|
||||
owner_identity=value["owner_identity"],
|
||||
audit_lineage_digest=value["audit_lineage_digest"],
|
||||
maximum_ttl_seconds=value["maximum_ttl_seconds"],
|
||||
policy_version=value["policy_version"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
policy.validate()
|
||||
except (KeyError, TypeError, OwnerMergeApprovalError) as exc:
|
||||
if isinstance(exc, OwnerMergeApprovalError):
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
return policy
|
||||
|
||||
|
||||
def _artifact_dict(artifact: SignedArtifact) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(artifact.payload),
|
||||
"signature": artifact.signature,
|
||||
"key_id": artifact.key_id,
|
||||
"namespace": artifact.namespace,
|
||||
}
|
||||
|
||||
|
||||
class SQLiteOwnerMergeIssuanceLedger:
|
||||
"""Durably replay a current approval and retain expired issuance history."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_issuance ("
|
||||
"nonce TEXT PRIMARY KEY, policy_digest TEXT NOT NULL, response_json TEXT NOT NULL, "
|
||||
"issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS owner_merge_issuance_policy_expiry "
|
||||
"ON owner_merge_issuance(policy_digest, expires_at)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
policy_digest: str,
|
||||
now: int,
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
validate_current: Callable[[dict[str, Any]], None],
|
||||
expiry_of: Callable[[dict[str, Any]], int],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT response_json, expires_at FROM owner_merge_issuance "
|
||||
"WHERE policy_digest=? AND expires_at>? ORDER BY issued_at DESC",
|
||||
(policy_digest, now),
|
||||
).fetchall()
|
||||
if len(rows) > 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if rows:
|
||||
try:
|
||||
response = json.loads(rows[0][0])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
connection.rollback()
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
if not isinstance(response, dict):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if type(rows[0][1]) is not int or expiry_of(response) != rows[0][1]:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
validate_current(response)
|
||||
connection.rollback()
|
||||
return response, True
|
||||
response = issuer()
|
||||
expires_at = expiry_of(response)
|
||||
if expires_at <= now:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_RESPONSE_EXPIRED")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("nonce"), str):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
encoded = json.dumps(
|
||||
response,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_issuance(nonce,policy_digest,response_json,issued_at,expires_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(payload["nonce"], policy_digest, encoded, now, expires_at),
|
||||
)
|
||||
connection.commit()
|
||||
return response, False
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.IntegrityError as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_NONCE_CONFLICT") from exc
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OwnerMergeApprovalService:
|
||||
"""A formal structured adapter, not an arbitrary signing oracle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy_path: Path,
|
||||
registry_path: Path,
|
||||
owner_key_path: Path,
|
||||
issuance_ledger_path: Path,
|
||||
allowed_uids: tuple[int, ...],
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] | None = None,
|
||||
) -> None:
|
||||
if not allowed_uids or any(type(uid) is not int or uid < 0 for uid in allowed_uids):
|
||||
_fail("OWNER_MERGE_ISSUER_ALLOWED_UIDS_INVALID")
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.require_root_owner = require_root_owner
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
self.ledger = SQLiteOwnerMergeIssuanceLedger(issuance_ledger_path)
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
_fail("OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_INVALID")
|
||||
if request.get("operation") != ISSUE_OPERATION:
|
||||
_fail("OWNER_MERGE_ISSUER_OPERATION_FORBIDDEN")
|
||||
if request.get("payload") != {}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_FIELDS_INVALID")
|
||||
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
_secure_regular(
|
||||
self.owner_key_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
primary = RegistryOwnerApprovalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OwnerMergeApprovalVerifier(primary=primary, clock=self.clock)
|
||||
|
||||
def expiry_of(response: dict[str, Any]) -> int:
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or type(payload.get("expires_at")) is not int:
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return payload["expires_at"]
|
||||
|
||||
def validate_current(response: dict[str, Any]) -> None:
|
||||
expiry_of(response)
|
||||
artifact = response["artifact"]
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
try:
|
||||
verifier.verify(artifact, policy)
|
||||
except OwnerMergeApprovalError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.token_urlsafe is not None:
|
||||
kwargs["token_urlsafe"] = self.token_urlsafe
|
||||
artifact = OwnerMergeApprovalAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
).issue()
|
||||
value = _artifact_dict(artifact)
|
||||
verifier.verify(value, policy)
|
||||
return {"status": "PASS", "artifact": value}
|
||||
|
||||
response, idempotent = self.ledger.issue_once(
|
||||
policy_digest=policy.digest,
|
||||
now=int(self.clock()),
|
||||
issuer=issue,
|
||||
validate_current=validate_current,
|
||||
expiry_of=expiry_of,
|
||||
)
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return {**response, "idempotent": idempotent}
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Bounded, read-only discovery over a verified immutable Git repository.
|
||||
|
||||
The module is deliberately independent from Task, lease, credential, worktree,
|
||||
and write code. Every public operation verifies the exact repository identity,
|
||||
resolves a floating ref once to a full commit OID, and then reads only that
|
||||
immutable snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from authoritative_git import AuthoritativeGitError, verify_repository
|
||||
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_IDENTITY_RE = re.compile(
|
||||
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
)
|
||||
_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})
|
||||
_MAX_COMMAND_OUTPUT = 1_048_576
|
||||
_MAX_RECORD_BYTES = 1_048_576
|
||||
_MAX_LIMIT = 1_000
|
||||
_MAX_DEPTH = 32
|
||||
_MAX_BYTES = 1_048_576
|
||||
_MAX_GLOBS = 16
|
||||
_MAX_QUERY_LENGTH = 512
|
||||
_MAX_CURSOR_LENGTH = 4_096
|
||||
_GIT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
class RepositoryDiscoveryError(RuntimeError):
|
||||
"""A fail-closed repository discovery request error."""
|
||||
|
||||
def __init__(self, code: str, *, status_code: int | None = None) -> None:
|
||||
self.code = str(code)
|
||||
self.status_code = (
|
||||
status_code if status_code is not None else self._status_for(self.code)
|
||||
)
|
||||
super().__init__(self.code)
|
||||
|
||||
@staticmethod
|
||||
def _status_for(code: str) -> int:
|
||||
if "OUTPUT_LIMIT" in code:
|
||||
return 413
|
||||
if code.endswith("_NOT_FOUND"):
|
||||
return 404
|
||||
if code in {"REPOSITORY_NOT_ALLOWED", "REPOSITORY_IDENTITY_MISMATCH"}:
|
||||
return 403
|
||||
if code in {
|
||||
"REPOSITORY_DISCOVERY_UNAVAILABLE",
|
||||
"REPOSITORY_DISCOVERY_GIT_FAILED",
|
||||
"REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID",
|
||||
"REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED",
|
||||
}:
|
||||
return 503
|
||||
return 400
|
||||
|
||||
|
||||
class RepositoryDiscoveryNotFound(RepositoryDiscoveryError):
|
||||
"""A validated immutable object or path was not found."""
|
||||
|
||||
|
||||
class RepositoryDiscoveryOutputTooLarge(RepositoryDiscoveryError):
|
||||
"""A fixed read-only Git query exceeded its collection boundary."""
|
||||
|
||||
|
||||
def _error(code: str) -> RepositoryDiscoveryError:
|
||||
return RepositoryDiscoveryError(code)
|
||||
|
||||
|
||||
def _request_mapping(request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return request
|
||||
|
||||
|
||||
def _required_request_value(request: Mapping[str, object], field: str) -> object:
|
||||
value = request.get(field)
|
||||
if value is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _positive_int(value: object, field: str, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise _error(f"REPOSITORY_DISCOVERY_INVALID_{field.upper()}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_path(
|
||||
value: object,
|
||||
field: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
allow_glob: bool = False,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or value != value.strip() or "\x00" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if "\\" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value in ("", "."):
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value.startswith(("/", "-")) or "//" in value or ":" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if not allow_glob and any(character in value for character in "*?["):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
parts = value.split("/")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if len(value) > 512:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_glob(value: object) -> str:
|
||||
return _safe_relative_path(value, "path_glob", allow_empty=False, allow_glob=True)
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> bool:
|
||||
return bool(
|
||||
_REF_RE.fullmatch(value)
|
||||
and ".." not in value
|
||||
and "//" not in value
|
||||
and "@{" not in value
|
||||
and not value.endswith(("/", ".", ".lock"))
|
||||
and not any(part in ("", ".", "..") for part in value.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def _parse_tree_record(record: bytes) -> dict[str, str] | None:
|
||||
try:
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, oid = header.decode("ascii").split(" ", 2)
|
||||
path = raw_path.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if kind not in {"blob", "tree", "commit"} or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
_safe_relative_path(path, "tree_path", allow_empty=False)
|
||||
except RepositoryDiscoveryError:
|
||||
# Git permits filenames outside the public POSIX-relative contract.
|
||||
# They are not exposed as usable discovery paths.
|
||||
return None
|
||||
return {"mode": mode, "type": kind, "object_oid": oid.lower(), "path": path}
|
||||
|
||||
|
||||
def _decode_output_path(raw: bytes) -> str:
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
return _safe_relative_path(value, "git_output_path", allow_empty=False)
|
||||
|
||||
|
||||
class RepositoryDiscovery:
|
||||
"""Repository-scoped immutable Git discovery with signed cursors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> None:
|
||||
configured = allowed_repositories
|
||||
if configured is None:
|
||||
configured = (os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""),)
|
||||
identities = tuple(configured)
|
||||
if not identities or any(
|
||||
not isinstance(item, str) or not _IDENTITY_RE.fullmatch(item)
|
||||
for item in identities
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_ALLOWLIST_INVALID")
|
||||
self._allowed_repositories = frozenset(identities)
|
||||
self._git_dir = git_dir
|
||||
secret = (
|
||||
cursor_secret
|
||||
if cursor_secret is not None
|
||||
else os.getenv("AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET", "")
|
||||
)
|
||||
if isinstance(secret, str):
|
||||
secret = secret.encode("utf-8")
|
||||
if not isinstance(secret, bytes) or len(secret) < 16:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED")
|
||||
self._cursor_secret = secret
|
||||
|
||||
def _repository_path(self, repository: object) -> Path:
|
||||
if not isinstance(repository, str) or repository not in self._allowed_repositories:
|
||||
raise _error("REPOSITORY_NOT_ALLOWED")
|
||||
try:
|
||||
path, actual_identity = verify_repository(
|
||||
git_dir=self._git_dir,
|
||||
repository_identity=repository,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
code = (
|
||||
"REPOSITORY_IDENTITY_MISMATCH"
|
||||
if "REPOSITORY_IDENTITY_MISMATCH" in str(exc)
|
||||
else "REPOSITORY_DISCOVERY_UNAVAILABLE"
|
||||
)
|
||||
raise _error(code) from exc
|
||||
if actual_identity != repository:
|
||||
raise _error("REPOSITORY_IDENTITY_MISMATCH")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _git_env() -> dict[str, str]:
|
||||
return {
|
||||
**os.environ,
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run(
|
||||
path: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
max_output: int = _MAX_COMMAND_OUTPUT,
|
||||
) -> bytes:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
output_limit = min(max_output, _MAX_COMMAND_OUTPUT)
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output = process.stdout.read(output_limit + 1)
|
||||
if len(output) > output_limit:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
raise RepositoryDiscoveryOutputTooLarge(
|
||||
"REPOSITORY_DISCOVERY_OUTPUT_LIMIT"
|
||||
)
|
||||
returncode = process.wait(timeout=_GIT_TIMEOUT_SECONDS)
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
if returncode != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _read_blob_prefix(path: Path, oid: str, maximum: int) -> tuple[bytes, bool]:
|
||||
"""Read at most maximum bytes plus one sentinel byte."""
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), "cat-file", "blob", oid],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
content = process.stdout.read(maximum + 1)
|
||||
truncated = len(content) > maximum
|
||||
if truncated:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
return content[:maximum], True
|
||||
if process.wait(timeout=_GIT_TIMEOUT_SECONDS) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return content, False
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
|
||||
def _iter_nul_records(self, path: Path, args: list[str]) -> Iterator[bytes]:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
buffer = b""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
if time.monotonic() - started > _GIT_TIMEOUT_SECONDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
if len(buffer) > _MAX_RECORD_BYTES and b"\0" not in buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
while b"\0" in buffer:
|
||||
record, buffer = buffer.split(b"\0", 1)
|
||||
if len(record) > _MAX_RECORD_BYTES:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
yield record
|
||||
if buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
if process.wait(
|
||||
timeout=max(0.1, _GIT_TIMEOUT_SECONDS - (time.monotonic() - started))
|
||||
) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
def _resolve_at(self, path: Path, revision: object, field: str) -> str:
|
||||
if (
|
||||
not isinstance(revision, str)
|
||||
or revision != revision.strip()
|
||||
or not revision
|
||||
or revision.startswith("-")
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
if _OID_RE.fullmatch(revision.lower()):
|
||||
candidates = [revision.lower()]
|
||||
elif revision == "HEAD":
|
||||
candidates = ["HEAD"]
|
||||
elif _safe_ref(revision) and not any(
|
||||
token in revision for token in ("^", "~", ":", "\\")
|
||||
):
|
||||
if revision.startswith("refs/"):
|
||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
candidates = [revision]
|
||||
else:
|
||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
||||
else:
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
resolved: list[str] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
output = self._run(
|
||||
path,
|
||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
||||
max_output=256,
|
||||
)
|
||||
except RepositoryDiscoveryError:
|
||||
continue
|
||||
oid = output.decode("ascii", errors="ignore").strip().lower()
|
||||
if _OID_RE.fullmatch(oid):
|
||||
resolved.append(oid)
|
||||
if not resolved:
|
||||
raise RepositoryDiscoveryNotFound(
|
||||
f"REPOSITORY_DISCOVERY_{field.upper()}_NOT_FOUND"
|
||||
)
|
||||
if len(set(resolved)) != 1:
|
||||
raise _error("REPOSITORY_DISCOVERY_AMBIGUOUS_REVISION")
|
||||
return resolved[0]
|
||||
|
||||
def resolve_revision(self, repository: str, revision: str) -> str:
|
||||
return self._resolve_at(self._repository_path(repository), revision, "revision")
|
||||
|
||||
def _cursor_offset(
|
||||
self,
|
||||
cursor: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
if (
|
||||
not isinstance(cursor, str)
|
||||
or not cursor
|
||||
or len(cursor) > _MAX_CURSOR_LENGTH
|
||||
or "." not in cursor
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
encoded, supplied_signature = cursor.split(".", 1)
|
||||
try:
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
|
||||
)
|
||||
except (UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID") from exc
|
||||
expected_signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
expected = {
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
}
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("v") != 1
|
||||
or any(payload.get(key) != value for key, value in expected.items())
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
offset = payload.get("offset")
|
||||
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
return offset
|
||||
|
||||
def _next_cursor(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
offset: int,
|
||||
) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
"offset": offset,
|
||||
}
|
||||
encoded = base64.urlsafe_b64encode(_canonical(payload)).decode("ascii").rstrip("=")
|
||||
signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def _iter_tree_entries(
|
||||
self,
|
||||
path: Path,
|
||||
oid: str,
|
||||
prefix: str,
|
||||
*,
|
||||
include_trees: bool,
|
||||
) -> Iterator[dict[str, str]]:
|
||||
args = ["ls-tree", "-z", "-r", "--full-tree"]
|
||||
if include_trees:
|
||||
args.append("-t")
|
||||
args.append(oid)
|
||||
if prefix:
|
||||
args.extend(["--", prefix])
|
||||
for record in self._iter_nul_records(path, args):
|
||||
entry = _parse_tree_record(record)
|
||||
if entry is None:
|
||||
continue
|
||||
if not prefix or entry["path"] == prefix or entry["path"].startswith(prefix + "/"):
|
||||
yield entry
|
||||
|
||||
def list_tree(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
prefix: str = "",
|
||||
depth: int = 8,
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
depth = _positive_int(depth, "depth", _MAX_DEPTH)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query = {"prefix": prefix, "depth": depth, "limit": limit}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, str]] = []
|
||||
matched = 0
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, prefix, include_trees=True):
|
||||
relative = entry["path"][len(prefix):].lstrip("/") if prefix else entry["path"]
|
||||
if relative.count("/") + 1 > depth:
|
||||
continue
|
||||
if matched >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
matched += 1
|
||||
has_more = offset + len(page) < matched
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def list_repository_tree(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
return self.list_tree(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
prefix=request.get("prefix", ""), # type: ignore[arg-type]
|
||||
depth=request.get("depth", 8), # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _blob_size(self, path: Path, oid: str) -> int:
|
||||
output = self._run(path, ["cat-file", "-s", oid], max_output=128)
|
||||
try:
|
||||
size = int(output.decode("ascii").strip())
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if size < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return size
|
||||
|
||||
def search_source(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
query: str,
|
||||
path_globs: Iterable[str] | None = None,
|
||||
max_matches: int = 100,
|
||||
max_bytes: int = 262_144,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(query, str)
|
||||
or not query
|
||||
or "\x00" in query
|
||||
or len(query) > _MAX_QUERY_LENGTH
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_QUERY_INVALID")
|
||||
max_matches = _positive_int(max_matches, "max_matches", _MAX_LIMIT)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
if path_globs is None:
|
||||
globs = ("*",)
|
||||
else:
|
||||
if isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
globs = tuple(_safe_glob(item) for item in path_globs)
|
||||
if not globs or len(globs) > _MAX_GLOBS:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query_binding = {
|
||||
"query": query,
|
||||
"path_globs": list(globs),
|
||||
"max_matches": max_matches,
|
||||
"max_bytes": max_bytes,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
)
|
||||
scanned_bytes = 0
|
||||
skipped_for_bound = False
|
||||
binary_blobs = 0
|
||||
matched = 0
|
||||
matches: list[dict[str, object]] = []
|
||||
has_more = False
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, "", include_trees=False):
|
||||
if entry["type"] != "blob" or not any(
|
||||
fnmatch.fnmatchcase(entry["path"], pattern) for pattern in globs
|
||||
):
|
||||
continue
|
||||
blob_size = self._blob_size(path, entry["object_oid"])
|
||||
remaining = max_bytes - scanned_bytes
|
||||
if blob_size > remaining:
|
||||
skipped_for_bound = True
|
||||
continue
|
||||
raw, clipped = self._read_blob_prefix(path, entry["object_oid"], blob_size)
|
||||
if clipped:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
scanned_bytes += len(raw)
|
||||
if b"\0" in raw:
|
||||
binary_blobs += 1
|
||||
continue
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if query not in line:
|
||||
continue
|
||||
if matched < offset:
|
||||
matched += 1
|
||||
continue
|
||||
if len(matches) >= max_matches:
|
||||
has_more = True
|
||||
break
|
||||
matches.append(
|
||||
{
|
||||
"path": entry["path"],
|
||||
"line_start": number,
|
||||
"line_end": number,
|
||||
"blob_oid": entry["object_oid"],
|
||||
"snippet": line[:512],
|
||||
}
|
||||
)
|
||||
matched += 1
|
||||
if has_more:
|
||||
break
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"matches": matches,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"max_bytes": max_bytes,
|
||||
"binary_blobs_skipped": binary_blobs,
|
||||
"truncated": bool(has_more or skipped_for_bound),
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
offset=offset + len(matches),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def search_repository_source(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
path_globs = request.get("path_globs")
|
||||
if path_globs is not None and (
|
||||
isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable)
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return self.search_source(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
query=_required_request_value(request, "query"), # type: ignore[arg-type]
|
||||
path_globs=path_globs, # type: ignore[arg-type]
|
||||
max_matches=request.get("max_matches", 100), # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _iter_diff_name_entries(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
) -> Iterator[dict[str, object]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--name-status",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
while True:
|
||||
try:
|
||||
status_raw = next(iterator)
|
||||
except StopIteration:
|
||||
return
|
||||
try:
|
||||
status = status_raw.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if not status or status[0] not in {"A", "C", "D", "M", "R", "T", "U", "X"}:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
if status[0] in {"R", "C"}:
|
||||
old_path = _decode_output_path(next(iterator))
|
||||
new_path = _decode_output_path(next(iterator))
|
||||
yield {
|
||||
"status": status,
|
||||
"path": new_path,
|
||||
"old_path": old_path,
|
||||
}
|
||||
else:
|
||||
yield {
|
||||
"status": status,
|
||||
"path": _decode_output_path(next(iterator)),
|
||||
"old_path": None,
|
||||
}
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_numstat_count(value: bytes) -> int | None:
|
||||
if value == b"-":
|
||||
return None
|
||||
try:
|
||||
parsed = int(value.decode("ascii"), 10)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if parsed < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return parsed
|
||||
|
||||
def _diff_stats(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
wanted_paths: set[str],
|
||||
) -> dict[str, tuple[int | None, int | None]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--numstat",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
stats: dict[str, tuple[int | None, int | None]] = {}
|
||||
for row in iterator:
|
||||
if not row:
|
||||
continue
|
||||
fields = row.split(b"\t", 2)
|
||||
if len(fields) != 3:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
additions = self._parse_numstat_count(fields[0])
|
||||
deletions = self._parse_numstat_count(fields[1])
|
||||
if fields[2]:
|
||||
stat_path = _decode_output_path(fields[2])
|
||||
else:
|
||||
try:
|
||||
next(iterator) # old path
|
||||
stat_path = _decode_output_path(next(iterator))
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if stat_path in wanted_paths:
|
||||
stats[stat_path] = (additions, deletions)
|
||||
if wanted_paths <= set(stats):
|
||||
break
|
||||
return stats
|
||||
|
||||
def diff_index(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
from_revision: str,
|
||||
to_revision: str,
|
||||
prefix: str = "",
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
from_oid = self._resolve_at(path, from_revision, "from")
|
||||
to_oid = self._resolve_at(path, to_revision, "to")
|
||||
query = {
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
"prefix": prefix,
|
||||
"limit": limit,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for entry in self._iter_diff_name_entries(path, from_oid, to_oid, prefix):
|
||||
if total >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
total += 1
|
||||
wanted_paths = {str(entry["path"]) for entry in page}
|
||||
stat_by_path = (
|
||||
self._diff_stats(path, from_oid, to_oid, prefix, wanted_paths)
|
||||
if page
|
||||
else {}
|
||||
)
|
||||
for entry in page:
|
||||
additions, deletions = stat_by_path.get(
|
||||
str(entry["path"]), (None, None)
|
||||
)
|
||||
entry["additions"] = additions
|
||||
entry["deletions"] = deletions
|
||||
has_more = offset + len(page) < total
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_from_oid": from_oid,
|
||||
"resolved_to_oid": to_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def get_diff_index(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
from_revision = request.get("from_revision", request.get("from"))
|
||||
to_revision = request.get("to_revision", request.get("to"))
|
||||
prefix = request.get("prefix", request.get("path_prefix", ""))
|
||||
if from_revision is None or to_revision is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.diff_index(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
from_revision=from_revision, # type: ignore[arg-type]
|
||||
to_revision=to_revision, # type: ignore[arg-type]
|
||||
prefix=prefix, # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
file_path: str,
|
||||
max_bytes: int = 262_144,
|
||||
) -> dict[str, object]:
|
||||
file_path = _safe_relative_path(file_path, "file_path", allow_empty=False)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
exact: list[dict[str, str]] = []
|
||||
for entry in self._iter_tree_entries(
|
||||
path, resolved_oid, file_path, include_trees=False
|
||||
):
|
||||
if entry["path"] == file_path and entry["type"] == "blob":
|
||||
exact.append(entry)
|
||||
if len(exact) != 1:
|
||||
raise RepositoryDiscoveryNotFound("REPOSITORY_DISCOVERY_FILE_NOT_FOUND")
|
||||
blob_oid = exact[0]["object_oid"]
|
||||
content, truncated = self._read_blob_prefix(path, blob_oid, max_bytes)
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"blob_oid": blob_oid,
|
||||
"path": file_path,
|
||||
"content": content.decode("utf-8", errors="replace"),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"content_bytes": len(content),
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
def read_repository_file(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
file_path = request.get("file_path", request.get("path"))
|
||||
if file_path is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.read_file(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
file_path=file_path, # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def create_repository_discovery(
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> RepositoryDiscovery:
|
||||
return RepositoryDiscovery(
|
||||
allowed_repositories=allowed_repositories,
|
||||
git_dir=git_dir,
|
||||
cursor_secret=cursor_secret,
|
||||
)
|
||||
|
||||
|
||||
def list_repository_tree(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).list_repository_tree(request)
|
||||
|
||||
|
||||
def search_repository_source(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).search_repository_source(request)
|
||||
|
||||
|
||||
def get_diff_index(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).get_diff_index(request)
|
||||
|
||||
|
||||
def read_repository_file(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).read_repository_file(request)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
"""Single-host operational delegation for AIOS migration.
|
||||
|
||||
This module is deliberately narrow:
|
||||
* Owner signs only OperationalDelegation and ExpiredTaskRecoveryDecision.
|
||||
* Machine signer signs only WorkerRuntimeBinding and WorkerRuntimeProof.
|
||||
* Every object is bound to one immutable Task identity and a narrow capability set.
|
||||
* There is no arbitrary sign-bytes API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_MACHINE_SIGNER_ID,
|
||||
DEFAULT_OWNER_SIGNER_ID,
|
||||
MACHINE_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry,
|
||||
SingleHostAuthorityError,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
verify_sshsig,
|
||||
)
|
||||
|
||||
OPERATIONAL_POLICY_SCHEMA = "aios.single-host-operational-policy.v1"
|
||||
OPERATIONAL_DELEGATION_SCHEMA = "aios.owner-operational-delegation.v1"
|
||||
RECOVERY_DECISION_SCHEMA = "aios.expired-task-recovery-decision.v1"
|
||||
WORKER_BINDING_SCHEMA = "aios.worker-runtime-binding.v1"
|
||||
WORKER_PROOF_SCHEMA = "aios.worker-runtime-proof.v1"
|
||||
REVOCATION_SCHEMA = "aios.operational-revocations.v1"
|
||||
|
||||
ALLOWED_OPERATIONAL_CAPABILITIES = frozenset({
|
||||
"task.recovery",
|
||||
"worker.runtime.bind",
|
||||
"worker.runtime.proof",
|
||||
"task.claim.guard",
|
||||
})
|
||||
ALLOWED_EXPECTED_STATE = "EXPIRED"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
|
||||
|
||||
class OperationalAuthorityError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _exact_fields(value: Mapping[str, Any], fields: set[str], code: str) -> None:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
fail(code)
|
||||
|
||||
|
||||
def _safe_id(value: str, code: str) -> str:
|
||||
if not isinstance(value, str) or not SAFE_ID.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _hex(value: str, n: int, code: str) -> str:
|
||||
regex = HEX40 if n == 40 else HEX64
|
||||
if not isinstance(value, str) or not regex.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _caps(value: Sequence[str], code: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or not value:
|
||||
fail(code)
|
||||
out = tuple(sorted(set(value)))
|
||||
if len(out) != len(value) or any(not isinstance(x, str) or not CAP.fullmatch(x) for x in out):
|
||||
fail(code)
|
||||
return out
|
||||
|
||||
|
||||
def task_identity_sha256(task: Mapping[str, Any]) -> str:
|
||||
"""Digest only immutable recovery/claim identity fields.
|
||||
|
||||
Revision, state, attempt and deadline are intentionally excluded.
|
||||
"""
|
||||
fields = (
|
||||
"task_id", "repository", "target_branch", "base_oid", "generation",
|
||||
"execution_plan_sha256", "external_job_identity",
|
||||
)
|
||||
try:
|
||||
value = {name: task[name] for name in fields}
|
||||
except KeyError as exc:
|
||||
raise OperationalAuthorityError("TASK_IDENTITY_FIELD_MISSING") from exc
|
||||
_safe_id(str(value["task_id"]), "TASK_ID_INVALID")
|
||||
_safe_id(str(value["repository"]), "REPOSITORY_INVALID")
|
||||
_safe_id(str(value["target_branch"]), "TARGET_BRANCH_INVALID")
|
||||
_hex(str(value["base_oid"]), 40, "BASE_OID_INVALID")
|
||||
if type(value["generation"]) is not int or value["generation"] < 1:
|
||||
fail("GENERATION_INVALID")
|
||||
_hex(str(value["execution_plan_sha256"]), 64, "EXECUTION_PLAN_DIGEST_INVALID")
|
||||
_safe_id(str(value["external_job_identity"]), "EXTERNAL_JOB_IDENTITY_INVALID")
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalPolicy:
|
||||
repository: str
|
||||
task_id: str
|
||||
task_identity_sha256: str
|
||||
target_branch: str
|
||||
base_oid: str
|
||||
generation: int
|
||||
execution_plan_sha256: str
|
||||
external_job_identity: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
allowed_worker_ids: tuple[str, ...]
|
||||
maximum_delegation_ttl_seconds: int
|
||||
maximum_recovery_decision_ttl_seconds: int
|
||||
maximum_binding_ttl_seconds: int
|
||||
maximum_proof_ttl_seconds: int
|
||||
maximum_heartbeat_age_seconds: int
|
||||
maximum_task_deadline_extension_seconds: int
|
||||
schema: str = OPERATIONAL_POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.schema != OPERATIONAL_POLICY_SCHEMA:
|
||||
fail("OPERATIONAL_POLICY_SCHEMA_INVALID")
|
||||
_safe_id(self.repository, "OPERATIONAL_POLICY_REPOSITORY_INVALID")
|
||||
_safe_id(self.task_id, "OPERATIONAL_POLICY_TASK_ID_INVALID")
|
||||
_hex(self.task_identity_sha256, 64, "OPERATIONAL_POLICY_TASK_IDENTITY_INVALID")
|
||||
_safe_id(self.target_branch, "OPERATIONAL_POLICY_BRANCH_INVALID")
|
||||
_hex(self.base_oid, 40, "OPERATIONAL_POLICY_BASE_OID_INVALID")
|
||||
if type(self.generation) is not int or self.generation < 1:
|
||||
fail("OPERATIONAL_POLICY_GENERATION_INVALID")
|
||||
_hex(self.execution_plan_sha256, 64, "OPERATIONAL_POLICY_PLAN_INVALID")
|
||||
_safe_id(self.external_job_identity, "OPERATIONAL_POLICY_JOB_INVALID")
|
||||
caps = _caps(self.allowed_capabilities, "OPERATIONAL_POLICY_CAPABILITIES_INVALID")
|
||||
if not set(caps).issubset(ALLOWED_OPERATIONAL_CAPABILITIES):
|
||||
fail("OPERATIONAL_POLICY_CAPABILITY_WIDENING")
|
||||
if not self.allowed_worker_ids or any(not SAFE_ID.fullmatch(x) for x in self.allowed_worker_ids):
|
||||
fail("OPERATIONAL_POLICY_WORKERS_INVALID")
|
||||
bounds = (
|
||||
(self.maximum_delegation_ttl_seconds, 300, 7 * 24 * 3600),
|
||||
(self.maximum_recovery_decision_ttl_seconds, 30, 900),
|
||||
(self.maximum_binding_ttl_seconds, 60, 24 * 3600),
|
||||
(self.maximum_proof_ttl_seconds, 15, 900),
|
||||
(self.maximum_heartbeat_age_seconds, 5, 300),
|
||||
(self.maximum_task_deadline_extension_seconds, 300, 24 * 3600),
|
||||
)
|
||||
if any(type(v) is not int or not lo <= v <= hi for v, lo, hi in bounds):
|
||||
fail("OPERATIONAL_POLICY_TTL_INVALID")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
self.validate()
|
||||
return sha256_json(asdict(self))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "OperationalPolicy":
|
||||
fields = set(cls.__dataclass_fields__)
|
||||
_exact_fields(value, fields, "OPERATIONAL_POLICY_FIELDS_INVALID")
|
||||
try:
|
||||
policy = cls(
|
||||
repository=value["repository"],
|
||||
task_id=value["task_id"],
|
||||
task_identity_sha256=value["task_identity_sha256"],
|
||||
target_branch=value["target_branch"],
|
||||
base_oid=value["base_oid"],
|
||||
generation=value["generation"],
|
||||
execution_plan_sha256=value["execution_plan_sha256"],
|
||||
external_job_identity=value["external_job_identity"],
|
||||
allowed_capabilities=tuple(value["allowed_capabilities"]),
|
||||
allowed_worker_ids=tuple(value["allowed_worker_ids"]),
|
||||
maximum_delegation_ttl_seconds=value["maximum_delegation_ttl_seconds"],
|
||||
maximum_recovery_decision_ttl_seconds=value["maximum_recovery_decision_ttl_seconds"],
|
||||
maximum_binding_ttl_seconds=value["maximum_binding_ttl_seconds"],
|
||||
maximum_proof_ttl_seconds=value["maximum_proof_ttl_seconds"],
|
||||
maximum_heartbeat_age_seconds=value["maximum_heartbeat_age_seconds"],
|
||||
maximum_task_deadline_extension_seconds=value["maximum_task_deadline_extension_seconds"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
except Exception as exc:
|
||||
raise OperationalAuthorityError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def build_exact_policy(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
worker_id: str,
|
||||
allowed_capabilities: Sequence[str] = tuple(sorted(ALLOWED_OPERATIONAL_CAPABILITIES)),
|
||||
) -> OperationalPolicy:
|
||||
identity = task_identity_sha256(task)
|
||||
policy = OperationalPolicy(
|
||||
repository=str(task["repository"]),
|
||||
task_id=str(task["task_id"]),
|
||||
task_identity_sha256=identity,
|
||||
target_branch=str(task["target_branch"]),
|
||||
base_oid=str(task["base_oid"]),
|
||||
generation=int(task["generation"]),
|
||||
execution_plan_sha256=str(task["execution_plan_sha256"]),
|
||||
external_job_identity=str(task["external_job_identity"]),
|
||||
allowed_capabilities=tuple(sorted(allowed_capabilities)),
|
||||
allowed_worker_ids=(worker_id,),
|
||||
maximum_delegation_ttl_seconds=24 * 3600,
|
||||
maximum_recovery_decision_ttl_seconds=300,
|
||||
maximum_binding_ttl_seconds=4 * 3600,
|
||||
maximum_proof_ttl_seconds=300,
|
||||
maximum_heartbeat_age_seconds=30,
|
||||
maximum_task_deadline_extension_seconds=4 * 3600,
|
||||
)
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def _signed_payload_dict(payload: Mapping[str, Any], *, key_id: str, namespace: str) -> dict[str, Any]:
|
||||
value = dict(payload)
|
||||
value["signature_namespace"] = namespace
|
||||
value["signature_key_id"] = key_id
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return value
|
||||
|
||||
|
||||
def _verify_digest_field(payload: Mapping[str, Any]) -> bool:
|
||||
if not isinstance(payload, Mapping) or "canonical_payload_digest" not in payload:
|
||||
return False
|
||||
value = dict(payload)
|
||||
supplied = value.pop("canonical_payload_digest", None)
|
||||
return isinstance(supplied, str) and supplied == sha256_json(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SignedOperationalArtifact:
|
||||
payload: dict[str, Any]
|
||||
signature: str
|
||||
key_id: str
|
||||
namespace: str
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> bytes:
|
||||
return canonical_json_bytes(self.payload)
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return hashlib.sha256(self.canonical_payload).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(self.payload),
|
||||
"signature": self.signature,
|
||||
"key_id": self.key_id,
|
||||
"namespace": self.namespace,
|
||||
}
|
||||
|
||||
|
||||
class OperationalOwnerAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=OWNER_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_operational_delegation(self, *, delegation_id: str, nonce: str, ttl_seconds: int) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
if not SAFE_ID.fullmatch(delegation_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("OPERATIONAL_DELEGATION_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 60 <= ttl_seconds <= p.maximum_delegation_ttl_seconds:
|
||||
fail("OPERATIONAL_DELEGATION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": OPERATIONAL_DELEGATION_SCHEMA,
|
||||
"delegation_id": delegation_id,
|
||||
"nonce": nonce,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"allowed_capabilities": list(p.allowed_capabilities),
|
||||
"allowed_worker_ids": list(p.allowed_worker_ids),
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
"revocation_epoch": 0,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_recovery_decision(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
decision_id: str,
|
||||
nonce: str,
|
||||
expected_revision: int,
|
||||
expected_attempt: int,
|
||||
original_deadline: int,
|
||||
new_deadline: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
verify_delegation_scope(delegation, self.policy, now=now)
|
||||
if "task.recovery" not in delegation["allowed_capabilities"]:
|
||||
fail("RECOVERY_CAPABILITY_NOT_DELEGATED")
|
||||
if not SAFE_ID.fullmatch(decision_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("RECOVERY_DECISION_ID_INVALID")
|
||||
if type(expected_revision) is not int or expected_revision < 0 or type(expected_attempt) is not int or expected_attempt < 0:
|
||||
fail("RECOVERY_CAS_INVALID")
|
||||
if type(original_deadline) is not int or type(new_deadline) is not int:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline <= now or new_deadline <= original_deadline:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline - now > self.policy.maximum_task_deadline_extension_seconds:
|
||||
fail("RECOVERY_DEADLINE_WIDENING")
|
||||
if type(ttl_seconds) is not int or not 15 <= ttl_seconds <= self.policy.maximum_recovery_decision_ttl_seconds:
|
||||
fail("RECOVERY_DECISION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": RECOVERY_DECISION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"task_id": self.policy.task_id,
|
||||
"task_identity_sha256": self.policy.task_identity_sha256,
|
||||
"expected_state": ALLOWED_EXPECTED_STATE,
|
||||
"expected_revision": expected_revision,
|
||||
"expected_attempt": expected_attempt,
|
||||
"original_deadline": original_deadline,
|
||||
"new_deadline": new_deadline,
|
||||
"repository": self.policy.repository,
|
||||
"target_branch": self.policy.target_branch,
|
||||
"base_oid": self.policy.base_oid,
|
||||
"generation": self.policy.generation,
|
||||
"execution_plan_sha256": self.policy.execution_plan_sha256,
|
||||
"external_job_identity": self.policy.external_job_identity,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
class OperationalMachineAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
||||
namespace=MACHINE_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=MACHINE_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_runtime_binding(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
binding_id: str,
|
||||
nonce: str,
|
||||
worker_id: str,
|
||||
runtime_id: str,
|
||||
runtime_sha256: str,
|
||||
task_contract_version: str,
|
||||
supported_contract_versions: Sequence[str],
|
||||
required_capabilities: Sequence[str],
|
||||
worker_capabilities: Sequence[str],
|
||||
max_concurrency: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
if "worker.runtime.bind" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_BIND_CAPABILITY_NOT_DELEGATED")
|
||||
if worker_id not in p.allowed_worker_ids:
|
||||
fail("WORKER_ID_NOT_DELEGATED")
|
||||
for val, code in [(binding_id, "BINDING_ID_INVALID"), (nonce, "BINDING_NONCE_INVALID"), (worker_id, "WORKER_ID_INVALID"), (runtime_id, "RUNTIME_ID_INVALID"), (task_contract_version, "CONTRACT_VERSION_INVALID")]:
|
||||
_safe_id(val, code)
|
||||
_hex(runtime_sha256, 64, "RUNTIME_SHA256_INVALID")
|
||||
supported = tuple(sorted(set(supported_contract_versions)))
|
||||
if task_contract_version not in supported or not supported or any(not SAFE_ID.fullmatch(x) for x in supported):
|
||||
fail("CONTRACT_INCOMPATIBLE")
|
||||
required = _caps(required_capabilities, "REQUIRED_CAPABILITIES_INVALID")
|
||||
worker_caps = _caps(worker_capabilities, "WORKER_CAPABILITIES_INVALID")
|
||||
if not set(required).issubset(set(worker_caps)):
|
||||
fail("CAPABILITY_MISMATCH")
|
||||
if type(max_concurrency) is not int or not 1 <= max_concurrency <= 64:
|
||||
fail("MAX_CONCURRENCY_INVALID")
|
||||
if type(ttl_seconds) is not int or not 30 <= ttl_seconds <= p.maximum_binding_ttl_seconds:
|
||||
fail("BINDING_TTL_INVALID")
|
||||
if now + ttl_seconds > int(delegation["expires_at"]):
|
||||
fail("BINDING_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_BINDING_SCHEMA,
|
||||
"binding_id": binding_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"worker_id": worker_id,
|
||||
"runtime_id": runtime_id,
|
||||
"runtime_sha256": runtime_sha256,
|
||||
"task_contract_version": task_contract_version,
|
||||
"supported_contract_versions": list(supported),
|
||||
"required_capabilities": list(required),
|
||||
"worker_capabilities": list(worker_caps),
|
||||
"max_concurrency": max_concurrency,
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_runtime_proof(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
binding: Mapping[str, Any],
|
||||
*,
|
||||
proof_id: str,
|
||||
nonce: str,
|
||||
heartbeat_at: int,
|
||||
adapter_readiness: bool,
|
||||
resource_readiness: bool,
|
||||
active_claim_count: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
verify_binding_scope(binding, delegation, p, now=now)
|
||||
if "worker.runtime.proof" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_PROOF_CAPABILITY_NOT_DELEGATED")
|
||||
_safe_id(proof_id, "PROOF_ID_INVALID")
|
||||
_safe_id(nonce, "PROOF_NONCE_INVALID")
|
||||
if type(heartbeat_at) is not int or heartbeat_at > now + 5 or now - heartbeat_at > p.maximum_heartbeat_age_seconds:
|
||||
fail("HEARTBEAT_STALE")
|
||||
if adapter_readiness is not True or resource_readiness is not True:
|
||||
fail("RUNTIME_NOT_READY")
|
||||
if type(active_claim_count) is not int or active_claim_count < 0 or active_claim_count >= int(binding["max_concurrency"]):
|
||||
fail("MAX_CONCURRENCY_REACHED")
|
||||
if type(ttl_seconds) is not int or not 10 <= ttl_seconds <= p.maximum_proof_ttl_seconds:
|
||||
fail("PROOF_TTL_INVALID")
|
||||
if now + ttl_seconds > min(int(binding["expires_at"]), int(delegation["expires_at"])):
|
||||
fail("PROOF_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_PROOF_SCHEMA,
|
||||
"proof_id": proof_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"binding_id": binding["binding_id"],
|
||||
"binding_digest": hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"worker_id": binding["worker_id"],
|
||||
"runtime_id": binding["runtime_id"],
|
||||
"runtime_sha256": binding["runtime_sha256"],
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"adapter_readiness": True,
|
||||
"resource_readiness": True,
|
||||
"active_claim_count": active_claim_count,
|
||||
"max_concurrency": binding["max_concurrency"],
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
DELEGATION_FIELDS = {
|
||||
"schema","delegation_id","nonce","owner_identity","repository","task_id",
|
||||
"task_identity_sha256","target_branch","base_oid","generation","execution_plan_sha256",
|
||||
"external_job_identity","allowed_capabilities","allowed_worker_ids","policy_digest",
|
||||
"issued_at","not_before","expires_at","revocation_epoch","status",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
BINDING_FIELDS = {
|
||||
"schema","binding_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"repository","task_id","task_identity_sha256","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities","worker_capabilities",
|
||||
"max_concurrency","policy_digest","issued_at","not_before","expires_at",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
PROOF_FIELDS = {
|
||||
"schema","proof_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"binding_id","binding_digest","repository","task_id","task_identity_sha256","generation",
|
||||
"execution_plan_sha256","worker_id","runtime_id","runtime_sha256","heartbeat_at",
|
||||
"adapter_readiness","resource_readiness","active_claim_count","max_concurrency",
|
||||
"policy_digest","issued_at","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
RECOVERY_FIELDS = {
|
||||
"schema","decision_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"task_id","task_identity_sha256","expected_state","expected_revision","expected_attempt",
|
||||
"original_deadline","new_deadline","repository","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","policy_digest","one_time_use",
|
||||
"issued_at","not_before","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_delegation_scope(value: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
policy.validate()
|
||||
_exact_fields(value, DELEGATION_FIELDS, "OPERATIONAL_DELEGATION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
value["schema"] != OPERATIONAL_DELEGATION_SCHEMA
|
||||
or value["status"] != "ACTIVE"
|
||||
or value["revocation_epoch"] != 0
|
||||
or value["owner_identity"] != OWNER_IDENTITY
|
||||
or value["repository"] != policy.repository
|
||||
or value["task_id"] != policy.task_id
|
||||
or value["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or value["target_branch"] != policy.target_branch
|
||||
or value["base_oid"] != policy.base_oid
|
||||
or value["generation"] != policy.generation
|
||||
or value["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or value["external_job_identity"] != policy.external_job_identity
|
||||
or value["policy_digest"] != policy.digest
|
||||
or not set(value["allowed_capabilities"]).issubset(set(policy.allowed_capabilities))
|
||||
or not set(value["allowed_worker_ids"]).issubset(set(policy.allowed_worker_ids))
|
||||
or value["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(value)
|
||||
or not int(value["not_before"]) <= now < int(value["expires_at"])
|
||||
):
|
||||
fail("OPERATIONAL_DELEGATION_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_binding_scope(binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(binding, BINDING_FIELDS, "WORKER_BINDING_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
expected_parent = hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
if (
|
||||
binding["schema"] != WORKER_BINDING_SCHEMA
|
||||
or binding["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or binding["parent_delegation_digest"] != expected_parent
|
||||
or binding["repository"] != policy.repository
|
||||
or binding["task_id"] != policy.task_id
|
||||
or binding["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or binding["target_branch"] != policy.target_branch
|
||||
or binding["base_oid"] != policy.base_oid
|
||||
or binding["generation"] != policy.generation
|
||||
or binding["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or binding["external_job_identity"] != policy.external_job_identity
|
||||
or binding["worker_id"] not in policy.allowed_worker_ids
|
||||
or binding["policy_digest"] != policy.digest
|
||||
or binding["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or not _verify_digest_field(binding)
|
||||
or not int(binding["not_before"]) <= now < int(binding["expires_at"])
|
||||
):
|
||||
fail("WORKER_BINDING_SCOPE_INVALID")
|
||||
required = set(binding["required_capabilities"])
|
||||
worker = set(binding["worker_capabilities"])
|
||||
if not required.issubset(worker) or binding["task_contract_version"] not in binding["supported_contract_versions"]:
|
||||
fail("WORKER_BINDING_COMPATIBILITY_INVALID")
|
||||
|
||||
|
||||
def verify_proof_scope(proof: Mapping[str, Any], binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(proof, PROOF_FIELDS, "WORKER_PROOF_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
proof["schema"] != WORKER_PROOF_SCHEMA
|
||||
or proof["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or proof["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or proof["binding_id"] != binding["binding_id"]
|
||||
or proof["binding_digest"] != hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest()
|
||||
or proof["repository"] != policy.repository
|
||||
or proof["task_id"] != policy.task_id
|
||||
or proof["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or proof["generation"] != policy.generation
|
||||
or proof["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or proof["worker_id"] != binding["worker_id"]
|
||||
or proof["runtime_id"] != binding["runtime_id"]
|
||||
or proof["runtime_sha256"] != binding["runtime_sha256"]
|
||||
or proof["max_concurrency"] != binding["max_concurrency"]
|
||||
or proof["policy_digest"] != policy.digest
|
||||
or proof["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or proof["adapter_readiness"] is not True
|
||||
or proof["resource_readiness"] is not True
|
||||
or not _verify_digest_field(proof)
|
||||
or not now < int(proof["expires_at"])
|
||||
or now - int(proof["heartbeat_at"]) > policy.maximum_heartbeat_age_seconds
|
||||
or int(proof["active_claim_count"]) >= int(proof["max_concurrency"])
|
||||
):
|
||||
fail("WORKER_PROOF_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_recovery_scope(decision: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(decision, RECOVERY_FIELDS, "RECOVERY_DECISION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
decision["schema"] != RECOVERY_DECISION_SCHEMA
|
||||
or decision["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or decision["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or decision["task_id"] != policy.task_id
|
||||
or decision["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or decision["expected_state"] != "EXPIRED"
|
||||
or decision["repository"] != policy.repository
|
||||
or decision["target_branch"] != policy.target_branch
|
||||
or decision["base_oid"] != policy.base_oid
|
||||
or decision["generation"] != policy.generation
|
||||
or decision["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or decision["external_job_identity"] != policy.external_job_identity
|
||||
or decision["policy_digest"] != policy.digest
|
||||
or decision["one_time_use"] is not True
|
||||
or decision["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(decision)
|
||||
or not int(decision["not_before"]) <= now < int(decision["expires_at"])
|
||||
or int(decision["new_deadline"]) <= now
|
||||
):
|
||||
fail("RECOVERY_DECISION_SCOPE_INVALID")
|
||||
|
||||
|
||||
class OperationalVerifier:
|
||||
"""Verify signatures through the public root-owned SignerRegistry."""
|
||||
|
||||
def __init__(self, registry_path: Path, *, require_root_owner: bool = True):
|
||||
self.registry_path = Path(registry_path)
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _verify(self, artifact: SignedOperationalArtifact | Mapping[str, Any], *, role: str, signer_id: str, namespace: str) -> dict[str, Any]:
|
||||
if isinstance(artifact, SignedOperationalArtifact):
|
||||
payload, signature, key_id, artifact_namespace = artifact.payload, artifact.signature, artifact.key_id, artifact.namespace
|
||||
else:
|
||||
_exact_fields(artifact, {"payload","signature","key_id","namespace"}, "SIGNED_ARTIFACT_FIELDS_INVALID")
|
||||
payload, signature, key_id, artifact_namespace = artifact["payload"], artifact["signature"], artifact["key_id"], artifact["namespace"]
|
||||
if artifact_namespace != namespace:
|
||||
fail("SIGNED_ARTIFACT_NAMESPACE_INVALID")
|
||||
registry = SignerRegistry(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
rec = registry.require(signer_id, role=role)
|
||||
if rec.fingerprint != key_id:
|
||||
fail("SIGNED_ARTIFACT_KEY_ID_INVALID")
|
||||
if role == "MACHINE_OPERATIONAL":
|
||||
owner_rec = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
||||
if rec.parent_signer_id != owner_rec.signer_id or rec.parent_fingerprint != owner_rec.fingerprint:
|
||||
fail("MACHINE_PARENT_BINDING_INVALID")
|
||||
# RegistryOwnerApprovalVerifier handles Owner namespace; generic machine uses registry public key.
|
||||
if role == "OWNER_AUTHORITY":
|
||||
verifier = RegistryOwnerApprovalVerifier(self.registry_path, require_root_owner=self.require_root_owner, signer_id=signer_id)
|
||||
ok = verifier.verify(namespace=namespace, payload=canonical_json_bytes(payload), signature=signature, key_id=key_id)
|
||||
else:
|
||||
from aios_single_host_authority import _load_public_key_line
|
||||
pub = _load_public_key_line(rec.public_key)
|
||||
ok = verify_sshsig(pub, canonical_json_bytes(payload), signature, namespace=namespace, expected_key_id=key_id)
|
||||
if not ok:
|
||||
fail("SIGNED_ARTIFACT_SIGNATURE_INVALID")
|
||||
return dict(payload)
|
||||
|
||||
def owner(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="OWNER_AUTHORITY", signer_id=DEFAULT_OWNER_SIGNER_ID, namespace=OWNER_NAMESPACE)
|
||||
|
||||
def machine(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="MACHINE_OPERATIONAL", signer_id=DEFAULT_MACHINE_SIGNER_ID, namespace=MACHINE_NAMESPACE)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Unix-domain fixed-schema service for operational migration authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
OWNER_NAMESPACE,
|
||||
MACHINE_NAMESPACE,
|
||||
SingleHostAuthorityError,
|
||||
)
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalMachineAuthority,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
OperationalVerifier,
|
||||
SignedOperationalArtifact,
|
||||
verify_binding_scope,
|
||||
verify_delegation_scope,
|
||||
)
|
||||
|
||||
DEFAULT_POLICY = Path("/etc/aios/single-host-operational-policy.json")
|
||||
DEFAULT_OWNER_KEY = Path("/var/lib/aios-owner-authority/owner-authority.key")
|
||||
DEFAULT_MACHINE_KEY = Path("/var/lib/aios-machine-signer/machine-operational.key")
|
||||
DEFAULT_OWNER_SOCKET = Path("/run/aios/owner-authority.sock")
|
||||
DEFAULT_MACHINE_SOCKET = Path("/run/aios/machine-signer.sock")
|
||||
DEFAULT_LEDGER = Path("/var/lib/aios-operational-authority/issuance-ledger.sqlite3")
|
||||
MAX_REQUEST = 128 * 1024
|
||||
READINESS_SCHEMA = "aios.signer-readiness-challenge.v1"
|
||||
|
||||
|
||||
class OperationalServiceError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalServiceError(code)
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool = True) -> OperationalPolicy:
|
||||
path=Path(path)
|
||||
if not path.is_absolute():
|
||||
fail("OPERATIONAL_POLICY_PATH_INVALID")
|
||||
flags=os.O_RDONLY
|
||||
if hasattr(os,"O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd=os.open(path,flags)
|
||||
except OSError as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_UNAVAILABLE") from exc
|
||||
try:
|
||||
st=os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
fail("OPERATIONAL_POLICY_NOT_REGULAR")
|
||||
mode=stat.S_IMODE(st.st_mode)
|
||||
if mode & 0o022:
|
||||
fail("OPERATIONAL_POLICY_WRITABLE_BY_GROUP_OR_OTHER")
|
||||
if require_root_owner and st.st_uid != 0:
|
||||
fail("OPERATIONAL_POLICY_NOT_ROOT_OWNED")
|
||||
raw=bytearray()
|
||||
while len(raw) <= MAX_REQUEST:
|
||||
chunk=os.read(fd,min(65536,MAX_REQUEST+1-len(raw)))
|
||||
if not chunk: break
|
||||
raw.extend(chunk)
|
||||
if not raw or len(raw)>MAX_REQUEST:
|
||||
fail("OPERATIONAL_POLICY_UNAVAILABLE")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
obj=json.loads(bytes(raw).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_INVALID") from exc
|
||||
return OperationalPolicy.from_mapping(obj)
|
||||
|
||||
|
||||
def _artifact(value: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
if not isinstance(value, Mapping) or set(value) != {"payload","signature","key_id","namespace"}:
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
if not isinstance(value["payload"], Mapping):
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
return SignedOperationalArtifact(
|
||||
payload=dict(value["payload"]),
|
||||
signature=str(value["signature"]),
|
||||
key_id=str(value["key_id"]),
|
||||
namespace=str(value["namespace"]),
|
||||
)
|
||||
|
||||
|
||||
def _peer_uid(conn: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
fail("PEER_CREDENTIALS_UNAVAILABLE")
|
||||
pid, uid, gid = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
class DurableIssuanceLedger:
|
||||
"""Root-owned exact-idempotent structured signing ledger."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if os.name == "posix": os.chmod(self.path.parent, 0o700)
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
fail("ISSUANCE_LEDGER_SYMLINK_FORBIDDEN")
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS issuance (
|
||||
role TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(role, operation, nonce)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def issue(self, *, role: str, operation: str, nonce: str, request: Mapping[str, Any], issuer: Any) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(request, sort_keys=True, separators=(",",":"), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT request_sha256,response_json FROM issuance WHERE role=? AND operation=? AND nonce=?",
|
||||
(role, operation, nonce),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != request_sha:
|
||||
conn.rollback()
|
||||
fail("NONCE_REUSE_WITH_DIFFERENT_REQUEST")
|
||||
result = json.loads(row[1])
|
||||
conn.commit()
|
||||
return result
|
||||
result = issuer()
|
||||
encoded = json.dumps(result, sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
||||
conn.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
class OperationalAuthorityService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
policy_path: Path = DEFAULT_POLICY,
|
||||
registry_path: Path = DEFAULT_REGISTRY_PATH,
|
||||
owner_key_path: Path = DEFAULT_OWNER_KEY,
|
||||
machine_key_path: Path = DEFAULT_MACHINE_KEY,
|
||||
socket_path: Path | None = None,
|
||||
ledger_path: Path = DEFAULT_LEDGER,
|
||||
allowed_uids: tuple[int, ...] = (0,),
|
||||
clock: Any = time.time,
|
||||
require_root_owner: bool = True,
|
||||
):
|
||||
if role not in {"owner","machine"}:
|
||||
fail("ROLE_INVALID")
|
||||
self.role = role
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.machine_key_path = Path(machine_key_path)
|
||||
self.socket_path = Path(socket_path or (DEFAULT_OWNER_SOCKET if role == "owner" else DEFAULT_MACHINE_SOCKET))
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.ledger = DurableIssuanceLedger(ledger_path)
|
||||
self.clock = clock
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(self.policy_path, require_root_owner=self.require_root_owner)
|
||||
verifier = OperationalVerifier(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
if self.role == "owner":
|
||||
signer = OperationalOwnerAuthority(self.owner_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(self.machine_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation","payload"} or not isinstance(request["payload"], Mapping):
|
||||
fail("REQUEST_INVALID")
|
||||
op = request["operation"]
|
||||
p = request["payload"]
|
||||
policy, verifier, signer = self._components()
|
||||
|
||||
if op == "identity":
|
||||
return {
|
||||
"status":"PASS",
|
||||
"role":self.role,
|
||||
"policy_digest":policy.digest,
|
||||
"key_id":signer.fingerprint,
|
||||
"socket":str(self.socket_path),
|
||||
"arbitrary_signing":False,
|
||||
}
|
||||
|
||||
if op == "readiness_challenge":
|
||||
if set(p) != {"challenge_id","challenge_sha256"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
challenge_id = str(p["challenge_id"])
|
||||
challenge_sha256 = str(p["challenge_sha256"])
|
||||
if not challenge_id or len(challenge_id) > 160:
|
||||
fail("READINESS_CHALLENGE_ID_INVALID")
|
||||
if not isinstance(challenge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", challenge_sha256):
|
||||
fail("READINESS_CHALLENGE_SHA256_INVALID")
|
||||
namespace = OWNER_NAMESPACE if self.role == "owner" else MACHINE_NAMESPACE
|
||||
def issue():
|
||||
art = signer._sign({
|
||||
"schema": READINESS_SCHEMA,
|
||||
"role": self.role,
|
||||
"challenge_id": challenge_id,
|
||||
"challenge_sha256": challenge_sha256,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": int(self.clock()),
|
||||
})
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(
|
||||
role=self.role,
|
||||
operation=op,
|
||||
nonce=challenge_id,
|
||||
request=p,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
if self.role == "owner":
|
||||
if op == "issue_operational_delegation":
|
||||
if set(p) != {"delegation_id","nonce","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
def issue():
|
||||
art = signer.issue_operational_delegation(
|
||||
delegation_id=str(p["delegation_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
if op == "issue_expired_task_recovery":
|
||||
if set(p) != {"delegation","decision_id","nonce","expected_revision","expected_attempt","original_deadline","new_deadline","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_recovery_decision(
|
||||
parent,
|
||||
decision_id=str(p["decision_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
expected_revision=p["expected_revision"],
|
||||
expected_attempt=p["expected_attempt"],
|
||||
original_deadline=p["original_deadline"],
|
||||
new_deadline=p["new_deadline"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
fail("OWNER_OPERATION_FORBIDDEN")
|
||||
|
||||
if op == "issue_worker_runtime_binding":
|
||||
if set(p) != {
|
||||
"delegation","binding_id","nonce","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities",
|
||||
"worker_capabilities","max_concurrency","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_binding(
|
||||
parent,
|
||||
binding_id=str(p["binding_id"]), nonce=str(p["nonce"]),
|
||||
worker_id=str(p["worker_id"]), runtime_id=str(p["runtime_id"]),
|
||||
runtime_sha256=str(p["runtime_sha256"]),
|
||||
task_contract_version=str(p["task_contract_version"]),
|
||||
supported_contract_versions=tuple(p["supported_contract_versions"]),
|
||||
required_capabilities=tuple(p["required_capabilities"]),
|
||||
worker_capabilities=tuple(p["worker_capabilities"]),
|
||||
max_concurrency=p["max_concurrency"], ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
if op == "issue_worker_runtime_proof":
|
||||
if set(p) != {
|
||||
"delegation","binding","proof_id","nonce","heartbeat_at","adapter_readiness",
|
||||
"resource_readiness","active_claim_count","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
binding_art = _artifact(p["binding"])
|
||||
parent = verifier.owner(parent_art)
|
||||
binding = verifier.machine(binding_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
verify_binding_scope(binding, parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_proof(
|
||||
parent, binding,
|
||||
proof_id=str(p["proof_id"]), nonce=str(p["nonce"]),
|
||||
heartbeat_at=p["heartbeat_at"],
|
||||
adapter_readiness=p["adapter_readiness"],
|
||||
resource_readiness=p["resource_readiness"],
|
||||
active_claim_count=p["active_claim_count"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
path = self.socket_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() or path.is_symlink():
|
||||
if path.is_socket():
|
||||
path.unlink()
|
||||
else:
|
||||
fail("SOCKET_PATH_OCCUPIED")
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(str(path))
|
||||
os.chmod(path, 0o600)
|
||||
sock.listen(16)
|
||||
while True:
|
||||
conn, _ = sock.accept()
|
||||
with conn:
|
||||
try:
|
||||
uid = _peer_uid(conn)
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
data = conn.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
total += len(data)
|
||||
if total > MAX_REQUEST:
|
||||
fail("REQUEST_TOO_LARGE")
|
||||
chunks.append(data)
|
||||
if b"\n" in data:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
line = raw.split(b"\n",1)[0]
|
||||
req = json.loads(line.decode("utf-8"))
|
||||
result = self.handle(req, peer_uid=uid)
|
||||
out = {"ok":True,"result":result}
|
||||
except (OperationalServiceError, OperationalAuthorityError, SingleHostAuthorityError) as exc:
|
||||
out = {"ok":False,"error":getattr(exc,"code",str(exc))}
|
||||
except Exception:
|
||||
out = {"ok":False,"error":"INTERNAL_FAIL_CLOSED"}
|
||||
conn.sendall(json.dumps(out, sort_keys=True, separators=(",",":")).encode("utf-8")+b"\n")
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--role", choices=("owner","machine"), required=True)
|
||||
ap.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
ap.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
ap.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
ap.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
ap.add_argument("--socket")
|
||||
ns = ap.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=ns.role,
|
||||
policy_path=Path(ns.policy),
|
||||
registry_path=Path(ns.registry),
|
||||
owner_key_path=Path(ns.owner_key),
|
||||
machine_key_path=Path(ns.machine_key),
|
||||
socket_path=Path(ns.socket) if ns.socket else None,
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -438,6 +438,71 @@ class TaskAdmissionStore:
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_grant_by_id(self, grant_id: str) -> TaskAdmissionGrant | None:
|
||||
"""Return one immutable admitted grant by its stable grant identity.
|
||||
|
||||
This is a read-only lookup used by the server-side TaskPrincipal
|
||||
recovery path. It never trusts caller-supplied grant content.
|
||||
"""
|
||||
if not isinstance(grant_id, str) or not grant_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_GRANT_ID_INVALID", 400)
|
||||
with self._connection() as con:
|
||||
row = con.execute(
|
||||
"SELECT grant_json FROM task_admission_grants WHERE grant_id=?",
|
||||
(grant_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(row["grant_json"])
|
||||
return TaskAdmissionGrant(
|
||||
**{
|
||||
**raw,
|
||||
"capabilities": tuple(raw["capabilities"]),
|
||||
"file_allowlist": tuple(raw["file_allowlist"]),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_DURABLE_GRANT_INVALID", 503
|
||||
) from exc
|
||||
|
||||
def get_grant_by_task_id(self, task_id: str) -> TaskAdmissionGrant | None:
|
||||
"""Return the immutable admitted grant bound to an authoritative Task."""
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_TASK_ID_INVALID", 400)
|
||||
with self._connection() as con:
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT g.grant_json
|
||||
FROM task_admission_grants AS g
|
||||
JOIN task_admission_receipts AS r
|
||||
ON r.envelope_id = g.envelope_id
|
||||
WHERE r.task_id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(row["grant_json"])
|
||||
grant = TaskAdmissionGrant(
|
||||
**{
|
||||
**raw,
|
||||
"capabilities": tuple(raw["capabilities"]),
|
||||
"file_allowlist": tuple(raw["file_allowlist"]),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_DURABLE_GRANT_INVALID", 503
|
||||
) from exc
|
||||
if grant.task_id != task_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_DURABLE_GRANT_TASK_MISMATCH", 503
|
||||
)
|
||||
return grant
|
||||
|
||||
def save_envelope(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -14,6 +14,7 @@ import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import time
|
||||
@@ -256,6 +257,28 @@ class AgentTaskAdmissionPort:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_TASK_STATE_UNKNOWN", 503)
|
||||
return recovered
|
||||
|
||||
def verify_active_task(
|
||||
self, grant: TaskAdmissionGrant
|
||||
) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
|
||||
"""Re-read and validate the authoritative Task for execution use.
|
||||
|
||||
Admission may complete while the Task is still QUEUED. A TaskPrincipal
|
||||
may be recovered only after the Task authority reports CLAIMED/RUNNING.
|
||||
The exact immutable metadata is revalidated on every call.
|
||||
"""
|
||||
execution = self._execution_plan(grant)
|
||||
task = self._read(grant, execution)
|
||||
if task is None:
|
||||
raise TaskAdmissionError("TASK_PRINCIPAL_TASK_NOT_FOUND", 403)
|
||||
state = str(task.get("state") or task.get("status") or "")
|
||||
if state not in {"CLAIMED", "RUNNING"}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_TASK_NOT_CLAIMED",
|
||||
409,
|
||||
details={"state": state},
|
||||
)
|
||||
return task, execution
|
||||
|
||||
def projection_result(
|
||||
self,
|
||||
grant: TaskAdmissionGrant,
|
||||
@@ -285,6 +308,151 @@ class AgentTaskAdmissionPort:
|
||||
"external_job_id": external_job_id,
|
||||
}
|
||||
|
||||
_BRIDGE_PRINCIPAL_CAPABILITIES = {
|
||||
"acquireWriterLease": "writer.lease",
|
||||
"commitFileGoverned": "governed.write.file",
|
||||
"commitFilesGoverned": "governed.write.files",
|
||||
"getGitOpsJob": "writer.lease",
|
||||
"resumeGitOpsJob": "writer.lease",
|
||||
"reconcileGitOpsJob": "writer.lease",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPrincipalProvider:
|
||||
"""Recover a short-lived execution principal from durable admission state.
|
||||
|
||||
This provider is server-side only. It never accepts an Owner grant,
|
||||
TaskPrincipal, or capability from caller JSON. Every use re-reads the
|
||||
admitted grant, re-verifies its parent Owner epoch (including revocation),
|
||||
re-reads the authoritative Task, and requires CLAIMED/RUNNING before a
|
||||
mutating Bridge operation can continue.
|
||||
"""
|
||||
|
||||
store: Any
|
||||
authorization: Any
|
||||
task_port: AgentTaskAdmissionPort
|
||||
epoch_id: str
|
||||
clock: Any = time.time
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
epoch = self.authorization.verify_delegation_epoch(self.epoch_id)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ready": False,
|
||||
"mode": "server-side-task-admission",
|
||||
"error": str(exc) or type(exc).__name__,
|
||||
}
|
||||
return {
|
||||
"ready": True,
|
||||
"mode": "server-side-task-admission",
|
||||
"epoch_id": epoch.epoch_id,
|
||||
"repository": epoch.repository,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _request_value(
|
||||
request: Mapping[str, Any], name: str, expected: object
|
||||
) -> None:
|
||||
if name in request and request[name] != expected:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_REQUEST_BINDING_MISMATCH",
|
||||
403,
|
||||
details={"field": name},
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
transport: Mapping[str, Any],
|
||||
request: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
operation = str(transport.get("operation") or "")
|
||||
capability = _BRIDGE_PRINCIPAL_CAPABILITIES.get(operation)
|
||||
if capability is None:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_BOOTSTRAP_ROUTE_REQUIRED", 403
|
||||
)
|
||||
task_id = request.get("task_id")
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise TaskAdmissionError("TASK_PRINCIPAL_TASK_ID_INVALID", 403)
|
||||
|
||||
grant = self.store.get_grant_by_task_id(task_id)
|
||||
if grant is None:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_AUTHORIZATION_REQUIRED", 403
|
||||
)
|
||||
if grant.parent_epoch_id is None:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_PARENT_EPOCH_REQUIRED", 403
|
||||
)
|
||||
|
||||
# Re-verify the exact parent on every use so revocation/expiry cannot be
|
||||
# bypassed by a previously admitted Task.
|
||||
epoch = self.authorization.verify_delegation_epoch(grant.parent_epoch_id)
|
||||
if (
|
||||
epoch.epoch_id != self.epoch_id
|
||||
or epoch.repository != grant.repository
|
||||
or epoch.trust_domain != grant.trust_domain
|
||||
or not set(grant.capabilities).issubset(epoch.allowed_capabilities)
|
||||
or not any(
|
||||
grant.target_branch.startswith(prefix)
|
||||
for prefix in epoch.allowed_branch_prefixes
|
||||
)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_PARENT_EPOCH_SCOPE_MISMATCH", 403
|
||||
)
|
||||
|
||||
now = int(self.clock())
|
||||
if not grant.issued_at <= now < grant.expires_at:
|
||||
raise TaskAdmissionError("TASK_PRINCIPAL_GRANT_EXPIRED", 403)
|
||||
if (
|
||||
"task.principal.issue" not in grant.capabilities
|
||||
or capability not in grant.capabilities
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_CAPABILITY_DENIED", 403
|
||||
)
|
||||
|
||||
task, execution = self.task_port.verify_active_task(grant)
|
||||
external_job_id = execution.get("external_job_id")
|
||||
if not isinstance(external_job_id, str) or not external_job_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_PRINCIPAL_EXECUTION_ID_INVALID", 503
|
||||
)
|
||||
|
||||
self._request_value(request, "task_id", grant.task_id)
|
||||
self._request_value(request, "job_id", external_job_id)
|
||||
self._request_value(request, "repository", grant.repository)
|
||||
self._request_value(request, "target_branch", grant.target_branch)
|
||||
self._request_value(request, "generation", grant.generation)
|
||||
if operation == "acquireWriterLease":
|
||||
self._request_value(
|
||||
request, "base_oid", grant.authority_base_oid
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "aios.task-principal-server-attestation.v1",
|
||||
"task_id": grant.task_id,
|
||||
"grant_id": grant.grant_id,
|
||||
"parent_epoch_id": grant.parent_epoch_id,
|
||||
"repository": grant.repository,
|
||||
"target_branch": grant.target_branch,
|
||||
"generation": grant.generation,
|
||||
"job_id": external_job_id,
|
||||
"capability": capability,
|
||||
"task_state": str(task.get("state") or task.get("status") or ""),
|
||||
"request_digest": sha256_json(dict(request)),
|
||||
"verified_at": now,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
"""Stable fail-closed host-composition error."""
|
||||
@@ -670,7 +838,7 @@ def task_admission_policy_from_epoch(
|
||||
)
|
||||
|
||||
|
||||
def _build_task_admission_handlers(
|
||||
def _build_task_admission_runtime(
|
||||
*,
|
||||
caller_service: str,
|
||||
epoch_root: Path,
|
||||
@@ -682,8 +850,8 @@ def _build_task_admission_handlers(
|
||||
task_control_token_file: Path,
|
||||
owner_verifier_module: Path,
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Compose handlers from already-trusted server-side coordinates."""
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
"""Compose admission handlers plus automatic execution-principal recovery."""
|
||||
from aios_task_admission_bootstrap import (
|
||||
TaskAdmissionBootstrapService,
|
||||
TaskAdmissionStore,
|
||||
@@ -708,34 +876,49 @@ def _build_task_admission_handlers(
|
||||
verified_epoch_provider=provider,
|
||||
signature_verifier=verifier,
|
||||
)
|
||||
transport = LocalAgentTaskHTTPTransport(
|
||||
base_url=task_api_url,
|
||||
token_file=task_control_token_file,
|
||||
require_root_owner=True,
|
||||
)
|
||||
task_port = AgentTaskAdmissionPort(
|
||||
LocalAgentTaskHTTPTransport(
|
||||
base_url=task_api_url,
|
||||
token_file=task_control_token_file,
|
||||
require_root_owner=True,
|
||||
),
|
||||
transport,
|
||||
execution_plan_provider=ExecutionPlanDirectoryProvider(
|
||||
root=execution_plan_dir,
|
||||
require_root_owner=True,
|
||||
),
|
||||
)
|
||||
store = TaskAdmissionStore(state_db)
|
||||
service = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(state_db),
|
||||
store=store,
|
||||
authorization=authorization,
|
||||
task_port=task_port,
|
||||
policy=task_admission_policy_from_epoch(epoch),
|
||||
post_admission=post_admission,
|
||||
)
|
||||
return TaskAdmissionBridgeContract(service).handlers()
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(service).handlers(),
|
||||
principal_verifier=TaskAdmissionPrincipalProvider(
|
||||
store=store,
|
||||
authorization=authorization,
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def build_task_admission_handlers_from_activation_file(
|
||||
def _build_task_admission_handlers(**kwargs: Any) -> Mapping[str, Any]:
|
||||
"""Compatibility wrapper for callers that only need admission routes."""
|
||||
return _build_task_admission_runtime(**kwargs).handlers
|
||||
|
||||
|
||||
def build_task_admission_runtime_from_activation_file(
|
||||
activation_file: Path,
|
||||
*,
|
||||
caller_service: str = "aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Load one root-owned, fail-closed staging activation descriptor."""
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
"""Load admission handlers plus server-side principal recovery."""
|
||||
_host_require_regular(activation_file, root_owned=True)
|
||||
value = _host_read_json_object(activation_file)
|
||||
required = {
|
||||
@@ -777,7 +960,7 @@ def build_task_admission_handlers_from_activation_file(
|
||||
if task_api_url != "http://127.0.0.1:18940":
|
||||
raise TaskAdmissionHostError("TASK_ADMISSION_ACTIVATION_TASK_API_INVALID")
|
||||
|
||||
return _build_task_admission_handlers(
|
||||
return _build_task_admission_runtime(
|
||||
caller_service=caller_service,
|
||||
epoch_root=paths["epoch_root"],
|
||||
epoch_id=epoch_id,
|
||||
@@ -791,12 +974,25 @@ def build_task_admission_handlers_from_activation_file(
|
||||
)
|
||||
|
||||
|
||||
def build_task_admission_handlers_from_environment(
|
||||
def build_task_admission_handlers_from_activation_file(
|
||||
activation_file: Path,
|
||||
*,
|
||||
caller_service: str = "aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Compose all three handlers from server-side trusted environment only."""
|
||||
return build_task_admission_runtime_from_activation_file(
|
||||
activation_file,
|
||||
caller_service=caller_service,
|
||||
post_admission=post_admission,
|
||||
).handlers
|
||||
|
||||
|
||||
def build_task_admission_runtime_from_environment(
|
||||
*,
|
||||
caller_service: str = "aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
"""Compose admission plus automatic TaskPrincipal recovery from trusted environment."""
|
||||
required = (
|
||||
"AIOS_TASK_ADMISSION_EPOCH_ROOT",
|
||||
"AIOS_TASK_ADMISSION_EPOCH_ID",
|
||||
@@ -813,7 +1009,7 @@ def build_task_admission_handlers_from_environment(
|
||||
"TASK_ADMISSION_HOST_ENV_MISSING:"
|
||||
+ ",".join(sorted(missing))
|
||||
)
|
||||
return _build_task_admission_handlers(
|
||||
return _build_task_admission_runtime(
|
||||
caller_service=caller_service,
|
||||
epoch_root=Path(os.environ["AIOS_TASK_ADMISSION_EPOCH_ROOT"]),
|
||||
epoch_id=os.environ["AIOS_TASK_ADMISSION_EPOCH_ID"],
|
||||
@@ -839,3 +1035,505 @@ def build_task_admission_handlers_from_environment(
|
||||
)),
|
||||
post_admission=post_admission,
|
||||
)
|
||||
|
||||
def build_task_admission_handlers_from_environment(
|
||||
*,
|
||||
caller_service: str = "aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission: Callable[[TaskAdmissionGrant, Mapping[str, Any]], Any] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
return build_task_admission_runtime_from_environment(
|
||||
caller_service=caller_service,
|
||||
post_admission=post_admission,
|
||||
).handlers
|
||||
|
||||
|
||||
# ---- producer phase core v12r4 (hash-pinned sibling source) ----
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
|
||||
TARGET_TASK_ID="TASK-SINGLEHOST-LINEAGE-SUPERSESSION-P0-20260905"
|
||||
TARGET_REPOSITORY="aios/aios"
|
||||
TARGET_BASE_OID="91f104ef62a3b688a9259b96e2ca247e0363b564"
|
||||
TARGET_BRANCH="agent/singlehost-lineage-supersession-p0-20260905"
|
||||
TARGET_GENERATION=1
|
||||
PLAN_SCHEMA_V3="aios.task-admission-execution-plan.v3"
|
||||
PLAN_FIELDS_V3=frozenset({
|
||||
"schema","task_id","repository","authority_base_oid","target_branch","generation",
|
||||
"file_allowlist","file_sha256","capabilities","risk",
|
||||
})
|
||||
_SHA256=re.compile(r"^[0-9a-f]{64}$")
|
||||
_OID=re.compile(r"^[0-9a-f]{40}$")
|
||||
_JOB_ID=re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$")
|
||||
|
||||
|
||||
class ProducerReceiptError(RuntimeError):
|
||||
def __init__(self,code:str):
|
||||
super().__init__(code)
|
||||
self.code=code
|
||||
|
||||
|
||||
def fail(code:str):
|
||||
raise ProducerReceiptError(code)
|
||||
|
||||
|
||||
def canonical_json_bytes(value:object)->bytes:
|
||||
return json.dumps(
|
||||
value,ensure_ascii=False,sort_keys=True,separators=(",",":"),allow_nan=False
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _pairs_no_duplicates(pairs):
|
||||
out={}
|
||||
for key,value in pairs:
|
||||
if key in out:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DUPLICATE_KEY")
|
||||
out[key]=value
|
||||
return out
|
||||
|
||||
|
||||
def strict_json_object(raw:bytes)->dict[str,Any]:
|
||||
try:
|
||||
value=json.loads(raw.decode("utf-8"),object_pairs_hook=_pairs_no_duplicates)
|
||||
except ProducerReceiptError:
|
||||
raise
|
||||
except Exception:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_JSON_INVALID")
|
||||
if not isinstance(value,dict):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_INVALID")
|
||||
if canonical_json_bytes(value)!=raw:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_NONCANONICAL")
|
||||
return value
|
||||
|
||||
|
||||
def _gv(grant:object,name:str)->Any:
|
||||
return grant.get(name) if isinstance(grant,Mapping) else getattr(grant,name,None)
|
||||
|
||||
|
||||
def require_target_grant(grant:object)->None:
|
||||
expected={
|
||||
"task_id":TARGET_TASK_ID,
|
||||
"repository":TARGET_REPOSITORY,
|
||||
"authority_base_oid":TARGET_BASE_OID,
|
||||
"target_branch":TARGET_BRANCH,
|
||||
"generation":TARGET_GENERATION,
|
||||
}
|
||||
for name,value in expected.items():
|
||||
if _gv(grant,name)!=value:
|
||||
fail("TASK_ADMISSION_POST_OWNER_RESERVATION_SCOPE_INVALID")
|
||||
digest=_gv(grant,"execution_digest")
|
||||
if not isinstance(digest,str) or not _SHA256.fullmatch(digest):
|
||||
fail("TASK_ADMISSION_EXECUTION_DIGEST_INVALID")
|
||||
|
||||
|
||||
def _safe_path(value:object)->str:
|
||||
if not isinstance(value,str) or not value or "\x00" in value:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
||||
p=Path(value)
|
||||
if p.is_absolute() or ".." in p.parts or "." in p.parts or p.as_posix()!=value:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
||||
if value==".git" or value.startswith(".git/"):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _unique_strings(value:object,code:str)->list[str]:
|
||||
if (
|
||||
not isinstance(value,list) or not value
|
||||
or not all(isinstance(x,str) and x for x in value)
|
||||
or len(set(value))!=len(value)
|
||||
):
|
||||
fail(code)
|
||||
return list(value)
|
||||
|
||||
|
||||
def validate_v3_plan(*,grant:object,raw:bytes)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
plan=strict_json_object(raw)
|
||||
if set(plan)!=PLAN_FIELDS_V3:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FIELDS_INVALID")
|
||||
if plan["schema"]!=PLAN_SCHEMA_V3:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_SCHEMA_INVALID")
|
||||
fields=(
|
||||
("task_id","TASK_ADMISSION_EXECUTION_PLAN_TASK_MISMATCH"),
|
||||
("repository","TASK_ADMISSION_EXECUTION_PLAN_REPOSITORY_MISMATCH"),
|
||||
("authority_base_oid","TASK_ADMISSION_EXECUTION_PLAN_BASE_MISMATCH"),
|
||||
("target_branch","TASK_ADMISSION_EXECUTION_PLAN_BRANCH_MISMATCH"),
|
||||
("generation","TASK_ADMISSION_EXECUTION_PLAN_GENERATION_MISMATCH"),
|
||||
)
|
||||
for field,code in fields:
|
||||
if plan[field]!=_gv(grant,field):
|
||||
fail(code)
|
||||
if not _OID.fullmatch(str(plan["authority_base_oid"])):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_BASE_INVALID")
|
||||
|
||||
allow=[_safe_path(x) for x in _unique_strings(
|
||||
plan["file_allowlist"],"TASK_ADMISSION_EXECUTION_PLAN_ALLOWLIST_INVALID"
|
||||
)]
|
||||
if allow!=list(_gv(grant,"file_allowlist") or ()):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_ALLOWLIST_MISMATCH")
|
||||
hashes=plan["file_sha256"]
|
||||
if not isinstance(hashes,dict) or set(hashes)!=set(allow):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_HASH_SET_MISMATCH")
|
||||
if any(not isinstance(v,str) or not _SHA256.fullmatch(v) for v in hashes.values()):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_HASH_INVALID")
|
||||
|
||||
caps=_unique_strings(
|
||||
plan["capabilities"],"TASK_ADMISSION_EXECUTION_PLAN_CAPABILITIES_INVALID"
|
||||
)
|
||||
if caps!=list(_gv(grant,"capabilities") or ()):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if plan["risk"]!=_gv(grant,"risk"):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_RISK_MISMATCH")
|
||||
if hashlib.sha256(raw).hexdigest()!=_gv(grant,"execution_digest"):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DIGEST_MISMATCH")
|
||||
return plan
|
||||
|
||||
|
||||
def load_v3_plan(root:Path,grant:object)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
try:
|
||||
rst=root.lstat()
|
||||
except OSError:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||
if (
|
||||
stat.S_ISLNK(rst.st_mode) or not stat.S_ISDIR(rst.st_mode)
|
||||
or rst.st_uid!=0 or rst.st_mode&0o022
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_DIRECTORY_INVALID")
|
||||
path=root/(str(_gv(grant,"execution_digest"))+".json")
|
||||
try:
|
||||
st=path.lstat()
|
||||
except OSError:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||
if (
|
||||
stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode)
|
||||
or st.st_uid!=0 or st.st_mode&0o022
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_FILE_INVALID")
|
||||
try:
|
||||
raw=path.read_bytes()
|
||||
except OSError:
|
||||
fail("TASK_ADMISSION_EXECUTION_PLAN_UNAVAILABLE")
|
||||
return validate_v3_plan(grant=grant,raw=raw)
|
||||
|
||||
|
||||
def reservation_idempotency_key(grant:object)->str:
|
||||
require_target_grant(grant)
|
||||
material=(
|
||||
str(_gv(grant,"grant_id"))+"\0"+str(_gv(grant,"execution_digest"))
|
||||
).encode("utf-8")
|
||||
return "task-admission-execution-reservation-"+hashlib.sha256(material).hexdigest()[:32]
|
||||
|
||||
|
||||
def build_reservation_request(grant:object)->dict[str,Any]:
|
||||
require_target_grant(grant)
|
||||
return {
|
||||
"task_id":TARGET_TASK_ID,
|
||||
"repository":TARGET_REPOSITORY,
|
||||
"base_oid":TARGET_BASE_OID,
|
||||
"target_branch":TARGET_BRANCH,
|
||||
"generation":TARGET_GENERATION,
|
||||
"risk":str(_gv(grant,"risk")),
|
||||
"description":"Reserve one inert durable GitOps Job identity after exact Owner TaskAdmission authorization; no Agent Task claim or execution.",
|
||||
"idempotency_key":reservation_idempotency_key(grant),
|
||||
}
|
||||
|
||||
|
||||
def _mapping(value:object,code:str)->Mapping[str,Any]:
|
||||
if not isinstance(value,Mapping):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def validate_bootstrap_response(
|
||||
*,
|
||||
grant:object,
|
||||
request:Mapping[str,Any],
|
||||
response:object,
|
||||
expected_side_effect_state:str,
|
||||
)->str:
|
||||
require_target_grant(grant)
|
||||
result=_mapping(response,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
||||
job_id=result.get("job_id")
|
||||
if not isinstance(job_id,str) or not _JOB_ID.fullmatch(job_id):
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_JOB_ID_INVALID")
|
||||
expected={
|
||||
"task_id":TARGET_TASK_ID,
|
||||
"job_id":job_id,
|
||||
"repository":TARGET_REPOSITORY,
|
||||
"base_oid":TARGET_BASE_OID,
|
||||
"expected_branch_oid":TARGET_BASE_OID,
|
||||
"target_branch":TARGET_BRANCH,
|
||||
"branch_mode":"CONTINUE_EXISTING",
|
||||
"generation":TARGET_GENERATION,
|
||||
"state":"BOUND",
|
||||
}
|
||||
for key,value in expected.items():
|
||||
if result.get(key)!=value:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_BINDING_CONFLICT")
|
||||
|
||||
receipt=_mapping(
|
||||
result.get("reservation_receipt"),
|
||||
"TASK_ADMISSION_EXECUTION_RESERVATION_RECEIPT_MISSING",
|
||||
)
|
||||
expected_keys={
|
||||
"job_id","state","side_effect_state",
|
||||
"producer_idempotency_key","request_idempotency_key",
|
||||
}
|
||||
if set(receipt)!=expected_keys:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_RECEIPT_FIELDS_INVALID")
|
||||
producer_key=receipt.get("producer_idempotency_key")
|
||||
if (
|
||||
receipt.get("job_id")!=job_id
|
||||
or receipt.get("state")!="REQUESTED"
|
||||
or receipt.get("side_effect_state")!=expected_side_effect_state
|
||||
or not isinstance(producer_key,str)
|
||||
or not _SHA256.fullmatch(producer_key)
|
||||
or job_id!="job-"+producer_key[:32]
|
||||
or receipt.get("request_idempotency_key")!=request.get("idempotency_key")
|
||||
):
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_RECEIPT_CONFLICT")
|
||||
return job_id
|
||||
|
||||
|
||||
def _approved_binding_fingerprint(response:Mapping[str,Any])->str:
|
||||
keys=(
|
||||
"task_id","job_id","repository","base_oid","expected_branch_oid",
|
||||
"target_branch","branch_mode","generation","state","reservation_receipt",
|
||||
)
|
||||
return hashlib.sha256(canonical_json_bytes({k:response.get(k) for k in keys})).hexdigest()
|
||||
|
||||
|
||||
def _stable_receipt_identity(response:Mapping[str,Any])->dict[str,Any]:
|
||||
receipt=_mapping(response.get("reservation_receipt"),"TASK_ADMISSION_EXECUTION_RESERVATION_RECEIPT_MISSING")
|
||||
return {
|
||||
"task_id":response.get("task_id"),
|
||||
"job_id":response.get("job_id"),
|
||||
"repository":response.get("repository"),
|
||||
"base_oid":response.get("base_oid"),
|
||||
"expected_branch_oid":response.get("expected_branch_oid"),
|
||||
"target_branch":response.get("target_branch"),
|
||||
"branch_mode":response.get("branch_mode"),
|
||||
"generation":response.get("generation"),
|
||||
"binding_state":response.get("state"),
|
||||
"receipt_state":receipt.get("state"),
|
||||
"producer_idempotency_key":receipt.get("producer_idempotency_key"),
|
||||
"request_idempotency_key":receipt.get("request_idempotency_key"),
|
||||
}
|
||||
|
||||
def validate_bootstrap_replay(
|
||||
*,
|
||||
grant:object,
|
||||
request:Mapping[str,Any],
|
||||
first:object,
|
||||
replay:object,
|
||||
replay_side_effect_state:str,
|
||||
)->str:
|
||||
first_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=first,expected_side_effect_state="QUEUED"
|
||||
)
|
||||
replay_id=validate_bootstrap_response(
|
||||
grant=grant,request=request,response=replay,
|
||||
expected_side_effect_state=replay_side_effect_state
|
||||
)
|
||||
if first_id!=replay_id:
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_JOB_CONFLICT")
|
||||
first_map=_mapping(first,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
||||
replay_map=_mapping(replay,"TASK_ADMISSION_EXECUTION_RESERVATION_RESPONSE_INVALID")
|
||||
if _stable_receipt_identity(first_map)!=_stable_receipt_identity(replay_map):
|
||||
fail("TASK_ADMISSION_EXECUTION_RESERVATION_REPLAY_IDENTITY_CONFLICT")
|
||||
return first_id
|
||||
|
||||
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — BEGIN =====
|
||||
import inspect as _fl_inspect
|
||||
|
||||
_FL_HARDENING_PROVIDER = ExecutionPlanDirectoryProvider
|
||||
_FL_BUILD_FROM_ACTIVATION = build_task_admission_handlers_from_activation_file
|
||||
_FL_BUILD_FROM_ENVIRONMENT = globals().get("build_task_admission_handlers_from_environment")
|
||||
_FL_APPROVED_BOOTSTRAP_SHA256="758895a8407568890fd2f8eb2f232a3dbf2cfa369e5f65ae8c6975bf8d01e3b5"
|
||||
REPLAY_SIDE_EFFECT_STATE="SAME_JOB"
|
||||
|
||||
def _fl_error(code:str,status:int=403):
|
||||
cls=globals().get("TaskAdmissionError",RuntimeError)
|
||||
try:
|
||||
return cls(code,status)
|
||||
except TypeError:
|
||||
return cls(code)
|
||||
|
||||
def _fl_sha(path:Path)->str:
|
||||
h=hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda:f.read(1024*1024),b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def _fl_provider_root(provider:object)->Path:
|
||||
found=[]
|
||||
for name in ("root","directory","plan_dir","execution_plan_dir"):
|
||||
raw=getattr(provider,name,None)
|
||||
if isinstance(raw,(str,os.PathLike)):
|
||||
p=Path(raw)
|
||||
if p.is_absolute():
|
||||
found.append(p)
|
||||
unique={str(p) for p in found}
|
||||
if len(unique)!=1:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_ROOT_INVALID",503)
|
||||
return Path(next(iter(unique)))
|
||||
|
||||
def _fl_assert_bootstrap_source(bootstrap:object)->None:
|
||||
raw=_fl_inspect.getsourcefile(type(bootstrap))
|
||||
if not raw:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_UNKNOWN",503)
|
||||
path=Path(raw)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_INVALID",503)
|
||||
if _fl_sha(path)!=_FL_APPROVED_BOOTSTRAP_SHA256:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BOOTSTRAP_SOURCE_DRIFT",503)
|
||||
|
||||
class _FLProducerReceiptReserver:
|
||||
def __init__(self,runtime:object):
|
||||
self.runtime=runtime
|
||||
|
||||
def __call__(self,grant:object,plan:Mapping[str,Any])->str:
|
||||
authority=getattr(self.runtime,"authority",None)
|
||||
bootstrap=getattr(self.runtime,"bootstrap",None)
|
||||
bindings=getattr(self.runtime,"bindings",None)
|
||||
if (
|
||||
authority is None or bootstrap is None
|
||||
or not callable(getattr(bootstrap,"create",None))
|
||||
or not isinstance(bindings,dict)
|
||||
):
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
|
||||
_fl_assert_bootstrap_source(bootstrap)
|
||||
|
||||
try:
|
||||
main_before=authority.read_ref("refs/heads/main")
|
||||
branch_before=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||
if main_before!=TARGET_BASE_OID or branch_before!=TARGET_BASE_OID:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_DRIFT",409)
|
||||
|
||||
request=build_reservation_request(grant)
|
||||
request_before=dict(request)
|
||||
try:
|
||||
first=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
||||
validate_bootstrap_response(grant=grant,request=request,response=first,expected_side_effect_state="QUEUED")
|
||||
|
||||
replay=bootstrap.create(request)
|
||||
if request!=request_before:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REQUEST_MUTATED",503)
|
||||
job_id=validate_bootstrap_replay(
|
||||
grant=grant,request=request,first=first,replay=replay,
|
||||
replay_side_effect_state=REPLAY_SIDE_EFFECT_STATE,
|
||||
)
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,409) from exc
|
||||
except Exception as exc:
|
||||
task_error=globals().get("TaskAdmissionError")
|
||||
if isinstance(task_error,type) and isinstance(exc,task_error):
|
||||
raise
|
||||
raise _fl_error(
|
||||
str(exc) or "TASK_ADMISSION_EXECUTION_RESERVATION_FAILED",503
|
||||
) from exc
|
||||
|
||||
# Existing runtime's public createGovernedTask projection performs this
|
||||
# in-memory binding update after bootstrap.create(). Mirror it only
|
||||
# after the exact producer receipt + replay proof has passed.
|
||||
bindings[TARGET_TASK_ID]=dict(replay)
|
||||
|
||||
try:
|
||||
main_after=authority.read_ref("refs/heads/main")
|
||||
branch_after=authority.read_ref("refs/heads/"+TARGET_BRANCH)
|
||||
except Exception as exc:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_AUTHORITY_UNAVAILABLE",503) from exc
|
||||
if main_after!=main_before or branch_after!=branch_before:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_REF_MUTATION",409)
|
||||
return job_id
|
||||
|
||||
class V3ProducerReceiptExecutionPlanDirectoryProvider(_FL_HARDENING_PROVIDER):
|
||||
_reserver:_FLProducerReceiptReserver|None=None
|
||||
|
||||
def _target(self,grant:object)->Mapping[str,Any]:
|
||||
if self._reserver is None:
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_RESERVATION_BACKEND_INVALID",503)
|
||||
try:
|
||||
plan=load_v3_plan(_fl_provider_root(self),grant)
|
||||
job_id=self._reserver(grant,plan)
|
||||
except ProducerReceiptError as exc:
|
||||
raise _fl_error(exc.code,403) from exc
|
||||
return {**plan,"external_job_id":job_id}
|
||||
|
||||
def __call__(self,grant:object)->Mapping[str,Any]:
|
||||
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||
parent=getattr(super(),"__call__",None)
|
||||
if callable(parent):
|
||||
return parent(grant)
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||
return self._target(grant)
|
||||
|
||||
def load(self,grant:object)->Mapping[str,Any]:
|
||||
if getattr(grant,"task_id",None)!=TARGET_TASK_ID:
|
||||
parent=getattr(super(),"load",None)
|
||||
if callable(parent):
|
||||
return parent(grant)
|
||||
parent_call=getattr(super(),"__call__",None)
|
||||
if callable(parent_call):
|
||||
return parent_call(grant)
|
||||
raise _fl_error("TASK_ADMISSION_EXECUTION_PLAN_PROVIDER_CONTRACT_INVALID",503)
|
||||
return self._target(grant)
|
||||
|
||||
def _fl_runtime_from_post_admission(post_admission:object)->object:
|
||||
runtime=getattr(post_admission,"__self__",None)
|
||||
if runtime is None or not callable(getattr(runtime,"project_admitted_task",None)):
|
||||
raise RuntimeError("TASK_ADMISSION_POST_OWNER_RUNTIME_BINDING_INVALID")
|
||||
return runtime
|
||||
|
||||
def _fl_call(builder,*args,post_admission=None,**kwargs):
|
||||
if post_admission is None:
|
||||
raise RuntimeError("TASK_ADMISSION_POST_OWNER_POST_ADMISSION_REQUIRED")
|
||||
reserver=_FLProducerReceiptReserver(_fl_runtime_from_post_admission(post_admission))
|
||||
|
||||
class BoundProvider(V3ProducerReceiptExecutionPlanDirectoryProvider):
|
||||
_reserver=reserver
|
||||
|
||||
global ExecutionPlanDirectoryProvider
|
||||
previous=ExecutionPlanDirectoryProvider
|
||||
ExecutionPlanDirectoryProvider=BoundProvider
|
||||
try:
|
||||
return builder(*args,post_admission=post_admission,**kwargs)
|
||||
finally:
|
||||
ExecutionPlanDirectoryProvider=previous
|
||||
|
||||
def build_task_admission_handlers_from_activation_file(
|
||||
activation_file:Path,*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
return _fl_call(
|
||||
_FL_BUILD_FROM_ACTIVATION,activation_file,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
)
|
||||
|
||||
def build_task_admission_handlers_from_environment(
|
||||
*,
|
||||
caller_service:str="aios-chatgpt-bridge-v2-staging.service",
|
||||
post_admission=None,
|
||||
):
|
||||
if not callable(_FL_BUILD_FROM_ENVIRONMENT):
|
||||
raise RuntimeError("TASK_ADMISSION_ENVIRONMENT_BUILDER_MISSING")
|
||||
return _fl_call(
|
||||
_FL_BUILD_FROM_ENVIRONMENT,
|
||||
caller_service=caller_service,post_admission=post_admission,
|
||||
)
|
||||
|
||||
ExecutionPlanDirectoryProvider=V3ProducerReceiptExecutionPlanDirectoryProvider
|
||||
# ===== AIOS FORMAL LINEAGE POST-OWNER PRODUCER NATIVE V12 — END =====
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Append-only ClaimGuard binding for the superseded Single-Host child Task."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
PROJECTION_RECEIPT_SCHEMA,
|
||||
PROJECTION_TABLE,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
)
|
||||
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
WORKER_ID = "p0-task-principal-v15-20260830"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
BINDING_SCHEMA = "aios.task-claim-guard-binding.v2"
|
||||
RECEIPT_SCHEMA = "aios.task-claim-guard-binding-receipt.v2"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ClaimGuardBindingError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ClaimGuardBindingError(code, status)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
if hasattr(row, "keys"):
|
||||
return {str(key): row[key] for key in row.keys()}
|
||||
_fail("TASK_ROW_MAPPING_UNAVAILABLE", 500)
|
||||
|
||||
|
||||
def _json(raw: Any, code: str) -> Any:
|
||||
if not isinstance(raw, str):
|
||||
_fail(code)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ClaimGuardBindingError(code) from exc
|
||||
|
||||
|
||||
def _default_policy_factory(value: Mapping[str, Any]):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
return OperationalPolicy.from_mapping(value)
|
||||
|
||||
|
||||
def _policy_value(policy: Any, name: str) -> Any:
|
||||
try:
|
||||
return getattr(policy, name)
|
||||
except AttributeError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
|
||||
|
||||
def _load_projection_evidence(connection, relation) -> dict[str, Any]:
|
||||
try:
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=? AND source_task_id=? "
|
||||
"AND supersession_ordinal=?",
|
||||
(CHILD_TASK_ID, SOURCE_TASK_ID, 1),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
raise ClaimGuardBindingError(
|
||||
"FORMAL_CHILD_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
) from exc
|
||||
if row is None:
|
||||
_fail("FORMAL_CHILD_INITIAL_PROJECTION_REQUIRED")
|
||||
value = _row_dict(row)
|
||||
receipt = _json(value.get("receipt_json"), "CHILD_PROJECTION_RECEIPT_INVALID")
|
||||
canonical_digest = value.get("canonical_child_execution_digest")
|
||||
source_digest = value.get("source_business_execution_digest")
|
||||
exact = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": 1,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"owner_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
if any(value.get(key) != expected for key, expected in exact.items()):
|
||||
_fail("CHILD_PROJECTION_LINEAGE_MISMATCH")
|
||||
if (
|
||||
not isinstance(canonical_digest, str)
|
||||
or HEX64.fullmatch(canonical_digest) is None
|
||||
or canonical_digest == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or source_digest != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or _sha_text(value["receipt_json"]) != value.get("receipt_sha256")
|
||||
or not isinstance(receipt, Mapping)
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest") != canonical_digest
|
||||
or receipt.get("source_business_execution_digest") != source_digest
|
||||
or receipt.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_RECEIPT_MISMATCH")
|
||||
return {
|
||||
**value,
|
||||
"canonical_child_execution_digest": canonical_digest,
|
||||
"source_business_execution_digest": source_digest,
|
||||
}
|
||||
|
||||
|
||||
def install_child_binding(
|
||||
connection,
|
||||
*,
|
||||
policy: Mapping[str, Any],
|
||||
runtime_id: str,
|
||||
runtime_observation_digest: str,
|
||||
task_contract_version: str,
|
||||
policy_factory: Callable[[Mapping[str, Any]], Any] = _default_policy_factory,
|
||||
now: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate and persist the only permitted child binding from current DB state."""
|
||||
if not isinstance(policy, Mapping):
|
||||
_fail("OPERATIONAL_POLICY_INVALID")
|
||||
if not isinstance(runtime_id, str) or not runtime_id or len(runtime_id) > 512:
|
||||
_fail("RUNTIME_ID_INVALID")
|
||||
if not isinstance(runtime_observation_digest, str) or HEX64.fullmatch(runtime_observation_digest) is None:
|
||||
_fail("RUNTIME_OBSERVATION_DIGEST_INVALID")
|
||||
if not isinstance(task_contract_version, str) or not task_contract_version or len(task_contract_version) > 128:
|
||||
_fail("TASK_CONTRACT_VERSION_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
task_row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
if task_row is None:
|
||||
_fail("CHILD_TASK_NOT_FOUND", 404)
|
||||
task = _row_dict(task_row)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) < 2
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_PROJECTED_CLAIMABLE_STATE_REQUIRED")
|
||||
event_rows = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
if [row[0] for row in event_rows] != [
|
||||
"TASK_CREATED",
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]:
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_REQUIRED")
|
||||
projection_event = _json(
|
||||
event_rows[-1][1], "CANONICAL_INITIAL_PROJECTION_EVENT_INVALID"
|
||||
)
|
||||
if (
|
||||
projection_event.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or projection_event.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or projection_event.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or _sha_json(projection_event) != projection["projection_event_sha256"]
|
||||
):
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_EVENT_MISMATCH")
|
||||
metadata_raw = task.get("metadata_json")
|
||||
capabilities_raw = task.get("required_capabilities_json")
|
||||
metadata = _json(metadata_raw, "CHILD_METADATA_INVALID")
|
||||
capabilities = _json(capabilities_raw, "CHILD_CAPABILITIES_INVALID")
|
||||
if (
|
||||
_sha_text(metadata_raw) != projection["metadata_sha256"]
|
||||
or _sha_text(capabilities_raw)
|
||||
!= projection["required_capabilities_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_TASK_DIGEST_MISMATCH")
|
||||
if not isinstance(metadata, Mapping) or not isinstance(capabilities, list) or not capabilities:
|
||||
_fail("CHILD_PROJECTION_INVALID")
|
||||
if any(not isinstance(value, str) or not value for value in capabilities) or len(set(capabilities)) != len(capabilities):
|
||||
_fail("CHILD_CAPABILITIES_INVALID")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("CHILD_EXECUTION_PROJECTION_MISSING")
|
||||
exact_execution = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_digest": canonical_execution_digest, "job_id": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in exact_execution.items()):
|
||||
_fail("CHILD_EXECUTION_BINDING_MISMATCH")
|
||||
parsed_policy = policy_factory(dict(policy))
|
||||
if hasattr(parsed_policy, "validate"):
|
||||
parsed_policy.validate()
|
||||
policy_exact = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_plan_sha256": canonical_execution_digest,
|
||||
"external_job_identity": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(_policy_value(parsed_policy, key) != value for key, value in policy_exact.items()):
|
||||
_fail("OPERATIONAL_POLICY_SCOPE_MISMATCH")
|
||||
if tuple(_policy_value(parsed_policy, "allowed_worker_ids")) != (WORKER_ID,):
|
||||
_fail("OPERATIONAL_POLICY_WORKER_MISMATCH")
|
||||
expected_oid = execution.get("expected_branch_oid", execution.get("base_oid"))
|
||||
if expected_oid is not None and _policy_value(parsed_policy, "base_oid") != expected_oid:
|
||||
_fail("OPERATIONAL_POLICY_BASE_OID_MISMATCH")
|
||||
policy_json = canonical_json(dict(policy))
|
||||
policy_sha = _sha_text(policy_json)
|
||||
policy_digest = str(_policy_value(parsed_policy, "digest"))
|
||||
if policy_digest != policy_sha:
|
||||
_fail("OPERATIONAL_POLICY_DIGEST_MISMATCH")
|
||||
binding = {
|
||||
"schema": BINDING_SCHEMA, "task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID, "supersession_ordinal": 1,
|
||||
"expected_revision": int(task["revision"]),
|
||||
"metadata_sha256": _sha_text(metadata_raw),
|
||||
"required_capabilities_sha256": _sha_json(sorted(capabilities)),
|
||||
"worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY, "target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"external_job_id": EXTERNAL_JOB_ID, "policy_sha256": policy_sha,
|
||||
"policy_digest": policy_digest, "runtime_id": runtime_id,
|
||||
"runtime_observation_digest": runtime_observation_digest,
|
||||
"task_contract_version": task_contract_version,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
binding_json = canonical_json(binding)
|
||||
binding_sha = _sha_text(binding_json)
|
||||
binding_id = "sha256:" + binding_sha
|
||||
existing = connection.execute(
|
||||
"SELECT binding_sha256 FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing[0] != binding_sha:
|
||||
_fail("CLAIM_GUARD_BINDING_ALREADY_EXISTS")
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_claim_guard_binding_receipts_v2 WHERE binding_id=?", (binding_id,)
|
||||
).fetchone()
|
||||
if receipt is None:
|
||||
_fail("CLAIM_GUARD_BINDING_RECEIPT_MISSING", 500)
|
||||
return {**json.loads(receipt[0]), "idempotent": True}
|
||||
created_at = now or _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO task_claim_guard_bindings_v2(
|
||||
binding_id,task_id,source_task_id,supersession_ordinal,expected_revision,
|
||||
metadata_sha256,required_capabilities_sha256,worker_id,execution_digest,
|
||||
repository,target_branch,generation,policy_json,policy_sha256,binding_json,
|
||||
binding_sha256,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(binding_id, CHILD_TASK_ID, SOURCE_TASK_ID, 1, binding["expected_revision"],
|
||||
binding["metadata_sha256"], binding["required_capabilities_sha256"], WORKER_ID,
|
||||
canonical_execution_digest, REPOSITORY, TARGET_BRANCH, GENERATION, policy_json, policy_sha,
|
||||
binding_json, binding_sha, created_at),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "BOUND", "binding_id": binding_id,
|
||||
"binding_sha256": binding_sha, "task_id": CHILD_TASK_ID,
|
||||
"expected_revision": binding["expected_revision"],
|
||||
"metadata_sha256": binding["metadata_sha256"], "worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
receipt_sha = _sha_json(receipt)
|
||||
receipt_id = "sha256:" + receipt_sha
|
||||
connection.execute(
|
||||
"INSERT INTO task_claim_guard_binding_receipts_v2 VALUES(?,?,?,?,?,?)",
|
||||
(receipt_id, binding_id, CHILD_TASK_ID, canonical_json(receipt), receipt_sha, created_at),
|
||||
)
|
||||
return {**receipt, "receipt_id": receipt_id, "receipt_sha256": receipt_sha, "idempotent": False}
|
||||
|
||||
|
||||
def _load_child_binding(connection) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_fail("CHILD_CLAIM_GUARD_BINDING_MISSING")
|
||||
value = _row_dict(row)
|
||||
binding = _json(value["binding_json"], "CLAIM_GUARD_BINDING_INVALID")
|
||||
policy = _json(value["policy_json"], "OPERATIONAL_POLICY_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? "
|
||||
"AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
if (
|
||||
_sha_text(value["binding_json"]) != value["binding_sha256"]
|
||||
or binding.get("schema") != BINDING_SCHEMA
|
||||
or value.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or binding.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or binding.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or policy.get("execution_plan_sha256") != canonical_execution_digest
|
||||
):
|
||||
_fail("CLAIM_GUARD_BINDING_DIGEST_MISMATCH")
|
||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
task = _row_dict(task)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) != int(binding["expected_revision"])
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or _sha_text(task.get("metadata_json", "")) != binding["metadata_sha256"]
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_CLAIM_GUARD_TASK_CAS_MISMATCH")
|
||||
return binding, policy
|
||||
|
||||
|
||||
def _run_child_guard(connection, binding, policy_value, agent_id):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy, OperationalVerifier
|
||||
from aios_singlehost_claim_guard_stage1 import _LiveReader, REGISTRY_PATH
|
||||
from aios_task_claim_guard import ClaimGuard, ClaimGuardError as CoreError
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("CLAIM_GUARD_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
policy = OperationalPolicy.from_mapping(policy_value)
|
||||
cfg = {
|
||||
"task_id": CHILD_TASK_ID, "expected_claim_revision": binding["expected_revision"],
|
||||
"task_metadata_sha256": binding["metadata_sha256"],
|
||||
"required_capabilities_sha256": binding["required_capabilities_sha256"],
|
||||
"task_contract_version": binding["task_contract_version"], "worker_id": WORKER_ID,
|
||||
"policy_digest": binding["policy_digest"], "runtime_id": binding["runtime_id"],
|
||||
"runtime_observation_digest": binding["runtime_observation_digest"],
|
||||
}
|
||||
reader = _LiveReader(connection, policy, cfg, now=int(datetime.now(timezone.utc).timestamp()))
|
||||
try:
|
||||
ClaimGuard(policy, OperationalVerifier(REGISTRY_PATH, require_root_owner=True)).validate_in_transaction(
|
||||
reader, task_id=CHILD_TASK_ID,
|
||||
expected_revision=int(binding["expected_revision"]), worker_id=agent_id,
|
||||
)
|
||||
except CoreError as exc:
|
||||
raise ClaimGuardBindingError(exc.code) from exc
|
||||
|
||||
|
||||
def enforce_claim_guard(connection, *, task_id: str, agent_id: str) -> None:
|
||||
"""Exact two-lineage dispatcher; unrelated Tasks retain their prior behavior."""
|
||||
if task_id == SOURCE_TASK_ID:
|
||||
try:
|
||||
from aios_singlehost_claim_guard_stage1 import enforce_claim_guard as frozen_guard
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("FROZEN_SOURCE_CLAIM_GUARD_UNAVAILABLE", 503) from exc
|
||||
frozen_guard(connection, task_id=task_id, agent_id=agent_id)
|
||||
return
|
||||
if task_id != CHILD_TASK_ID:
|
||||
return
|
||||
if agent_id != WORKER_ID:
|
||||
_fail("WORKER_ID_MISMATCH", 403)
|
||||
binding, policy = _load_child_binding(connection)
|
||||
_run_child_guard(connection, binding, policy, agent_id)
|
||||
@@ -0,0 +1,339 @@
|
||||
"""One-time, Owner-signed, append-only supersession for one exact Task lineage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
DECISION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-lineage-supersession-receipt.v1"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
||||
OWNER_KEY_ID = "SHA256:rPqpxfPHtaZPM3zSWojX8Q7VxzCknsYqOokyo/aAM3A"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXECUTION_DIGEST = "e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
class TaskLineageSupersessionError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise TaskLineageSupersessionError(code, status)
|
||||
|
||||
|
||||
def _utc(value: Any, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
try:
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise TaskLineageSupersessionError(field.upper() + "_INVALID", 400) from exc
|
||||
if result.tzinfo is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return result.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
DECISION_FIELDS = {
|
||||
"schema", "decision_id", "nonce", "source_task_id",
|
||||
"source_task_identity_sha256", "source_metadata_sha256",
|
||||
"source_expected_status", "source_expected_revision", "source_expected_attempt",
|
||||
"source_expected_claimed_by", "source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at", "repository", "target_branch", "generation",
|
||||
"execution_digest", "external_job_id", "child_task_id", "supersession_ordinal",
|
||||
"canonical_projection_profile", "canonical_projection_version", "supersession_reason",
|
||||
"owner_identity", "signature_namespace", "signature_principal", "key_id",
|
||||
"issued_at", "not_before", "expires_at", "one_time_use", "signature",
|
||||
}
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != DECISION_FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(field.upper() + "_MISMATCH", 403 if field.startswith(("owner", "signature", "key")) else 409)
|
||||
for field in ("decision_id", "nonce"):
|
||||
value = _text(raw[field], field, maximum=256)
|
||||
if SAFE_ID.fullmatch(value) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(raw[field], str) or HEX64.fullmatch(raw[field]) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
if type(raw["source_expected_revision"]) is not int or raw["source_expected_revision"] < 1:
|
||||
_fail("SOURCE_EXPECTED_REVISION_INVALID", 400)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > expires_at or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at - issued_at > MAX_TTL or expires_at <= issued_at:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
now = now.astimezone(timezone.utc)
|
||||
if now < not_before or now >= expires_at:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
if issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_ISSUED_AT_INVALID", 403)
|
||||
return {**dict(raw), "issued_at_dt": issued_at, "not_before_dt": not_before, "expires_at_dt": expires_at}
|
||||
|
||||
|
||||
def _default_identity_digest(row: sqlite3.Row) -> str:
|
||||
try:
|
||||
from aios_expired_task_recovery import task_identity_digest
|
||||
except ImportError as exc:
|
||||
raise TaskLineageSupersessionError("FORMAL_TASK_IDENTITY_HELPER_UNAVAILABLE", 503) from exc
|
||||
return str(task_identity_digest(row))
|
||||
|
||||
|
||||
def _json_object(row: sqlite3.Row, field: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(row[field])
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise TaskLineageSupersessionError("SOURCE_TASK_JSON_INVALID", 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("SOURCE_TASK_JSON_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Callable[[sqlite3.Row], str]) -> None:
|
||||
if row["task_id"] != SOURCE_TASK_ID:
|
||||
_fail("SOURCE_TASK_ID_MISMATCH")
|
||||
cas = (
|
||||
row["status"] == "EXPIRED"
|
||||
and int(row["revision"]) == d["source_expected_revision"]
|
||||
and int(row["attempt"]) == 0
|
||||
and row["claimed_by"] is None
|
||||
and row["lease_token_hash"] is None
|
||||
and row["lease_expires_at"] is None
|
||||
)
|
||||
if not cas:
|
||||
_fail("SOURCE_TASK_EXACT_CAS_MISMATCH")
|
||||
metadata_raw = row["metadata_json"]
|
||||
if not isinstance(metadata_raw, str) or sha256_text(metadata_raw) != d["source_metadata_sha256"]:
|
||||
_fail("SOURCE_METADATA_DIGEST_MISMATCH")
|
||||
if identity_digest(row) != d["source_task_identity_sha256"]:
|
||||
_fail("SOURCE_TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
metadata = _json_object(row, "metadata_json")
|
||||
execution = metadata.get("execution")
|
||||
constraints = _json_object(row, "constraints_json")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("SOURCE_EXECUTION_BINDING_INVALID")
|
||||
expected = {
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in expected.items()):
|
||||
_fail("SOURCE_EXECUTION_BINDING_MISMATCH")
|
||||
if constraints.get("generation") != GENERATION:
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest or _default_identity_digest
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
required = {"tasks", "events", "task_lineage_supersessions", "task_lineage_supersession_receipts"}
|
||||
actual = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not required.issubset(actual):
|
||||
_fail("SUPERSESSION_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||
task_columns = {
|
||||
"task_id", "goal", "instructions", "success_criteria_json",
|
||||
"required_capabilities_json", "constraints_json", "metadata_json", "deadline",
|
||||
"priority", "max_attempts", "attempt", "status", "claimed_by",
|
||||
"lease_token_hash", "lease_expires_at", "result_json", "result_digest",
|
||||
"result_idempotency_key", "verification_json", "revision", "created_at", "updated_at",
|
||||
}
|
||||
if not task_columns.issubset(columns):
|
||||
_fail("TASK_SCHEMA_UNSUPPORTED", 503)
|
||||
|
||||
def supersede(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload_bytes = canonical_json(decision_payload(raw)).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=payload_bytes,
|
||||
signature=decision["signature"], key_id=decision["key_id"],
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
now_text = now.isoformat()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["owner_decision_sha256"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
result = json.loads(replay["receipt_json"])
|
||||
connection.commit()
|
||||
return {**result, "idempotent": True}
|
||||
source = connection.execute("SELECT * FROM tasks WHERE task_id=?", (SOURCE_TASK_ID,)).fetchone()
|
||||
if source is None:
|
||||
_fail("SOURCE_TASK_NOT_FOUND", 404)
|
||||
_verify_source(source, decision, self.identity_digest)
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL),
|
||||
).fetchone():
|
||||
_fail("SUPERSESSION_ALREADY_EXISTS")
|
||||
if connection.execute("SELECT 1 FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone():
|
||||
_fail("CHILD_TASK_ALREADY_EXISTS")
|
||||
connection.execute(
|
||||
"""INSERT INTO tasks(
|
||||
task_id,goal,instructions,success_criteria_json,required_capabilities_json,
|
||||
constraints_json,metadata_json,deadline,priority,max_attempts,attempt,status,
|
||||
claimed_by,lease_token_hash,lease_expires_at,result_json,result_digest,
|
||||
result_idempotency_key,verification_json,revision,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
CHILD_TASK_ID, source["goal"], source["instructions"], source["success_criteria_json"],
|
||||
"[]", source["constraints_json"], "{}", None, source["priority"], source["max_attempts"],
|
||||
0, "QUEUED", None, None, None, None, None, None, None, 1, now_text, now_text,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersessions(
|
||||
source_task_id,supersession_ordinal,child_task_id,source_task_identity_sha256,
|
||||
source_metadata_sha256,owner_decision_sha256,decision_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)""",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID,
|
||||
decision["source_task_identity_sha256"], decision["source_metadata_sha256"],
|
||||
decision_sha, decision["decision_id"], now_text),
|
||||
)
|
||||
event_payload = {
|
||||
"task_id": CHILD_TASK_ID, "supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(CHILD_TASK_ID, None, "TASK_CREATED", canonical_json(event_payload), now_text),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "SUPERSEDED",
|
||||
"decision_id": decision["decision_id"], "owner_decision_sha256": decision_sha,
|
||||
"source_task_id": SOURCE_TASK_ID, "source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"source_status": "EXPIRED", "source_revision": decision["source_expected_revision"],
|
||||
"source_attempt": 0, "source_history_preserved": True,
|
||||
"child_task_id": CHILD_TASK_ID, "supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"child_status": "QUEUED", "child_revision": 1, "child_attempt": 0,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"exact_identity_cas": True, "one_time_use": True,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": sha256_text(decision["signature"]),
|
||||
}
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersession_receipts(
|
||||
decision_id,nonce,source_task_id,supersession_ordinal,child_task_id,
|
||||
owner_decision_sha256,decision_json,receipt_json,consumed_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(decision["decision_id"], decision["nonce"], SOURCE_TASK_ID, SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID, decision_sha, canonical_json(dict(raw)), canonical_json(receipt), now_text),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Canonical initial projection for the exact Single-Host supersession child.
|
||||
|
||||
The source business execution digest is lineage evidence. It is deliberately
|
||||
not used as the child execution-envelope digest: the canonical envelope binds
|
||||
the child Task id and therefore has its own server-derived digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
_now,
|
||||
_require_string,
|
||||
_required_authorization_id,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
ExecutionProjectionError,
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
from aios_task_lineage_supersession import (
|
||||
CANONICAL_PROFILE,
|
||||
CANONICAL_PROFILE_VERSION,
|
||||
CHILD_TASK_ID,
|
||||
DECISION_SCHEMA,
|
||||
EXECUTION_DIGEST as SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
EXTERNAL_JOB_ID,
|
||||
GENERATION,
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
RECEIPT_SCHEMA as SUPERSESSION_RECEIPT_SCHEMA,
|
||||
REPOSITORY,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
TARGET_BRANCH,
|
||||
canonical_json,
|
||||
decision_payload,
|
||||
)
|
||||
|
||||
|
||||
PROJECTION_INTENT_SCHEMA = "aios.task-lineage-supersession-initial-projection-intent.v1"
|
||||
PROJECTION_RECEIPT_SCHEMA = "aios.task-lineage-supersession-initial-projection-receipt.v1"
|
||||
PROJECTION_IDEMPOTENCY_KEY = "task-lineage-supersession-s1-initial-projection-v1"
|
||||
PROJECTION_REASON = "canonical-initial-projection-for-formal-supersession-child"
|
||||
PROJECTION_TABLE = "task_lineage_supersession_initial_projections"
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _json_object(raw: Any, code: str) -> dict[str, Any]:
|
||||
if not isinstance(raw, str):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InitialExecutionProjectionError(code, 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupersessionChildProjectionIntent:
|
||||
"""Caller-selectable portion of the exact child projection.
|
||||
|
||||
Task identity, CAS values, Job identity, projection profile, reason and
|
||||
idempotency are intentionally absent from the request and server-owned.
|
||||
"""
|
||||
|
||||
adapter: str
|
||||
required_capability: str
|
||||
authorization_id: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, Any]
|
||||
) -> "SupersessionChildProjectionIntent":
|
||||
if not isinstance(value, Mapping) or set(value) != {
|
||||
"adapter",
|
||||
"required_capability",
|
||||
"authorization_id",
|
||||
}:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_FIELDS_INVALID", 400
|
||||
)
|
||||
adapter = _require_string(value["adapter"], "RUNTIME_ADAPTER_INVALID")
|
||||
capability = _require_string(
|
||||
value["required_capability"],
|
||||
"RUNTIME_CAPABILITY_INVALID",
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
)
|
||||
if adapter not in {"hermes", "openclaw"} or capability != f"agent.runtime.{adapter}":
|
||||
raise InitialExecutionProjectionError("RUNTIME_CAPABILITY_MISMATCH", 400)
|
||||
authorization_id = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID"
|
||||
)
|
||||
return cls(adapter, capability, authorization_id)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PROJECTION_INTENT_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"adapter": self.adapter,
|
||||
"required_capability": self.required_capability,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
|
||||
def initial_request(self, canonical_execution_digest: str) -> InitialExecutionProjectionRequest:
|
||||
return InitialExecutionProjectionRequest.from_mapping(
|
||||
{
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"expected_execution_digest": canonical_execution_digest,
|
||||
"required_capability": self.required_capability,
|
||||
"adapter": self.adapter,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _load_lineage_context(
|
||||
connection: sqlite3.Connection, *, expect_projected: bool
|
||||
) -> dict[str, Any]:
|
||||
required_tables = {
|
||||
"tasks",
|
||||
"events",
|
||||
"task_lineage_supersessions",
|
||||
"task_lineage_supersession_receipts",
|
||||
PROJECTION_TABLE,
|
||||
}
|
||||
if not required_tables.issubset(_table_names(connection)):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions "
|
||||
"WHERE source_task_id=? AND supersession_ordinal=? AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
raise InitialExecutionProjectionError("FORMAL_SUPERSESSION_REQUIRED", 409)
|
||||
receipt_row = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersession_receipts "
|
||||
"WHERE decision_id=? AND source_task_id=? AND supersession_ordinal=? "
|
||||
"AND child_task_id=?",
|
||||
(
|
||||
relation["decision_id"],
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID,
|
||||
),
|
||||
).fetchone()
|
||||
if receipt_row is None:
|
||||
raise InitialExecutionProjectionError("SUPERSESSION_RECEIPT_REQUIRED", 409)
|
||||
decision = _json_object(receipt_row["decision_json"], "SUPERSESSION_DECISION_INVALID")
|
||||
receipt = _json_object(receipt_row["receipt_json"], "SUPERSESSION_RECEIPT_INVALID")
|
||||
decision_sha256 = _sha_text(canonical_json(decision_payload(decision)))
|
||||
exact_decision = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
if any(decision.get(key) != expected for key, expected in exact_decision.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_DECISION_SCOPE_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
decision_sha256 != relation["owner_decision_sha256"]
|
||||
or decision_sha256 != receipt_row["owner_decision_sha256"]
|
||||
or decision.get("source_task_identity_sha256")
|
||||
!= relation["source_task_identity_sha256"]
|
||||
or decision.get("source_metadata_sha256") != relation["source_metadata_sha256"]
|
||||
or receipt.get("schema") != SUPERSESSION_RECEIPT_SCHEMA
|
||||
or receipt.get("owner_decision_sha256") != decision_sha256
|
||||
or receipt.get("source_task_id") != SOURCE_TASK_ID
|
||||
or receipt.get("child_task_id") != CHILD_TASK_ID
|
||||
or receipt.get("supersession_ordinal") != SUPERSESSION_ORDINAL
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_LINEAGE_EVIDENCE_MISMATCH", 409
|
||||
)
|
||||
events = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
expected_events = ["TASK_CREATED"]
|
||||
if expect_projected:
|
||||
expected_events.append("TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED")
|
||||
if [str(row["event_type"]) for row in events] != expected_events:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
created = _json_object(events[0]["payload_json"], "SUPERSESSION_CREATED_EVENT_INVALID")
|
||||
exact_created = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha256,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
if any(created.get(key) != expected for key, expected in exact_created.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CREATED_EVENT_SCOPE_MISMATCH", 409
|
||||
)
|
||||
return {
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_decision_sha256": decision_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _validate_canonical_child_execution(
|
||||
resolved: Mapping[str, Any],
|
||||
*,
|
||||
intent: SupersessionChildProjectionIntent,
|
||||
runtime_input: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
execution = validate_execution_envelope(resolved, binding=binding)
|
||||
except ExecutionProjectionError as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_INVALID", 409
|
||||
) from exc
|
||||
exact_binding = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
}
|
||||
if any(execution.get(key) != expected for key, expected in exact_binding.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_BINDING_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != intent.adapter
|
||||
or execution.get("capability_scope") != [intent.required_capability]
|
||||
or execution.get("runtime_input") != dict(runtime_input)
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or execution.get("governance", {}).get("writer_lease") is not False
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_AGENT_RUNTIME_ENVELOPE_INVALID", 409
|
||||
)
|
||||
if execution["execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST:
|
||||
raise InitialExecutionProjectionError(
|
||||
"CHILD_CANONICAL_DIGEST_COLLIDES_WITH_SOURCE_BUSINESS_DIGEST", 409
|
||||
)
|
||||
return execution
|
||||
|
||||
|
||||
class _SupersessionProjectionTransactionStore(InitialExecutionProjectionStore):
|
||||
"""Per-invocation store that adds immutable lineage evidence atomically."""
|
||||
|
||||
allows_supersession_child = True
|
||||
|
||||
def __init__(self, db_path, resolved: Mapping[str, Any], intent):
|
||||
self._resolved = dict(resolved)
|
||||
self.intent = intent
|
||||
super().__init__(db_path, self._resolve_once)
|
||||
|
||||
def _resolve_once(self, *_args):
|
||||
return dict(self._resolved)
|
||||
|
||||
def _projection_event_extension(
|
||||
self, connection, task_id, request, execution, applied_revision
|
||||
):
|
||||
if task_id != CHILD_TASK_ID:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_TASK_ID_MISMATCH", 409
|
||||
)
|
||||
context = _load_lineage_context(connection, expect_projected=False)
|
||||
return {
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
|
||||
def _insert_projection_extension(
|
||||
self,
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
event_payload,
|
||||
applied_revision,
|
||||
):
|
||||
context = _load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT metadata_json,required_capabilities_json FROM tasks WHERE task_id=?",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
intent_json = canonical_json(self.intent.public())
|
||||
intent_sha256 = _sha_text(intent_json)
|
||||
receipt = {
|
||||
"schema": PROJECTION_RECEIPT_SCHEMA,
|
||||
"projection_id": "sha256:" + intent_sha256,
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"adapter": self.intent.adapter,
|
||||
"required_capability": self.intent.required_capability,
|
||||
"expected_revision": 1,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": 0,
|
||||
"metadata_sha256": _sha_text(task["metadata_json"]),
|
||||
"required_capabilities_sha256": _sha_text(
|
||||
task["required_capabilities_json"]
|
||||
),
|
||||
"projection_event_sha256": _sha_json(dict(event_payload)),
|
||||
}
|
||||
receipt_json = canonical_json(receipt)
|
||||
receipt_sha256 = _sha_text(receipt_json)
|
||||
connection.execute(
|
||||
f"""INSERT INTO {PROJECTION_TABLE}(
|
||||
projection_id,task_id,source_task_id,supersession_ordinal,
|
||||
intent_sha256,intent_json,owner_decision_sha256,
|
||||
source_task_identity_sha256,source_metadata_sha256,
|
||||
source_business_execution_digest,canonical_child_execution_digest,
|
||||
external_job_id,repository,target_branch,generation,adapter,
|
||||
required_capability,expected_revision,applied_revision,metadata_sha256,
|
||||
required_capabilities_sha256,projection_event_sha256,receipt_json,
|
||||
receipt_sha256,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
receipt["projection_id"],
|
||||
CHILD_TASK_ID,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
intent_sha256,
|
||||
intent_json,
|
||||
context["owner_decision_sha256"],
|
||||
context["source_task_identity_sha256"],
|
||||
context["source_metadata_sha256"],
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
execution["execution_digest"],
|
||||
EXTERNAL_JOB_ID,
|
||||
REPOSITORY,
|
||||
TARGET_BRANCH,
|
||||
GENERATION,
|
||||
self.intent.adapter,
|
||||
self.intent.required_capability,
|
||||
1,
|
||||
applied_revision,
|
||||
receipt["metadata_sha256"],
|
||||
receipt["required_capabilities_sha256"],
|
||||
receipt["projection_event_sha256"],
|
||||
receipt_json,
|
||||
receipt_sha256,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SupersessionChildInitialProjectionStore:
|
||||
"""Server-owned adapter from exact supersession lineage to current ABI."""
|
||||
|
||||
def __init__(self, db_path, resolver):
|
||||
self.db_path = db_path
|
||||
self.resolver = resolver
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _replay(connection, intent: SupersessionChildProjectionIntent):
|
||||
if PROJECTION_TABLE not in _table_names(connection):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
tables = _table_names(connection)
|
||||
if "task_initial_execution_projections" in tables:
|
||||
generic = connection.execute(
|
||||
"SELECT 1 FROM task_initial_execution_projections "
|
||||
"WHERE task_id=? AND idempotency_key=?",
|
||||
(CHILD_TASK_ID, PROJECTION_IDEMPOTENCY_KEY),
|
||||
).fetchone()
|
||||
if generic is not None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return None
|
||||
intent_sha256 = _sha_json(intent.public())
|
||||
if row["intent_sha256"] != intent_sha256:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_INTENT_CONFLICT", 409
|
||||
)
|
||||
receipt = _json_object(
|
||||
row["receipt_json"], "SUPERSESSION_PROJECTION_RECEIPT_INVALID"
|
||||
)
|
||||
if (
|
||||
_sha_text(row["receipt_json"]) != row["receipt_sha256"]
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest")
|
||||
!= row["canonical_child_execution_digest"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_DIGEST_MISMATCH", 500
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if (
|
||||
task is None
|
||||
or task["status"] != "QUEUED"
|
||||
or int(task["revision"]) != int(row["applied_revision"])
|
||||
or int(task["attempt"]) != 0
|
||||
or task["claimed_by"] is not None
|
||||
or task["lease_token_hash"] is not None
|
||||
or task["lease_expires_at"] is not None
|
||||
or _sha_text(task["metadata_json"]) != row["metadata_sha256"]
|
||||
or _sha_text(task["required_capabilities_json"])
|
||||
!= row["required_capabilities_sha256"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTED_TASK_CAS_MISMATCH", 409
|
||||
)
|
||||
return {**receipt, "idempotent": True}
|
||||
|
||||
def project(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
intent = SupersessionChildProjectionIntent.from_mapping(value)
|
||||
if intent.authorization_id != _required_authorization_id():
|
||||
raise InitialExecutionProjectionError("AUTHORIZATION_ID_MISMATCH", 403)
|
||||
with self._connect() as connection:
|
||||
replay = self._replay(connection, intent)
|
||||
if replay is not None:
|
||||
return replay
|
||||
probe_request = intent.initial_request("0" * 64)
|
||||
probe_store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, {}, intent
|
||||
)
|
||||
task, _, _, _ = probe_store._load_and_validate(
|
||||
connection, CHILD_TASK_ID, probe_request
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=False)
|
||||
runtime_input = probe_store._runtime_input(task)
|
||||
resolved = dict(
|
||||
self.resolver(
|
||||
CHILD_TASK_ID,
|
||||
EXTERNAL_JOB_ID,
|
||||
intent.required_capability,
|
||||
intent.adapter,
|
||||
runtime_input,
|
||||
)
|
||||
)
|
||||
execution = _validate_canonical_child_execution(
|
||||
resolved, intent=intent, runtime_input=runtime_input
|
||||
)
|
||||
store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, resolved, intent
|
||||
)
|
||||
result = store.project(
|
||||
CHILD_TASK_ID, intent.initial_request(execution["execution_digest"])
|
||||
)
|
||||
with self._connect() as connection:
|
||||
receipt = self._replay(connection, intent)
|
||||
if receipt is None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return {**receipt, "idempotent": bool(result["idempotent"])}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Fixed Owner operation for one exact Task-lineage supersession.
|
||||
|
||||
This staging overlay deliberately extends the installed single-host operational
|
||||
authority instead of exposing a generic signing primitive. The caller can
|
||||
only provide the three live CAS values which cannot be known at build time.
|
||||
Every authority, lineage and execution field is constructed server-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import OWNER_IDENTITY, OWNER_NAMESPACE, sha256_json
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
SignedOperationalArtifact,
|
||||
)
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION = "issue_task_lineage_supersession"
|
||||
TASK_LINEAGE_SUPERSESSION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST = (
|
||||
"e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
)
|
||||
EXTERNAL_JOB_IDENTITY = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
CANONICAL_PROJECTION_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROJECTION_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
DECISION_TTL_SECONDS = 300
|
||||
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _assert_fixed_policy(policy: OperationalPolicy) -> None:
|
||||
"""Refuse issuance if the live policy is not the approved source scope."""
|
||||
policy.validate()
|
||||
exact = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
if any(getattr(policy, field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_MISMATCH")
|
||||
if policy.maximum_recovery_decision_ttl_seconds < DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_TTL_TOO_NARROW")
|
||||
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_FIELDS = {
|
||||
"schema",
|
||||
"decision_id",
|
||||
"nonce",
|
||||
"source_task_id",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
"source_expected_status",
|
||||
"source_expected_revision",
|
||||
"source_expected_attempt",
|
||||
"source_expected_claimed_by",
|
||||
"source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at",
|
||||
"repository",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"source_business_execution_digest",
|
||||
"external_job_identity",
|
||||
"child_task_id",
|
||||
"supersession_ordinal",
|
||||
"canonical_projection_profile",
|
||||
"canonical_projection_version",
|
||||
"supersession_reason",
|
||||
"owner_identity",
|
||||
"policy_digest",
|
||||
"one_time_use",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_task_lineage_supersession_scope(
|
||||
decision: Mapping[str, Any],
|
||||
policy: OperationalPolicy,
|
||||
*,
|
||||
now: int,
|
||||
) -> None:
|
||||
"""Validate a signature-verified decision against the fixed source scope."""
|
||||
_assert_fixed_policy(policy)
|
||||
if not isinstance(decision, Mapping) or set(decision) != TASK_LINEAGE_SUPERSESSION_FIELDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_FIELDS_INVALID")
|
||||
exact = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": policy.digest,
|
||||
"one_time_use": True,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
}
|
||||
if any(decision.get(field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SCOPE_INVALID")
|
||||
if decision.get("source_task_identity_sha256") != policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(decision.get(field), str) or HEX64.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_DIGEST_INVALID")
|
||||
if type(decision.get("source_expected_revision")) is not int or not 1 <= decision["source_expected_revision"] < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
for field in ("decision_id", "nonce"):
|
||||
if not isinstance(decision.get(field), str) or SAFE_TOKEN.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(decision.get(field)) is not int:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if not decision["not_before"] <= now < decision["expires_at"]:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["issued_at"] > now + 5 or now - decision["issued_at"] > DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["expires_at"] - decision["issued_at"] != DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
digest_value = dict(decision)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if supplied_digest != sha256_json(digest_value):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
|
||||
class TaskLineageSupersessionOwnerAuthority(OperationalOwnerAuthority):
|
||||
"""Owner signer with one bounded constructor and no caller-defined message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
token_hex: Callable[[int], str] = secrets.token_hex,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def issue_task_lineage_supersession(
|
||||
self,
|
||||
*,
|
||||
source_expected_revision: int,
|
||||
source_task_identity_sha256: str,
|
||||
source_metadata_sha256: str,
|
||||
) -> SignedOperationalArtifact:
|
||||
_assert_fixed_policy(self.policy)
|
||||
if type(source_expected_revision) is not int or not 1 <= source_expected_revision < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
if source_task_identity_sha256 != self.policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
if not isinstance(source_metadata_sha256, str) or HEX64.fullmatch(source_metadata_sha256) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_METADATA_DIGEST_INVALID")
|
||||
|
||||
decision_id = "task-lineage-supersession-" + self._token_hex(16)
|
||||
nonce = "task-lineage-supersession-nonce-" + self._token_hex(16)
|
||||
if SAFE_TOKEN.fullmatch(decision_id) is None or SAFE_TOKEN.fullmatch(nonce) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
now = int(self.clock())
|
||||
payload = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_task_identity_sha256": source_task_identity_sha256,
|
||||
"source_metadata_sha256": source_metadata_sha256,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_revision": source_expected_revision,
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + DECISION_TTL_SECONDS,
|
||||
}
|
||||
return self._sign(payload)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Staging-only server overlay for the fixed lineage Owner operation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import DEFAULT_REGISTRY_PATH
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalMachineAuthority,
|
||||
OperationalVerifier,
|
||||
)
|
||||
from aios_single_host_operational_authority_server import (
|
||||
DEFAULT_LEDGER,
|
||||
DEFAULT_MACHINE_KEY,
|
||||
DEFAULT_OWNER_KEY,
|
||||
DEFAULT_POLICY,
|
||||
DurableIssuanceLedger,
|
||||
OperationalAuthorityService as BaseOperationalAuthorityService,
|
||||
_read_policy,
|
||||
fail,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
TaskLineageSupersessionOwnerAuthority,
|
||||
)
|
||||
|
||||
REQUEST_FIELDS = {
|
||||
"source_expected_revision",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
}
|
||||
|
||||
|
||||
class TaskLineageSupersessionIssuanceLedger(DurableIssuanceLedger):
|
||||
"""Operation-scoped one-time fence layered on the existing durable ledger."""
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(
|
||||
request,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT 1 FROM issuance WHERE role=? AND operation=? LIMIT 1",
|
||||
(role, operation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ALREADY_ISSUED")
|
||||
result = issuer()
|
||||
artifact = result.get("artifact")
|
||||
nonce = artifact.get("payload", {}).get("nonce") if isinstance(artifact, Mapping) else None
|
||||
if not isinstance(nonce, str) or not nonce:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ISSUER_RESULT_INVALID")
|
||||
encoded = json.dumps(
|
||||
result,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
connection.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OperationalAuthorityService(BaseOperationalAuthorityService):
|
||||
"""Preserve all existing operations and add one exact Owner-only operation."""
|
||||
|
||||
def __init__(self, *args: Any, token_hex=None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ledger = TaskLineageSupersessionIssuanceLedger(self.ledger.path)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OperationalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.role == "owner":
|
||||
kwargs = {}
|
||||
if self._token_hex is not None:
|
||||
kwargs["token_hex"] = self._token_hex
|
||||
signer = TaskLineageSupersessionOwnerAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(
|
||||
self.machine_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
fail("REQUEST_INVALID")
|
||||
if request.get("operation") != TASK_LINEAGE_SUPERSESSION_OPERATION:
|
||||
return super().handle(request, peer_uid=peer_uid)
|
||||
if self.role != "owner":
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
payload = request.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != REQUEST_FIELDS:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
_policy, _verifier, signer = self._components()
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
artifact = signer.issue_task_lineage_supersession(
|
||||
source_expected_revision=payload["source_expected_revision"],
|
||||
source_task_identity_sha256=payload["source_task_identity_sha256"],
|
||||
source_metadata_sha256=payload["source_metadata_sha256"],
|
||||
)
|
||||
return {"status": "PASS", "artifact": artifact.to_dict()}
|
||||
|
||||
return self.ledger.issue_once(
|
||||
role=self.role,
|
||||
operation=TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
request=payload,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--role", choices=("owner", "machine"), required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
parser.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
parser.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
parser.add_argument("--socket")
|
||||
parser.add_argument("--ledger", default=str(DEFAULT_LEDGER))
|
||||
arguments = parser.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=arguments.role,
|
||||
policy_path=Path(arguments.policy),
|
||||
registry_path=Path(arguments.registry),
|
||||
owner_key_path=Path(arguments.owner_key),
|
||||
machine_key_path=Path(arguments.machine_key),
|
||||
socket_path=Path(arguments.socket) if arguments.socket else None,
|
||||
ledger_path=Path(arguments.ledger),
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -15,7 +15,7 @@ import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
@@ -133,6 +133,7 @@ class TrustedCandidateBuilderConfig:
|
||||
recipes: Mapping[str, Mapping[str, str]]
|
||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
||||
policy_sha256: str
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
@@ -227,14 +228,63 @@ class TrustedCandidateBuilderConfig:
|
||||
return recipe
|
||||
|
||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.fixed_recipe(version)
|
||||
# Intent-only callers do not choose a candidate identity. The
|
||||
# policy-owned recipe supplies it; explicit worker requests are
|
||||
# still checked against the same immutable recipe.
|
||||
effective_candidate_id = str(candidate_id or recipe.get("candidate_id") or "")
|
||||
if {"version": recipe.get("version"), "candidate_id": recipe.get("candidate_id"), "source_commit": recipe.get("source_commit")} != {"version": version, "candidate_id": effective_candidate_id, "source_commit": source_commit}:
|
||||
recipe = self.recipes.get(version)
|
||||
if recipe is not None:
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
effective_candidate_id = str(candidate_id or resolved.get("candidate_id") or "")
|
||||
if {
|
||||
"version": resolved.get("version"),
|
||||
"candidate_id": resolved.get("candidate_id"),
|
||||
"source_commit": resolved.get("source_commit"),
|
||||
} != {
|
||||
"version": version,
|
||||
"candidate_id": effective_candidate_id,
|
||||
"source_commit": source_commit,
|
||||
}:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {
|
||||
"mode",
|
||||
"task_id",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_commit",
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
if (
|
||||
set(admitted) != required
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
or not isinstance(admitted.get("candidate_id"), str)
|
||||
or not admitted.get("candidate_id")
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return recipe
|
||||
for name in ("base_oid", "source_commit"):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) != 40
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
return admitted
|
||||
|
||||
|
||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
||||
@@ -776,6 +826,30 @@ def _main() -> None:
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
||||
source_admission_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT", ""
|
||||
).strip()
|
||||
if source_admission_root:
|
||||
from release_source_admission import ReleaseSourceAdmissionStore
|
||||
|
||||
try:
|
||||
source_admission_hmac = (
|
||||
Path(credentials) / "source_admission_hmac"
|
||||
).read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_HMAC_UNAVAILABLE") from exc
|
||||
source_store = ReleaseSourceAdmissionStore(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy_sha256=config.policy_sha256,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=0,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_store.resolve_recipe,
|
||||
)
|
||||
signer = CredentialPathEd25519Signer(
|
||||
keyring.signing_private_key_path,
|
||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||
|
||||
@@ -25,8 +25,17 @@ class ReconciliationController:
|
||||
def reconcile(self, actual: Mapping[str, Any]) -> ReconciliationReport:
|
||||
drift: list[str] = []
|
||||
for left, right, label in self.CHECKS:
|
||||
if actual.get(left) != actual.get(right):
|
||||
left_value = actual.get(left)
|
||||
right_value = actual.get(right)
|
||||
expected_type = bool if left == "declared_production_state" else str
|
||||
complete = (
|
||||
left in actual
|
||||
and right in actual
|
||||
and isinstance(left_value, expected_type)
|
||||
and isinstance(right_value, expected_type)
|
||||
and (expected_type is bool or bool(left_value.strip()) and bool(right_value.strip()))
|
||||
)
|
||||
if not complete or left_value != right_value:
|
||||
drift.append(label)
|
||||
actions = () if not drift else ("open_reconciliation_task", "open_reconciliation_pr", "attach_evidence", "notify_owner")
|
||||
return ReconciliationReport(not drift, tuple(drift), actions)
|
||||
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed admission of an exact remote Git commit into the release source closure.
|
||||
|
||||
The admitter is intentionally separate from the trusted candidate builder:
|
||||
it may use a narrowly-scoped Gitea read/fetch credential, while the builder
|
||||
remains PrivateNetwork=true and reads only the authoritative Git object store
|
||||
plus immutable admission records.
|
||||
|
||||
An admission never moves ``main`` and never treats an arbitrary caller-supplied
|
||||
OID as trusted. It binds:
|
||||
remote branch ref -> exact full commit OID -> authoritative Git object/tree
|
||||
-> current authoritative main ancestry -> immutable release recipe metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
authoritative_head_oid,
|
||||
authoritative_lineage,
|
||||
run_authoritative_git,
|
||||
)
|
||||
from aios_gitea_machine_transport import GiteaMachineTransport, GiteaTransportError
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
SCHEMA = "aios.release-source-admission.v1"
|
||||
RECIPE_MODE = "governed-source-admission"
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$")
|
||||
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_REF_PREFIX = "refs/heads/"
|
||||
|
||||
|
||||
class ReleaseSourceAdmissionError(RuntimeError):
|
||||
"""A stable fail-closed source-admission error code."""
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _oid(value: object, field: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _OID_RE.fullmatch(text):
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _digest(value: object, field: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _DIGEST_RE.fullmatch(text):
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _identifier(value: object, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not _IDENTIFIER_RE.fullmatch(text) or ".." in text or "\x00" in text:
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _source_ref(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text.startswith(_REF_PREFIX):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
||||
branch = text[len(_REF_PREFIX):]
|
||||
if (
|
||||
not _BRANCH_RE.fullmatch(branch)
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _version(value: object) -> str:
|
||||
try:
|
||||
return validate_release_version(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ReleaseSourceAdmissionError("VERSION_INVALID") from exc
|
||||
|
||||
|
||||
def _version_slug(version: str) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9]+", "-", version.lower()).strip("-")
|
||||
if not value:
|
||||
raise ReleaseSourceAdmissionError("VERSION_INVALID")
|
||||
return value[:80]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _secure_stat(path: Path, *, expected_owner_uid: int | None, directory: bool) -> None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
||||
if path.is_symlink():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if directory and not path.is_dir():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if not directory and not path.is_file():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_OWNER_MISMATCH")
|
||||
# Group/other write would allow an untrusted process to forge release input.
|
||||
if info.st_mode & 0o022:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_PERMISSIONS_INVALID")
|
||||
|
||||
|
||||
def _validate_record(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
repository: str,
|
||||
policy_sha256: str,
|
||||
hmac_key: bytes,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
required = {
|
||||
"schema",
|
||||
"admission_id",
|
||||
"repository",
|
||||
"task_id",
|
||||
"version",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_ref",
|
||||
"source_commit",
|
||||
"remote_ref_oid",
|
||||
"tree_oid",
|
||||
"purpose",
|
||||
"policy_sha256",
|
||||
"admitted_at",
|
||||
"mac",
|
||||
}
|
||||
if set(value) != required or value.get("schema") != SCHEMA:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
record = dict(value)
|
||||
if _identifier(record["repository"], "repository") != repository:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REPOSITORY_MISMATCH")
|
||||
_identifier(record["task_id"], "task_id")
|
||||
record["version"] = _version(record["version"])
|
||||
_identifier(record["candidate_id"], "candidate_id")
|
||||
_identifier(record["purpose"], "purpose")
|
||||
_identifier(record["admission_id"], "admission_id")
|
||||
record["base_oid"] = _oid(record["base_oid"], "base_oid")
|
||||
record["source_commit"] = _oid(record["source_commit"], "source_commit")
|
||||
record["remote_ref_oid"] = _oid(record["remote_ref_oid"], "remote_ref_oid")
|
||||
record["tree_oid"] = _oid(record["tree_oid"], "tree_oid")
|
||||
record["source_ref"] = _source_ref(record["source_ref"])
|
||||
record["policy_sha256"] = _digest(record["policy_sha256"], "policy_sha256")
|
||||
if record["policy_sha256"] != policy_sha256:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_POLICY_MISMATCH")
|
||||
if record["remote_ref_oid"] != record["source_commit"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REMOTE_MISMATCH")
|
||||
admitted_at = record.get("admitted_at")
|
||||
if not isinstance(admitted_at, str) or not admitted_at.endswith("Z") or len(admitted_at) < 20:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_TIME_INVALID")
|
||||
supplied_mac = record.get("mac")
|
||||
if (
|
||||
not isinstance(supplied_mac, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", supplied_mac)
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
||||
unsigned = {key: value for key, value in record.items() if key != "mac"}
|
||||
expected_mac = hmac.new(hmac_key, _canonical(unsigned), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(supplied_mac, expected_mac):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
||||
return record
|
||||
|
||||
|
||||
class ReleaseSourceBackend(Protocol):
|
||||
def authoritative_main_oid(self) -> str: ...
|
||||
def remote_ref_oid(self, source_ref: str) -> str: ...
|
||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str: ...
|
||||
def tree_oid(self, source_commit: str) -> str: ...
|
||||
def require_lineage(self, base_oid: str, source_commit: str) -> None: ...
|
||||
|
||||
|
||||
class ReleaseSourceAdmissionStore:
|
||||
"""Durable immutable admission records keyed by exact source commit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: str | os.PathLike[str],
|
||||
*,
|
||||
repository: str,
|
||||
policy_sha256: str,
|
||||
hmac_key: bytes,
|
||||
expected_owner_uid: int | None = None,
|
||||
writable: bool = False,
|
||||
) -> None:
|
||||
self.root = Path(root)
|
||||
self.repository = _identifier(repository, "repository")
|
||||
self.policy_sha256 = _digest(policy_sha256, "policy_sha256")
|
||||
if not isinstance(hmac_key, (bytes, bytearray)) or len(hmac_key) < 32:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_HMAC_INVALID")
|
||||
self.hmac_key = bytes(hmac_key)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
self.writable = bool(writable)
|
||||
if self.writable and not self.root.exists():
|
||||
self.root.mkdir(parents=True, mode=0o750)
|
||||
if self.root.exists():
|
||||
_secure_stat(
|
||||
self.root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
|
||||
def _path(self, source_commit: str) -> Path:
|
||||
return self.root / f"{_oid(source_commit, 'source_commit')}.json"
|
||||
|
||||
def register(self, record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not self.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
supplied = dict(record)
|
||||
if "mac" not in supplied:
|
||||
supplied["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(supplied),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
checked = _validate_record(
|
||||
supplied,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
path = self._path(checked["source_commit"])
|
||||
encoded = _canonical(checked) + b"\n"
|
||||
if path.exists():
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
existing = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
||||
if existing == encoded:
|
||||
return dict(checked)
|
||||
try:
|
||||
previous = _validate_record(
|
||||
json.loads(existing),
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
||||
comparable_previous = {
|
||||
key: value
|
||||
for key, value in previous.items()
|
||||
if key not in {"admitted_at", "mac"}
|
||||
}
|
||||
comparable_checked = {
|
||||
key: value
|
||||
for key, value in checked.items()
|
||||
if key not in {"admitted_at", "mac"}
|
||||
}
|
||||
if comparable_previous == comparable_checked:
|
||||
return dict(previous)
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CONFLICT")
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
_secure_stat(
|
||||
self.root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
fd, raw_name = tempfile.mkstemp(
|
||||
prefix=f".{checked['source_commit']}.",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
)
|
||||
temp_path = Path(raw_name)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "wb", closefd=True) as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, path)
|
||||
os.chmod(path, 0o640)
|
||||
directory_fd = os.open(self.root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
return dict(checked)
|
||||
|
||||
def lookup_source(self, source_commit: str) -> dict[str, Any] | None:
|
||||
path = self._path(source_commit)
|
||||
if not path.exists():
|
||||
return None
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > 64 * 1024:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
decoded = json.loads(raw)
|
||||
except ReleaseSourceAdmissionError:
|
||||
raise
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
||||
return _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
|
||||
def resolve_recipe(
|
||||
self,
|
||||
version: str,
|
||||
candidate_id: str | None,
|
||||
source_commit: str,
|
||||
) -> dict[str, str]:
|
||||
record = self.lookup_source(source_commit)
|
||||
if record is None:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_NOT_FOUND")
|
||||
if record["version"] != version:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_VERSION_MISMATCH")
|
||||
supplied_candidate = str(candidate_id or "")
|
||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
||||
return {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
"base_oid": record["base_oid"],
|
||||
"source_commit": record["source_commit"],
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaReleaseSourceBackend:
|
||||
transport: GiteaMachineTransport
|
||||
git_dir: Path
|
||||
|
||||
def authoritative_main_oid(self) -> str:
|
||||
return authoritative_head_oid(git_dir=self.git_dir)
|
||||
|
||||
def remote_ref_oid(self, source_ref: str) -> str:
|
||||
ref = _source_ref(source_ref)
|
||||
branch = ref[len(_REF_PREFIX):]
|
||||
value = self.transport.read_branch(branch)
|
||||
if value is None:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_NOT_FOUND")
|
||||
return _oid(value, "remote_ref_oid")
|
||||
|
||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str:
|
||||
ref = _source_ref(source_ref)
|
||||
branch = ref[len(_REF_PREFIX):]
|
||||
try:
|
||||
value = self.transport.fetch_branch_exact(
|
||||
branch,
|
||||
_oid(source_commit, "source_commit"),
|
||||
)
|
||||
except GiteaTransportError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_FAILED") from exc
|
||||
return _oid(value, "fetched_source_commit")
|
||||
|
||||
def tree_oid(self, source_commit: str) -> str:
|
||||
source = _oid(source_commit, "source_commit")
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
["rev-parse", "--verify", f"{source}^{{tree}}"],
|
||||
git_dir=self.git_dir,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_COMMIT_NOT_FOUND") from exc
|
||||
return _oid(result.stdout.strip().lower(), "tree_oid")
|
||||
|
||||
def require_lineage(self, base_oid: str, source_commit: str) -> None:
|
||||
try:
|
||||
authoritative_lineage(
|
||||
_oid(base_oid, "base_oid"),
|
||||
_oid(source_commit, "source_commit"),
|
||||
git_dir=self.git_dir,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_LINEAGE_MISMATCH") from exc
|
||||
|
||||
|
||||
class ReleaseSourceAdmitter:
|
||||
def __init__(
|
||||
self,
|
||||
store: ReleaseSourceAdmissionStore,
|
||||
backend: ReleaseSourceBackend,
|
||||
*,
|
||||
purpose: str = "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
) -> None:
|
||||
if not store.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
self.store = store
|
||||
self.backend = backend
|
||||
self.purpose = _identifier(purpose, "purpose")
|
||||
|
||||
def admit(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
task_id: str,
|
||||
source_ref: str,
|
||||
source_commit: str,
|
||||
) -> dict[str, Any]:
|
||||
version_value = _version(version)
|
||||
task_value = _identifier(task_id, "task_id")
|
||||
ref_value = _source_ref(source_ref)
|
||||
source_value = _oid(source_commit, "source_commit")
|
||||
|
||||
remote_before = self.backend.remote_ref_oid(ref_value)
|
||||
if remote_before != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
||||
base_oid = _oid(self.backend.authoritative_main_oid(), "base_oid")
|
||||
fetched = _oid(
|
||||
self.backend.fetch_exact_ref(ref_value, source_value),
|
||||
"fetched_source_commit",
|
||||
)
|
||||
if fetched != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.backend.remote_ref_oid(ref_value)
|
||||
if remote_after != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
||||
self.backend.require_lineage(base_oid, source_value)
|
||||
tree_oid = _oid(self.backend.tree_oid(source_value), "tree_oid")
|
||||
|
||||
candidate_id = f"rc-{_version_slug(version_value)}-{source_value[:12]}"
|
||||
admission_id = "source-admission-" + hashlib.sha256(
|
||||
f"{self.store.repository}\0{version_value}\0{source_value}\0{ref_value}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
record = {
|
||||
"schema": SCHEMA,
|
||||
"admission_id": admission_id,
|
||||
"repository": self.store.repository,
|
||||
"task_id": task_value,
|
||||
"version": version_value,
|
||||
"candidate_id": candidate_id,
|
||||
"base_oid": base_oid,
|
||||
"source_ref": ref_value,
|
||||
"source_commit": source_value,
|
||||
"remote_ref_oid": remote_after,
|
||||
"tree_oid": tree_oid,
|
||||
"purpose": self.purpose,
|
||||
"policy_sha256": self.store.policy_sha256,
|
||||
"admitted_at": _now(),
|
||||
}
|
||||
return self.store.register(record)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
admit = subparsers.add_parser("admit")
|
||||
admit.add_argument("--version", required=True)
|
||||
admit.add_argument("--task-id", required=True)
|
||||
admit.add_argument("--source-ref", required=True)
|
||||
admit.add_argument("--source-commit", required=True)
|
||||
admit.add_argument("--policy", required=True, type=Path)
|
||||
admit.add_argument("--store-root", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.command != "admit":
|
||||
raise SystemExit("unsupported command")
|
||||
repository = os.environ.get("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
||||
api_base = os.environ.get("AIOS_GITEA_API_BASE", "")
|
||||
remote_url = os.environ.get("AIOS_GITEA_REMOTE_URL", "")
|
||||
raw_git_dir = os.environ.get("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")
|
||||
if not repository or not api_base or not remote_url or not raw_git_dir:
|
||||
raise SystemExit("release source admission environment is incomplete")
|
||||
git_dir = Path(raw_git_dir)
|
||||
transport = GiteaMachineTransport(
|
||||
api_base=api_base,
|
||||
repository_id=repository,
|
||||
remote_url=remote_url,
|
||||
authority_git_dir=git_dir,
|
||||
credential_name=os.environ.get(
|
||||
"AIOS_GITEA_MACHINE_CREDENTIAL_NAME",
|
||||
"gitea-machine-token",
|
||||
),
|
||||
)
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
try:
|
||||
admission_hmac = (Path(credentials) / "source_admission_hmac").read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise SystemExit("source_admission_hmac credential is required") from exc
|
||||
store = ReleaseSourceAdmissionStore(
|
||||
arguments.store_root,
|
||||
repository=repository,
|
||||
policy_sha256=_sha256_file(arguments.policy),
|
||||
hmac_key=admission_hmac,
|
||||
expected_owner_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
||||
writable=True,
|
||||
)
|
||||
result = ReleaseSourceAdmitter(
|
||||
store,
|
||||
GiteaReleaseSourceBackend(transport=transport, git_dir=git_dir),
|
||||
).admit(
|
||||
version=arguments.version,
|
||||
task_id=arguments.task_id,
|
||||
source_ref=arguments.source_ref,
|
||||
source_commit=arguments.source_commit,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -3531,6 +3531,7 @@ class SilentReleaseActionService:
|
||||
trusted_policy: TrustedReleasePolicy | None = None,
|
||||
orchestrator: ReleaseOrchestrator | None = None,
|
||||
builder_readiness_probe: Callable[[], Mapping[str, Any]] | None = None,
|
||||
source_admission_resolver: Callable[[str], Mapping[str, Any] | None] | None = None,
|
||||
allow_legacy_candidate_input: bool = False,
|
||||
expected_repository: str = "",
|
||||
policy_sha256: str = "",
|
||||
@@ -3547,6 +3548,7 @@ class SilentReleaseActionService:
|
||||
self.trusted_policy = trusted_policy
|
||||
self.orchestrator = orchestrator
|
||||
self.builder_readiness_probe = builder_readiness_probe
|
||||
self.source_admission_resolver = source_admission_resolver
|
||||
self.allow_legacy_candidate_input = bool(
|
||||
allow_legacy_candidate_input
|
||||
)
|
||||
@@ -3638,6 +3640,40 @@ class SilentReleaseActionService:
|
||||
version = str(payload.get("version") or "")
|
||||
if version:
|
||||
_validate_identifier("version", version)
|
||||
|
||||
source_admission: dict[str, Any] | None = None
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is not None:
|
||||
try:
|
||||
raw_admission = resolver(source_commit)
|
||||
except Exception as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"release source admission is invalid"
|
||||
) from exc
|
||||
if raw_admission is not None:
|
||||
source_admission = dict(raw_admission)
|
||||
admitted_source = str(
|
||||
source_admission.get("source_commit") or ""
|
||||
)
|
||||
admitted_version = str(source_admission.get("version") or "")
|
||||
admission_id = str(
|
||||
source_admission.get("admission_id") or ""
|
||||
)
|
||||
if (
|
||||
admitted_source != source_commit
|
||||
or not admitted_version
|
||||
or not admission_id
|
||||
):
|
||||
raise CapabilityAuthorizationError(
|
||||
"release source admission is invalid"
|
||||
)
|
||||
_validate_identifier("version", admitted_version)
|
||||
_validate_identifier("admission_id", admission_id)
|
||||
if version and version != admitted_version:
|
||||
raise CapabilityAuthorizationError(
|
||||
"release source admission version mismatch"
|
||||
)
|
||||
version = admitted_version
|
||||
intent_id = "intent-" + hashlib.sha256(
|
||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
@@ -3657,12 +3693,16 @@ class SilentReleaseActionService:
|
||||
}
|
||||
if version:
|
||||
record["version"] = version
|
||||
if source_admission is not None:
|
||||
record["source_admission_id"] = source_admission["admission_id"]
|
||||
if was_existing:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
existing.get("event_id") != event_id
|
||||
or existing.get("source_commit") != source_commit
|
||||
or str(existing.get("version") or "") != version
|
||||
or str(existing.get("source_admission_id") or "")
|
||||
!= str(record.get("source_admission_id") or "")
|
||||
):
|
||||
raise SagaConflict(
|
||||
"release intent id is bound to different evidence"
|
||||
@@ -3742,14 +3782,23 @@ class SilentReleaseActionService:
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except CapabilityAuthorizationError:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||
"retryable": False,
|
||||
"status": "BUILDER_TERMINAL_FAILURE",
|
||||
}
|
||||
)
|
||||
except CapabilityAuthorizationError as exc:
|
||||
if str(exc) == "release source admission required":
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "SOURCE_ADMISSION_REQUIRED",
|
||||
"retryable": False,
|
||||
"status": "SOURCE_ADMISSION_REQUIRED",
|
||||
}
|
||||
)
|
||||
else:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_REJECTED",
|
||||
"retryable": False,
|
||||
"status": "BUILDER_TERMINAL_FAILURE",
|
||||
}
|
||||
)
|
||||
_durable_json(path, record)
|
||||
return self._intent_response(record, idempotent=False)
|
||||
except (ConnectionError, OSError, TimeoutError):
|
||||
|
||||
@@ -940,23 +940,29 @@ class TaskPlaneStore:
|
||||
def heartbeat_claim(self, task_id: str, claim_id: str, lease_seconds: int) -> WorkerClaim:
|
||||
now = _now()
|
||||
lease_until = now + max(1, lease_seconds)
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM claims WHERE claim_id=? AND task_id=?",
|
||||
(claim_id, task_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise NotFoundError("claim")
|
||||
claim = self._row_to_claim(row)
|
||||
if claim.status != "ACTIVE":
|
||||
raise ConflictError("claim not active")
|
||||
if claim.lease_until < now:
|
||||
raise LockedError("lease expired")
|
||||
|
||||
with self._transaction():
|
||||
self.conn.execute(
|
||||
"UPDATE claims SET lease_until=? WHERE claim_id=?",
|
||||
(lease_until, claim_id),
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM claims WHERE claim_id=? AND task_id=?",
|
||||
(claim_id, task_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise NotFoundError("claim")
|
||||
claim = self._row_to_claim(row)
|
||||
if claim.status != "ACTIVE":
|
||||
raise ConflictError("claim not active")
|
||||
if claim.lease_until <= now:
|
||||
raise LockedError("lease expired")
|
||||
if self.get_task(task_id).status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
||||
raise ConflictError("task is terminal")
|
||||
updated = self.conn.execute(
|
||||
"""
|
||||
UPDATE claims SET lease_until=?
|
||||
WHERE claim_id=? AND task_id=? AND status='ACTIVE' AND lease_until>?
|
||||
""",
|
||||
(lease_until, claim_id, task_id, now),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise LockedError("claim changed during heartbeat")
|
||||
self.append_event(task_id, "TASK_PROGRESS", {"claim_id": claim_id, "stage": "heartbeat"}, subject=f"task/{task_id}")
|
||||
return self.get_claim(claim_id)
|
||||
|
||||
@@ -1064,7 +1070,14 @@ class TaskPlaneStore:
|
||||
),
|
||||
)
|
||||
self._fault("recover.after_probe_before_state")
|
||||
if probe.outcome == ProbeOutcome.APPLIED_VERIFIED:
|
||||
current_task = self.get_task(task_id)
|
||||
if current_task.status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
||||
# Recovery records what happened; it cannot undo a terminal Owner
|
||||
# decision or reopen a finished task. This check shares the state
|
||||
# transaction with the claim close and the appended probe event.
|
||||
task_status = current_task.status
|
||||
claim_status = "CANCELLED" if current_task.status == "CANCELLED" else "CLOSED_BY_RECOVERY"
|
||||
elif probe.outcome == ProbeOutcome.APPLIED_VERIFIED:
|
||||
task_status = "COMPLETED"
|
||||
claim_status = "COMPLETED"
|
||||
elif probe.outcome == ProbeOutcome.CONFLICT:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersessions (
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL CHECK (supersession_ordinal = 1),
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_identity_sha256 TEXT NOT NULL,
|
||||
source_metadata_sha256 TEXT NOT NULL,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
decision_id TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersession_receipts (
|
||||
decision_id TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL,
|
||||
child_task_id TEXT NOT NULL UNIQUE,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
decision_json TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
consumed_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_claim_guard_bindings_v2 (
|
||||
binding_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL,
|
||||
expected_revision INTEGER NOT NULL CHECK (expected_revision >= 1),
|
||||
metadata_sha256 TEXT NOT NULL,
|
||||
required_capabilities_sha256 TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
execution_digest TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
policy_json TEXT NOT NULL,
|
||||
policy_sha256 TEXT NOT NULL,
|
||||
binding_json TEXT NOT NULL,
|
||||
binding_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_claim_guard_binding_receipts_v2 (
|
||||
receipt_id TEXT PRIMARY KEY,
|
||||
binding_id TEXT NOT NULL UNIQUE,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
receipt_json TEXT NOT NULL,
|
||||
receipt_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (binding_id) REFERENCES task_claim_guard_bindings_v2(binding_id)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersessions_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersessions BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersessions_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersessions BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_receipts_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersession_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_receipts_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersession_receipts BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_bindings_v2_no_update
|
||||
BEFORE UPDATE ON task_claim_guard_bindings_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_bindings_v2_no_delete
|
||||
BEFORE DELETE ON task_claim_guard_bindings_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_binding_receipts_v2_no_update
|
||||
BEFORE UPDATE ON task_claim_guard_binding_receipts_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS task_claim_guard_binding_receipts_v2_no_delete
|
||||
BEFORE DELETE ON task_claim_guard_binding_receipts_v2 BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_CLAIM_GUARD_BINDING_RECEIPT_APPEND_ONLY');
|
||||
END;
|
||||
@@ -0,0 +1,41 @@
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_lineage_supersession_initial_projections (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
source_task_id TEXT NOT NULL,
|
||||
supersession_ordinal INTEGER NOT NULL CHECK (supersession_ordinal = 1),
|
||||
intent_sha256 TEXT NOT NULL UNIQUE,
|
||||
intent_json TEXT NOT NULL,
|
||||
owner_decision_sha256 TEXT NOT NULL UNIQUE,
|
||||
source_task_identity_sha256 TEXT NOT NULL,
|
||||
source_metadata_sha256 TEXT NOT NULL,
|
||||
source_business_execution_digest TEXT NOT NULL,
|
||||
canonical_child_execution_digest TEXT NOT NULL UNIQUE,
|
||||
external_job_id TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
adapter TEXT NOT NULL,
|
||||
required_capability TEXT NOT NULL,
|
||||
expected_revision INTEGER NOT NULL CHECK (expected_revision = 1),
|
||||
applied_revision INTEGER NOT NULL CHECK (applied_revision = 2),
|
||||
metadata_sha256 TEXT NOT NULL,
|
||||
required_capabilities_sha256 TEXT NOT NULL,
|
||||
projection_event_sha256 TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL,
|
||||
receipt_sha256 TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (source_task_id, supersession_ordinal)
|
||||
REFERENCES task_lineage_supersessions(source_task_id, supersession_ordinal)
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_initial_projections_no_update
|
||||
BEFORE UPDATE ON task_lineage_supersession_initial_projections BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_INITIAL_PROJECTION_APPEND_ONLY');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS task_lineage_supersession_initial_projections_no_delete
|
||||
BEFORE DELETE ON task_lineage_supersession_initial_projections BEGIN
|
||||
SELECT RAISE(ABORT, 'TASK_LINEAGE_SUPERSESSION_INITIAL_PROJECTION_APPEND_ONLY');
|
||||
END;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.activation-checklist.v1",
|
||||
"profile": "isolated-validation",
|
||||
"production_activation_supported": false,
|
||||
"status": "NOT_RUN",
|
||||
"checks": [
|
||||
{"id": "identity", "required": "Replace @RELEASE_ROOT@; record source, artifact and configuration digests separately; verify package bytes.", "status": "NOT_RUN"},
|
||||
{"id": "authority", "required": "Bind an explicitly isolated TaskPlaneStore; record existing online Task/Job/Release write authorities; do not route online admission into this profile.", "status": "NOT_RUN"},
|
||||
{"id": "delegation", "required": "Use installed trusted Owner public keys and an explicitly authorized signed Grant. Never generate a production trust root to satisfy a missing credential.", "status": "NOT_RUN"},
|
||||
{"id": "credentials", "required": "Project encrypted credentials directly into each consuming unit; preserve encrypted credential names; check permissions and independent restart/rotation.", "status": "NOT_RUN"},
|
||||
{"id": "privileges", "required": "Create separate service and worker users; grant only read-only status access to the registered systemd service; no SSH, root shell or global write.", "status": "NOT_RUN"},
|
||||
{"id": "configuration", "required": "Use loopback server URL, trusted fixed operation/component bindings and unique state directories. Client request bodies may not select tenant or credential paths.", "status": "NOT_RUN"},
|
||||
{"id": "storage", "required": "Check directory owner, free space, SQLite WAL commits, durable receipts, recovery permissions and restore procedure on the actual storage device.", "status": "NOT_RUN"},
|
||||
{"id": "nonrelease_loop", "required": "Record a real registered host service diagnostic through Action, Task claim, operation, receipt and result; unhealthy diagnostics remain observable failures of health.", "status": "NOT_RUN"},
|
||||
{"id": "online_cutover", "required": "Separate later change: bind verified official Task authority adapter, release acceptance verifier, credential identities, approved installation and single-writer evidence before enabling online admission.", "status": "NOT_SUPPORTED_BY_THIS_PROFILE"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Validation 实例的凭证边界
|
||||
|
||||
本目录的 `.service.example` 是候选验证实例模板。它们不自动安装,不替换现有 Task 权威,也不支持 production profile。先按 `docs/implementation-baseline-1.1/INSTALLATION.md` 完成离线验证,并由安装者将 `@RELEASE_ROOT@` 替换为确定的、只读的发行目录,并将 `@PYTHON_EXECUTABLE@` 绑定到已验收的独立虚拟环境 Python 绝对路径。
|
||||
|
||||
服务端账户为 `aios-baseline`;Worker 账户为 `aios-baseline-worker`。Worker 不获得机器签名私钥、Owner 私钥、服务端 token 清单或服务端数据库读取权。Worker 的 transport token 仅映射到已注册 Worker 身份;它不能自行声明租户、用户或权限。
|
||||
|
||||
| 消费者 | 凭证名 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| validation API | `aios-baseline-machine-key` | 已安装的短期执行证明和 operation receipt 签名身份 |
|
||||
| validation API | `aios-baseline-user-token` | 验证配置中一位用户的 transport token;实际多用户要逐个绑定可信身份 |
|
||||
| validation API | `aios-baseline-worker-token` | 验证 Worker 的 transport token |
|
||||
| validation Worker | `aios-baseline-worker-token` | 仅用于连接本机 API |
|
||||
|
||||
每个 unit 使用自己的 `LoadCredentialEncrypted=` 从稳定的 `/etc/credstore.encrypted/` 来源投影。两个服务可以获得同一个受治理 token 的独立投影,但任何服务都不读取另一服务的 `/run/credentials/<unit>/`。加密凭证名称与 `LoadCredentialEncrypted` 左侧名称必须一致,避免更名导致解密身份校验失败。
|
||||
|
||||
JSON 中仅允许以 `${CREDENTIALS_DIRECTORY}/<name>` 引用自己的投影目录;不要写入 token、签名私钥或跨服务路径。Owner 公钥和 Runtime Contract 是受治理配置;Owner 私钥留在既有签发边界,不交给 API 或 Worker。凭证缺失时服务启动或操作准入必须失败,不能临时新建信任根或借用其他服务的运行目录。
|
||||
|
||||
本包不提供生成生产信任根的脚本。测试在临时目录生成测试专用密钥;它们不能用于真实部署。实际安装前还要确认当前主机 systemd 支持这些加固项和 encrypted credential,并对已渲染 unit 运行 `systemd-analyze verify`。本地模板静态检查不能代替实际服务重启、token 轮换及读取权限验收。
|
||||
|
||||
`service-status` 使用固定 `/usr/bin/systemctl show` 参数和可信 unit 映射,只读取注册组件状态。若目标主机限制状态读取,应对该命名 operation 配置最小读取权限;不要改成以 root 运行 API,也不要开放任意命令或 SSH。
|
||||
@@ -0,0 +1,54 @@
|
||||
[Unit]
|
||||
Description=AIOS Baseline 1.1 isolated validation API (no production authority cutover)
|
||||
After=local-fs.target
|
||||
ConditionPathExists=@PYTHON_EXECUTABLE@
|
||||
ConditionPathExists=/etc/aios-baseline/server.json
|
||||
ConditionPathExists=@RELEASE_ROOT@/active-runtime/app/aios_baseline/cli.py
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-baseline
|
||||
Group=aios-baseline
|
||||
WorkingDirectory=@RELEASE_ROOT@
|
||||
Environment=PYTHONPATH=@RELEASE_ROOT@/active-runtime/app
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=PATH=/usr/bin:/bin
|
||||
LoadCredentialEncrypted=aios-baseline-machine-key:/etc/credstore.encrypted/aios-baseline-machine-key
|
||||
LoadCredentialEncrypted=aios-baseline-user-token:/etc/credstore.encrypted/aios-baseline-user-token
|
||||
LoadCredentialEncrypted=aios-baseline-worker-token:/etc/credstore.encrypted/aios-baseline-worker-token
|
||||
ExecStart=@PYTHON_EXECUTABLE@ -B -m aios_baseline.cli server --config /etc/aios-baseline/server.json
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStopSec=30s
|
||||
KillMode=control-group
|
||||
UMask=0077
|
||||
StateDirectory=aios-baseline
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/var/lib/aios-baseline
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
RestrictRealtime=yes
|
||||
TasksMax=64
|
||||
MemoryMax=512M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,54 @@
|
||||
[Unit]
|
||||
Description=AIOS Baseline 1.1 isolated validation worker (registered transport identity)
|
||||
After=aios-baseline-validation.service
|
||||
Requires=aios-baseline-validation.service
|
||||
ConditionPathExists=@PYTHON_EXECUTABLE@
|
||||
ConditionPathExists=/etc/aios-baseline/worker.json
|
||||
ConditionPathExists=@RELEASE_ROOT@/active-runtime/app/aios_baseline/cli.py
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-baseline-worker
|
||||
Group=aios-baseline-worker
|
||||
WorkingDirectory=@RELEASE_ROOT@
|
||||
Environment=PYTHONPATH=@RELEASE_ROOT@/active-runtime/app
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=PATH=/usr/bin:/bin
|
||||
LoadCredentialEncrypted=aios-baseline-worker-token:/etc/credstore.encrypted/aios-baseline-worker-token
|
||||
ExecStart=@PYTHON_EXECUTABLE@ -B -m aios_baseline.cli worker --config /etc/aios-baseline/worker.json
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStopSec=15s
|
||||
KillMode=control-group
|
||||
UMask=0077
|
||||
StateDirectory=aios-baseline-worker
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/var/lib/aios-baseline-worker
|
||||
InaccessiblePaths=-/var/lib/aios-baseline -/etc/aios-baseline/server.json
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
RestrictRealtime=yes
|
||||
TasksMax=32
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"contracts": [
|
||||
{
|
||||
"bindings": {},
|
||||
"component_id": "sample",
|
||||
"contract_revision": 1,
|
||||
"contract_schema": "aios.runtime-contract.v1",
|
||||
"credential_refs": {},
|
||||
"entrypoints": {
|
||||
"status": {
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://sample/status"
|
||||
}
|
||||
},
|
||||
"environment": "staging",
|
||||
"lifecycle": "active",
|
||||
"operations": [
|
||||
"service-status"
|
||||
],
|
||||
"resource_refs": {},
|
||||
"tenant_scope": "validation"
|
||||
}
|
||||
],
|
||||
"host_bindings": {
|
||||
"validation:staging:sample": "REPLACE_WITH_EXISTING_SERVICE.service"
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"role": "owner",
|
||||
"subject_id": "owner",
|
||||
"tenant_id": "validation",
|
||||
"token_file": "${CREDENTIALS_DIRECTORY}/aios-baseline-user-token"
|
||||
},
|
||||
{
|
||||
"role": "worker",
|
||||
"subject_id": "machine-worker",
|
||||
"tenant_id": "validation",
|
||||
"token_file": "${CREDENTIALS_DIRECTORY}/aios-baseline-worker-token",
|
||||
"worker_id": "diagnostic-worker"
|
||||
}
|
||||
],
|
||||
"listen": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8769
|
||||
},
|
||||
"machine_key_file": "${CREDENTIALS_DIRECTORY}/aios-baseline-machine-key",
|
||||
"machine_key_id": "validation-machine",
|
||||
"operations": [
|
||||
{
|
||||
"effect_class": "READ_ONLY",
|
||||
"handler_ref": "host.service_status",
|
||||
"input_schema": "aios.operation.service-status.input.v1",
|
||||
"operation_id": "service-status",
|
||||
"owner_boundary": {
|
||||
"mode": "WITHIN_GRANT"
|
||||
},
|
||||
"readback": {
|
||||
"mode": "NONE",
|
||||
"required": false
|
||||
},
|
||||
"reconcile": {
|
||||
"mode": "NONE"
|
||||
},
|
||||
"required_permissions": [
|
||||
"service-status"
|
||||
],
|
||||
"retry": {
|
||||
"max_attempts": 2,
|
||||
"mode": "SAFE"
|
||||
},
|
||||
"rollback": {
|
||||
"mode": "NONE"
|
||||
},
|
||||
"timeout_seconds": 10,
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"owner_key_scopes": {
|
||||
"validation-owner": {
|
||||
"subject_ids": [
|
||||
"owner"
|
||||
],
|
||||
"tenant_ids": [
|
||||
"validation"
|
||||
]
|
||||
}
|
||||
},
|
||||
"owner_public_key_files": {
|
||||
"validation-owner": "/etc/aios-baseline/owner-public.pem"
|
||||
},
|
||||
"profile": "isolated-validation",
|
||||
"schema": "aios.baseline-server-config.v1",
|
||||
"state_dir": "/var/lib/aios-baseline",
|
||||
"workers": [
|
||||
{
|
||||
"capabilities": [
|
||||
"service-status"
|
||||
],
|
||||
"fallback_executor": false,
|
||||
"heartbeat_seconds": 15,
|
||||
"identity": "machine-worker",
|
||||
"max_concurrency": 1,
|
||||
"priority": 10,
|
||||
"subject_scope": [
|
||||
"owner"
|
||||
],
|
||||
"tenant_scope": [
|
||||
"validation"
|
||||
],
|
||||
"worker_id": "diagnostic-worker"
|
||||
}
|
||||
],
|
||||
"receipt_public_key_files": {}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"base_url": "http://127.0.0.1:8769",
|
||||
"poll_seconds": 2,
|
||||
"schema": "aios.baseline-worker-config.v1",
|
||||
"token_file": "${CREDENTIALS_DIRECTORY}/aios-baseline-worker-token"
|
||||
}
|
||||
@@ -11,9 +11,10 @@ WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=AIOS_BRIDGE_SCHEMA_VERSION=aios.chatgpt-gitops-bridge.v2
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=true
|
||||
Environment=AIOS_P0_TASK_PRINCIPAL_WRITES_ENABLED=true
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_GITEA_TOKEN_FILE=/run/credentials/aios-chatgpt-bridge-v2-staging.service/gitea_token
|
||||
Environment=AIOS_GITEA_TOKEN_FILE=%d/gitea-governance-executor-token
|
||||
Environment=AIOS_GITEA_REMOTE=gitea-http
|
||||
Environment=AIOS_GITOPS_BASELINE_AUTHORITY=/var/lib/aios-gitops/model-api-staging/baseline/current.json
|
||||
Environment=AIOS_CONTROL_PLANE_TARGET_VERSION=V2R2C1
|
||||
@@ -21,7 +22,7 @@ Environment=AIOS_CONTROL_PLANE_TARGET_OID=@SOURCE_OID@
|
||||
Environment=AIOS_CONTROL_PLANE_ARTIFACT_SHA256=@ARTIFACT_SHA256@
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@:@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
Environment=AIOS_DEPLOYMENT_STATE=STAGING
|
||||
LoadCredential=gitea_token:/run/credentials/aios-governance-executor.service/gitea-governance-executor-token
|
||||
LoadCredentialEncrypted=gitea-governance-executor-token:/etc/credstore.encrypted/gitea-governance-executor-token
|
||||
ExecStartPre=/usr/bin/install -d -o aiman -g gitops -m 0750 /var/lib/aios-gitops/bridge-v2-staging
|
||||
ExecStartPre=/usr/bin/setfacl -m u:aiman:--x /usr/local/libexec
|
||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/app/aios_chatgpt_bridge_v2_server.py
|
||||
|
||||
@@ -7,8 +7,10 @@ Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_SOCKET=/run/aios-trusted-candidate-bu
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_PUBLIC_KEYRING=/etc/aios-gitops/trusted-builder/public-keyring.json
|
||||
Environment=AIOS_TRUSTED_CANDIDATE_BUILDER_CREDENTIALS_DIRECTORY=%d
|
||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
||||
Environment=AIOS_SILENT_RELEASE_TRUSTED_POLICY=/opt/aios/gitops-model-api/<VERIFIED_PACKAGE_OID>/runtime/config/gitops/trusted-release-policy.json
|
||||
LoadCredential=builder_hmac:/etc/aios-gitops/credential-references/trusted-candidate-builder-hmac
|
||||
LoadCredential=source_admission_hmac:/etc/aios-gitops/credential-references/release-source-admission-hmac
|
||||
# The credential source above is a reference only; no secret value belongs here.
|
||||
# Keep execution shadow-disabled and do not add Owner/deploy/tag/baseline switches.
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
|
||||
@@ -27,6 +27,7 @@ Environment=AIOS_GITOPS_DEPLOYMENT_STATE=STAGING
|
||||
Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.service
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
@@ -44,7 +45,7 @@ ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@ /data/aios-gitops/repo.git /var/lib/aios-gitops-runtime-anchor/baseline
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@ /data/aios-gitops/repo.git /var/lib/aios-gitops-runtime-anchor/baseline -/var/lib/aios-release-source-admission
|
||||
ReadWritePaths=/var/lib/aios-gitops/model-api-staging
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=AIOS governed release source admission
|
||||
After=network-online.target aios-gitea.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
UMask=0027
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITEA_API_BASE=https://g.cnnetmkt.top/git/api/v1
|
||||
Environment=AIOS_GITEA_REMOTE_URL=https://g.cnnetmkt.top/git/aios/aios.git
|
||||
Environment=AIOS_GITEA_MACHINE_CREDENTIAL_NAME=gitea-machine-token
|
||||
EnvironmentFile=/run/aios-release-source-admission/request.env
|
||||
LoadCredentialEncrypted=gitea-machine-token:/etc/aios/credentials/gitea-machine.token.cred
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-source-admission/current/app/release_source_admission.py admit --version ${AIOS_RELEASE_VERSION} --task-id ${AIOS_RELEASE_TASK_ID} --source-ref ${AIOS_RELEASE_SOURCE_REF} --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --store-root /var/lib/aios-release-source-admission
|
||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-source-admission/request.env
|
||||
RuntimeDirectory=aios-release-source-admission
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-release-source-admission
|
||||
StateDirectoryMode=0750
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/release-source-admission /opt/aios/trusted-candidate-builder /etc/aios/credentials /etc/credstore.encrypted
|
||||
ReadWritePaths=/data/aios-gitops/repo.git /var/lib/aios-release-source-admission /run/aios-release-source-admission
|
||||
@@ -10,10 +10,12 @@ Group=root
|
||||
UMask=0077
|
||||
LoadCredentialEncrypted=signing_private_key:/etc/credstore.encrypted/aios-trusted-candidate-builder/signing_private_key
|
||||
LoadCredentialEncrypted=builder_hmac:/etc/credstore.encrypted/aios-trusted-candidate-builder/builder_hmac
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
Environment=AIOS_TRUSTED_BUILDER_POLICY=/opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
||||
Environment=AIOS_TRUSTED_BUILDER_NONCE_LEDGER=/var/lib/aios-trusted-candidate-builder/nonce-ledger.json
|
||||
Environment=AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT=/var/lib/aios-release-source-admission
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
@@ -37,7 +39,7 @@ MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence
|
||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence -/var/lib/aios-release-source-admission
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR: Owner-final governed merge authorization v2
|
||||
|
||||
Date: 2026-09-07
|
||||
|
||||
Status: Accepted for the P0 Owner-final authorization repair
|
||||
|
||||
## Context
|
||||
|
||||
AIOS is a single-Owner system. A signer, verifier, required check, branch
|
||||
protection rule, or merge executor may prove or execute an Owner decision, but
|
||||
must not become an independent policy authority that can permanently override
|
||||
an exact, valid Owner authorization.
|
||||
|
||||
The legacy approval seam also names the proof implementation as the Owner
|
||||
identity. That conflates the policy subject with a key or adapter and makes a
|
||||
default signer outage look like a higher-level veto.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce the versioned `aios.owner-merge-approval.v2` artifact and make it
|
||||
the policy input to a governed merge admission. Its canonical payload binds:
|
||||
|
||||
- repository, pull-request number and exact pull-request head OID;
|
||||
- the expected `main` OID used as the CAS base;
|
||||
- `merge.pull-request` as the only operation;
|
||||
- required-check contract digest, policy version/digest and audit lineage
|
||||
digest;
|
||||
- generated approval ID and nonce, issued/not-before/expiry times; and
|
||||
- a distinct Owner policy identity plus the separate proof key identity.
|
||||
|
||||
The structured Owner authority creates the complete canonical payload from an
|
||||
immutable merge policy. It accepts no arbitrary caller bytes and has no
|
||||
generic signing operation. Changing PR head, CAS base, policy, repository,
|
||||
operation, or approval expiry invalidates the approval; a new policy-bound
|
||||
approval is required.
|
||||
|
||||
An issuance ledger replays the same still-valid artifact for idempotent client
|
||||
delivery, retains expired artifacts for audit, and issues a fresh approval
|
||||
with a new nonce after expiry. It must never turn an expired approval into a
|
||||
permanent signing deadlock.
|
||||
|
||||
Proof verification is public-key based. A recovery verifier validates an
|
||||
already-issued v2 approval against the registered Owner root without calling
|
||||
the default signing service. It is not a second policy principal and cannot
|
||||
mint, broaden, or replace an Owner approval. Absence of every trusted proof
|
||||
fails closed.
|
||||
|
||||
## Governing merge admission
|
||||
|
||||
The admission service consumes a valid approval only after independent live
|
||||
technical evidence satisfies the exact required-check contract. It then
|
||||
durably claims the approval nonce before requesting the protected merge. A
|
||||
duplicate or uncertain nonce is never retried as another side effect. The
|
||||
executor remains responsible for branch protection and CAS; its result is
|
||||
accepted only after Authority, Gitea, and repository API OID reconciliation.
|
||||
|
||||
Owner approval never writes a check result and never converts failed CI,
|
||||
security, provenance, repository-boundary, or OID-integrity evidence into
|
||||
PASS. Failure of those gates remains fail-closed. A post-side-effect
|
||||
reconciliation failure is recorded as unknown/reconciliation-required, not
|
||||
reported as a successful merge or blindly retried.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model distinguishes three roles:
|
||||
|
||||
```text
|
||||
Owner approval decides whether this exact merge is allowed
|
||||
signer/verifier proves that exact Owner decision and its binding
|
||||
technical gates/CAS establish whether the merge can safely execute
|
||||
merge executor performs the protected operation and records outcome
|
||||
```
|
||||
|
||||
The v2 contract is additive and versioned. It does not reinterpret legacy
|
||||
approvals or relax existing branch protection, CI, security, provenance,
|
||||
tenant-boundary, nonce, expiry, audit, or reconciliation controls.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR: Bounded Immutable Repository Discovery
|
||||
|
||||
- Status: Implemented locally; pending governed publication and live runtime
|
||||
deployment
|
||||
- Date: 2026-09-02
|
||||
- Scope: AIOS Repository Discovery P0
|
||||
|
||||
## Context
|
||||
|
||||
Repository discovery was needed to inspect unknown paths and compare exact
|
||||
repository snapshots, while the ordinary SSH route was not an authenticated
|
||||
execution plane. The discovery surface must not turn a readable runtime into a
|
||||
general-purpose shell, grep, checkout, or Git writer. It also must remain safe
|
||||
when a branch or tag moves between requests and when a diff is large.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a repository-scoped, read-only module behind the formal Bridge and
|
||||
Actions read routes:
|
||||
|
||||
1. Verify the exact `aios/aios` identity before every request.
|
||||
2. Resolve `HEAD`, branch, tag, or full commit input to one full commit OID;
|
||||
reject ambiguous short names and revision expressions.
|
||||
3. Invoke only fixed read-only Git subcommands (`rev-parse`, `ls-tree`,
|
||||
`cat-file`, and `diff`) with `shell=False`, disabled prompts, and bounded
|
||||
output. Stream NUL-delimited tree and diff records rather than collecting a
|
||||
complete tree or patch.
|
||||
4. Enforce normalized relative paths, repository allowlisting, depth/page/
|
||||
match/byte limits, binary-source exclusion, and no patch response.
|
||||
5. Sign pagination cursors with an HMAC over the operation, repository,
|
||||
resolved OID, complete normalized query, and offset. A cursor for another
|
||||
snapshot or query is invalid.
|
||||
6. Return resolved commit identity for every read and blob identity for file
|
||||
reads. Keep the four operations read-only and non-consequential at the
|
||||
Bridge contract boundary.
|
||||
7. Keep the mutating implementation workflow separate: Task and TaskPrincipal
|
||||
admission, claim/heartbeat, WriterLease, CAS, and `commitFilesGoverned` are
|
||||
still mandatory for source changes.
|
||||
|
||||
The canonical runtime paths are `/v2/repository/*`; the Actions adapter maps
|
||||
the same contract to `/api/repository/*`. The OpenAPI document records the
|
||||
mapping without copying a Path Item or duplicating operation IDs.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- Retrying arbitrary SSH credentials or inventing a new network profile would
|
||||
bypass the supported execution-plane boundary.
|
||||
- User-controlled `grep`, shell commands, checkout paths, or direct bare-repo
|
||||
reads would make the repository and host boundary non-deterministic.
|
||||
- Reusing the write route would introduce unnecessary Task/lease/credential
|
||||
authority into a read-only request and make a read endpoint a write probe.
|
||||
- Returning a full patch or collecting all Git output before pagination would
|
||||
violate the bounded-output requirement for large repositories.
|
||||
|
||||
## Consequences
|
||||
|
||||
The API callers must carry the returned OID/cursor and must not assume that a
|
||||
floating ref remains unchanged. Search may return fewer results when its byte
|
||||
bound is exhausted, and a diff is an index rather than a patch. Cursor secret
|
||||
configuration becomes a required runtime dependency for paginated operations.
|
||||
|
||||
The isolated implementation is testable against a disposable bare repository.
|
||||
Windows can compile and run the portable discovery tests, but importing the
|
||||
full Bridge/Actions runtime requires POSIX `fcntl`; Linux HTTP and locking
|
||||
integration remains a separate verification item.
|
||||
|
||||
At the time of this decision, live HTTPS authority/runtime readback was
|
||||
available, but the live OpenAPI/capability surface did not advertise these four
|
||||
discovery operations and no authorized P0 Task envelope was present in the
|
||||
execution context. Therefore this ADR does not claim a governed commit,
|
||||
remote publication, or deployed runtime.
|
||||
|
||||
## Verification gate
|
||||
|
||||
This decision is complete for local implementation only after Python compile,
|
||||
discovery unit/large-diff tests, and Bridge/OpenAPI/Actions drift tests pass.
|
||||
It becomes live only after a formal Task, TaskPrincipal, lease, governed
|
||||
commit, and independent authority/remote/API OID readback prove the exact same
|
||||
commit, with all frozen write and staging invariants unchanged.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR: Owner Scoped Task Lineage Supersession P0
|
||||
|
||||
## 决策
|
||||
|
||||
`TASK-SINGLEHOST-LINEAGE-SUPERSESSION-P0-20260905` 新增一个只服务于
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904` 到
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904-S1` 的控制面 capability。
|
||||
它创建 append-only child lineage;绝不重写 source Task、metadata、revision、attempt、claim 或 lease。
|
||||
|
||||
## 安全边界
|
||||
|
||||
请求只携带 Owner decision 和 source 身份断言。控制面复用正式 Task identity helper
|
||||
与现有 Owner verifier,严格校验 namespace、principal、当前 Owner key、短 TTL、nonce、
|
||||
decision ID、exact-CAS 字段以及唯一 child ID/ordinal。未知字段、任意 child payload、通配
|
||||
Task/repository/branch、旧 signer 或 replay 均 fail-closed。
|
||||
|
||||
child payload 由 canonical initial-execution profile 在服务端构造:`QUEUED`、revision 1、
|
||||
attempt 0、无 claim/lease、空初始 capabilities 和 pristine metadata。child 普通 event 链
|
||||
只允许 `TASK_CREATED`;lineage/decision/receipt 审计必须写入独立 append-only 表。
|
||||
|
||||
## 一致性和重放
|
||||
|
||||
单一 `BEGIN IMMEDIATE` 事务重读 source exact-CAS、检查 source+ordinal/child/decision/nonce
|
||||
唯一性、插入 child、lineage、decision 和 receipt,并整体提交。任一步失败必须回滚,不能
|
||||
留下半创建 child。只有 decision digest 完全相同的请求可返回同一 receipt;相同唯一键而
|
||||
digest 不同必须拒绝。append-only 表禁止 UPDATE 和 DELETE。
|
||||
|
||||
## ClaimGuard
|
||||
|
||||
source 的 frozen ClaimGuard artifact 永不修改。child 的 Task identity 和 projected metadata
|
||||
identity 必然不同,必须使用新建的版本化、append-only child binding/receipt,精确绑定
|
||||
child task、projected revision/metadata/capabilities、worker、execution digest、repository、
|
||||
branch、generation 和 external job。dispatch 只识别 source 与 exact child;缺失或重复 binding
|
||||
一律拒绝。不得使用 latest、通配符或 caller 指定路径。
|
||||
|
||||
## 验收
|
||||
|
||||
live invocation 前必须在 isolated SQLite 完成 source exact state 到 canonical projection、
|
||||
@@ -240,11 +240,177 @@ components:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
RepositoryTreeEntry:
|
||||
type: object
|
||||
required: [path, type, object_oid]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
type: {type: string, enum: [blob, tree, commit]}
|
||||
object_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
mode: {type: string}
|
||||
RepositoryTreeResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, entries, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
entries: {type: array, items: {$ref: '#/components/schemas/RepositoryTreeEntry'}}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositorySearchMatch:
|
||||
type: object
|
||||
required: [path, line_start, line_end, blob_oid, snippet]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
line_start: {type: integer, minimum: 1}
|
||||
line_end: {type: integer, minimum: 1}
|
||||
blob_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
snippet: {type: string, maxLength: 512}
|
||||
RepositorySearchResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, matches, scanned_bytes, max_bytes, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
matches: {type: array, items: {$ref: '#/components/schemas/RepositorySearchMatch'}}
|
||||
scanned_bytes: {type: integer, minimum: 0}
|
||||
max_bytes: {type: integer, minimum: 1, maximum: 1048576}
|
||||
binary_blobs_skipped: {type: integer, minimum: 0}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositoryDiffEntry:
|
||||
type: object
|
||||
required: [path, status, additions, deletions, old_path]
|
||||
properties:
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
status: {type: string, pattern: '^[ACDMRTUX][0-9]*$'}
|
||||
additions: {type: integer, nullable: true, minimum: 0}
|
||||
deletions: {type: integer, nullable: true, minimum: 0}
|
||||
old_path: {type: string, nullable: true, maxLength: 512}
|
||||
RepositoryDiffIndexResponse:
|
||||
type: object
|
||||
required: [repository, resolved_from_oid, resolved_to_oid, entries, truncated, next_cursor]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_from_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
resolved_to_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
entries: {type: array, items: {$ref: '#/components/schemas/RepositoryDiffEntry'}}
|
||||
truncated: {type: boolean}
|
||||
next_cursor: {type: string, nullable: true, maxLength: 4096}
|
||||
RepositoryFileResponse:
|
||||
type: object
|
||||
required: [repository, resolved_oid, blob_oid, path, content, content_base64, content_bytes, truncated]
|
||||
properties:
|
||||
repository: {type: string, const: aios/aios}
|
||||
resolved_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
blob_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
content: {type: string}
|
||||
content_base64: {type: string, format: byte}
|
||||
content_bytes: {type: integer, minimum: 0}
|
||||
truncated: {type: boolean}
|
||||
paths:
|
||||
/v2/repository/tree:
|
||||
get:
|
||||
operationId: listRepositoryTree
|
||||
summary: List one bounded page of an immutable repository tree
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/tree
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: prefix, schema: {type: string, maxLength: 512}}
|
||||
- {in: query, name: depth, schema: {type: integer, minimum: 1, maximum: 32, default: 8}}
|
||||
- {in: query, name: limit, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Immutable tree page, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryTreeResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, path, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref or path not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/search:
|
||||
get:
|
||||
operationId: searchRepositorySource
|
||||
summary: Search bounded source blobs at one immutable repository revision
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/search
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: query, required: true, schema: {type: string, minLength: 1, maxLength: 512}}
|
||||
- {in: query, name: path_globs, style: form, explode: true, schema: {type: array, maxItems: 16, items: {type: string, maxLength: 512}}}
|
||||
- {in: query, name: max_matches, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: max_bytes, required: true, schema: {type: integer, minimum: 1, maximum: 1048576}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Bounded source matches with blob identity, content: {application/json: {schema: {$ref: '#/components/schemas/RepositorySearchResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, query, path glob, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/diff-index:
|
||||
get:
|
||||
operationId: getDiffIndex
|
||||
summary: List a bounded immutable diff index without returning a patch
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/diff-index
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: from, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: to, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: path_prefix, schema: {type: string, maxLength: 512}}
|
||||
- {in: query, name: limit, required: true, schema: {type: integer, minimum: 1, maximum: 1000}}
|
||||
- {in: query, name: cursor, schema: {type: string, maxLength: 4096}}
|
||||
responses:
|
||||
'200': {description: Bounded diff index with immutable endpoints, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryDiffIndexResponse'}}}}
|
||||
'400': {description: Invalid repository, revision, path prefix, bounds, or cursor}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository revision not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/repository/file:
|
||||
get:
|
||||
operationId: readRepositoryFile
|
||||
summary: Read one bounded file from an immutable repository revision
|
||||
security: []
|
||||
x-openai-isConsequential: false
|
||||
x-aios-read-only: true
|
||||
x-aios-bounded: true
|
||||
x-aios-repository-scoped: true
|
||||
x-aios-no-shell: true
|
||||
x-aios-legacy-path: /api/repository/file
|
||||
parameters:
|
||||
- {in: query, name: repository, required: true, schema: {type: string, const: aios/aios}}
|
||||
- {in: query, name: ref, required: true, schema: {type: string, minLength: 1, maxLength: 240}}
|
||||
- {in: query, name: path, required: true, schema: {type: string, minLength: 1, maxLength: 512}}
|
||||
- {in: query, name: max_bytes, schema: {type: integer, minimum: 1, maximum: 1048576, default: 262144}}
|
||||
responses:
|
||||
'200': {description: Bounded file content with resolved commit and blob identity, content: {application/json: {schema: {$ref: '#/components/schemas/RepositoryFileResponse'}}}}
|
||||
'400': {description: Invalid repository, ref, path, or bounds}
|
||||
'403': {description: Repository identity is not allowed}
|
||||
'404': {description: Repository ref or file not found}
|
||||
'503': {description: Authoritative repository or discovery configuration unavailable}
|
||||
/v2/commit-files:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Submit one atomic governed commit containing multiple files
|
||||
security: [{taskPrincipalAuth: []}]
|
||||
x-aios-task-principal: Bridge resolves a fresh server-side TaskPrincipal from the authoritative admitted+claimed Task and re-verifies its Owner epoch, then checks exact task/job/repository/branch/generation/capability binding; caller JSON cannot provide a principal and missing verification is denied. An explicitly injected legacy bearer verifier may still be used by a trusted host.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -254,6 +420,7 @@ paths:
|
||||
additionalProperties: false
|
||||
required:
|
||||
- task_id
|
||||
- job_id
|
||||
- repository
|
||||
- expected_branch_oid
|
||||
- target_branch
|
||||
@@ -283,8 +450,12 @@ paths:
|
||||
message: {type: string, minLength: 1, maxLength: 200}
|
||||
responses:
|
||||
'200': {description: Governed commit job and verified branch result}
|
||||
'202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}
|
||||
'401': {description: No verified Task principal is available}
|
||||
'403': {description: Verified Task principal lacks exact task, external Job, or capability scope}
|
||||
'400': {description: Contract or policy rejection}
|
||||
'409': {description: Branch CAS or idempotency conflict}
|
||||
'423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
operationId: createReleaseIntent
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Repository Discovery P0 Runbook
|
||||
|
||||
This runbook covers the read-only repository discovery surface and the
|
||||
governed workflow used to publish a change to it. It does not authorize a
|
||||
production deployment, a protected-ref update, a tag operation, or a runtime
|
||||
mode change.
|
||||
|
||||
## Safety invariants
|
||||
|
||||
Before any operation, record the repository identity and the exact authority
|
||||
OID. The only repository identity in this contract is `aios/aios`. A floating
|
||||
ref is resolved once; all subsequent reads in the response are bound to the
|
||||
returned full `resolved_oid` (or `resolved_from_oid` and `resolved_to_oid` for
|
||||
a diff) and, for files, the returned `blob_oid`.
|
||||
|
||||
The following invariants remain frozen for this P0:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
legacy_global_writes_enabled=false
|
||||
18893 runtime_mode=IMMUTABLE_STAGING
|
||||
direct bare-repository writes/pushes=forbidden
|
||||
direct Task DB writes=forbidden
|
||||
```
|
||||
|
||||
Discovery is a read-only operation. It does not acquire a WriterLease and it
|
||||
does not create a Task. Any implementation change still has to use the formal
|
||||
Task → TaskPrincipal → TaskLease/claim/heartbeat → WriterLease →
|
||||
`commitFilesGoverned` path. A request to discovery must never be used as a
|
||||
write-capability probe.
|
||||
|
||||
## Execution-plane preflight
|
||||
|
||||
1. Prefer the formal HTTPS control plane at the configured public API/Bridge
|
||||
endpoint. If raw SSH fails before authentication, preserve that evidence
|
||||
and do not retry arbitrary usernames, keys, ports, or shell commands.
|
||||
2. Read runtime, capability, snapshot, and OpenAPI facts. Confirm that the
|
||||
authority identity and OID are the expected ones, and record whether the
|
||||
discovery routes are actually deployed on that plane.
|
||||
3. Classify the available plane as A (network-enabled Codex profile), B
|
||||
(attested online worker), C (formal HTTPS control plane), or D (existing
|
||||
executor/coordinator). Select only a supported plane; do not invent a new
|
||||
SSH or credential path.
|
||||
4. Treat a readable runtime or a candidate branch as evidence of availability,
|
||||
not as authorization to write.
|
||||
|
||||
The durable discovery record is
|
||||
`execution-plane-discovery.json`. It must not be included in the governed
|
||||
source change unless the task explicitly allowlists it.
|
||||
|
||||
## Read operations
|
||||
|
||||
The canonical Bridge routes are:
|
||||
|
||||
| Operation | Method | Required query fields | Optional query fields |
|
||||
| --- | --- | --- | --- |
|
||||
| `listRepositoryTree` | `GET /v2/repository/tree` | `repository`, `ref`, `limit` | `prefix`, `depth`, `cursor` |
|
||||
| `searchRepositorySource` | `GET /v2/repository/search` | `repository`, `ref`, `query`, `max_matches`, `max_bytes` | `path_globs`, `cursor` |
|
||||
| `getDiffIndex` | `GET /v2/repository/diff-index` | `repository`, `from`, `to`, `limit` | `path_prefix`, `cursor` |
|
||||
| `readRepositoryFile` | `GET /v2/repository/file` | `repository`, `ref`, `path` | `max_bytes` |
|
||||
|
||||
The Actions adapter exposes the corresponding read-only legacy paths under
|
||||
`/api/repository/*`; the OpenAPI document records each mapping with
|
||||
`x-aios-legacy-path`.
|
||||
|
||||
Every request is bounded. Repository identity is allowlisted, refs are
|
||||
limited to a branch/tag/full commit form, paths are normalized POSIX-relative
|
||||
paths, and traversal, option-like paths, shell metacharacters, and ambiguous
|
||||
refs fail closed. Search skips binary blobs and enforces its cumulative byte
|
||||
bound. Tree, search, and diff responses are paginated. A cursor is an HMAC-
|
||||
bound record containing the operation, repository, resolved OID, complete
|
||||
query, and offset; never edit or manually construct one.
|
||||
|
||||
The diff endpoint returns an index only: status, path, optional old path, and
|
||||
bounded additions/deletions. It never returns a patch body. The file endpoint
|
||||
returns bounded text plus `content_base64`, `resolved_oid`, and `blob_oid`.
|
||||
|
||||
## Publishing an implementation change
|
||||
|
||||
1. Preserve the existing dirty worktree. Create a new isolated worktree at
|
||||
the exact authority OID and use a task-scoped branch.
|
||||
2. Run discovery unit, pagination, cursor, identity, traversal, binary,
|
||||
max-byte, immutable-head, and large-diff tests. Run the Bridge/OpenAPI/
|
||||
Actions drift tests and compile checks.
|
||||
3. Obtain a formal governed Task and keep its exact task, job, generation,
|
||||
idempotency, branch, and base-OID identities. Claim and heartbeat through
|
||||
the supported control plane; do not edit the Task DB or copy a credential.
|
||||
4. Acquire the Task-bound principal and WriterLease. Submit the allowlisted
|
||||
source files through `commitFilesGoverned`, then reconcile the Job if the
|
||||
result is uncertain.
|
||||
5. Independently read back the authority Git ref, the remote Git API ref, and
|
||||
the control-plane projection. Completion requires three-way equality to the
|
||||
governed commit OID, plus the required deployment/acceptance/restart/
|
||||
official-verification evidence for any release claim.
|
||||
|
||||
If the discovery routes are absent from the live OpenAPI/runtime surface, stop
|
||||
at local validation. Report the implementation and tests as local evidence and
|
||||
mark governed Task, WriterLease, governed commit, and post-commit readback as
|
||||
`NOT_VERIFIED`.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- `REPOSITORY_NOT_ALLOWED`, identity mismatch, invalid path/ref, invalid
|
||||
cursor, or ambiguous ref is a client or security rejection; do not broaden
|
||||
the allowlist.
|
||||
- An unavailable authority repository or missing cursor secret is a runtime
|
||||
configuration failure; do not disable cursor signing or fall back to a
|
||||
workspace checkout.
|
||||
- A timeout, oversized output, or large diff is bounded failure. Narrow the
|
||||
prefix/page or repair the existing supported runtime, never return an
|
||||
unbounded patch.
|
||||
- On Windows, a missing `fcntl` means POSIX integration is unverified. Keep
|
||||
the Python compile and portable unit evidence, then rerun the HTTP and file
|
||||
locking probes on the supported Linux runtime.
|
||||
|
||||
No recovery step in this runbook resets, cleans, overwrites, force-pushes, or
|
||||
auto-commits a user worktree.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task Lineage Supersession P0 Runbook
|
||||
|
||||
## 范围锁定
|
||||
|
||||
只允许 source `TASK-SINGLEHOST-TRUSTROOT-P0-20260904`、ordinal `1` 与 child
|
||||
`TASK-SINGLEHOST-TRUSTROOT-P0-20260904-S1`。每次 live 操作前从 Task API/DB 的只读正式路径
|
||||
重读 source:必须是 `EXPIRED`、attempt 0、无 claim/lease,并重新计算正式 identity digest。
|
||||
同时确认 repository、branch、generation、execution digest、external job 与 source metadata
|
||||
digest 均为 decision 的 exact 值,确认当前 Local Owner Authority 和 global/legacy writes 状态。
|
||||
|
||||
## 实施和测试
|
||||
|
||||
1. 在专用 staging worktree 完成 controller、API wrapper、migration、child ClaimGuard binding、
|
||||
ADR/runbook 和 isolated SQLite tests。
|
||||
2. 覆盖错误 signer/namespace、旧 signer、所有 source CAS 字段漂移、nonce/replay、TTL、
|
||||
child/payload injection、existing child/lineage、projection/ClaimGuard compatibility 的失败路径。
|
||||
3. 验证失败后 source 的字节等价快照、child 不存在、没有 lineage/receipt/event 残留。
|
||||
4. 在 backup/isolated DB 跑 complete dry-run:supersession -> child rev1 -> Initial Projection ->
|
||||
需要时 Supervisor Projection -> immutable child ClaimGuard binding -> guarded claim attempt 1。
|
||||
|
||||
## Staging 激活与 live 操作
|
||||
|
||||
仅在测试与 dry-run 全部 PASS 后,将新 API overlay 放在 Task API `PYTHONPATH` 最前并切换到
|
||||
lineage wrapper;daemon-reload/restart 仅限非生产 Task API/projection/ClaimGuard 服务。验证
|
||||
health 及固定 supersession route 后,再次重读 live source 并通过当前 Owner authority 签发一份
|
||||
短 TTL、one-time decision。只调用一次正式 endpoint;不得用 sqlite、maintenance shell、
|
||||
direct Git ref 或任意签名接口替代。
|
||||
|
||||
## 后续和停止条件
|
||||
|
||||
成功后只沿 child lineage:Initial Projection、必要的 Supervisor Projection、新 immutable
|
||||
ClaimGuard binding、worker `p0-task-principal-v15-20260830` 的 guarded claim、governed execution。
|
||||
@@ -0,0 +1,48 @@
|
||||
# AIOS Baseline 1.1 验收矩阵
|
||||
|
||||
本矩阵定义 AT-01~AT-23 的验收标准和证据边界。它不以“测试文件存在”冒充通过:本次本地自动化的实际命令、退出码和数量以包内测试结果文件为准。下表标注的是覆盖入口及尚需现场核验的范围,单项 AT 只有在全部必要证据齐备后才能标记验收通过。
|
||||
|
||||
状态约定:`LOCAL_AUTOMATED` 表示已有可执行本地测试入口;`LOCAL_PARTIAL` 表示只能覆盖该场景的一部分;`SITE_REQUIRED` 表示必须在准确绑定的现场环境补充验收。上述标签不是 PASS。未执行、证据过期、身份不符或无法访问目标均为 `NOT_VERIFIED/UNKNOWN`,不能按健康放行。
|
||||
|
||||
| ID | 场景与必须满足的结果 | 本地代码/测试入口 | 当前证据边界与现场补充 |
|
||||
| --- | --- | --- | --- |
|
||||
| AT-01 | 缺失/过期 observation、错误任务身份、main 与 runtime 不同;UNKNOWN fail-closed;drift 比较 DeploymentTarget 与 observation,不拿 main HEAD 当 desired | `test_contracts.py`、`test_loop.py`;Runtime Contract / deployment evaluator | LOCAL_PARTIAL;需现场核对 Resolver、正式入口、canonical ref 和 artifact identity |
|
||||
| AT-02 | 支持包缺文件、改一字节、额外未声明内容必须校验失败;生成身份与内容可追溯 | `test_package_tools.py`;`bundle.py verify/stage` | LOCAL_AUTOMATED(源码包边界);线上运行镜像与 overlay 构建仍须独立验证 |
|
||||
| AT-03 | 每个允许执行的 operation 在 dry-run/shadow 下都不调用副作用 writer,不伪造已执行或发布成功 | `test_job_safety.py`;既有 Job executor dry-run 分支 | LOCAL_PARTIAL;新 adapter 加入注册表时必须补相同负面测试,不能沿用其他 adapter 结论 |
|
||||
| AT-04 | 同一有效 Grant 下短期证明过期/服务重启仍能完成后续授权范围内任务;不重复业务审批、不扩大权限 | `test_authorization.py`、`test_loop.py` | LOCAL_AUTOMATED(授权/本地任务);完整生产发布链仍需 AT-16 |
|
||||
| AT-05 | 父 Grant 撤销、过期、签名错误、跨 tenant/subject/scope 重放、伪造 Mapping 在派生与消费两端均拒绝 | `test_authorization.py`、`test_transport.py` | LOCAL_AUTOMATED;真实密钥轮换和撤销传播需部署证据 |
|
||||
| AT-06 | 过期 lease、终态 Job 再领 lease、旧 generation 延迟写均拒绝;新 claim 保持独占 | `test_job_safety.py`、`test_worker_host.py`;TaskPlaneStore 原有回归 | LOCAL_PARTIAL;每个实际外部写接收端须证明 fencing 生效,不能只在发送端检查 |
|
||||
| AT-07 | 外部成功而 receipt 丢失、对账中止、双 observer 乱序;复用 effect、精确 readback、旧结果不能覆盖新事实 | `test_job_safety.py`、`test_release_lineage.py` | LOCAL_AUTOMATED(可控 effect sink);实际远端 readback 身份契约需验收 |
|
||||
| AT-08 | ready+600 秒后扩大 eligible Worker;普通与 fallback Worker 竞争仍只有一个 CAS claim,不抢有效 lease | `test_worker_host.py`、`test_loop.py`;WorkerProfile/TaskPlaneStore | LOCAL_PARTIAL;需常驻实例 heartbeat/transport 与竞争运行证据;聊天 GPT 不算 Worker |
|
||||
| AT-09 | blocked 任务未进入 ready 时不静默无限等待;显示 blocker、持续时间、修复和升级边界 | 服务端状态与错误返回;本包实现范围以源码报告为准 | SITE_REQUIRED;完整 Doctor 自动修复/升级场景不能用普通任务成功代替 |
|
||||
| AT-10 | Worker/子进程退出、取消、超时、重启;有限终止、服务端恢复,同 effect 不因缓存丢失重复 | `test_worker_host.py`、`test_loop.py`、`test_transport.py`、`test_job_safety.py` | LOCAL_PARTIAL;实际 systemd kill/restart、取消竞争和主机启动顺序需演练 |
|
||||
| AT-11 | 重复提交/事件/回执、提交后投递器退出;outbox 持久、去重、终态不倒退 | `test_loop.py`;TaskPlaneStore outbox 与幂等接口既有测试 | LOCAL_PARTIAL;通知投递器/inbox 的端到端故障仍需专门证据 |
|
||||
| AT-12 | 跨 tenant/subject 查询、取消、执行和关联被拒;Worker 不能读服务端私钥和其他工作目录 | `test_authorization.py`、`test_loop.py`、`test_transport.py`、`test_release_lineage.py`;独立 UID unit 模板 | LOCAL_PARTIAL;artifact 下载、cache key、文件权限及真实子进程边界需现场覆盖 |
|
||||
| AT-13 | Bridge/provider 独立重启或 token 轮换后使用自身 credential projection,不依赖另一服务临时目录 | 修订现有 staging unit;`deploy/baseline-v1.1/` 凭证模板 | SITE_REQUIRED;静态 unit 不等同于解密、轮换和重启已验证 |
|
||||
| AT-14 | 子任务、重复诊断和连续失败不能用新 task ID 重置 Grant 预算;超限有界停止 | `test_authorization.py`;持久 effect 预算;`test_optimization.py` | LOCAL_PARTIAL;目标级总成本、墙钟、模型预算的完整现场计费来源仍须接线 |
|
||||
| AT-15 | 支持 profile 可在干净目录执行;错 source/config 的证据不能放行;未绑定线上权威明确拒绝 | `test_package_tools.py`、`test_cli.py`、`test_contracts.py` | LOCAL_AUTOMATED(隔离 profile);目标 OS、Python、systemd 版本与现场配置仍需记录 |
|
||||
| AT-16 | 同一 Grant:patch→tests→受保护 merge→build→staging→业务验收→production→生产业务验收;全程有唯一关联 | `test_release_lineage.py`;ReleaseLedger/recipe/receipt verifier 接口 | SITE_REQUIRED;本包的状态机和模拟 adapter 测试不等于真实发布链已完成 |
|
||||
| AT-17 | 编码 Worker 自报成功、伪造 receipt、降低验收规则或读签名私钥不能放行 | `test_authorization.py`、`test_release_lineage.py`、`test_optimization.py`;API/Worker 独立 UID | LOCAL_PARTIAL;生产 verifier 独立职责、规则冻结和私钥访问权限需现场验证 |
|
||||
| AT-18 | 优化无改善或越过质量底线不晋升;优化提案只生成受治理任务,不直接写 policy DB | `test_optimization.py`;签名 OptimizationEnvelope | LOCAL_AUTOMATED(提案与质量约束);真实实验数据来源、回滚及模型成本路由需后续验收 |
|
||||
| AT-19 | 旧 schema/任务与新版本并存时保留原签名字节;main 合法前进不等于 runtime drift;旧 handler 明确处理 | `test_authorization.py`、`test_contracts.py`、`test_release_lineage.py` | LOCAL_PARTIAL;线上旧任务排空、writer 停用、迁移/路由策略需现场清单 |
|
||||
| AT-20 | 坏候选、控制面不能启动、数据迁移不可逆时只允许兼容回退或前向修复;无万能 root | `test_release_lineage.py`、`test_package_tools.py`;源码回滚、Release 回退约束 | LOCAL_PARTIAL;数据库迁移/外部副作用不由源码 rollback 撤销;独立最小恢复流程需现场演练 |
|
||||
| AT-21 | SQLite/Git/receipt 一致备份在隔离环境恢复,保留身份和未决 effect;实测 RPO/RTO | 各 ledger 重启测试是局部依据;安装文档给出一致备份边界 | SITE_REQUIRED;本包没有以“可重开 SQLite”宣称整机备份恢复验收通过 |
|
||||
| AT-22 | persist intent→外部 effect 成功→主机在 receipt 落盘前失去执行能力;恢复同 effect→RECONCILIATION_REQUIRED→readback→补 receipt;外部效果次数仍为 1 | `test_job_safety.py`;子进程退出/独立持久 effect sink 故障注入 | LOCAL_PARTIAL;SIGKILL/进程崩溃不能证明磁盘缓存断电持久性,物理/虚拟机硬掉电需现场验证 |
|
||||
| AT-23 | intent/lease 无法持久化时不得开始 effect;effect 后 receipt 无法提交则停止新 effect 并恢复对账;磁盘满/WAL/receipt unavailable 不冒充完成 | `test_job_safety.py`;SQLite/receipt 写失败注入及持久停止边界 | LOCAL_PARTIAL;实际 ENOSPC、WAL checkpoint、目录权限/介质损坏需隔离卷或现场故障试验 |
|
||||
|
||||
## 每次验收必须保存的记录
|
||||
|
||||
每条记录至少包含:AT 编号、完整命令、开始/结束时间、执行环境、源码/构建/配置身份、Grant/Task/effect/job/Release/deployment/acceptance 关联(适用时)、注入点、实际 observation、receipt 引用、退出码、明确结论和剩余缺口。密钥/token 只保存 CredentialReference,不能复制到报告。
|
||||
|
||||
`UNKNOWN`、`RECONCILIATION_REQUIRED`、预算耗尽导致的有界停止都可能是正确故障处理结果;它们不是业务成功。只有可信且当前的业务 acceptance,准确绑定 deployment+release+artifact,才能关闭相应发布验收。
|
||||
|
||||
## M0–M5 的放行顺序
|
||||
|
||||
| 实施包 | 放行条件 | 不可用来代替放行的证据 |
|
||||
| --- | --- | --- |
|
||||
| M0 Truth & Identity | 静态 Contract、动态 target、observation、provenance 分离;Resolver 与 CredentialReference 对应真实目标;UNKNOWN fail-closed | 目录存在、进程健康、main 与 runtime 碰巧相同 |
|
||||
| M1 Authorization & Effect Safety | Grant→短期证明→Principal→实际 lease/fencing;意图先持久化;未知结果精确对账;AT-22/23 本地故障通过 | 仅延长 TTL、发送端自称 fenced、effect 成功但未写 receipt |
|
||||
| M2 Real Autonomous Loop | 至少一条真实非发布 Action→Task→常驻 Worker→已注册 operation→receipt→result;tenant/subject 在服务端派生 | 仅 scheduler 单元测试、伪造 worker 或直接函数调用绕过 admission |
|
||||
| M3 Release Closure | 同一 Grant 完成真实 patch 至 production business acceptance,至少一次 ambiguous publication/worker crash 自动恢复 | 本地 Release 状态机、模拟 adapter、部署命令返回 0 |
|
||||
| M4 Surface Reduction | 每类事实 active write authority=1;旧入口停止接收新任务;历史任务可追溯;恢复演练通过 | 新模块存在但旧 writers 仍在写,或把全系统静态函数数当活跃写者数 |
|
||||
| M5 Optimization | 签名 Envelope 限定范围;真实效果和质量底线可核验;只经受治理任务晋升并能回退 | optimizer 自报改善、直接改 policy DB、放宽验收阈值 |
|
||||
@@ -0,0 +1,20 @@
|
||||
# 交付统一验收结果
|
||||
|
||||
本次验收在新建虚拟环境中,仅使用包内锁定离线 wheel 安装依赖。没有连接生产系统。
|
||||
|
||||
| 范围 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 新增 Baseline 1.1 | 165 passed;0 skipped |
|
||||
| 全量源码回归 | 1212 passed;154 subtests passed;3 skipped |
|
||||
| 全量退出码 | 0 |
|
||||
| Python | 3.12.13 |
|
||||
| 平台 | Linux-6.18.35-x86_64-with-glibc2.39 |
|
||||
| 验收完成时间(UTC) | 2026-09-07T19:07:27.816913+00:00 |
|
||||
|
||||
JUnit tests=1369 包含子测试,不能再把这个数字与 1212 相加。3 项跳过分别是 live Gitea tag publication、opt-in live signer integration,以及离线验收主动隔离的 deployed signer 检查。它们保持现场未验收状态,没有改成通过。
|
||||
|
||||
实际命令为完整包 `run_acceptance.py --profile all --output ...`,该工具在一次性源码副本中执行,显式向子进程传递源码 PYTHONPATH,使进程崩溃/重启测试不依赖编辑器环境,也避免历史测试的 runtime 输出污染发行目录。副本保留原始 runtime/agent-os 源码;只隔离生成输出,不删除历史实现。首次干净环境运行发现并修正了这个验收启动器的子进程导入问题;修正后完整重跑通过。
|
||||
|
||||
完整日志、JUnit、依赖版本、开始/结束时间、命令、退出码和逐源码文件 SHA 在完整包 `acceptance/full/`;新增基线证据在 `acceptance/baseline/`。源码改动后应重新生成相应证据,不能沿用本结果放行。
|
||||
|
||||
AT-22 的进程故障注入通过;真实主机硬掉电仍需现场演练。AT-23 的 intent/receipt 持久化失败与停止/恢复通过;真实介质 ENOSPC、WAL checkpoint、备份 RPO/RTO 仍需现场验收。生产发布、正式 Task API 接入和旧写权威清退不在本次执行范围。
|
||||
@@ -0,0 +1,107 @@
|
||||
# AIOS 最终架构修改建议:Implementation Baseline 1.1
|
||||
|
||||
本基线吸收七项最终修订,作为后续编码、集成与验收的共同约束。目标是一台机器、一人维护、多个常驻自治 Worker:Owner 给出目标和真实业务边界,系统在既有授权内自动发现对象、领任务、执行、验证、恢复并产生后续优化任务。
|
||||
|
||||
本文件依据本次明确要求、上传源码和可见交互/评审材料;相关材料反映的持续诉求是少反复授权、少人工补路径、可恢复、低维护成本和严格身份隔离。它不声称已逐条读取全部三个月历史聊天,也不将材料里的现场状态当作本次在线测量。
|
||||
|
||||
## 1. 最高原则与冻结决策
|
||||
|
||||
**AIOS 的自动化程度不得高于其可观测、可恢复和可归因程度。**
|
||||
|
||||
不能精确识别对象、持久化 intent、证明当前授权或围栏旧执行者,就不开始副作用。结果未知时先对账;不能验证业务结果时不宣称完成;无法安全恢复时有界停止并给出最小决策材料。正常续期、已授权恢复、同一范围内重试不重新索要 Owner 业务审批。
|
||||
|
||||
继续使用 SQLite、Git、systemd;不引入 Kubernetes、消息中间件、Backstage 或新 Secrets 平台。不打开 legacy global write,不增加万能 SSH/root,不把 GPT 对话包装成执行身份。Task、Job、Release 保持各自事实域,Action 只负责入口。自动优化使用与普通开发相同的授权、执行、验证和发布边界。
|
||||
|
||||
“单一写权威”按事实及资源范围判定,不等于全系统共用一张状态表或一个 daemon。允许同一进程承载多个模块,但模块不能直接修改另一模块的权威表。
|
||||
|
||||
## 2. 事实域及写权威
|
||||
|
||||
| 事实域 | 唯一写入口 | 权威内容 | 其他模块的使用方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| Authorization | `AuthorizationService` | 已验签 Grant、当前 generation、撤销、effect 预算预留 | 请求派生证明,在执行边界重新验证 |
|
||||
| Task | 既有正式 Task authority;隔离 profile 使用既有 `TaskPlaneStore` | 准入、任务状态、claim、Worker 登记、任务 outbox | 通过正式 API/适配器调用,不能另写 Task 状态 |
|
||||
| Job / Effect | 既有 `JobController` 与受治理 executor | intent、attempt、资源 lease/fence、receipt、reconciliation | 持久化请求后执行;由权威 receipt 解释结果 |
|
||||
| Release / DeploymentTarget | 正式 Release authority;候选实现为 `ReleaseLedger` | 发布 recipe/saga、动态目标、部署事实、验收收口 | CAS 更新目标;消费经可信 verifier 验证的 receipt |
|
||||
| Runtime Contract | 受版本治理的配置入口 | 组件结构、入口、能力、资源及凭证引用 | Resolver 解析;不保存动态 desired |
|
||||
| Runtime Observation | 对应组件的可信观测适配器 | 实际部署身份、时间、来源、健康事实 | 只作为带新鲜度和来源的证据,不反写目标 |
|
||||
| 关联图 | `LineageLedger` | 不可改绑的身份关系 | `goal-explain` 查询;不拥有 Task/Job 状态机 |
|
||||
| Optimization | `OptimizationController` | 提案、实验证据、停止或晋升申请 | 只能请求受治理 Task,不能写政策或部署库 |
|
||||
|
||||
新增库是明确事实域的持久层,不是新的线上写权威自动生效。当前 CLI 只支持 `isolated-validation`,复用上传源码中的 TaskPlaneStore;连接真实 Task API 前必须证明其正式入口、身份与兼容性,不能因为本地新库可启动就切换线上 admission。
|
||||
|
||||
## 3. 运行结构、期望目标和实际观测分离
|
||||
|
||||
| 对象 | 回答的问题 | 关键身份 |
|
||||
| --- | --- | --- |
|
||||
| `RuntimeContract` | 组件是什么、正式入口和允许能力是什么 | component、environment、tenant scope、contract revision、引用 |
|
||||
| `EnvironmentDeploymentTarget` | 此部署资源现在应运行哪个 Release | 部署资源范围、release、artifact、target revision |
|
||||
| `RuntimeObservation` | 实际运行什么,证据多新 | component、deployment、actual artifact、observed time、source |
|
||||
| `ReleaseProvenance` | 制品从哪里产生 | artifact/source/builder 身份与构建证据 |
|
||||
|
||||
drift 比较同一部署资源的 desired 与 observed。main HEAD、staging source、production Release 不同本身不是故障;缺目标、缺来源、观测过期或身份绑定不完整应为 UNKNOWN。用户报告的 `0136a… / 91f… / 998d…` 以及缺 `gitea_token` 的 UNKNOWN,只构成待核验输入,不作为本包生产运行结论。
|
||||
|
||||
M0 只做薄合同和可信解析:回答操作哪个组件、入口、desired/observed、制品来源、凭证引用和证据新鲜度,不做大目录迁移。当前实现提供合同校验、动态目标评价和受信固定绑定;完整在线 Resolver/Doctor 仍需适配实际 authority 与服务清单。
|
||||
|
||||
目标的唯一性固定为“tenant + component/deployment scope + environment”,subject 用于授权及审计。`ReleaseLedger` 必须按资源主键实施 CAS,不能让不同用户为同一物理组件建立互相冲突的 desired。主键迁移必须检测已有目标冲突,不能静默合并或用当前观测覆盖期望目标。
|
||||
|
||||
## 4. 持续业务授权与短期执行权
|
||||
|
||||
授权链固定为 `OwnerDelegationGrant → OperationalEpoch → TaskPrincipal`,执行时另行获得正式 `ClaimLease / Fencing`。Grant 保存目标/任务范围、tenant、subject、环境、操作能力、破坏性上限、预算、明确期限或 UNTIL_REVOKED,以及撤销代次。
|
||||
|
||||
新增长期 schema `aios.owner-delegation-grant.v1` 和短期 `aios.operational-epoch.v1`;历史 `owner-delegation-epoch.v1/v2` 不改名、不改签名字节,也不重解释为长期 Grant。派生证明保存根授权 digest/generation;代码字段为 `root_grant_digest`、`root_grant_generation`,承担提议中 `parent_grant_digest` 的根绑定语义。Principal 另绑定其父 Epoch digest。
|
||||
|
||||
签名采用显式受信公钥验证,调用者不能附一份 Mapping 或公钥就自称受信。每次实际副作用检查根授权当前状态、精确 scope、破坏性上限、预算及真实 lease。短期证明过期可在有效 Grant 内自动更新;撤销、到期、跨环境或越过破坏性上限不能靠续期绕过。租约来自 Task/Job 权威,Principal 不伪造 claim。
|
||||
|
||||
当前实现提供持久 `max_effects` 预留:同一稳定 effect 不重复扣额,换 task ID 不重置同一 Grant generation 的限额。总模型费用、计算资源和整个 mission 的综合预算仍需真实计量接入;不能把 effect 计数声称为完整成本控制。
|
||||
|
||||
## 5. 不可改绑的业务关联与执行恢复
|
||||
|
||||
各对象共享 `mission_id`、授权身份和 tenant/subject,但不共享状态机:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G["Mission / Grant"] --> T["Task 权威"]
|
||||
T --> J["Effect / Job 权威"]
|
||||
J --> R["Release 权威"]
|
||||
R --> D["Deployment"]
|
||||
D --> A["Business Acceptance"]
|
||||
T -. 身份关系 .-> L["Lineage / goal-explain"]
|
||||
J -. 身份关系 .-> L
|
||||
A -. 验证结果 .-> L
|
||||
```
|
||||
|
||||
每个新 operation Job 必须绑定一个真实 Task;effect/Job 不能换绑另一任务。计划中的 job_id 必须传到正式 Job adapter,并在权威确认后才能显示为已存在的 Job。产生 Release 的 effect 只能指向唯一 release 身份,Release 保存来源链;Acceptance 必须绑定 deployment、release、artifact。非发布诊断任务只生成真实存在的 Task/Job/receipt,不能为补齐图形虚构 Release。
|
||||
|
||||
effect 的稳定身份先于执行产生,attempt 可以变化。正常路径为持久 intent、有效 claim/lease/fence、执行、精确 readback、receipt、业务结果;结果不确定或跨域投递失败时进入 reconciliation。没有 readback/reconcile 能力时明确停止,不退回 execute。已有 Task outbox 及持久阶段记录支持恢复,但跨事实域不声称存在一个 SQLite 分布式事务;消费必须幂等,断点必须保留,关联缺口由受控重放补齐。
|
||||
|
||||
旧执行者的 fencing 必须由副作用接收端再次检查;只在 scheduler 端检查不能阻止延迟写。AT-22 覆盖外部成功而 receipt 未提交后的同 effect 对账;AT-23 要求 intent/lease 无法持久化时 effect 不开始,receipt 提交失败后阻止新 effect。进程崩溃测试和 SQLite 错误注入已作为本地入口;真实掉电、ENOSPC、WAL checkpoint 和介质故障仍须现场验证。
|
||||
|
||||
## 6. Operation Registry 与无人值守 Worker
|
||||
|
||||
`OperationDefinition` 声明版本、输入 schema、所需能力、可信 handler 引用、期限、effect class,以及独立的 retry/readback/reconcile/rollback/owner-boundary 策略。Registry 是受治理查表入口,不是动态 shell 或任意 Python 加载入口。
|
||||
|
||||
| 主 effect class | 未知结果的处理原则 |
|
||||
| --- | --- |
|
||||
| READ_ONLY | 可在预算内重新观察;诊断执行成功不代表对象健康 |
|
||||
| IDEMPOTENT | 仅在幂等键、权限、围栏和重试策略仍有效时重试 |
|
||||
| CONDITIONAL | 重新读 revision,保留 expected revision/CAS,不能删掉条件重试 |
|
||||
| RECONCILABLE | 先按稳定 effect 精确 readback,再决定补 receipt 或后续动作 |
|
||||
| IRREVERSIBLE | 不盲重试;遵守显式 Owner 边界和恢复能力 |
|
||||
|
||||
这些属性会重叠,因此 class 负责主分派,幂等性、可观察性和可逆性另作约束。service-restart 不能仅凭 `active` 宣称本次重启已发生,需调用实例/事件等精确关联;当前 validation 内置仅为固定参数 `service-status`。旧 COMMIT_FILE adapter 缺乏 intent 可定位的未引用 commit readback 时明确停止,不为打通演示而制造成功。
|
||||
|
||||
Worker 登记包含身份、capabilities、tenant/subject scope、priority、fallback_executor、heartbeat 和并发限制。正常选择 eligible Worker;从 `ready_at` 起 600 秒后扩大 fallback eligibility,仍使用同一 CAS/lease/fencing。blocked 时间不冒充 ready 时间,现存有效 claim 不被强抢。“主 Agent”只是部署策略;聊天 GPT 不拥有 Worker 的常驻进程身份。
|
||||
|
||||
## 7. Tenant-first 与优化边界
|
||||
|
||||
核心记录从第一天携带 tenant、subject、scope,身份从受信服务端认证上下文取得。查询、取消、执行、关联以及制品/cache/工作目录/credential 都必须在各自边界验证;不能只在 UI 隐藏对象。首版不做租户 UI、billing、独立租户管理服务或复杂公平调度。当前独立 UID 模板与 API 隔离不是所有未来 artifact/cache 实现已验收的证明。
|
||||
|
||||
签名 `OptimizationEnvelope` 限定参数、组件、非生产环境、变更量、计算预算、质量下限、回退要求和晋升条件。初期仅开放有限 retry/backoff、worker routing、evidence reuse、timeout、poll interval、通知去重和非强制测试选择。授权政策、信任根、租户隔离、验收阈值、破坏性能力、Grant 语义和生产迁移规则禁止修改。
|
||||
|
||||
优化流程固定为观察、提案、受治理 Task、实验、验证、晋升申请;优化器不能直接修改 policy DB 或运行配置。质量评价区分越高越好/越低越好,无改善或低于门槛就停止。当前模块已实现这些约束和持久提案,真实实验执行器、费用来源、运行参数晋升与回退仍需通过同一治理链接线。
|
||||
|
||||
## 8. 交付状态和实施顺序
|
||||
|
||||
本包包含实际代码、负面测试、可运行的 loopback 非发布闭环、安全源码 stage/rollback 和 AT-01~AT-23 标准。M0/M1 的本地模块与 M2 隔离纵向闭环已交付;M3 的关联/目标/saga/验签是候选基础,未宣称真实生产发布通过;M4 的旧写者排空和生产 imports 清理未执行;M5 是有界提案实现,未自动优化线上系统。
|
||||
|
||||
上线顺序为 M0 Truth & Identity、M1 Authorization & Effect Safety、M2 Real Autonomous Loop、M3 Release Closure、M4 Surface Reduction、M5 Optimization。每阶段以完整输入、实际 adapter 与退出标准放行,详见 `M0_M5_IMPLEMENTATION_RUNBOOK.md`。不使用“架构方案正确”替代接收端围栏、正式权威绑定、可信业务验收或备份恢复证据。
|
||||
@@ -0,0 +1,210 @@
|
||||
# Baseline 1.1 signed authorization chain
|
||||
|
||||
## Result
|
||||
|
||||
`aios_baseline.authorization` implements a new authorization chain without
|
||||
changing the frozen historical owner-delegation wire formats:
|
||||
|
||||
1. an Owner signs `aios.owner-delegation-grant.v1` with a supplied Ed25519 key;
|
||||
2. the local authorization service verifies and durably registers that grant;
|
||||
3. the service issues a short, signed `aios.operational-epoch.v1`;
|
||||
4. the service embeds that epoch in a signed `aios.task-principal.v1` and binds
|
||||
the principal to it by digest;
|
||||
5. every authorization and effect reservation rereads SQLite and checks the
|
||||
current root grant digest, generation, revocation state, expiry, signature,
|
||||
tenant, subject and narrowed scope.
|
||||
|
||||
The TaskPrincipal is a short authorization credential. It does not manufacture
|
||||
a Task claim or ClaimLease. The task authority and executor must independently
|
||||
enforce their authoritative claim lease, heartbeat and fencing token.
|
||||
|
||||
## Public API
|
||||
|
||||
```python
|
||||
AuthorizationService(
|
||||
db_path,
|
||||
owner_public_keys, # key_id -> Ed25519PublicKey
|
||||
machine_private_key,
|
||||
machine_key_id,
|
||||
*,
|
||||
owner_key_scopes, # key_id -> exact tenant_ids/subject_ids lists
|
||||
clock=time.time,
|
||||
control_context_verifier=None,
|
||||
trusted_machine_public_keys=None,
|
||||
)
|
||||
|
||||
sign_owner_delegation_grant(
|
||||
owner_private_key,
|
||||
owner_key_id,
|
||||
*,
|
||||
grant_id,
|
||||
generation,
|
||||
tenant_id,
|
||||
subject_id,
|
||||
mission_ids,
|
||||
task_ids,
|
||||
operations,
|
||||
component_ids,
|
||||
environments,
|
||||
issued_at,
|
||||
validity_mode, # required: UNTIL_REVOKED or EXPIRES_AT
|
||||
expires_at=None,
|
||||
max_effects=None,
|
||||
destructive_ceiling="NONE",
|
||||
)
|
||||
|
||||
service.register_grant(signed_envelope) -> dict
|
||||
service.issue_principal(grant_id, *, exact_scope, ttl_seconds) -> dict
|
||||
service.authorize(principal, *, exact_scope) -> dict
|
||||
service.assert_effect_bounds(principal, operation_definition, *, exact_scope) -> dict
|
||||
service.reserve_effect(grant_id, effect_id, principal, *, exact_scope) -> bool
|
||||
service.revoke_grant(grant_id, *, control_context) -> int
|
||||
```
|
||||
|
||||
`register_grant` and `authorize` return JSON-safe copies of the signed grant.
|
||||
`reserve_effect` returns `True` for the first reservation and `False` for an
|
||||
idempotent retry of the same stable effect ID. It reauthorizes and reserves in
|
||||
one `BEGIN IMMEDIATE` transaction. A configured `max_effects` applies per grant
|
||||
generation. An omitted `max_effects` leaves mission-level budget accounting to
|
||||
the calling service.
|
||||
|
||||
The grant signing helper requires an explicit validity mode. It never creates a
|
||||
key and does not accept an unsigned grant payload as authority. `EXPIRES_AT`
|
||||
requires an integer expiry after issuance. `UNTIL_REVOKED` forbids an expiry.
|
||||
The signed `destructive_ceiling` defaults to `NONE` and is strictly one of
|
||||
`NONE`, `REVERSIBLE`, or `IRREVERSIBLE`. `assert_effect_bounds` accepts only a
|
||||
trusted `OperationDefinition`: `NONE` permits only `READ_ONLY`; `REVERSIBLE`
|
||||
also requires rollback, required state/exact readback, and reconciliation; and
|
||||
`IRREVERSIBLE` is an explicit maximum. A ceiling never replaces Job
|
||||
authorization, durable intent, claim lease, receiver fencing, readback, or
|
||||
reconciliation.
|
||||
|
||||
Owner public-key trust is separately constrained by the required constructor
|
||||
argument `owner_key_scopes`. Its key set must exactly equal `owner_public_keys`;
|
||||
each key has exact nonempty `tenant_ids` and `subject_ids` lists. A wildcard is
|
||||
effective only when the configuration explicitly supplies the sole list item
|
||||
`"*"`. Registration and every later durable grant load check the signing key's
|
||||
scope, so placing keys for several tenants in one process does not let one
|
||||
tenant's Owner key sign another tenant's grant.
|
||||
|
||||
## Exact wire objects
|
||||
|
||||
Every signed object uses an exact `signing` object:
|
||||
|
||||
```json
|
||||
{"algorithm":"Ed25519","key_id":"...","signature":"base64-64-byte-signature"}
|
||||
```
|
||||
|
||||
The signature covers the schema, every payload field, the signing algorithm and
|
||||
key ID, and, for a TaskPrincipal, the complete nested signed epoch. The message
|
||||
starts with the `AIOS-AUTHORIZATION` domain, a NUL separator, the schema, and a
|
||||
second NUL separator before canonical JSON.
|
||||
|
||||
An OwnerDelegationGrant has exact outer keys `schema`, `payload`, `signing`.
|
||||
Its payload has:
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_id": "...",
|
||||
"generation": 1,
|
||||
"tenant_id": "...",
|
||||
"subject_id": "...",
|
||||
"scope": {
|
||||
"mission_ids": ["..."],
|
||||
"task_ids": ["..."],
|
||||
"operations": ["..."],
|
||||
"component_ids": ["..."],
|
||||
"environments": ["..."]
|
||||
},
|
||||
"issued_at": 2000000000,
|
||||
"validity": {"mode": "UNTIL_REVOKED"},
|
||||
"limits": {},
|
||||
"destructive_ceiling": "NONE"
|
||||
}
|
||||
```
|
||||
|
||||
The alternative validity is
|
||||
`{"mode":"EXPIRES_AT","expires_at":<integer>}`. `limits` is either empty or
|
||||
contains one positive integer `max_effects`. Each scope list is nonempty and
|
||||
may contain explicit values or the sole wildcard `"*"`.
|
||||
|
||||
An OperationalEpoch has exact outer keys `schema`, `payload`, `signing`. Its
|
||||
payload has `epoch_id`, `root_grant_id`, `root_grant_digest`,
|
||||
`root_grant_generation`, all seven exact binding fields (`tenant_id`,
|
||||
`subject_id`, `mission_id`, `task_id`, `operation`, `component_id`,
|
||||
`environment`), `issued_at` and `expires_at`.
|
||||
|
||||
A TaskPrincipal has exact outer keys `schema`, `payload`, `operational_epoch`,
|
||||
`signing`. Its payload has `credential_id`, `nonce`, `parent_epoch_digest`, the
|
||||
same three root fields, the same seven binding fields, `issued_at` and
|
||||
`expires_at`. Epoch and principal validity is capped at 300 seconds and at an
|
||||
explicit root-grant expiry.
|
||||
|
||||
## Durable state and revocation
|
||||
|
||||
SQLite stores the canonical signed grant, its digest, its current durable
|
||||
generation and revocation flag. Revocation increments the durable generation in
|
||||
an immediate transaction, records a revocation row, and leaves old proofs
|
||||
invalid even when their short expiry has not elapsed. Reopening the service on
|
||||
the same database retains that decision. The same signed generation cannot
|
||||
clear revocation. Reactivation requires a newly Owner-signed generation greater
|
||||
than the durable revocation generation.
|
||||
|
||||
`revoke_grant` fails closed unless `control_context_verifier` returns the exact
|
||||
boolean `True` for the caller's authenticated local control-service context.
|
||||
The authorization module does not authenticate network requests; the hosting
|
||||
service must construct that trusted context from its local authenticated API
|
||||
boundary. Authorization never relies on the verifier or a cache for freshness;
|
||||
it reads current durable state on every use.
|
||||
|
||||
The active machine signing key is always trusted for newly issued objects.
|
||||
`trusted_machine_public_keys` can retain previous public keys for the remainder
|
||||
of already-issued proof lifetimes during rotation. Owner trust keys and machine
|
||||
trust keys remain separate.
|
||||
|
||||
## Verification
|
||||
|
||||
The initial failure-first run stopped during collection because
|
||||
`aios_baseline.authorization` did not yet exist. After implementation, a focused
|
||||
run found and fixed strict nonce validation and clarified that substituting the
|
||||
nested epoch invalidates the outer signature before the parent-digest check.
|
||||
The final focused commands were:
|
||||
|
||||
```console
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app \
|
||||
python3 -m pytest -q \
|
||||
tests/baseline_v1_1/test_authorization.py \
|
||||
tests/baseline_v1_1/test_claim_heartbeat.py
|
||||
```
|
||||
|
||||
Result before the ceiling extension: `25 passed in 0.80s`. The post-extension
|
||||
focused run reported `28 passed in 0.35s`. After the Owner trust-root and claim
|
||||
heartbeat hardening, the current focused result is `35 passed`.
|
||||
|
||||
The tests cover exact-scope success, all three signature layers, unknown Owner
|
||||
keys, unsigned payload refusal, fake Mapping parents, nested-parent
|
||||
substitution, every cross-scope dimension, grant narrowing, live and persisted
|
||||
revocation, proof renewal without Owner reapproval, explicit root expiry,
|
||||
Owner-signed generation reactivation, strict types and signature encoding,
|
||||
explicit helper validity, durable idempotent effect budgets, unbounded budget
|
||||
delegation, machine-key rotation, refusal of untrusted retired keys, NaN and
|
||||
empty-identity rejection, and the revocation-generation boundary around durable
|
||||
effect budgets. Revocation cannot reset a budget or revive the immutable old
|
||||
signed generation; only a newer Owner-signed generation can reactivate it.
|
||||
The extension tests also cover signed ceiling tampering, conservative default
|
||||
denial, rollback/readback requirements, and explicit irreversible authority.
|
||||
They now also cover exact Owner-key scope configuration, cross-tenant and
|
||||
cross-subject Owner-key refusal, explicitly configured global trust, cancellation
|
||||
before heartbeat, and the exact lease-expiry boundary.
|
||||
|
||||
## Security review disposition
|
||||
|
||||
| Severity | Status | Boundary | Disposition |
|
||||
| --- | --- | --- | --- |
|
||||
| High | Closed | Owner trust roots | Each trusted Owner key now requires explicit tenant and subject scope; no implicit cross-tenant authority remains. |
|
||||
| High | Closed | Revocation and effect budget | Every use rereads root generation and revocation state in SQLite. Revocation cannot revive or reset the old generation's reservations. |
|
||||
| High | Closed | Receipt and recovery integration | Generic Jobs require the exact transition graph and trusted, fully bound signed receipts; Task recovery preserves terminal Owner cancellation. |
|
||||
| Medium | Closed | Claim heartbeat | Heartbeat now checks terminal Task state and performs the live-lease update with `status='ACTIVE'` and `lease_until>now` under one immediate transaction. |
|
||||
| High | Pending M3 | Mutating receiver fence | The supplied handler remains read-only. Any future mutating receiver must atomically enforce the authoritative fence at the sink and recheck authorization, claim and deadline around receiver execution. |
|
||||
| Medium | Accepted for initial single-host profile | Admission scope | The durable unknown-effect admission latch intentionally stops new effects across the shared Job store. This conservative availability policy is documented in SERVICE_LOOP_REPORT.md; later tenant/resource partitioning must retain a global storage-integrity gate. |
|
||||
@@ -0,0 +1,220 @@
|
||||
# Truth and Operation Contracts Report
|
||||
|
||||
## Scope
|
||||
|
||||
Implemented Task 3 and the adjacent C01 trusted-snapshot correction without live calls or production access.
|
||||
|
||||
Files changed for this work:
|
||||
|
||||
- `active-runtime/app/aios_baseline/runtime_contract.py`
|
||||
- `active-runtime/app/aios_baseline/operations.py`
|
||||
- `active-runtime/app/reconciliation_controller.py`
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2_server.py`
|
||||
- `deploy/systemd/aios-chatgpt-bridge-v2-staging.service`
|
||||
- `tests/baseline_v1_1/test_contracts.py`
|
||||
- `tests/test_completed_replay_reconciliation_v7.py`
|
||||
- `tests/test_isolated_writer_exact_path_policy.py`
|
||||
- `tests/test_r7_atomic_recovery_and_bridge_sandbox.py`
|
||||
- `docs/implementation-baseline-1.1/CONTRACTS_REPORT.md`
|
||||
|
||||
No package `__init__.py` was changed by Task 3. No arbitrary command executor, production trust root, live call, deployment, or Git commit was created.
|
||||
|
||||
## Public functions and payload schemas
|
||||
|
||||
### `validate_contract(document: Mapping[str, Any]) -> dict[str, Any]`
|
||||
|
||||
Required stable contract fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"contract_schema": "aios.runtime-contract.v1",
|
||||
"contract_revision": 1,
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"tenant_scope": "tenant-a",
|
||||
"lifecycle": "active",
|
||||
"entrypoints": {
|
||||
"host": {
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://sample/status"
|
||||
}
|
||||
},
|
||||
"bindings": {},
|
||||
"resource_refs": {},
|
||||
"credential_refs": {},
|
||||
"operations": ["service-status"]
|
||||
}
|
||||
```
|
||||
|
||||
`lifecycle` is one of `active`, `candidate`, `legacy`, or `example`. Reference groups are mappings and may be empty. A reference value may be a symbolic string, which is normalized with the contract's component and environment, or an object containing matching `component_id` and `environment` plus exactly one of `resolver_ref`, `path_ref`, or `credential_ref`. If a reference object supplies `tenant_scope`, it must match the contract.
|
||||
|
||||
`path_ref` must be relative and cannot contain traversal, backslashes, or NUL. Credential references accept `credential://`, `secret://`, and `systemd-credential:` references; raw `/run/credentials/...` paths and inline secret fields are rejected. The keys `desired`, `desired_commit`, `desired_release`, `desired_target`, and `desired_targets` are rejected at every depth because release targets are separate objects.
|
||||
|
||||
### `evaluate_deployment(contract, target, observation, provenance, now) -> dict[str, Any]`
|
||||
|
||||
Target payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"release_id": "release-17",
|
||||
"deployment_id": "deployment-22",
|
||||
"artifact_digest": "sha256:<64 lowercase or uppercase hex characters>",
|
||||
"revision": 4
|
||||
}
|
||||
```
|
||||
|
||||
Observation payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"observed_at": "2026-09-07T11:59:30Z",
|
||||
"max_age_seconds": 60,
|
||||
"source": "systemd-readback.v1",
|
||||
"actual_artifact_digest": "sha256:<64 hex characters>",
|
||||
"healthy": true,
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"deployment_id": "deployment-22",
|
||||
"revision": 4
|
||||
}
|
||||
```
|
||||
|
||||
`max_age` is accepted as an alias for `max_age_seconds`; `actual_artifact` and `artifact_digest` are accepted aliases for `actual_artifact_digest`. The final four binding fields are optional, but when present they must be well typed and match the target. `healthy` is nullable at the source boundary, but null is evaluated as `UNKNOWN`, never healthy.
|
||||
|
||||
Provenance payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"artifact_digest": "sha256:<64 hex characters>",
|
||||
"source_digest": "sha256:<64 hex characters>",
|
||||
"builder_digest": "sha256:<64 hex characters>"
|
||||
}
|
||||
```
|
||||
|
||||
`artifact_hash`, `source_hash`, and `builder_hash` are accepted aliases. `now` accepts a timezone-aware `datetime` or ISO-8601 string and is injected by tests. Missing, malformed, future, stale, or nullable evidence returns `status/state/verification = UNKNOWN`. A complete incompatible target returns `DRIFT`. Matching contract, target, observation, and provenance returns `status = VERIFIED`, `state = CONVERGED`, and `converged = true`. A repository's current HEAD is deliberately ignored.
|
||||
|
||||
### `OperationDefinition.from_dict(data)`
|
||||
|
||||
Operation definition payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "service-status",
|
||||
"version": 1,
|
||||
"effect_class": "READ_ONLY",
|
||||
"handler_ref": "host.service_status",
|
||||
"input_schema": "aios.operation.service-status.input.v1",
|
||||
"required_permissions": ["service-status"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "NONE", "required": false},
|
||||
"retry": {"mode": "SAFE", "max_attempts": 2},
|
||||
"reconcile": {"mode": "NONE"},
|
||||
"rollback": {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions` is accepted as an alias for `required_permissions`. Primary effect classes are `READ_ONLY`, `IDEMPOTENT`, `CONDITIONAL`, `RECONCILABLE`, and `IRREVERSIBLE`. Retry modes are `NEVER`, `SAFE`, `AFTER_READBACK`, and `RECONCILE_FIRST`. Readback, retry, reconcile, rollback, and owner-boundary data remain separate mappings rather than being inferred from the effect class. A service restart must declare required `EXACT` readback and reconciliation. Top-level `command`, `shell`, `argv`, `executable`, and `script` fields are rejected, and handler references cannot contain shell syntax.
|
||||
|
||||
### Registry and operation helpers
|
||||
|
||||
- `OperationRegistry(definitions).get(operation_id) -> OperationDefinition` performs lookup only and raises `KeyError` for an unregistered ID.
|
||||
- `OperationRegistry.definitions -> tuple[OperationDefinition, ...]` exposes immutable registration order.
|
||||
- `operation_timeout_action(definition, outcome="AMBIGUOUS", attempts=1) -> str` returns `RETRY`, `RECONCILE`, or `STOP`. It never retries an irreversible effect and requires reconciliation for an ambiguous mutating outcome when reconciliation/readback exists.
|
||||
- `verify_service_restart(before, after) -> dict` requires a changed instance identity and a later start timestamp. An active state without restart identity returns `UNKNOWN`.
|
||||
- `load_production_observation(path, now=None) -> dict` reads a bounded, regular, non-group/world-writable `production-observation.json` bundle with exact keys `contract`, `target`, `observation`, and `provenance`. It projects `ACTIVE` only after `evaluate_deployment` returns `VERIFIED`; otherwise it projects `UNKNOWN` without a baseline version or target OID.
|
||||
|
||||
## Reconciliation and snapshot corrections
|
||||
|
||||
`ReconciliationController.reconcile` now treats absent, null, wrong-type, or empty evidence as drift requiring reconciliation. This fixes the root cause where every missing pair compared as `None == None` and an empty mapping returned healthy.
|
||||
|
||||
`GovernedRuntime.snapshot` no longer embeds `V2R1C7`, `c7adece1cc8beffe7c718d53c775898b45d6c7f8`, or `ACTIVE`. It reads `<state_root>/production-observation.json`; absent or unverified evidence retains the existing three-field production response shape with `deployment_state: UNKNOWN`.
|
||||
|
||||
The staging unit now projects the existing encrypted credential ID directly for its own service:
|
||||
|
||||
```ini
|
||||
LoadCredentialEncrypted=gitea-governance-executor-token:/etc/credstore.encrypted/gitea-governance-executor-token
|
||||
Environment=AIOS_GITEA_TOKEN_FILE=%d/gitea-governance-executor-token
|
||||
```
|
||||
|
||||
The legacy bridge sandbox test now parses both directives and asserts that the projected credential name, encrypted-file basename, and consumer `%d` basename are identical. It also rejects any reference to the governance executor's runtime credential directory.
|
||||
|
||||
## Failure-first and verification evidence
|
||||
|
||||
Initial Task 3 collection before implementation:
|
||||
|
||||
```text
|
||||
ERROR tests/baseline_v1_1/test_contracts.py
|
||||
ModuleNotFoundError: No module named 'aios_baseline'
|
||||
1 error in 0.15s
|
||||
```
|
||||
|
||||
The empty optional groups and permissions-alias tests failed before their implementation:
|
||||
|
||||
```text
|
||||
FAILED test_structural_contract_allows_empty_optional_reference_groups
|
||||
FAILED test_read_only_definition_accepts_permissions_alias_and_registry_lists_definitions
|
||||
2 failed, 39 passed in 0.20s
|
||||
```
|
||||
|
||||
The trusted snapshot tests failed before C01 implementation:
|
||||
|
||||
```text
|
||||
ImportError: cannot import name 'load_production_observation'
|
||||
1 error in 0.28s
|
||||
```
|
||||
|
||||
Focused Task 3, C01, and legacy reconciliation command:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/baseline_v1_1/test_contracts.py tests/test_autonomy_v1_components.py
|
||||
.................................................................... [100%]
|
||||
68 passed in 0.30s
|
||||
```
|
||||
|
||||
Existing Bridge/control-plane compatibility command:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py tests/test_control_plane_convergence_contract.py tests/test_canonical_task_projection_recovery.py
|
||||
............................................ [100%]
|
||||
44 passed in 0.99s
|
||||
```
|
||||
|
||||
The standalone completed-replay HTTP test originally returned `423` with body error `TASK_PRINCIPAL_WRITES_DISABLED`. Its mutating-operation adapter exposed no `getRuntimeStatus`, and the fixture provided neither the required TaskPrincipal verifier nor an authorization header. The fixture now supplies the exact ready gate facts and authenticated verifier; no production gate was relaxed:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_completed_replay_reconciliation_v7.py
|
||||
........... [100%]
|
||||
11 passed in 0.60s
|
||||
```
|
||||
|
||||
Fresh final scoped verification across Task 3, C01, Bridge/control-plane compatibility, completed replay, and isolated-writer fixtures:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/baseline_v1_1/test_contracts.py tests/test_autonomy_v1_components.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py tests/test_control_plane_convergence_contract.py tests/test_canonical_task_projection_recovery.py tests/test_completed_replay_reconciliation_v7.py tests/test_isolated_writer_exact_path_policy.py tests/test_isolated_writer_policy.py
|
||||
........................................................................ [ 47%]
|
||||
................................................................................ [100%]
|
||||
152 passed, 64 subtests passed in 1.87s
|
||||
```
|
||||
|
||||
`git diff --check` completed with no output for all files in this report.
|
||||
|
||||
The isolated-writer legacy subset initially had two pristine-baseline negative-fixture failures. The audience and branch tests did not establish the required `gitops` group lookup, so the unchanged production guard correctly stopped at `isolated_writer_path_policy_group_rejected` before either intended assertion. The fixtures now supply only that valid prerequisite; production guard order and strictness are unchanged:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_isolated_writer_exact_path_policy.py tests/test_isolated_writer_policy.py
|
||||
........................ [ 82%]
|
||||
..... [100%]
|
||||
29 passed, 64 subtests passed in 0.40s
|
||||
```
|
||||
|
||||
Focused legacy systemd assertion:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_r7_atomic_recovery_and_bridge_sandbox.py
|
||||
.. [100%]
|
||||
2 passed in 0.04s
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# AIOS Implementation Baseline 1.1 Implementation Plan
|
||||
|
||||
> For agentic workers: use the approved user architecture and the task interfaces below. Implement continuously, with failure-first tests and scoped review. No production access is part of this package-build task.
|
||||
|
||||
Goal: deliver source changes, runnable integration/acceptance, safe migration/package tools and precise deployment instructions for the seven final architecture revisions.
|
||||
Architecture: extend the existing Task/Job/Release authorities through explicit adapters. RuntimeContract describes stable structure; deployment targets are dynamic Release-owned records. Historical authorization wire formats remain frozen. New Grant and OperationalEpoch are separate objects. Generic operation execution persists intent before effects and uses receiver fencing and reconciliation.
|
||||
Tech stack: Python 3.11+, SQLite/Git/systemd, existing cryptography and pytest; no new online services or middleware.
|
||||
Spec: ARCHITECTURE_BASELINE_1_1.md (generated from the approved baseline and current user's seven revisions).
|
||||
|
||||
Global constraints:
|
||||
- The imported ZIP SHA256 is fd28f8f065873c5600f17de0a536cae6f4bc18567687fd3f178d67ad8c1f931a; local snapshot commit is an import identity, not remote main.
|
||||
- Legacy global writes remain false. No arbitrary remote shell, generated production trust roots, direct production policy DB writes or implicit authority cutover.
|
||||
- Tenant and subject come from authenticated service context. All durable identity links are tenant-scoped. Existing TaskPlaneStore remains the task authority for the explicit standalone profile; existing official Task API integrations use adapters and activation evidence, not silent replacement.
|
||||
- Every code task: write behavioral tests, observe expected failure, implement, run covering regression, record output. Package inspection and deployment preflight are read-only by default.
|
||||
|
||||
## Task 1: Existing Job reliability
|
||||
Files: aios_gitops_job_controller.py, aios_gitops_job_executor.py, tests/baseline_v1_1/test_job_safety.py and affected existing fixtures.
|
||||
Deliver: finite leases/state checks, recoverable reconcile claims/epoch CAS, universal dry-run, complete operation receipt/readback. Unknown effects must not blindly repeat; DB failure latches stop further effects until explicit successful reconciliation. Preserve old campaign authorization limits.
|
||||
Tests: dry-run mutator never called; expired and terminal lease refused; observer crash/reordering; duplicate execution; intent commit failure; external success followed by receipt storage failure.
|
||||
|
||||
## Task 2: Signed authorization chain
|
||||
Files: aios_baseline/authorization.py; tests/baseline_v1_1/test_authorization.py.
|
||||
Deliver: signed owner-delegation-grant.v1, new operational-epoch.v1 and task-principal.v1 with root grant digest/generation, persistent grant/revocation state, expiry/UNTIL_REVOKED, narrowing, Ed25519 verified trusted keys. Preserve old v1/v2 modules.
|
||||
Tests: signature tampering, fake parent Mapping, cross-tenant/subject/task/scope, revoked generation, expired proof auto-refresh under active grant, restart persistence.
|
||||
|
||||
## Task 3: Truth and operation contracts
|
||||
Files: aios_baseline/runtime_contract.py, aios_baseline/operations.py, reconciliation_controller.py; tests/baseline_v1_1/test_contracts.py.
|
||||
Public interfaces: validate_contract(document)->dict; evaluate_deployment(contract,target,observation,provenance,now)->dict; OperationDefinition.from_dict(data); OperationRegistry(definitions).get(operation_id).
|
||||
Deliver: structural contract rejects dynamic desired; dynamic target/observation/provenance validation and UNKNOWN, effect class plus orthogonal readback/retry/reversibility policy. No arbitrary command execution in registry.
|
||||
|
||||
## Task 4: Task loop and identity linkage
|
||||
Files: aios_baseline/lineage.py, service.py, worker.py, http_api.py, host.py; existing task_plane through adapters; tests/baseline_v1_1/test_loop.py.
|
||||
Deliver: use TaskPlaneStore for tasks/claims; authenticate tenant-scoped thin Action; persistent correlation graph; registry-based fallback eligibility at ready_at+600; bounded subprocess host-status operation; cancellation and evidence; no special main-agent branch. Worker heartbeat/transport are real.
|
||||
Tests: authenticated HTTP submit->claim->real local diagnostic->receipt->result; tenant denial; duplicate submission; restart and fallback; lineage referential constraints.
|
||||
|
||||
## Task 5: Release and optimization
|
||||
Files: aios_baseline/release.py, optimization.py; tests/baseline_v1_1/test_release_optimization.py.
|
||||
Deliver: Release-owned targets, deployment/acceptance IDs and source lineage, recipe/receipt adapters to governed operations; bounded OptimizationEnvelope, proposal/task-only execution, objective quality floors. No direct policy mutation.
|
||||
Tests: acceptance wrong deployment rejected; restartable saga; stale target CAS; forbidden optimization; quality regression stops promotion.
|
||||
|
||||
## Task 6: Packaging, activation and acceptance
|
||||
Files: scripts/baseline_v1_1/*, deploy/baseline_v1_1/*, docs/implementation-baseline-1.1/*; tests/baseline_v1_1/test_package_tools.py.
|
||||
Deliver: manifest with before/after hashes, patch/full source packages, conflict-safe stage/apply/rollback, authority and credential preflight, sanitized config examples, Linux systemd profile, full AT-01..23 matrix with actual statuses. AT-22 uses subprocess crash plus durable separate effect sink; physical power failure is marked online-required. AT-23 exercises SQLite and receipt failures with fail-closed admission.
|
||||
Tests: clean staging, wrong baseline/tamper refusal, repeated apply, rollback conflict refusal, credential path validation; run integrated regression and public-interface suites.
|
||||
|
||||
Rulings:
|
||||
- Effect class is primary dispatch metadata, with independent retry/readback/rollback properties, since categories overlap.
|
||||
- Historical owner-delegation-epoch.v2 is not relabeled or reinterpreted. New proof schema refers to the new Grant; migration verifies original bytes.
|
||||
- Live activation requires observed official authority bindings and valid installed signing identities. Packaging does not claim production activation.
|
||||
@@ -0,0 +1,146 @@
|
||||
# AIOS Implementation Baseline 1.1:安装、验证与回滚
|
||||
|
||||
本包交付架构修订、可执行代码、隔离验证闭环和源码变更工具。默认运行 `isolated-validation` profile;线上 Task/Job/Release 的实际写权威绑定、真实发布和生产业务验收仍须通过现场门禁。源码包安装完成不能作为生产发布完成的证据。
|
||||
|
||||
## 1. 先确认包身份和运行前提
|
||||
|
||||
提供的源码来自用户上传 ZIP,原始 ZIP SHA-256 为 `fd28f8f065873c5600f17de0a536cae6f4bc18567687fd3f178d67ad8c1f931a`。本地导入基线为 `f5d015249604ce5bdacfd707608f83e8a3875f60`;这是离线快照身份,不是远端 main 或当前 runtime 的证明。
|
||||
|
||||
最终包的顶层路径约定:
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `source/aios/` | 完整修改后源码;首先阅读根 README 和本文件 |
|
||||
| `source-baseline.git.bundle` | 可离线恢复原始导入 commit 的 Git 对象 |
|
||||
| `source-changes.zip` | 每文件 before/after hash、旧/新内容及 manifest |
|
||||
| 根目录校验清单与验收结果 | 本次实际生成的包身份及本地测试证据;以根 README 中的实际文件名为准 |
|
||||
|
||||
需要 Python 3.11+、Git,以及源码依赖清单中固定版本的 `cryptography`。运行测试需要 `pytest`。真实服务诊断需要 Linux/systemd 以及可读取状态的现存 `.service`。源码包工具只依赖 Python 标准库和 Git。使用包内依赖说明准备独立虚拟环境,不自动升级或覆盖正在运行的 AIOS Python 环境。
|
||||
|
||||
SHA-256 校验只证明字节完整性;它不证明 Owner 业务授权。使用可信的交付来源,并保留实际下载包的校验值。不要将测试生成的密钥、token 或 Grant 移入生产信任域。
|
||||
|
||||
## 2. 源码隔离部署:先 dry-run,再 stage
|
||||
|
||||
以下命令从已解压完整包的顶层执行。路径变量只用于这次安装;所有目标目录必须全新,父目录须存在。
|
||||
|
||||
```bash
|
||||
export AIOS_PACKAGE_ROOT="$PWD"
|
||||
export AIOS_BASE_REPO="$AIOS_PACKAGE_ROOT/clean-baseline"
|
||||
export AIOS_STAGE="$AIOS_PACKAGE_ROOT/staged-baseline-1.1"
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" verify \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip"
|
||||
|
||||
git init "$AIOS_BASE_REPO"
|
||||
git -C "$AIOS_BASE_REPO" fetch "$AIOS_PACKAGE_ROOT/source-baseline.git.bundle" HEAD
|
||||
git -C "$AIOS_BASE_REPO" checkout --detach FETCH_HEAD
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" stage \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip" \
|
||||
--source "$AIOS_BASE_REPO" --destination "$AIOS_STAGE" --dry-run
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" stage \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip" \
|
||||
--source "$AIOS_BASE_REPO" --destination "$AIOS_STAGE"
|
||||
```
|
||||
|
||||
`stage` 不修改 `--source`。它校验源仓库 clean、HEAD、所有基线受跟踪文件的 hash/mode,再创建独立副本;应用 manifest 后保留基线 HEAD 和可审查的工作区差异。它拒绝既有目标、未知目标覆盖、符号链接、路径穿越、ZIP 未声明成员、缺失文件、错基线及字节篡改。不会启动 systemd、写线上权威库、启用 legacy global write 或运行仓库脚本。
|
||||
|
||||
已有真实仓库与导入基线不一致时,`stage` 拒绝是预期行为。不要使用 `--force`、重写线上 HEAD 或伪造 before hash。将包内变化在真实仓库的独立 worktree 中审查/移植,重新建立该仓库的源码与验收身份,再走原有变更治理链。本工具没有绕过开关。
|
||||
|
||||
Git 忽略文件不属于基线内容,不会复制到候选目录。不要在源目录依赖未声明的 overlay、运行态数据库或私有凭证。工具当前以普通文件和可执行普通文件为交付对象,遇到符号链接、Git submodule、异常文件布局或超限包会安全拒绝。
|
||||
|
||||
## 3. 执行本地验收
|
||||
|
||||
在已准备依赖的独立 Python 环境中运行:
|
||||
|
||||
```bash
|
||||
cd "$AIOS_STAGE"
|
||||
export PYTHONPATH="$AIOS_STAGE/active-runtime/app"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
python3 -m pytest -q tests/baseline_v1_1
|
||||
```
|
||||
|
||||
本地测试包含真实 SQLite、Ed25519 签名、临时 Git 仓库、HTTP transport 和受限子进程等行为测试;其中可控替身用于模拟远端系统和故障。测试通过不代表已连接用户主机、不代表主机断电与磁盘耗尽已现场复现,也不代表生产业务验收通过。具体 AT-01~AT-23 范围见 `ACCEPTANCE_MATRIX.md` 和本次实际测试结果。
|
||||
|
||||
保存测试命令、Python/依赖版本、源码身份、配置摘要和退出码;不要把 token 或私钥放进验收报告。任何源代码或运行配置变化后,旧证据不能无条件用于放行新候选。
|
||||
|
||||
## 4. 跑第一条真实非发布闭环
|
||||
|
||||
选择当前 Linux 主机中一个已存在、允许当前用户只读查询的服务,并将下方 `YOUR_EXISTING_SERVICE.service` 替换成准确的 unit 名。此步骤只调用固定的 `systemctl show` 状态读取;不重启服务。
|
||||
|
||||
```bash
|
||||
export AIOS_VALIDATION_DIR="$AIOS_PACKAGE_ROOT/validation-run"
|
||||
python3 -m aios_baseline.cli init-validation \
|
||||
--directory "$AIOS_VALIDATION_DIR" \
|
||||
--service-unit YOUR_EXISTING_SERVICE.service
|
||||
|
||||
python3 -m aios_baseline.cli preflight --config "$AIOS_VALIDATION_DIR/server.json"
|
||||
python3 -m aios_baseline.cli grant-import \
|
||||
--config "$AIOS_VALIDATION_DIR/server.json" --grant "$AIOS_VALIDATION_DIR/grant.json"
|
||||
```
|
||||
|
||||
`init-validation` 仅为新建的本地验证实例生成测试身份和一个最多 20 个 effect、限定 `service-status/sample/staging` 的 Grant。它不会覆盖既有目录。实际部署使用已安装的信任身份和明确签署的业务授权,不复用这里生成的密钥。`preflight` 返回 `CONFIG_VALID` 仅表示配置检查通过,同时会明确 `online_authority=NOT_VERIFIED`、`production_activation=BLOCKED`。
|
||||
|
||||
在终端 A 启动服务:
|
||||
|
||||
```bash
|
||||
python3 -m aios_baseline.cli server --config "$AIOS_VALIDATION_DIR/server.json"
|
||||
```
|
||||
|
||||
在已设置相同路径变量和 `PYTHONPATH` 的终端 B 提交目标并运行真实 Worker:
|
||||
|
||||
```bash
|
||||
python3 -m aios_baseline.cli goal-submit \
|
||||
--token-file "$AIOS_VALIDATION_DIR/user.token" \
|
||||
--request "$AIOS_VALIDATION_DIR/goal.json" \
|
||||
--idempotency-key first-service-diagnostic
|
||||
|
||||
python3 -m aios_baseline.cli worker \
|
||||
--config "$AIOS_VALIDATION_DIR/worker.json" --once
|
||||
|
||||
python3 -m aios_baseline.cli goal-explain \
|
||||
--token-file "$AIOS_VALIDATION_DIR/user.token" \
|
||||
--mission-id validation-mission
|
||||
```
|
||||
|
||||
关联链应包含 Task、effect/job 和经验证的诊断 receipt;这是非发布任务,不应虚构 Release/Deployment/Acceptance。进程完成“诊断”与被诊断服务 `healthy=true` 是两个不同事实。服务不健康时仍须返回真实 observation,不能将诊断执行成功冒充组件健康。
|
||||
|
||||
常驻 Worker 使用相同命令去掉 `--once`。聊天中的 GPT 没有此 Worker 的常驻身份、heartbeat 或 transport,不是 fallback worker。将受信 Worker 配置的 `fallback_executor` 设为 true 后,调度按 readiness 时间扩大其 eligibility,仍走同一 CAS、lease 和 fencing。
|
||||
|
||||
## 5. systemd 验证实例与现场门禁
|
||||
|
||||
候选 unit 位于 `deploy/baseline-v1.1/`,均以 `.example` 结尾。API 与 Worker 使用独立 UID、各自 credential projection、固定 loopback transport 和有限资源。替换 `@RELEASE_ROOT@` 和 `@PYTHON_EXECUTABLE@`(已验收的独立 Python 环境)、配置只读发行目录、既有信任身份与独立状态目录之后,先执行 `systemd-analyze verify` 检查已渲染 unit。详细凭证名称及隔离规则见该目录的 `CREDENTIALS.md`。
|
||||
|
||||
本包不会自动创建系统用户、复制凭证、安装或启用 unit。上述操作由实际安装角色在已批准的主机范围内完成。它们是配置绑定与部署行为,不要求重新签署仍有效的业务 Grant;若实际组件、环境、破坏性级别或预算超出授权,才进入真实 Owner 边界。
|
||||
|
||||
将 `ACTIVATION_GATES.json` 复制为本次验收记录并逐项填入证据引用。不得把 `NOT_RUN` 批量改成 PASS。生产接入需要另外完成官方 Task authority adapter、Release recipe 的实际受治理 operation adapter、可信业务验收 verifier、单一活跃写权威切换和恢复演练。本地 validation profile 明确拒绝 production 配置。
|
||||
|
||||
## 6. 回滚与存储恢复
|
||||
|
||||
仅回滚隔离候选源码:
|
||||
|
||||
```bash
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" rollback \
|
||||
--directory "$AIOS_STAGE" --dry-run
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" rollback \
|
||||
--directory "$AIOS_STAGE"
|
||||
```
|
||||
|
||||
工具在首次写入前检查所有受影响文件当前仍为已部署 after hash/mode,并要求 HEAD 未产生后续提交;发现本地修改、符号链接或对象冲突即拒绝,不覆盖后改内容。回滚只恢复包内源码,不撤销远端副作用、不回退数据库、不修改 systemd。执行回滚前停止使用该候选目录的验证进程;对运行中的发行目录不使用本工具原地回滚。
|
||||
|
||||
源码回退与业务补偿是不同操作。Release 是否允许回退由目标 artifact、数据兼容性和已验证 rollback operation 决定。存储恢复需先停止新 effect 准入,使用 SQLite backup API 或停止写者后的完整一致副本,同时恢复 Git/receipt/关联身份;只复制活跃 SQLite 主 DB 文件不构成可靠备份。恢复后先对账未决 effect,再恢复调度,记录实测 RPO/RTO。无法可靠记录 intent/lease/receipt 时,不得继续产生新副作用。
|
||||
|
||||
## 7. 重新生成变更包
|
||||
|
||||
由维护者在受审查的修改后源码仓库中执行;输出必须位于源码目录之外,且不能覆盖既有文件:
|
||||
|
||||
```bash
|
||||
python3 tools/baseline_v1_1/bundle.py create \
|
||||
--repo "$AIOS_STAGE" \
|
||||
--base f5d015249604ce5bdacfd707608f83e8a3875f60 \
|
||||
--output "$AIOS_PACKAGE_ROOT/reviewed-source-changes.zip"
|
||||
```
|
||||
|
||||
输出 manifest 保存每个 added/modified/deleted 文件的 before/after SHA-256 与 mode,并包含精确的旧、新内容。重新生成后须重新验收和记录新包身份;不能沿用旧包的验收结论。
|
||||
@@ -0,0 +1,91 @@
|
||||
# Job safety implementation report
|
||||
|
||||
## Scope
|
||||
|
||||
Task 1 hardens the existing SQLite Job controller and campaign executor. Legacy
|
||||
campaign, repository, branch, authorization-digest, protected-branch and
|
||||
machine-identity restrictions remain in force.
|
||||
|
||||
## Delivered behavior
|
||||
|
||||
- Branch and typed resource leases require an `AUTHORIZED` Job, a timezone-aware
|
||||
future deadline no more than 300 seconds away, and revision CAS. Effect
|
||||
receivers recheck the active lease ID, expiry and generation.
|
||||
- Generic registered operations can persist an `aios.operation-job.v1` binding
|
||||
in the existing JobStore. Resource generations are allocated monotonically per
|
||||
`(resource_kind, resource_id)` and are available through
|
||||
`get_execution_lease()`.
|
||||
- Generic Jobs use an explicit transition graph. `LOCAL_VERIFIED` and
|
||||
`COMPLETED` require a request-bound, lease-bound operation receipt accepted by
|
||||
the mandatory `generic_receipt_verifier`; direct `REQUESTED` to `COMPLETED`
|
||||
transitions and self-asserted booleans are rejected.
|
||||
- Reconciliation uses a durable claim ID, incrementing epoch, base revision and
|
||||
short deadline. An expired `RECONCILING` claim can be taken over; late observer
|
||||
results fail with `RECONCILIATION_CLAIM_LOST`.
|
||||
- Every campaign executor operation passes through the same dry-run boundary.
|
||||
Dry-run performs validation/read-only planning and leaves the Job in
|
||||
`REQUESTED`; no mutating adapter is called.
|
||||
- Real execution persists `EXECUTING / INTENT_COMMITTED` before an effect,
|
||||
checks the lease at the effect boundary, performs exact readback and commits a
|
||||
receipt with effect ID, operation and observation. An adapter without exact
|
||||
readback yields `RECONCILIATION_REQUIRED`, `UNKNOWN` and the explicit
|
||||
`EXACT_READBACK_UNSUPPORTED` blocker; it does not fabricate completion.
|
||||
- A persistence failure latches the executor against further effects. Durable
|
||||
in-flight/UNKNOWN Jobs also block new-effect admission after restart through
|
||||
`assert_effect_admission()`. Explicit reconciliation is the recovery path.
|
||||
- COMMIT_FILE now restores its content-addressed `payload_ref` and `payload_size`
|
||||
from the durable request. Its current Authority adapter cannot identify an
|
||||
unreferenced commit from intent alone, so execution reports the explicit exact
|
||||
readback blocker instead of risking a duplicate commit.
|
||||
|
||||
Campaign readback support is explicit: branch advance reads Authority and Gitea,
|
||||
branch sync reads Gitea, and main reconciliation reads Authority. PR, status and
|
||||
merge operations require adapter methods `read_pr`, `read_status` and
|
||||
`read_merge`; absent methods yield the blocker above before an effect. This is a
|
||||
safe integration requirement for the current adapters, not a success claim.
|
||||
|
||||
## Integration API
|
||||
|
||||
For a registered non-Git operation:
|
||||
|
||||
1. `submit()` with schema `aios.operation-job.v1` and tenant, subject, mission,
|
||||
grant, task, effect, operation/version, resource kind/ID and input digest.
|
||||
2. `advance(job_id, AUTHORIZED, ..., expected_revision=...)`.
|
||||
3. `lease_resource_job(..., generation=None, lease_expires_at=..., expected_revision=...)`.
|
||||
4. Read the assigned fence through `get_execution_lease(job_id)`.
|
||||
5. Call `assert_effect_admission(exclude_job_id=job_id)` before admitting a new
|
||||
effect and `assert_execution_lease(job_id, lease_id=..., generation=...)`
|
||||
immediately before the handler.
|
||||
6. Persist `EXECUTING` intent before the handler and a verified receipt plus
|
||||
`COMPLETED` state after exact readback. On ambiguity call `reconcile()` with a
|
||||
read-only observer; do not repeat the handler.
|
||||
|
||||
Construct the controller with
|
||||
`generic_receipt_verifier=<trusted Ed25519 envelope verifier>`. Generic operation
|
||||
completion is unavailable when this verifier is absent.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
Failure-first run before implementation: `13 failed` in the new safety suite,
|
||||
all at the missing clock/reconciliation/executor boundaries.
|
||||
|
||||
Latest focused run before this report:
|
||||
|
||||
```text
|
||||
................................................ [100%]
|
||||
48 passed in 1.13s
|
||||
```
|
||||
|
||||
This combines 16 Task 1 safety tests (including AT-22 subprocess crash and
|
||||
AT-23 persistence fault injection) with the pre-existing selected 32 Job tests.
|
||||
No network or production system was used.
|
||||
|
||||
The separate completed-replay regression suite reports `11 passed in 1.16s`.
|
||||
The generic operation loop integration reports `6 passed in 0.55s`.
|
||||
|
||||
Final combined run:
|
||||
|
||||
```text
|
||||
................................................................. [100%]
|
||||
65 passed in 1.71s
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# M0–M5 实施手册与退出标准
|
||||
|
||||
本手册区分已交付本地代码、尚待接线的现场能力和明确剩余实现。先按 `INSTALLATION.md` 校验包、建立独立 stage 并执行实际测试命令;所有阶段保留源码、配置、授权及证据身份。线上部署仍沿用既有批准的变更边界,不能把 validation profile 当作正式 authority adapter。
|
||||
|
||||
当前使用者可立即执行本地 CLI 诊断闭环、测试和源码包工具。标为“剩余实现”的项目必须完成代码及针对性测试;标为“现场绑定”的项目必须提交真实目标证据,不能仅填写配置后标为完成。同一有效 Grant 内的机器证明续期和恢复不重复业务审批。
|
||||
|
||||
## M0 — Truth & Identity
|
||||
|
||||
**已交付代码:** `runtime_contract.py` 校验静态合同并拒绝动态 desired;目标/观测/provenance 评价;缺失 reconciliation 观测不再默认健康;运行 snapshot 的可信状态修正;消费服务自身 encrypted credential projection;源码 before/after hash 工具。
|
||||
|
||||
**输入:** 原始源码/制品身份、组件清单、正式入口、权威角色、命名资源和凭证引用、受信配置来源。
|
||||
|
||||
**执行:** 先只读采集 authority map;分别建立 Contract、DeploymentTarget、Observation 和 provenance。核对目标资源一致及新鲜度,显式保留 UNKNOWN。对已渲染配置执行 preflight;检查每个服务自身的凭证投影,按组件登记缺失项。
|
||||
|
||||
**剩余实现/现场绑定:** 正式在线 Resolver、按操作 Doctor 和自动修复目录;真实 package/build identity 与运行内容对照;现场已有目标映射到 tenant/component/environment 后的冲突检测。当前固定绑定不是通用 Resolver 已完成。
|
||||
|
||||
**退出标准:** 执行者能机器回答对象、入口、desired、observed、artifact/source、CredentialReference 和证据年龄;AT-01/02/13/15 对应范围有证据。main 前进不误判 drift,UNKNOWN 不放行。**回滚:** 撤销候选解析/配置切换,保留旧权威读取;不批量迁移目录,不重写动态 target 来迎合旧观测。
|
||||
|
||||
## M1 — Authorization & Effect Safety
|
||||
|
||||
**已交付代码:** 新 Grant/OperationalEpoch/Principal 签名链、撤销和 effect 预算;真实资源 lease 及单调 fencing;Job intent/receipt、可接管对账;统一 dry-run;持久未决 effect 阻止新 effect 的恢复门禁。
|
||||
|
||||
**输入:** 已安装 Owner 公钥、机器签名身份、明确有效 Grant、OperationDefinition、精确资源及读回接口。
|
||||
|
||||
**执行:** 注册已验签 Grant;自动派生收窄 Principal;在同一 intent 下申请真实 lease;执行前再次检查根授权/预算/接收端 fence。验证签名伪造、撤销、过期、scope 重放、CAS 竞争及 dry-run 无副作用;注入成功后 receipt 丢失、存储失败和对账 observer 乱序。
|
||||
|
||||
**剩余实现/现场绑定:** 每个真实写 adapter 的 effect-keyed readback 与接收端 fencing;COMMIT_FILE 未引用 commit 的可定位恢复;实际密钥轮换、掉电、ENOSPC/WAL 故障。缺能力时维持显式 blocker,不能自动退回旧 endpoint。
|
||||
|
||||
**退出标准:** AT-03~07、17、22、23 的适用项通过;intent 无法提交不执行,未知结果不盲重试。**回滚:** 停止新 effect 准入,保留 Grant 撤销记录、intent 和 receipt,先对账再恢复;代码回退不能删掉未决 Job 或重新解释旧签名。
|
||||
|
||||
## M2 — Real Autonomous Loop
|
||||
|
||||
**已交付代码:** 认证 HTTP 薄入口、既有 TaskPlaneStore 适配、Worker transport/heartbeat、能力与 fallback 策略、固定参数 Host status executor、签名诊断 receipt、Task 结果和 `goal-explain`。
|
||||
|
||||
**输入:** M0/M1 基线、现存可读 `.service`、隔离状态目录、用户/Worker 固定认证身份、显式声明的 Worker scopes。
|
||||
|
||||
**执行:** 按安装手册执行 `init-validation → preflight → grant-import`;启动 server,提交 goal,运行 Worker,查询任务及 mission。尽早通过这条真实非发布闭环接线;之后测试重复请求、跨租户访问、取消、超时、重启以及 ready+600 秒 fallback 竞争。
|
||||
|
||||
**剩余实现/现场绑定:** 正式线上 Task API adapter;完整 blocked Doctor/修复/升级;目标级总计算和模型费用计量;artifact/cache 隔离;通知投递器/inbox 的故障闭环。现有 effect 数和任务 attempt 预算不是全部成本预算。
|
||||
|
||||
**退出标准:** 真实常驻 Worker 经正式 admission/claim 执行已注册诊断并返回可归因 receipt;无伪造 Release;AT-08~12/14 的实施范围清晰。**回滚:** 停止候选 admission/Worker,恢复未决 claim/Job;已有 Task 不因切换丢失,不建立第二套线上 Task 表。
|
||||
|
||||
## M3 — Release Closure
|
||||
|
||||
**已交付代码:** `LineageLedger` 不可改绑关系;`ReleaseLedger` 动态 target CAS、不可变 recipe、稳定阶段 effect、saga 恢复;独立签名 receipt/acceptance verifier。ReleaseEpoch 语义由 release 身份及发布记录承载,不新增通用超级状态表。
|
||||
|
||||
**输入:** 真实 Task/Job/Release 权威绑定、组件部署资源键、受保护 merge/build/deploy 操作、已验签 receipt、业务验收 verifier,以及同一 Grant 覆盖的准确阶段范围。
|
||||
|
||||
**执行:** 先核验资源主键并补齐正式适配器。将每个 recipe 阶段接到正式 Job 权威,显式传递 task/grant/effect/job 身份,先持久 effect 后调用;验证 readback、权威 Job 和签名绑定,再推进 saga。执行同 Grant 的 patch→tests→受保护 merge→build→staging→业务验收→production→生产业务验收;至少注入一次 ambiguous publication 或 Worker crash 并恢复。
|
||||
|
||||
**剩余实现/现场绑定:** 以上真实 mutation receiver、精确 publication readback、受信验收来源与完整发布链。本包不将历史 HTTP 写端点包成绕过新授权的 adapter;模拟 callback 成功仅证明状态机约束。
|
||||
|
||||
**退出标准:** AT-16/17/19/20 有真实链路证据;acceptance 精确绑定 deployment+release+artifact。当前状态为生产发布 **NOT_VERIFIED**。**回滚:** 仅使用预先声明兼容的 rollback release/artifact 与受治理补偿操作;不可逆迁移走前向修复,保留失败部署事实。
|
||||
|
||||
## M4 — Surface Reduction
|
||||
|
||||
**已交付代码:** 不可改绑身份模型、源码 manifest、隔离 stage/冲突保护 rollback、部署门禁清单。它们支持切换审查,不自动停用线上写者。
|
||||
|
||||
**输入:** 实际活跃 writer 清单、旧任务/schema/签名、迁移映射、接入路由、备份和最小恢复入口。
|
||||
|
||||
**执行:** 按事实域和资源范围列出当前写入口;先验证新正式入口,再停止旧入口接受新任务,保留旧任务 handler 或进行明确迁移。逐一删除旁路调用、跨服务凭证路径和 production legacy imports。使用 SQLite backup API 或停止写者后的完整一致副本,在隔离环境恢复 SQLite/Git/receipt 并对账未决效果。
|
||||
|
||||
**剩余实现/现场绑定:** 实际旧 writers 排空和路由切换、历史任务映射、数据库兼容迁移、整机一致备份与恢复脚本/演练。不能伪造历史缺失的 Task 或 receipt。
|
||||
|
||||
**退出标准:** 每类事实 active write authority=1;旧 Task/Release writers 和 legacy 新任务入口均为 0;cross-service credential path、production legacy imports 为 0;AT-19~21 完成并记录实测 RPO/RTO。当前未宣称达标。**回滚:** 仅按已验证迁移/handler 兼容计划恢复单一入口;不同时重开新旧 writers,不将只复制 SQLite 主文件当一致恢复。
|
||||
|
||||
## M5 — Optimization
|
||||
|
||||
**已交付代码:** 签名 Envelope 校验、封闭低风险参数目录、提案持久化、可信实验 receipt、质量方向/底线判断、无改善停止,以及只生成受治理晋升 Task 的接口。
|
||||
|
||||
**输入:** 已稳定通过前述闭环的运行证据、显式 Envelope、基线/候选指标、计算预算、强制测试/质量门、回退计划。
|
||||
|
||||
**执行:** 首批选 retry/backoff、有限 Worker routing、证据复用、timeout、poll、通知去重等可测参数。记录提案,提交实验 Task,消费可信结果;只有满足质量底线、预算和最小改善才提交晋升 Task,再按普通治理链落地。
|
||||
|
||||
**剩余实现/现场绑定:** 真实实验执行、费用计量、运行参数应用和回退 adapter;模型成本路由及经验适用范围管理。首版不自动重写架构核心代码或生产迁移规则。
|
||||
|
||||
**退出标准:** AT-18 通过;授权政策、信任根、隔离、验收标准和 Grant 语义始终不在可优化范围;提案不能直接写 policy/runtime DB。**回滚:** 停止新提案与晋升,按受治理 Task 恢复已记录旧值;保留失败实验和停止原因,不降低验收标准换取成功。
|
||||
@@ -0,0 +1,105 @@
|
||||
# Bounded Optimization Implementation Report
|
||||
|
||||
## Scope
|
||||
|
||||
Baseline 1.1 adds a proposal-only optimization controller. It can record a
|
||||
candidate and ask the authenticated task service to evaluate or promote it. It
|
||||
has no adapter for policy, authorization, trust-root, tenant-isolation,
|
||||
acceptance, destructive-operation, OwnerGrant, production-migration, or runtime
|
||||
configuration writes.
|
||||
|
||||
The implemented public interfaces are:
|
||||
|
||||
- `OptimizationEnvelope.from_dict(data, *, verify_envelope)`
|
||||
- `validate_proposal(envelope, proposal)`
|
||||
- `OptimizationController(path, envelope, submit_task, *, verify_receipt)`
|
||||
- `OptimizationController.propose(proposal, *, authenticated_context)`
|
||||
- `OptimizationController.record_evaluation(proposal_id, evaluation, *, authenticated_context)`
|
||||
- `OptimizationController.promote(proposal_id, promotion, *, authenticated_context)`
|
||||
- `OptimizationController.get_proposal(proposal_id, *, authenticated_context)`
|
||||
|
||||
## Trust and authority boundaries
|
||||
|
||||
Envelope parsing requires a verifier callback. The callback receives the key
|
||||
ID, canonical unsigned JSON bytes, and signature. A missing verifier, a
|
||||
verification exception, a non-`True` result, or any post-signature change is
|
||||
rejected. Semantic validation happens after signature verification and limits a
|
||||
signed envelope to a built-in set of low-risk parameter paths and hard numeric
|
||||
ranges. A trusted signer therefore cannot accidentally turn the envelope into a
|
||||
generic patch or broad capability mechanism.
|
||||
|
||||
Task receipts are authenticated through a separate required verifier. The
|
||||
controller then checks the receipt schema, evaluation task ID, proposal ID,
|
||||
proposal digest, action, success result, and digest of the complete quality
|
||||
result. Promotion re-verifies the stored receipt and its quality-result binding.
|
||||
|
||||
The authenticated root-service context supplies tenant, subject, grant, and
|
||||
mission IDs. All reads and state transitions require the same bindings that
|
||||
created the proposal. The controller includes those IDs in each task request.
|
||||
The task callback is the only outward effect.
|
||||
|
||||
## Bounded behavior
|
||||
|
||||
The closed parameter registry covers finite retry, backoff, limited candidate
|
||||
routing weight, tenant-scoped evidence reuse TTL, operation timeout, poll
|
||||
interval, notification-deduplication window, and optional-test sampling. Exact
|
||||
paths are required. Unknown paths and generic code-patch paths are rejected.
|
||||
|
||||
Each proposal must remain within its envelope's component, non-production
|
||||
environment, change-count, compute-budget, and per-parameter bounds. Mandatory
|
||||
tests and quality gates must be present in both the proposed evaluation and the
|
||||
verified result. Evidence reuse, when supplied, must match the exact tenant,
|
||||
component, and environment; it cannot request a global or cross-tenant cache.
|
||||
A required rollback plan can only reference restoration of previous values and
|
||||
cannot embed a command.
|
||||
|
||||
Direction-aware quality floors support both higher-is-better and
|
||||
lower-is-better metrics. The SQLite record contains the baseline, candidate,
|
||||
and normalized effect comparison. A floor violation, regression, missing
|
||||
mandatory check, budget overrun, or insufficient objective improvement moves
|
||||
the proposal to a terminal `STOPPED_*` state. Only an `ELIGIBLE` proposal can
|
||||
submit a promotion task. Promotion still does not apply the candidate directly.
|
||||
|
||||
## Durable states
|
||||
|
||||
The SQLite workflow uses these states:
|
||||
|
||||
1. `PROPOSAL_RECORDED`
|
||||
2. `EVALUATION_SUBMITTED`
|
||||
3. `ELIGIBLE` or terminal `STOPPED_*`
|
||||
4. `PROMOTION_SUBMITTED`
|
||||
|
||||
Invalid proposals with a safe, tenant-matching proposal ID are retained as
|
||||
`STOPPED_INVALID` without calling the task submitter. Proposal IDs are
|
||||
digest-bound, so reuse with different content is rejected. Evaluation and stop
|
||||
states cannot be overwritten by later evaluation submissions.
|
||||
|
||||
## Test evidence
|
||||
|
||||
The failure-first run was:
|
||||
|
||||
```text
|
||||
python3 -m pytest -q tests/baseline_v1_1/test_optimization.py
|
||||
ERROR tests/baseline_v1_1/test_optimization.py
|
||||
ModuleNotFoundError: No module named 'aios_baseline.optimization'
|
||||
```
|
||||
|
||||
After implementation and the terminal-state regression fix, the focused run
|
||||
was:
|
||||
|
||||
```text
|
||||
19 passed in 0.13s
|
||||
```
|
||||
|
||||
The tests cover signature tampering and missing verification, forbidden and
|
||||
generic paths, numeric limits, mandatory tests and gates, evidence-isolation
|
||||
scope, identity-bound task submission, invalid-candidate stopping, cross-tenant
|
||||
and cross-subject denial, untrusted and wrong-task receipts, quality regression,
|
||||
no improvement, terminal stop behavior, task-only promotion, and restart
|
||||
persistence of baseline/candidate/effect data.
|
||||
|
||||
The separately assigned loopback transport suite also completed with 7 passed.
|
||||
It covers the real submit-to-receipt HTTP flow, cancel/query/explain, server-side
|
||||
token identity, tenant-spoof rejection, strict body/deadline/JSON handling,
|
||||
sanitized failures, redirect and non-loopback rejection, and the interruptible
|
||||
resident worker loop.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Release and Lineage Implementation Report
|
||||
|
||||
## Delivered authority boundaries
|
||||
|
||||
`aios_baseline.lineage.LineageLedger` is a SQLite relationship authority. It
|
||||
does not own Task or Job state. Its constructor requires Task and Job authorities, and
|
||||
every `link_task` resolves the real Task before writing. The returned Task must
|
||||
carry the requested tenant and subject in its issuer or baseline metadata;
|
||||
mission, component, and Grant metadata must also match. Every `link_effect`
|
||||
resolves the durable Job and verifies its tenant, subject, mission, Grant,
|
||||
Task, and effect binding before recording the relationship.
|
||||
|
||||
Opaque Task, effect, Job, Release, deployment, and acceptance IDs are unique
|
||||
within a tenant. The same opaque ID may exist in another tenant. Composite
|
||||
foreign keys also bind subject, mission, component, Grant, Release artifact, and deployment at
|
||||
the SQLite layer. The API treats an exact replay as idempotent and rejects any
|
||||
attempt to rebind:
|
||||
|
||||
- a Task to another subject, mission, Grant, or source Task;
|
||||
- an effect to another Task or Job;
|
||||
- a Job to another effect/Task;
|
||||
- a release-producing effect to a second Release;
|
||||
- a Deployment to another Release, artifact, target revision, or environment;
|
||||
- an Acceptance to another Deployment, Release, artifact, tenant, or subject.
|
||||
|
||||
`explain(tenant_id, subject_id, mission_id)` uses equality predicates for all
|
||||
three scope fields. It returns Tasks, effects/Jobs, Releases, Deployments, and
|
||||
Acceptances only for that exact mission.
|
||||
|
||||
`aios_baseline.release.ReleaseLedger` owns dynamic
|
||||
`EnvironmentDeploymentTarget` records. A target is never written into a
|
||||
`RuntimeContract`. One target row exists per `(tenant, component, environment)`;
|
||||
the subject is the authenticated actor recorded for audit. Every target action
|
||||
passes through a configured resolver with tenant, subject, component,
|
||||
environment, and action. Multiple authorized subjects therefore share one CAS
|
||||
revision instead of creating conflicting desired state. `set_target` uses an expected-revision CAS; `None` creates
|
||||
revision 1 and later writes require the exact current revision. A `STAGED`
|
||||
target records source and observed-main revisions independently, so main being
|
||||
ahead of the staged source is valid. A `ROLLBACK_STAGED` target is admitted only
|
||||
when the current record has an explicit compatible rollback Release and exact
|
||||
artifact binding. The outer artifact object rejects recursive self-hash fields.
|
||||
|
||||
Recipes and their canonical SHA-256 identities are immutable. A saga persists
|
||||
its recipe identity, component and source Task/effect/Grant, stable per-stage effect ID, effect intent, raw receiver
|
||||
receipt state, typed verified receipt binding, deployment facts, target
|
||||
revision, and CAS revision. The adapter must first create or resolve a real
|
||||
Job via `prepare`; only then does the lineage authority link the effect. An
|
||||
operation exception leaves `EFFECT_UNKNOWN`. Recovery calls adapter readback,
|
||||
and missing readback fails with `RECONCILIATION_REQUIRED` without repeating the
|
||||
effect. A returned but unverified receipt is persisted
|
||||
as `RECEIPT_UNVERIFIED`; it is reverified rather than causing the effect to be
|
||||
issued again. Only an injected verifier returning `VerifiedReceipt` with the
|
||||
exact component, Task, source effect, Grant, Job, effect, operation, tenant,
|
||||
subject, Deployment, Release, and artifact can
|
||||
advance a stage. Acceptance follows the same pattern through
|
||||
`VerifiedAcceptanceEvidence` and includes an explicit `acceptance_id`.
|
||||
|
||||
## Public interfaces
|
||||
|
||||
```python
|
||||
LineageLedger(path, task_authority, job_authority)
|
||||
ledger.link_task(tenant_id, subject_id, mission_id, grant_id, task_id,
|
||||
source_task_id=None, metadata=None)
|
||||
ledger.link_effect(tenant_id, subject_id, mission_id, task_id, effect_id,
|
||||
job_id, source_effect_id=None, metadata=None)
|
||||
ledger.link_release(tenant_id, subject_id, mission_id, effect_id, release_id,
|
||||
artifact, metadata=None)
|
||||
ledger.link_deployment(tenant_id, subject_id, mission_id, release_id,
|
||||
deployment_id, component_id, environment, target_revision, artifact,
|
||||
metadata=None)
|
||||
ledger.record_acceptance(tenant_id, subject_id, mission_id, component_id, acceptance_id,
|
||||
deployment_id, release_id, artifact, accepted,
|
||||
evidence=raw_evidence, verifier=trusted_verifier)
|
||||
ledger.explain(tenant_id, subject_id, mission_id)
|
||||
|
||||
ReleaseLedger(path, lineage=ledger, receipt_verifier=trusted_verifier,
|
||||
target_authorizer=authorization_resolver)
|
||||
releases.register_recipe(recipe_id, stages)
|
||||
releases.set_target(tenant_id, subject_id, component_id, environment, release_id, artifact,
|
||||
expected_revision=revision_or_none, stage="STAGED", ...)
|
||||
releases.start_saga(tenant_id, subject_id, mission_id, component_id, saga_id, release_id,
|
||||
deployment_id, environment, artifact, recipe_id,
|
||||
expected_target_revision=revision)
|
||||
releases.advance_saga(tenant_id, subject_id, saga_id, expected_revision,
|
||||
governed_operation_adapter)
|
||||
releases.get_saga(tenant_id, subject_id, saga_id)
|
||||
releases.deployment_facts(tenant_id, subject_id, component_id, deployment_id)
|
||||
```
|
||||
|
||||
An artifact argument may be a non-empty string or canonical JSON object. The
|
||||
object is compared by canonical JSON identity; outer self-hash fields such as
|
||||
`manifest_sha256` are forbidden.
|
||||
|
||||
## Existing Bridge and governance audit
|
||||
|
||||
The existing loopback Bridge exposes `/v2/release/artifact`,
|
||||
`/v2/release/execute`, `/v2/release/consistency`, and
|
||||
`/v2/release/acceptance`. These endpoints use the historical static tuple
|
||||
`version`, `target_oid`, `evidence_closure_oid`, and `approval_plan_id` (plus
|
||||
`execute` where applicable). Static source review found that this protocol is
|
||||
not a conforming ReleaseLedger operation receiver:
|
||||
|
||||
1. The Bridge allowlist rejects the new tenant, subject, mission, component, Grant, Task,
|
||||
Job, effect, Deployment, target-revision, and idempotency fields.
|
||||
2. Release routes are outside the Bridge's TaskPrincipal-protected mutating
|
||||
operation set. They therefore cannot demonstrate the new Grant/Task/Job
|
||||
authorization chain.
|
||||
3. Historical release responses are unsigned and are not bound to the stable
|
||||
saga effect ID, Deployment ID, Release ID, subject, or canonical artifact.
|
||||
A `status`, `accepted`, or `ACTIVE_VERIFIED` field is not verifier authority.
|
||||
4. The trusted-plan executor deliberately rejects production execution with
|
||||
`PRODUCTION_EXECUTION_REQUIRES_APPROVED_RELEASE_EXECUTOR`. The legacy path
|
||||
invokes its pre-existing privileged production adapter, but it is not routed
|
||||
through the new Grant/Job/effect protocol and must not be repurposed as that
|
||||
protocol.
|
||||
5. The Bridge has no effect-keyed readback endpoint that can reconcile an
|
||||
unknown ReleaseLedger stage after response loss.
|
||||
6. Historical acceptance is not bound to an explicit acceptance and
|
||||
Deployment identity and does not return verifier-authenticated evidence for
|
||||
the new ledger.
|
||||
|
||||
## Configured Ed25519 verification
|
||||
|
||||
`aios_baseline.verifiers` provides verification-only factories. Trusted public
|
||||
keys are supplied by local configuration when the factory is built and copied
|
||||
into an immutable map. A request cannot add or replace a key. Values may be an
|
||||
`Ed25519PublicKey`, 32 raw public-key bytes, or an unpadded base64url public key.
|
||||
|
||||
```python
|
||||
from aios_baseline.release import ReleaseLedger
|
||||
from aios_baseline.verifiers import (
|
||||
ed25519_acceptance_evidence_verifier,
|
||||
ed25519_release_receipt_verifier,
|
||||
)
|
||||
|
||||
trusted_release_receivers = {
|
||||
"staging-receiver-2026": configured_ed25519_public_key,
|
||||
}
|
||||
trusted_acceptance_observers = {
|
||||
"staging-observer-2026": configured_acceptance_public_key,
|
||||
}
|
||||
|
||||
releases = ReleaseLedger(
|
||||
state_path,
|
||||
lineage=lineage,
|
||||
receipt_verifier=ed25519_release_receipt_verifier(
|
||||
trusted_release_receivers
|
||||
),
|
||||
target_authorizer=configured_component_target_authorizer,
|
||||
)
|
||||
acceptance_verifier = ed25519_acceptance_evidence_verifier(
|
||||
trusted_acceptance_observers
|
||||
)
|
||||
```
|
||||
|
||||
Operation receipts use envelope schema
|
||||
`aios.release-operation-receipt-envelope.v1` and signature domain
|
||||
`aios.release-operation-receipt.v1`. Acceptance evidence uses envelope schema
|
||||
`aios.release-acceptance-evidence-envelope.v1` and the separate signature domain
|
||||
`aios.release-acceptance-evidence.v1`. The signature input is the ASCII domain,
|
||||
a zero byte, and strict canonical JSON for the payload. The verifier rejects
|
||||
unknown envelope/payload fields, unknown key IDs, caller-provided public-key
|
||||
fields, cross-domain signatures, a non-`SUCCEEDED` operation outcome, a
|
||||
non-Boolean acceptance decision, and any component/Task/Grant/Job/effect/
|
||||
Deployment/Release/artifact binding mismatch. This key
|
||||
set and both domains are separate from the diagnostic `evidence.ReceiptSigner`.
|
||||
|
||||
For that reason, this package does not add a loopback HTTP writer around the
|
||||
historical endpoints. Such a wrapper would create a governance bypass while
|
||||
appearing to satisfy the new contract. The currently safe, observation-only
|
||||
projection is:
|
||||
|
||||
```json
|
||||
{
|
||||
"transport": "loopback-http",
|
||||
"base_url": "http://127.0.0.1:18892",
|
||||
"operations": {
|
||||
"release.consistency.observe": {
|
||||
"method": "POST",
|
||||
"path": "/v2/release/consistency",
|
||||
"request_projection": [
|
||||
"version",
|
||||
"target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id"
|
||||
],
|
||||
"receipt_use": "diagnostic-only-unverified"
|
||||
}
|
||||
},
|
||||
"mutating_operations": {},
|
||||
"verified_receipt_operations": {}
|
||||
}
|
||||
```
|
||||
|
||||
A future conforming receiver must admit a new OperationalEpoch/TaskPrincipal,
|
||||
create or reuse the authoritative Job before the effect, fence idempotency by
|
||||
`effect_id`, expose effect-keyed readback, and return a signature-verified
|
||||
receipt over all ReleaseLedger binding fields. That receiver belongs at the
|
||||
existing Job and approved-release authority boundary, not in this relationship
|
||||
ledger.
|
||||
|
||||
## Verification and honest status
|
||||
|
||||
Failure-first execution initially failed at import because the modules did not
|
||||
exist. After implementation:
|
||||
|
||||
- `test_release_lineage.py`: **15 passed**. This uses a local SQLite file and
|
||||
reopens both ledgers across simulated process boundaries. Task authority and
|
||||
receipt verifiers are controlled callback fixtures; the operation callback
|
||||
is a fake configured sink used only to test fencing and verification. Two
|
||||
additional tests use real Ed25519 keys and cryptographic verification for
|
||||
valid signatures, signature tampering, cross-domain replay, caller key
|
||||
injection, decision mismatch, and Deployment mismatch. The valid signed
|
||||
acceptance is also persisted through the real SQLite ledger path.
|
||||
- Candidate-schema tests verify independent targets for two components in one
|
||||
environment, one shared CAS across authorized subjects, denial of an
|
||||
unauthorized subject, cross-component receipt rejection, refusal to repeat
|
||||
an unknown effect without readback, and fail-closed rejection of an
|
||||
unversioned candidate database. These schemas have not been released, so no
|
||||
implicit legacy migration is provided.
|
||||
- A targeted local loop test passed with the real `TaskPlaneStore` and
|
||||
`JobStore.read` authorities, including parsing the durable Job's
|
||||
`request_json.binding` and checking tenant/subject/mission/Grant/Task/effect.
|
||||
|
||||
No Bridge server, remote Git authority, production executor, production health
|
||||
endpoint, or installed signing identity was exercised. Remote deployment and
|
||||
acceptance are **NOT_VERIFIED**. No production success or live activation is
|
||||
claimed.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 实际任务闭环、集成边界与审查修正
|
||||
|
||||
## 可执行入口
|
||||
|
||||
`python -m aios_baseline.cli` 提供 `init-validation`、`preflight`、`grant-import`、`grant-revoke`、`server`、`worker`、`goal-submit`、`goal-explain`、`task-get`、`task-cancel`。真实 transport 是 IPv4 loopback HTTP;身份来自服务端持有的 token 映射。Worker 进程登记、heartbeat、claim、renew、execute,不能提交自行声称成功的 receipt,也不能从请求指定 tenant/subject 或任意命令。
|
||||
|
||||
Task/claim/outbox 使用既有 `TaskPlaneStore`;新增 runtime 信息在同一 Task 数据库中,仅含 readiness、blocker、effect/job 关联。Generic Job 使用既有 JobController/JobStore,独立资源围栏按 tenant/component/environment 分配单调 generation。Lineage 查询真实 Task 和 Job 权威后才保存关系;它不创建这些事实。
|
||||
|
||||
配置文件、私钥、token 和公钥均通过 no-follow 打开并对实际文件描述符检查所有者、权限、大小;已有非空状态目录必须带精确的隔离权威标识。Owner key 显式绑定 tenant/subject scope。业务 Grant 与机器密钥分离;机器 receipt key 轮换通过受信历史公钥保留旧证据,不能因签名密钥变化就重新执行已完成 effect。
|
||||
|
||||
## 第一个纵向闭环的真实范围
|
||||
|
||||
`goal-submit → Task admission → HTTP Worker claim → Job intent → Grant/lease/fencing → 受限 systemctl show 子进程 → 签名 receipt → Task completion → goal-explain`。
|
||||
|
||||
`service-status` 是内置 READ_ONLY operation,组件绑定必须精确到 `tenant:environment:component`,不会尝试路径、扫描所有凭证或访问 SSH。测试启动真实 HTTP server、独立子进程、SQLite 和 Ed25519;可控 systemctl 程序用于构造稳定观测。现场运行使用 `/usr/bin/systemctl` 与已登记 unit,实际目标健康、systemd 权限及凭证解密仍需现场证据。
|
||||
|
||||
“完成诊断”不等于“组件健康”。receipt 保留实际 `healthy` 和 systemd 观测;没有有效观测不会生成成功 receipt。任务查询同时返回 Job state、side-effect state、blocker 和剩余 Task budget。
|
||||
|
||||
当前 server 对 host operation 采用有界串行执行,HTTP 并发连接最多 16 个,token 长度统一为 32~512 字符。HTTP deadline 限制执行锁等待并传到 receiver;host 子进程固定 argv、无继承凭证环境、固定可执行文件身份、累计 stdout/stderr 上限 64 KiB、超时杀进程组。单机首次实现采用全 Job 域的保守停止准入策略:未决效果会阻止新的效果;物理存储故障保持全局停止。更细的租户/资源并行度属于后续可测优化,不以牺牲恢复判断换吞吐。
|
||||
|
||||
## 本次审查发现并已修复的具体缺陷
|
||||
|
||||
| 缺陷 | 修正 | 行为测试 |
|
||||
| --- | --- | --- |
|
||||
| 每任务 attempt 从 1 开始,被误用于全资源围栏 | Job 权威按资源分配 generation;旧持有者被拒绝 | 连续两任务围栏代数为 1、2 |
|
||||
| Generic Job 可直接 REQUESTED→COMPLETED | 显式状态转换、已持久 lease、强制可信 receipt verifier | 越级完成、缺 verifier、改绑均拒绝 |
|
||||
| 签名正确但 job/mission/grant 等关联错误仍能完成 Task | 对照实际 Job request/lease/Task 逐项验证身份、输入摘要和 observation | 多字段签名错绑拒绝 |
|
||||
| 取消后恢复可将任务重新完成或置为可重试 | Task 权威在恢复事务内保留终态;heartbeat 也拒绝终态和到期 lease | 已取消 expired claim 不复活 |
|
||||
| 跨租户安装的 Owner key 自动获得全租户签发权 | 显式 owner_key_scopes,并在每次持久读取验证 | 跨 tenant/subject、重启收窄 trust 均拒绝 |
|
||||
| 已完成 receipt 的密钥轮换被误判成 effect 未发生 | receipt 带 key_id,显式旧公钥信任;无法验签进入未知而非重做 | 旧证据仅在明确受信时可用 |
|
||||
| 排队请求可越过客户端 deadline 开始执行 | 有期限的执行锁及 receiver deadline、claim/fence 二次核验 | 过期排队请求不启动 host |
|
||||
| 子进程输出在读完后才限额 | 增量有界读取,超限立即终止进程组 | 输出洪泛与 binary 被替换时拒绝 |
|
||||
| 生产 snapshot 固定 ACTIVE 和旧版本 | 验证动态 observation/target/provenance;缺失或过期为 UNKNOWN | 新鲜、错绑、缺失、过期观测 |
|
||||
| staging 消费另一服务临时 credential 路径 | 每消费者直接投影同名 encrypted credential,使用自身 `%d` | unit 投影身份与独立路径断言 |
|
||||
| DeploymentTarget 按 subject 分裂、缺组件维度 | tenant/component/environment 资源主键、统一 CAS 与独立授权 | 两组件目标、同组件多操作者和拒权 |
|
||||
| Release 把计划 Job ID 当事实,未知时可退回 execute | adapter prepare 后查询真实 Job;UNKNOWN 只走 reconcile | 虚假 Job、无 reconcile、错组件验收拒绝 |
|
||||
|
||||
## 尚未接入现场的部分
|
||||
|
||||
正式 Task API adapter、完整 Doctor 修复/升级、整条真实发布 recipe、独立业务 acceptance executor、目标级模型/费用计量、artifact/cache/工作目录的全部生产路径、旧 writer 排空及整机一致备份恢复,都需要具体现场绑定或剩余适配代码。对应 M0–M5 runbook 与 Release 报告已列出输入、职责和门禁。本包不会通过扩大角色权限、伪造远端回执或启用 legacy global write 来掩盖缺口。
|
||||
|
||||
新 Lineage/Release 候选数据库带 schema version;未知或早期无版本候选库明确拒绝。包从新隔离目录启动,不静默修改线上数据库。历史 epoch 签名格式和受保护分支政策保留原语义。
|
||||
|
||||
实际统一验收的命令、版本、源码逐文件 SHA、JUnit 和退出码在完整包 `acceptance/`;各模块报告中的早期数量只用于记录开发过程,以最后统一结果为准。
|
||||
@@ -0,0 +1,666 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.authorization import (
|
||||
AuthorizationError,
|
||||
AuthorizationService,
|
||||
sign_owner_delegation_grant,
|
||||
)
|
||||
from aios_baseline.operations import OperationDefinition
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self, value: int = 2_000_000_000) -> None:
|
||||
self.value = value
|
||||
|
||||
def __call__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
def grant(
|
||||
owner_key: Ed25519PrivateKey,
|
||||
clock: Clock,
|
||||
*,
|
||||
grant_id: str = "grant-0001",
|
||||
generation: int = 1,
|
||||
tenant_id: str = "tenant-a",
|
||||
subject_id: str = "owner-a",
|
||||
task_ids: tuple[str, ...] = ("task-a",),
|
||||
operations: tuple[str, ...] = ("deploy",),
|
||||
expires_at: int | None = None,
|
||||
max_effects: int | None = None,
|
||||
destructive_ceiling: str = "NONE",
|
||||
) -> dict:
|
||||
return sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
grant_id=grant_id,
|
||||
generation=generation,
|
||||
tenant_id=tenant_id,
|
||||
subject_id=subject_id,
|
||||
mission_ids=("mission-a",),
|
||||
task_ids=task_ids,
|
||||
operations=operations,
|
||||
component_ids=("component-a",),
|
||||
environments=("staging",),
|
||||
issued_at=clock(),
|
||||
validity_mode="EXPIRES_AT" if expires_at is not None else "UNTIL_REVOKED",
|
||||
expires_at=expires_at,
|
||||
max_effects=max_effects,
|
||||
destructive_ceiling=destructive_ceiling,
|
||||
)
|
||||
|
||||
|
||||
def service(tmp_path, owner_key: Ed25519PrivateKey, machine_key: Ed25519PrivateKey, clock: Clock, **kwargs):
|
||||
return AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
SCOPE = {
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "owner-a",
|
||||
"mission_id": "mission-a",
|
||||
"task_id": "task-a",
|
||||
"operation": "deploy",
|
||||
"component_id": "component-a",
|
||||
"environment": "staging",
|
||||
}
|
||||
|
||||
|
||||
def test_signed_chain_authorizes_exact_narrowed_scope(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
|
||||
registered = auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert registered == signed_grant
|
||||
assert principal["schema"] == "aios.task-principal.v1"
|
||||
assert principal["operational_epoch"]["schema"] == "aios.operational-epoch.v1"
|
||||
assert principal["payload"]["root_grant_digest"] == auth.grant_digest(signed_grant)
|
||||
assert principal["payload"]["root_grant_generation"] == 1
|
||||
assert isinstance(principal["payload"]["credential_id"], str)
|
||||
assert isinstance(principal["payload"]["nonce"], str)
|
||||
assert auth.authorize(principal, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "field", "replacement"),
|
||||
[
|
||||
("grant", "tenant_id", "tenant-b"),
|
||||
("epoch", "task_id", "task-b"),
|
||||
("principal", "operation", "delete"),
|
||||
],
|
||||
)
|
||||
def test_any_signature_tampering_is_rejected(tmp_path, target, field, replacement):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
|
||||
if target == "grant":
|
||||
tampered = copy.deepcopy(signed_grant)
|
||||
tampered["payload"][field] = replacement
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(tampered)
|
||||
return
|
||||
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
tampered = copy.deepcopy(principal)
|
||||
if target == "epoch":
|
||||
tampered["operational_epoch"]["payload"][field] = replacement
|
||||
else:
|
||||
tampered["payload"][field] = replacement
|
||||
with pytest.raises(AuthorizationError, match="signature|parent"):
|
||||
auth.authorize(tampered, **SCOPE)
|
||||
|
||||
|
||||
def test_unknown_owner_key_and_unsigned_grant_are_rejected(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
unknown = grant(owner_key, clock)
|
||||
unknown["signing"]["key_id"] = "attacker-key"
|
||||
|
||||
with pytest.raises(AuthorizationError, match="trusted owner key"):
|
||||
auth.register_grant(unknown)
|
||||
with pytest.raises(AuthorizationError, match="fields"):
|
||||
auth.register_grant(grant(owner_key, clock)["payload"])
|
||||
|
||||
|
||||
def test_fake_mapping_parent_and_parent_digest_substitution_are_rejected(tmp_path):
|
||||
class FakeParent(dict):
|
||||
pass
|
||||
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
fake = copy.deepcopy(principal)
|
||||
fake["operational_epoch"] = FakeParent(fake["operational_epoch"])
|
||||
with pytest.raises(AuthorizationError, match="plain object"):
|
||||
auth.authorize(fake, **SCOPE)
|
||||
|
||||
other = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
substituted = copy.deepcopy(principal)
|
||||
substituted["operational_epoch"] = other["operational_epoch"]
|
||||
with pytest.raises(AuthorizationError, match="parent|signature"):
|
||||
auth.authorize(substituted, **SCOPE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "wrong"),
|
||||
[
|
||||
("tenant_id", "tenant-b"),
|
||||
("subject_id", "owner-b"),
|
||||
("mission_id", "mission-b"),
|
||||
("task_id", "task-b"),
|
||||
("operation", "delete"),
|
||||
("component_id", "component-b"),
|
||||
("environment", "production"),
|
||||
],
|
||||
)
|
||||
def test_authorize_denies_cross_scope_use(tmp_path, field, wrong):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
attempted = dict(SCOPE)
|
||||
attempted[field] = wrong
|
||||
|
||||
with pytest.raises(AuthorizationError, match="scope"):
|
||||
auth.authorize(principal, **attempted)
|
||||
|
||||
|
||||
def test_principal_issuance_must_narrow_root_scope(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, task_ids=("task-a", "task-b")))
|
||||
|
||||
denied = dict(SCOPE)
|
||||
denied["task_id"] = "task-c"
|
||||
with pytest.raises(AuthorizationError, match="grant scope"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=30, **denied)
|
||||
|
||||
narrowed = dict(SCOPE)
|
||||
narrowed["task_id"] = "task-b"
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **narrowed)
|
||||
assert auth.authorize(principal, **narrowed)["payload"]["grant_id"] == "grant-0001"
|
||||
|
||||
|
||||
def test_revocation_is_durable_and_checked_during_proof_lifetime(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
trusted_context = object()
|
||||
verifier = lambda value: value is trusted_context
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=verifier,
|
||||
)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=60, **SCOPE)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="trusted control context"):
|
||||
auth.revoke_grant("grant-0001", control_context=object())
|
||||
assert auth.revoke_grant("grant-0001", control_context=trusted_context) == 2
|
||||
with pytest.raises(AuthorizationError, match="generation|revoked"):
|
||||
auth.authorize(principal, **SCOPE)
|
||||
|
||||
restarted = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=verifier,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="generation|revoked"):
|
||||
restarted.authorize(principal, **SCOPE)
|
||||
|
||||
|
||||
def test_expired_proof_refreshes_without_owner_reapproval_and_survives_restart(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
auth.register_grant(signed_grant)
|
||||
expired = auth.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
clock.value += 5
|
||||
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.authorize(expired, **SCOPE)
|
||||
|
||||
restarted = service(tmp_path, owner_key, machine_key, clock)
|
||||
refreshed = restarted.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
assert refreshed != expired
|
||||
assert restarted.authorize(refreshed, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
def test_explicit_grant_expiry_caps_child_lease_and_cannot_be_refreshed(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, expires_at=clock() + 7))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert principal["payload"]["expires_at"] == clock() + 7
|
||||
assert principal["operational_epoch"]["payload"]["expires_at"] == clock() + 7
|
||||
clock.value += 7
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.authorize(principal, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
|
||||
|
||||
def test_higher_owner_signed_generation_reactivates_but_stale_proofs_stay_invalid(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
control = object()
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=lambda value: value is control,
|
||||
)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
old = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
auth.revoke_grant("grant-0001", control_context=control)
|
||||
|
||||
new_grant = grant(owner_key, clock, generation=3)
|
||||
auth.register_grant(new_grant)
|
||||
fresh = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="generation"):
|
||||
auth.authorize(old, **SCOPE)
|
||||
assert auth.authorize(fresh, **SCOPE) == new_grant
|
||||
|
||||
|
||||
def test_strict_schema_types_and_signature_encoding(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["generation"] = True
|
||||
with pytest.raises(AuthorizationError, match="generation"):
|
||||
auth.register_grant(malformed)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["signing"]["signature"] = base64.b64encode(b"short").decode("ascii")
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(malformed)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="ttl_seconds"):
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
auth.issue_principal("grant-0001", ttl_seconds=True, **SCOPE)
|
||||
|
||||
|
||||
def test_grant_helper_requires_an_explicit_validity_mode():
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
arguments = {
|
||||
"grant_id": "grant-0001",
|
||||
"generation": 1,
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "owner-a",
|
||||
"mission_ids": ("mission-a",),
|
||||
"task_ids": ("task-a",),
|
||||
"operations": ("deploy",),
|
||||
"component_ids": ("component-a",),
|
||||
"environments": ("staging",),
|
||||
"issued_at": clock(),
|
||||
}
|
||||
with pytest.raises(TypeError):
|
||||
sign_owner_delegation_grant(owner_key, "owner-key-1", **arguments)
|
||||
with pytest.raises(AuthorizationError, match="expires_at"):
|
||||
sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
validity_mode="EXPIRES_AT",
|
||||
expires_at=None,
|
||||
**arguments,
|
||||
)
|
||||
|
||||
string_scope = dict(arguments)
|
||||
string_scope["mission_ids"] = "abc"
|
||||
with pytest.raises(AuthorizationError, match="mission_ids.*list or tuple"):
|
||||
sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
validity_mode="UNTIL_REVOKED",
|
||||
**string_scope,
|
||||
)
|
||||
|
||||
|
||||
def test_effect_budget_reserves_each_stable_effect_id_once_and_persists(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, max_effects=1))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is False
|
||||
|
||||
restarted = service(tmp_path, owner_key, machine_key, clock)
|
||||
with pytest.raises(AuthorizationError, match="effect budget"):
|
||||
restarted.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE)
|
||||
|
||||
|
||||
def test_unbounded_grant_leaves_mission_budget_to_caller(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
assert auth.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE) is True
|
||||
|
||||
|
||||
def test_old_machine_proof_can_be_verified_during_machine_key_rotation(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
old_machine_key = Ed25519PrivateKey.generate()
|
||||
new_machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
original = service(tmp_path, owner_key, old_machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
original.register_grant(signed_grant)
|
||||
old_principal = original.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
rotated = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
new_machine_key,
|
||||
"machine-key-2",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
trusted_machine_public_keys={
|
||||
"machine-key-1": old_machine_key.public_key(),
|
||||
"machine-key-2": new_machine_key.public_key(),
|
||||
},
|
||||
)
|
||||
assert rotated.authorize(old_principal, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
def test_revocation_generation_cannot_reset_budget_or_revive_immutable_grant(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
control = object()
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=lambda value: value is control,
|
||||
)
|
||||
signed_grant = grant(owner_key, clock, max_effects=1)
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
|
||||
assert auth.revoke_grant("grant-0001", control_context=control) == 2
|
||||
with pytest.raises(AuthorizationError, match="stale"):
|
||||
auth.register_grant(signed_grant)
|
||||
with pytest.raises(AuthorizationError, match="revoked|generation"):
|
||||
auth.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="revoked"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
|
||||
def test_rejects_nan_empty_identity_and_untrusted_retired_machine_key(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
old_machine_key = Ed25519PrivateKey.generate()
|
||||
new_machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
original = service(tmp_path, owner_key, old_machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
original.register_grant(signed_grant)
|
||||
old_principal = original.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["issued_at"] = float("nan")
|
||||
with pytest.raises(AuthorizationError, match="issued_at"):
|
||||
original.register_grant(malformed)
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["tenant_id"] = ""
|
||||
with pytest.raises(AuthorizationError, match="tenant_id"):
|
||||
original.register_grant(malformed)
|
||||
|
||||
rotated_without_old_trust = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
new_machine_key,
|
||||
"machine-key-2",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="trusted machine key"):
|
||||
rotated_without_old_trust.authorize(old_principal, **SCOPE)
|
||||
|
||||
|
||||
def operation_definition(effect_class: str, *, reversible: bool = False) -> OperationDefinition:
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "deploy",
|
||||
"version": 1,
|
||||
"effect_class": effect_class,
|
||||
"handler_ref": "trusted.deploy",
|
||||
"input_schema": "aios.deploy.input.v1",
|
||||
"required_permissions": ["deploy"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "EXACT", "required": True},
|
||||
"retry": {"mode": "AFTER_READBACK", "max_attempts": 1},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "OPERATION", "operation_id": "deploy.rollback"}
|
||||
if reversible
|
||||
else {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_signed_destructive_ceiling_defaults_none_and_denies_mutating_definition(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
assert signed_grant["payload"]["destructive_ceiling"] == "NONE"
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.assert_effect_bounds(
|
||||
principal, operation_definition("READ_ONLY"), **SCOPE
|
||||
) == signed_grant
|
||||
with pytest.raises(AuthorizationError, match="destructive ceiling"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("IDEMPOTENT"), **SCOPE
|
||||
)
|
||||
|
||||
|
||||
def test_reversible_ceiling_requires_rollback_and_irreversible_ceiling_is_explicit(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, destructive_ceiling="REVERSIBLE"))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="rollback"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("RECONCILABLE", reversible=False), **SCOPE
|
||||
)
|
||||
assert auth.assert_effect_bounds(
|
||||
principal, operation_definition("RECONCILABLE", reversible=True), **SCOPE
|
||||
)["payload"]["destructive_ceiling"] == "REVERSIBLE"
|
||||
with pytest.raises(AuthorizationError, match="irreversible"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("IRREVERSIBLE", reversible=False), **SCOPE
|
||||
)
|
||||
|
||||
owner_key_2 = Ed25519PrivateKey.generate()
|
||||
auth_2 = AuthorizationService(
|
||||
tmp_path / "authorization-2.sqlite3",
|
||||
{"owner-key-1": owner_key_2.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
auth_2.register_grant(grant(owner_key_2, clock, destructive_ceiling="IRREVERSIBLE"))
|
||||
principal_2 = auth_2.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
assert auth_2.assert_effect_bounds(
|
||||
principal_2, operation_definition("IRREVERSIBLE"), **SCOPE
|
||||
)["payload"]["destructive_ceiling"] == "IRREVERSIBLE"
|
||||
|
||||
|
||||
def test_destructive_ceiling_is_signed_and_strictly_enumerated(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
tampered = grant(owner_key, clock)
|
||||
tampered["payload"]["destructive_ceiling"] = "IRREVERSIBLE"
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(tampered)
|
||||
with pytest.raises(AuthorizationError, match="destructive_ceiling"):
|
||||
grant(owner_key, clock, destructive_ceiling="UNBOUNDED")
|
||||
|
||||
|
||||
def test_owner_key_trust_is_explicitly_bound_to_tenant_and_subject(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
|
||||
with pytest.raises(TypeError, match="owner_key_scopes"):
|
||||
AuthorizationService(
|
||||
tmp_path / "missing-scopes.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
auth.register_grant(grant(owner_key, clock, tenant_id="tenant-b"))
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
auth.register_grant(grant(owner_key, clock, subject_id="owner-b"))
|
||||
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
reopened_with_narrower_trust = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-b"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
reopened_with_narrower_trust.authorize(principal, **SCOPE)
|
||||
|
||||
globally_scoped = AuthorizationService(
|
||||
tmp_path / "explicit-global.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {"tenant_ids": ["*"], "subject_ids": ["*"]}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
assert globally_scoped.register_grant(
|
||||
grant(owner_key, clock, tenant_id="tenant-b", subject_id="owner-b")
|
||||
)["payload"]["tenant_id"] == "tenant-b"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scopes",
|
||||
[
|
||||
{},
|
||||
{"owner-key-1": {"tenant_ids": ["tenant-a"]}},
|
||||
{
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["*", "tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": [],
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_owner_key_scope_configuration_is_exact_and_fail_closed(tmp_path, scopes):
|
||||
with pytest.raises(AuthorizationError, match="owner_key_scopes"):
|
||||
AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": Ed25519PrivateKey.generate().public_key()},
|
||||
Ed25519PrivateKey.generate(),
|
||||
"machine-key-1",
|
||||
owner_key_scopes=scopes,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from task_plane.api import TaskPlaneService
|
||||
from task_plane.errors import ConflictError, LockedError
|
||||
from task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
IDENTITY = "spiffe://aios/agent/heartbeat"
|
||||
WORKER_ID = "heartbeat-worker"
|
||||
|
||||
|
||||
def claimed_task():
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(
|
||||
store,
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
identity_capabilities={"*": set(ALL_ACTION_CAPABILITIES)},
|
||||
),
|
||||
)
|
||||
service.register_worker(
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"worker_type": "test",
|
||||
"identity": IDENTITY,
|
||||
"capabilities": ["code.read"],
|
||||
"trust_zone": "test",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
task = service.create_task(
|
||||
{"task_id": "task-heartbeat", "required_capabilities": ["code.read"]}
|
||||
)
|
||||
claim = service.claim(
|
||||
task["task_id"],
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"actor_identity": IDENTITY,
|
||||
"lease_seconds": 30,
|
||||
},
|
||||
)
|
||||
return store, task, claim
|
||||
|
||||
|
||||
def test_cancelled_task_cannot_renew_a_still_active_claim():
|
||||
store, task, claim = claimed_task()
|
||||
original_lease = store.get_claim(claim["claim_id"]).lease_until
|
||||
current = store.get_task(task["task_id"])
|
||||
store.cancel_task_with_event(task["task_id"], current.revision, "owner cancelled")
|
||||
|
||||
with pytest.raises(ConflictError, match="task is terminal"):
|
||||
store.heartbeat_claim(task["task_id"], claim["claim_id"], 60)
|
||||
|
||||
assert store.get_claim(claim["claim_id"]).lease_until == original_lease
|
||||
assert store.get_task(task["task_id"]).status == "CANCELLED"
|
||||
|
||||
|
||||
def test_lease_at_current_time_is_expired_and_cannot_be_renewed():
|
||||
store, task, claim = claimed_task()
|
||||
boundary = store.get_claim(claim["claim_id"]).lease_until
|
||||
|
||||
with mock.patch("task_plane.store._now", return_value=boundary):
|
||||
with pytest.raises(LockedError, match="lease expired"):
|
||||
store.heartbeat_claim(task["task_id"], claim["claim_id"], 30)
|
||||
|
||||
assert store.get_claim(claim["claim_id"]).lease_until == boundary
|
||||
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from aios_baseline.cli import initialize_validation, load_configuration, build_service
|
||||
|
||||
|
||||
def test_generated_validation_configuration_is_usable_and_not_production(tmp_path):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
config,tokens=load_configuration(root/'server.json')
|
||||
assert config['profile']=='isolated-validation'
|
||||
assert len(tokens)==2
|
||||
assert not (root/'state').exists() # preflight is read only
|
||||
service=build_service(config)
|
||||
assert service.jobs.store is not None
|
||||
assert (root/'owner-private.pem').stat().st_mode & 0o077 == 0
|
||||
raw=json.loads((root/'server.json').read_text())
|
||||
raw['profile']='production'
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='PROFILE'): load_configuration(root/'server.json')
|
||||
|
||||
|
||||
def test_config_cannot_register_duplicate_tokens_or_expand_secret_variables(tmp_path,monkeypatch):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
raw=json.loads((root/'server.json').read_text())
|
||||
raw['identities'][1]['token_file']=raw['identities'][0]['token_file']
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='TOKEN'): load_configuration(root/'server.json')
|
||||
raw['identities'][1]['token_file']='$HOME/token'
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='PATH'): load_configuration(root/'server.json')
|
||||
|
||||
|
||||
def test_config_rejects_symlink_public_config_and_untrusted_state_marker(tmp_path):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
alias=tmp_path/'alias.json';alias.symlink_to(root/'server.json')
|
||||
with pytest.raises(OSError): load_configuration(alias)
|
||||
config,_=load_configuration(root/'server.json')
|
||||
state=root/'state';state.mkdir(mode=0o700)
|
||||
(state/'ISOLATED_VALIDATION_AUTHORITY').write_text('different-authority\n')
|
||||
with pytest.raises(ValueError,match='MARKER'): build_service(config)
|
||||
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_baseline.operations import (
|
||||
OperationDefinition,
|
||||
OperationRegistry,
|
||||
operation_timeout_action,
|
||||
verify_service_restart,
|
||||
)
|
||||
from aios_baseline.runtime_contract import evaluate_deployment, validate_contract
|
||||
from aios_chatgpt_bridge_v2_server import (
|
||||
GovernedRuntime,
|
||||
RuntimeConfig,
|
||||
load_production_observation,
|
||||
)
|
||||
from reconciliation_controller import ReconciliationController
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, tzinfo=timezone.utc)
|
||||
DIGEST = "sha256:" + "a" * 64
|
||||
SOURCE_DIGEST = "sha256:" + "b" * 64
|
||||
BUILDER_DIGEST = "sha256:" + "c" * 64
|
||||
|
||||
|
||||
def runtime_contract() -> dict:
|
||||
return {
|
||||
"contract_schema": "aios.runtime-contract.v1",
|
||||
"contract_revision": 3,
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"tenant_scope": "tenant-a",
|
||||
"lifecycle": "active",
|
||||
"entrypoints": {
|
||||
"task_api": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://task-plane/task-api",
|
||||
}
|
||||
},
|
||||
"bindings": {
|
||||
"service": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://task-plane/worker",
|
||||
}
|
||||
},
|
||||
"resource_refs": {
|
||||
"state_root": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"path_ref": "runtime/state/task-plane",
|
||||
}
|
||||
},
|
||||
"credential_refs": {
|
||||
"task_api": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"credential_ref": "systemd-credential:task-plane-api",
|
||||
}
|
||||
},
|
||||
"operations": ["host.status.v1", "task.claim.v1"],
|
||||
}
|
||||
|
||||
|
||||
def deployment_target() -> dict:
|
||||
return {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"release_id": "release-17",
|
||||
"deployment_id": "deployment-22",
|
||||
"artifact_digest": DIGEST,
|
||||
"revision": 4,
|
||||
}
|
||||
|
||||
|
||||
def observation() -> dict:
|
||||
return {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"deployment_id": "deployment-22",
|
||||
"observed_at": "2026-09-07T11:59:30Z",
|
||||
"max_age_seconds": 60,
|
||||
"source": "systemd-readback.v1",
|
||||
"actual_artifact_digest": DIGEST,
|
||||
"revision": 4,
|
||||
"healthy": True,
|
||||
}
|
||||
|
||||
|
||||
def provenance() -> dict:
|
||||
return {
|
||||
"artifact_digest": DIGEST,
|
||||
"source_digest": SOURCE_DIGEST,
|
||||
"builder_digest": BUILDER_DIGEST,
|
||||
}
|
||||
|
||||
|
||||
def operation(**updates: object) -> dict:
|
||||
data = {
|
||||
"operation_id": "service.restart.v1",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": "host.service_restart",
|
||||
"input_schema": "aios.operation.service-restart.input.v1",
|
||||
"required_permissions": ["host:service:restart"],
|
||||
"timeout_seconds": 30,
|
||||
"readback": {"mode": "EXACT", "required": True},
|
||||
"retry": {"mode": "AFTER_READBACK", "max_attempts": 2},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "OPERATION", "operation_id": "service.restore.v1"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
data.update(updates)
|
||||
return data
|
||||
|
||||
|
||||
def test_structural_contract_is_normalized_without_dynamic_target() -> None:
|
||||
result = validate_contract(runtime_contract())
|
||||
assert result["component_id"] == "task-plane"
|
||||
assert result["contract_revision"] == 3
|
||||
assert result["operations"] == ["host.status.v1", "task.claim.v1"]
|
||||
|
||||
|
||||
def test_structural_contract_allows_empty_optional_reference_groups() -> None:
|
||||
document = runtime_contract()
|
||||
document["bindings"] = {}
|
||||
document["resource_refs"] = {}
|
||||
document["credential_refs"] = {}
|
||||
assert validate_contract(document)["credential_refs"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
["desired", "desired_commit", "desired_release", "desired_target", "desired_targets"],
|
||||
)
|
||||
def test_structural_contract_rejects_dynamic_desired_fields_at_any_depth(field: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["bindings"]["service"][field] = "must-live-in-release-target"
|
||||
with pytest.raises(ValueError, match="dynamic_field_forbidden"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_resolver_binding_must_match_component_and_environment() -> None:
|
||||
document = runtime_contract()
|
||||
document["resource_refs"]["state_root"]["environment"] = "production"
|
||||
with pytest.raises(ValueError, match="binding_environment_mismatch"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_rejects_cross_tenant_resolver_binding() -> None:
|
||||
document = runtime_contract()
|
||||
document["entrypoints"]["task_api"]["tenant_scope"] = "tenant-b"
|
||||
with pytest.raises(ValueError, match="binding_tenant_mismatch"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path_ref", ["/etc/aios/state", "../other/state", "runtime/../secret", "bad\x00ref"])
|
||||
def test_contract_rejects_unsafe_path_references(path_ref: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["resource_refs"]["state_root"]["path_ref"] = path_ref
|
||||
with pytest.raises(ValueError, match="unsafe_path_ref"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credential_ref",
|
||||
[
|
||||
"/run/credentials/other-service/admin-token",
|
||||
"plain-secret-bytes",
|
||||
],
|
||||
)
|
||||
def test_contract_rejects_cross_service_or_inline_credentials(credential_ref: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["credential_refs"]["task_api"]["credential_ref"] = credential_ref
|
||||
with pytest.raises(ValueError, match="credential_ref"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_rejects_secret_value_fields_even_when_reference_is_present() -> None:
|
||||
document = runtime_contract()
|
||||
document["credential_refs"]["task_api"]["token"] = "secret-bytes"
|
||||
with pytest.raises(ValueError, match="secret_material_forbidden"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "observed", "proof", "reason"),
|
||||
[
|
||||
(None, observation(), provenance(), "target_missing"),
|
||||
(deployment_target(), None, provenance(), "observation_missing"),
|
||||
(deployment_target(), observation(), None, "provenance_missing"),
|
||||
],
|
||||
)
|
||||
def test_missing_evidence_is_unknown(target, observed, proof, reason: str) -> None:
|
||||
result = evaluate_deployment(runtime_contract(), target, observed, proof, NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert result["state"] == "UNKNOWN"
|
||||
assert reason in result["reasons"]
|
||||
|
||||
|
||||
def test_stale_observation_is_unknown_with_injected_clock() -> None:
|
||||
observed = observation()
|
||||
observed["observed_at"] = "2026-09-07T11:58:59Z"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "observation_stale" in result["reasons"]
|
||||
|
||||
|
||||
def test_nullable_health_and_wrong_types_are_unknown() -> None:
|
||||
observed = observation()
|
||||
observed["healthy"] = None
|
||||
assert evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)["status"] == "UNKNOWN"
|
||||
observed["healthy"] = "true"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "observation_healthy_invalid" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_incompatible_target_is_drift() -> None:
|
||||
target = deployment_target()
|
||||
target["environment"] = "production"
|
||||
observed = observation()
|
||||
observed["environment"] = "production"
|
||||
result = evaluate_deployment(runtime_contract(), target, observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "target_environment_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_cross_tenant_target_is_drift() -> None:
|
||||
target = deployment_target()
|
||||
target["tenant_scope"] = "tenant-b"
|
||||
result = evaluate_deployment(runtime_contract(), target, observation(), provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "target_tenant_scope_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_cross_tenant_observation_is_drift_without_target_tenant_field() -> None:
|
||||
observed = observation()
|
||||
observed["tenant_scope"] = "tenant-b"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "observation_tenant_scope_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_artifact_mismatch_is_drift_only_when_evidence_is_complete() -> None:
|
||||
observed = observation()
|
||||
observed["actual_artifact_digest"] = "sha256:" + "d" * 64
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "artifact_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_matching_bindings_are_verified_and_converged_despite_unrelated_main_head() -> None:
|
||||
observed = observation()
|
||||
observed["main_head"] = "different-from-release-source"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "VERIFIED"
|
||||
assert result["state"] == "CONVERGED"
|
||||
assert result["verification"] == "VERIFIED"
|
||||
assert result["converged"] is True
|
||||
|
||||
|
||||
def test_operation_definition_keeps_effect_and_safety_policies_orthogonal() -> None:
|
||||
definition = OperationDefinition.from_dict(operation())
|
||||
assert definition.effect_class == "RECONCILABLE"
|
||||
assert definition.retry["mode"] == "AFTER_READBACK"
|
||||
assert definition.rollback["mode"] == "OPERATION"
|
||||
assert definition.owner_boundary["mode"] == "WITHIN_GRANT"
|
||||
|
||||
|
||||
def test_read_only_definition_accepts_permissions_alias_and_registry_lists_definitions() -> None:
|
||||
data = {
|
||||
"operation_id": "service.status.v1",
|
||||
"version": 1,
|
||||
"effect_class": "READ_ONLY",
|
||||
"handler_ref": "host.service_status",
|
||||
"input_schema": "aios.operation.service-status.input.v1",
|
||||
"permissions": ["service-status"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "NONE", "required": False},
|
||||
"retry": {"mode": "SAFE", "max_attempts": 2},
|
||||
"reconcile": {"mode": "NONE"},
|
||||
"rollback": {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
definition = OperationDefinition.from_dict(data)
|
||||
registry = OperationRegistry([definition])
|
||||
assert definition.required_permissions == ("service-status",)
|
||||
assert definition.permissions == ("service-status",)
|
||||
assert registry.definitions == (definition,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effect_class", ["READ_ONLY", "IDEMPOTENT", "CONDITIONAL", "RECONCILABLE", "IRREVERSIBLE"])
|
||||
def test_registry_accepts_all_primary_effect_classes(effect_class: str) -> None:
|
||||
data = operation(
|
||||
operation_id=f"effect.{effect_class.lower()}.v1",
|
||||
effect_class=effect_class,
|
||||
retry={"mode": "NEVER", "max_attempts": 1},
|
||||
rollback={"mode": "NONE"},
|
||||
)
|
||||
if effect_class == "IRREVERSIBLE":
|
||||
data["owner_boundary"] = {"mode": "EXPLICIT_AUTHORIZATION"}
|
||||
definition = OperationDefinition.from_dict(data)
|
||||
registry = OperationRegistry([definition])
|
||||
assert registry.get(data["operation_id"]) is definition
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forbidden", ["command", "shell", "argv", "executable", "script"])
|
||||
def test_registry_definitions_cannot_embed_arbitrary_execution(forbidden: str) -> None:
|
||||
data = operation()
|
||||
data[forbidden] = "sh -c whoami"
|
||||
with pytest.raises(ValueError, match="arbitrary_execution_forbidden"):
|
||||
OperationDefinition.from_dict(data)
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_operation_ids() -> None:
|
||||
definition = OperationDefinition.from_dict(operation())
|
||||
with pytest.raises(ValueError, match="duplicate_operation_id"):
|
||||
OperationRegistry([definition, definition])
|
||||
|
||||
|
||||
def test_timeout_never_blindly_retries_irreversible_or_ambiguous_effects() -> None:
|
||||
irreversible = OperationDefinition.from_dict(
|
||||
operation(
|
||||
effect_class="IRREVERSIBLE",
|
||||
retry={"mode": "SAFE", "max_attempts": 4},
|
||||
rollback={"mode": "NONE"},
|
||||
owner_boundary={"mode": "EXPLICIT_AUTHORIZATION"},
|
||||
)
|
||||
)
|
||||
assert operation_timeout_action(irreversible, outcome="NOT_OBSERVED") == "STOP"
|
||||
assert operation_timeout_action(OperationDefinition.from_dict(operation()), outcome="AMBIGUOUS") == "RECONCILE"
|
||||
|
||||
|
||||
def test_service_active_state_alone_cannot_prove_a_restart() -> None:
|
||||
result = verify_service_restart(
|
||||
{"active": True, "instance_id": "before", "started_at": "2026-09-07T11:00:00Z"},
|
||||
{"active": True},
|
||||
)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "restart_identity_missing" in result["reasons"]
|
||||
|
||||
|
||||
def test_service_restart_is_verified_by_changed_instance_and_fresh_start_time() -> None:
|
||||
result = verify_service_restart(
|
||||
{"active": True, "instance_id": "before", "started_at": "2026-09-07T11:00:00Z"},
|
||||
{"active": True, "instance_id": "after", "started_at": "2026-09-07T11:59:00Z"},
|
||||
)
|
||||
assert result == {"status": "VERIFIED", "reasons": []}
|
||||
|
||||
|
||||
def production_observation_bundle() -> dict:
|
||||
contract = runtime_contract()
|
||||
contract["environment"] = "production"
|
||||
for group in ("entrypoints", "bindings", "resource_refs", "credential_refs"):
|
||||
for binding in contract[group].values():
|
||||
binding["environment"] = "production"
|
||||
target = deployment_target()
|
||||
target.update(
|
||||
environment="production",
|
||||
release_id="V2R1C9",
|
||||
target_oid="d" * 40,
|
||||
)
|
||||
observed = observation()
|
||||
observed.update(environment="production", observed_at="2026-09-07T11:59:45Z")
|
||||
return {
|
||||
"contract": contract,
|
||||
"target": target,
|
||||
"observation": observed,
|
||||
"provenance": provenance(),
|
||||
}
|
||||
|
||||
|
||||
def test_missing_production_observation_is_unknown(tmp_path) -> None:
|
||||
assert load_production_observation(tmp_path / "missing.json", NOW) == {
|
||||
"baseline_version": None,
|
||||
"target_oid": None,
|
||||
"deployment_state": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def test_stale_production_observation_cannot_claim_active(tmp_path) -> None:
|
||||
bundle = production_observation_bundle()
|
||||
bundle["observation"]["observed_at"] = "2026-09-07T11:50:00Z"
|
||||
path = tmp_path / "production-observation.json"
|
||||
path.write_text(__import__("json").dumps(bundle), encoding="utf-8")
|
||||
assert load_production_observation(path, NOW)["deployment_state"] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_verified_production_observation_projects_compatible_snapshot_fields(tmp_path) -> None:
|
||||
path = tmp_path / "production-observation.json"
|
||||
path.write_text(__import__("json").dumps(production_observation_bundle()), encoding="utf-8")
|
||||
assert load_production_observation(path, NOW) == {
|
||||
"baseline_version": "V2R1C9",
|
||||
"target_oid": "d" * 40,
|
||||
"deployment_state": "ACTIVE",
|
||||
}
|
||||
|
||||
|
||||
def test_bridge_snapshot_defaults_production_to_unknown(tmp_path) -> None:
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.config = RuntimeConfig(
|
||||
target_oid="e" * 40,
|
||||
authoritative_git_dir=tmp_path / "repo.git",
|
||||
state_root=tmp_path,
|
||||
v2_job_root=tmp_path / "jobs",
|
||||
token_path=tmp_path / "token",
|
||||
ssh_key_path=tmp_path / "ssh",
|
||||
)
|
||||
runtime.authority = type("Authority", (), {"read_ref": lambda self, ref: "f" * 40})()
|
||||
runtime._snapshot_task = lambda: {}
|
||||
runtime.runtime_status = lambda: {
|
||||
"bridge_schema_version": "aios.chatgpt-gitops-bridge.v2",
|
||||
"control_plane_release_version": "V2R2C1",
|
||||
"runtime_release_commit": "e" * 40,
|
||||
"runtime_path": str(tmp_path),
|
||||
"artifact_sha256": None,
|
||||
"drift_status": "PASS",
|
||||
"governed_writes_enabled": False,
|
||||
}
|
||||
assert runtime.snapshot()["production"] == {
|
||||
"baseline_version": None,
|
||||
"target_oid": None,
|
||||
"deployment_state": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def test_staging_unit_uses_its_own_encrypted_credential_projection() -> None:
|
||||
unit = (
|
||||
__import__("pathlib").Path(__file__).resolve().parents[2]
|
||||
/ "deploy/systemd/aios-chatgpt-bridge-v2-staging.service"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "LoadCredentialEncrypted=gitea-governance-executor-token:/etc/credstore.encrypted/gitea-governance-executor-token" in unit
|
||||
assert "Environment=AIOS_GITEA_TOKEN_FILE=%d/gitea-governance-executor-token" in unit
|
||||
assert "/run/credentials/aios-governance-executor.service/" not in unit
|
||||
|
||||
|
||||
def test_empty_or_nullable_reconciliation_evidence_is_not_healthy() -> None:
|
||||
controller = ReconciliationController()
|
||||
empty = controller.reconcile({})
|
||||
assert empty.healthy is False
|
||||
assert set(empty.drift) == {label for _, _, label in controller.CHECKS}
|
||||
|
||||
nullable = controller.reconcile(
|
||||
{
|
||||
key: None
|
||||
for pair in controller.CHECKS
|
||||
for key in pair[:2]
|
||||
}
|
||||
)
|
||||
assert nullable.healthy is False
|
||||
|
||||
|
||||
def test_body_claiming_production_state_is_not_blanket_truth() -> None:
|
||||
report = ReconciliationController().reconcile({"body": {"production_state": "ACTIVE"}})
|
||||
assert report.healthy is False
|
||||
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
import aios_gitops_job_executor as executor_module
|
||||
from aios_gitops_job_controller import (
|
||||
GitOpsJobController,
|
||||
JobError,
|
||||
JobState,
|
||||
JobStore,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
|
||||
|
||||
OLD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
NEW = "3fbbc18fb2131bbbd9e511004a59ee95cf3e86b7"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = datetime(2026, 9, 8, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.value
|
||||
|
||||
|
||||
class Authority:
|
||||
def __init__(self) -> None:
|
||||
self.branch = OLD
|
||||
self.main = OLD
|
||||
self.mutations = 0
|
||||
|
||||
def read_ref(self, ref: str):
|
||||
return self.main if ref == "refs/heads/main" else self.branch
|
||||
|
||||
def ancestry_check(self, older: str, newer: str) -> bool:
|
||||
return older == newer or (older, newer) == (OLD, NEW)
|
||||
|
||||
def advance_task_branch_CAS(self, _branch: str, expected: str, new: str) -> str:
|
||||
assert self.branch == expected
|
||||
self.mutations += 1
|
||||
self.branch = new
|
||||
return new
|
||||
|
||||
def ff_CAS_main_after_verified_protected_merge(self, expected: str, new: str) -> str:
|
||||
assert self.main == expected
|
||||
self.mutations += 1
|
||||
self.main = new
|
||||
return new
|
||||
|
||||
def commit_file_in_isolated_worktree(self, **_kwargs) -> str:
|
||||
self.mutations += 1
|
||||
return NEW
|
||||
|
||||
|
||||
class Broker:
|
||||
def __init__(self) -> None:
|
||||
self.remote = OLD
|
||||
self.mutations = 0
|
||||
|
||||
def verify_identity(self):
|
||||
return {"login": "aios-autonomy-agent", "repository": executor_module.REPOSITORY}
|
||||
|
||||
def readback(self):
|
||||
return self.remote
|
||||
|
||||
def push_branch(self, new: str) -> str:
|
||||
self.mutations += 1
|
||||
self.remote = new
|
||||
return new
|
||||
|
||||
def create_pr(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"number": 1}
|
||||
|
||||
def status(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"status": "success"}
|
||||
|
||||
def merge(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"merged": True}
|
||||
|
||||
|
||||
def campaign_request(operation: str = "SYNC_TASK_BRANCH_TO_GITEA", **updates):
|
||||
request = {
|
||||
"campaign_id": executor_module.CAMPAIGN_ID,
|
||||
"repository": executor_module.REPOSITORY,
|
||||
"operation": operation,
|
||||
"branch": executor_module.BRANCH,
|
||||
"expected_branch_oid": OLD if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH" else NEW,
|
||||
"payload_digest": "0" * 64,
|
||||
"task_id": executor_module.TASK_ID,
|
||||
"new_branch_oid": NEW,
|
||||
"authorization_recovery_digest": executor_module.AUTHORIZATION_DIGEST,
|
||||
}
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
request["expected_main_oid"] = OLD
|
||||
request.update(updates)
|
||||
return request
|
||||
|
||||
|
||||
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 generic_request(**updates):
|
||||
request = {
|
||||
"schema": "aios.operation-job.v1",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "subject-a",
|
||||
"mission_id": "mission-a",
|
||||
"grant_id": "grant-a",
|
||||
"task_id": "task-a",
|
||||
"effect_id": "effect-a",
|
||||
"operation": "host.status.v1",
|
||||
"operation_version": "1",
|
||||
"resource_kind": "host",
|
||||
"resource_id": "tenant-a/staging/local-host",
|
||||
"input_digest": hashlib.sha256(b"{}").hexdigest(),
|
||||
}
|
||||
request.update(updates)
|
||||
return request
|
||||
|
||||
|
||||
def receipt_signer():
|
||||
private = Ed25519PrivateKey.generate()
|
||||
|
||||
def sign(payload):
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
return {
|
||||
"schema": "aios.operation-receipt.v1",
|
||||
"key_id": "test-machine",
|
||||
"payload": payload,
|
||||
"signature": base64.b64encode(private.sign(raw)).decode(),
|
||||
}
|
||||
|
||||
def verify(envelope):
|
||||
try:
|
||||
raw = json.dumps(envelope["payload"], sort_keys=True, separators=(",", ":")).encode()
|
||||
private.public_key().verify(base64.b64decode(envelope["signature"], validate=True), raw)
|
||||
return envelope.get("key_id") == "test-machine"
|
||||
except (InvalidSignature, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
return sign, verify
|
||||
|
||||
|
||||
def signed_generic_receipt(sign, request, job_id, lease):
|
||||
return sign({
|
||||
**{key: request[key] for key in (
|
||||
"tenant_id", "subject_id", "mission_id", "grant_id", "task_id",
|
||||
"effect_id", "operation", "operation_version", "resource_kind",
|
||||
"resource_id", "input_digest",
|
||||
)},
|
||||
"job_id": job_id,
|
||||
"claim_id": lease["lease_id"],
|
||||
"generation": int(lease["generation"]),
|
||||
"observation": {"active": True},
|
||||
"verified": True,
|
||||
})
|
||||
|
||||
|
||||
def make_executor(tmp_path: Path, monkeypatch, operation="SYNC_TASK_BRANCH_TO_GITEA"):
|
||||
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: recovery())
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
job = controller.submit(campaign_request(operation))
|
||||
authority, broker = Authority(), Broker()
|
||||
if operation != "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
authority.branch = NEW
|
||||
executor = executor_module.DurableJobExecutor(
|
||||
controller, authority, broker, tmp_path / "recovery.json", clock=clock
|
||||
)
|
||||
return clock, controller, job, authority, broker, executor
|
||||
|
||||
|
||||
def test_lease_requires_authorized_live_job_and_bounded_future_deadline(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
job = controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
kwargs = dict(
|
||||
lease_id="lease-1",
|
||||
lineage_anchor_oid=OLD,
|
||||
current_head_oid=OLD,
|
||||
generation=1,
|
||||
expected_revision=job.revision,
|
||||
)
|
||||
with pytest.raises(JobError, match="LEASE_JOB_STATE_INVALID"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(), **kwargs)
|
||||
authorized = controller.advance(job.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
kwargs["expected_revision"] = authorized.revision
|
||||
with pytest.raises(JobError, match="LEASE_EXPIRED"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value - timedelta(seconds=1)).isoformat(), **kwargs)
|
||||
with pytest.raises(JobError, match="LEASE_DURATION_EXCEEDED"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value + timedelta(hours=1)).isoformat(), **kwargs)
|
||||
|
||||
terminal_controller = GitOpsJobController(JobStore(tmp_path / "terminal"), clock=clock)
|
||||
terminal = terminal_controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
terminal = terminal_controller.advance(
|
||||
terminal.job_id,
|
||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
side_effect_state="MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
)
|
||||
with pytest.raises(JobError, match="LEASE_JOB_STATE_INVALID"):
|
||||
terminal_controller.lease_job(
|
||||
terminal.job_id,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
**{**kwargs, "expected_revision": terminal.revision},
|
||||
)
|
||||
|
||||
|
||||
def test_resource_job_profile_and_lease_are_typed_and_fenced(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
request = {
|
||||
"schema": "aios.operation-job.v1",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "subject-a",
|
||||
"mission_id": "mission-a",
|
||||
"grant_id": "grant-a",
|
||||
"task_id": "task-a",
|
||||
"effect_id": "effect-a",
|
||||
"operation": "host.status.v1",
|
||||
"operation_version": "1",
|
||||
"resource_kind": "host",
|
||||
"resource_id": "local-host",
|
||||
"input_digest": hashlib.sha256(b"{}").hexdigest(),
|
||||
}
|
||||
submitted = controller.submit(request)
|
||||
authorized = controller.advance(submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id,
|
||||
lease_id="resource-lease-1",
|
||||
resource_kind="host",
|
||||
resource_id="local-host",
|
||||
generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
assert leased.state is JobState.LEASED
|
||||
first_lease = controller.get_execution_lease(submitted.job_id)
|
||||
assert first_lease["generation"] == 1
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=1)
|
||||
with pytest.raises(JobError, match="LEASE_FENCE_REJECTED"):
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=0)
|
||||
|
||||
clock.value += timedelta(seconds=31)
|
||||
second_request = {**request, "task_id": "task-b", "effect_id": "effect-b"}
|
||||
second = controller.submit(second_request)
|
||||
second = controller.advance(second.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
controller.lease_resource_job(
|
||||
second.job_id,
|
||||
lease_id="resource-lease-2",
|
||||
resource_kind="host",
|
||||
resource_id="local-host",
|
||||
generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=second.revision,
|
||||
)
|
||||
assert controller.get_execution_lease(second.job_id)["generation"] == 2
|
||||
with pytest.raises(JobError, match="LEASE_FENCE_REJECTED"):
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=1)
|
||||
|
||||
|
||||
def test_generic_job_cannot_skip_intent_readback_or_trusted_receipt(tmp_path):
|
||||
clock = Clock()
|
||||
sign, verify = receipt_signer()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, generic_receipt_verifier=verify
|
||||
)
|
||||
request = generic_request()
|
||||
submitted = controller.submit(request)
|
||||
invented = sign({"verified": True})
|
||||
with pytest.raises(JobError, match="GENERIC_JOB_TRANSITION_INVALID"):
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.COMPLETED,
|
||||
side_effect_state="VERIFIED", receipt=invented,
|
||||
)
|
||||
assert controller.store.read(submitted.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
authorized = controller.advance(
|
||||
submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED"
|
||||
)
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id, lease_id="claim-a", resource_kind=request["resource_kind"],
|
||||
resource_id=request["resource_id"], generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.EXECUTING,
|
||||
side_effect_state="INTENT_COMMITTED", expected_revision=leased.revision,
|
||||
)
|
||||
lease = controller.get_execution_lease(submitted.job_id)
|
||||
valid_receipt = signed_generic_receipt(sign, request, submitted.job_id, lease)
|
||||
tampered = {**valid_receipt, "payload": {**valid_receipt["payload"], "input_digest": "f" * 64}}
|
||||
with pytest.raises(JobError, match="GENERIC_RECEIPT_BINDING_MISMATCH"):
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=tampered,
|
||||
)
|
||||
local = controller.advance(
|
||||
submitted.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=valid_receipt,
|
||||
)
|
||||
completed = controller.advance(
|
||||
submitted.job_id, JobState.COMPLETED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=valid_receipt,
|
||||
expected_revision=local.revision,
|
||||
)
|
||||
assert completed.state is JobState.COMPLETED
|
||||
|
||||
|
||||
def test_generic_complete_requires_configured_verifier_and_recovery_accepts_bound_receipt(tmp_path):
|
||||
clock = Clock()
|
||||
sign, verify = receipt_signer()
|
||||
request = generic_request()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, generic_receipt_verifier=verify,
|
||||
)
|
||||
submitted = controller.submit(request)
|
||||
authorized = controller.advance(submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id, lease_id="claim-a", resource_kind=request["resource_kind"],
|
||||
resource_id=request["resource_id"], generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
executing = controller.advance(
|
||||
submitted.job_id, JobState.EXECUTING,
|
||||
side_effect_state="INTENT_COMMITTED", expected_revision=leased.revision,
|
||||
)
|
||||
receipt = signed_generic_receipt(
|
||||
sign, request, submitted.job_id, controller.get_execution_lease(submitted.job_id)
|
||||
)
|
||||
pending = controller.advance(
|
||||
submitted.job_id, JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN", expected_revision=executing.revision,
|
||||
)
|
||||
clock.value += timedelta(seconds=31)
|
||||
completed = controller.reconcile(
|
||||
submitted.job_id,
|
||||
lambda _row: {
|
||||
"state": JobState.COMPLETED.value,
|
||||
"side_effect_state": "READBACK_VERIFIED",
|
||||
"receipt": receipt,
|
||||
},
|
||||
)
|
||||
assert completed.state is JobState.COMPLETED
|
||||
|
||||
untrusted = GitOpsJobController(JobStore(tmp_path / "untrusted"), clock=Clock())
|
||||
other_request = generic_request(effect_id="effect-b", task_id="task-b")
|
||||
other = untrusted.submit(other_request)
|
||||
other = untrusted.advance(other.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
other = untrusted.lease_resource_job(
|
||||
other.job_id, lease_id="claim-b", resource_kind=other_request["resource_kind"],
|
||||
resource_id=other_request["resource_id"], generation=None,
|
||||
lease_expires_at=(Clock().value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=other.revision,
|
||||
)
|
||||
untrusted.advance(other.job_id, JobState.EXECUTING, side_effect_state="INTENT_COMMITTED")
|
||||
other_receipt = signed_generic_receipt(
|
||||
sign, other_request, other.job_id, untrusted.get_execution_lease(other.job_id)
|
||||
)
|
||||
with pytest.raises(JobError, match="GENERIC_RECEIPT_VERIFIER_REQUIRED"):
|
||||
untrusted.advance(
|
||||
other.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=other_receipt,
|
||||
)
|
||||
|
||||
|
||||
def test_reconcile_crash_can_be_taken_over_and_stale_observer_cannot_commit(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, reconcile_lease_seconds=10
|
||||
)
|
||||
job = controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
controller.advance(job.job_id, JobState.RECOVERABLE_FAILURE, side_effect_state="UNKNOWN")
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
controller.reconcile(job.job_id, lambda _row: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.RECONCILING.value
|
||||
with pytest.raises(JobError, match="RECONCILIATION_CLAIM_ACTIVE"):
|
||||
controller.reconcile(job.job_id, lambda _row: {"state": "REQUESTED"})
|
||||
|
||||
clock.value += timedelta(seconds=11)
|
||||
|
||||
def stale_observer(_row):
|
||||
clock.value += timedelta(seconds=11)
|
||||
winner = controller.reconcile(
|
||||
job.job_id,
|
||||
lambda _new: {"state": "REQUESTED", "side_effect_state": "RECONCILED_ABSENT"},
|
||||
)
|
||||
assert winner.state is JobState.REQUESTED
|
||||
return {"state": "COMPLETED", "side_effect_state": "VERIFIED"}
|
||||
|
||||
with pytest.raises(JobError, match="RECONCILIATION_CLAIM_LOST"):
|
||||
controller.reconcile(job.job_id, stale_observer)
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operation",
|
||||
[
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA",
|
||||
"CREATE_PR",
|
||||
"PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE",
|
||||
"RECONCILE_AUTHORITY_MAIN",
|
||||
"COMMIT_FILE",
|
||||
],
|
||||
)
|
||||
def test_dry_run_never_calls_a_mutating_adapter(tmp_path, monkeypatch, operation):
|
||||
_clock, controller, job, authority, broker, executor = make_executor(
|
||||
tmp_path, monkeypatch, operation
|
||||
)
|
||||
if operation == "COMMIT_FILE":
|
||||
payload = b'{"path":"x.txt","content_base64":"eA=="}'
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "commit-jobs"), clock=Clock())
|
||||
job = controller.submit(
|
||||
campaign_request(operation, payload_digest=hashlib.sha256(payload).hexdigest()),
|
||||
payload=payload,
|
||||
)
|
||||
executor = executor_module.DurableJobExecutor(
|
||||
controller, authority, broker, tmp_path / "recovery.json", clock=Clock()
|
||||
)
|
||||
result = executor.execute(job.job_id, dry_run=True)
|
||||
assert result["mode"] == "DRY_RUN"
|
||||
assert authority.mutations == broker.mutations == 0
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
|
||||
def test_execution_does_not_call_effect_when_intent_commit_is_refused(tmp_path, monkeypatch):
|
||||
_clock, controller, job, authority, broker, executor = make_executor(tmp_path, monkeypatch)
|
||||
original = controller.advance
|
||||
|
||||
def refuse(job_id, state, **kwargs):
|
||||
if state is JobState.EXECUTING:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE")
|
||||
return original(job_id, state, **kwargs)
|
||||
|
||||
monkeypatch.setattr(controller, "advance", refuse)
|
||||
with pytest.raises(ServiceUnavailable, match="JOB_DB_UNAVAILABLE"):
|
||||
executor.execute(job.job_id)
|
||||
assert authority.mutations == broker.mutations == 0
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(job.job_id)
|
||||
|
||||
|
||||
def test_external_success_receipt_failure_latches_then_readback_recovers_without_repeat(tmp_path, monkeypatch):
|
||||
_clock, controller, job, _authority, broker, executor = make_executor(tmp_path, monkeypatch)
|
||||
original = controller.advance
|
||||
fail = {"once": True}
|
||||
|
||||
def refuse_receipt(job_id, state, **kwargs):
|
||||
if state is JobState.COMPLETED and fail["once"]:
|
||||
fail["once"] = False
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE")
|
||||
return original(job_id, state, **kwargs)
|
||||
|
||||
monkeypatch.setattr(controller, "advance", refuse_receipt)
|
||||
with pytest.raises(ServiceUnavailable, match="JOB_DB_UNAVAILABLE"):
|
||||
executor.execute(job.job_id)
|
||||
assert broker.mutations == 1
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(job.job_id)
|
||||
next_job = controller.submit(campaign_request("CREATE_PR", idempotency_key="after-receipt-failure"))
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(next_job.job_id)
|
||||
assert broker.mutations == 1
|
||||
|
||||
restarted = executor_module.DurableJobExecutor(
|
||||
controller, executor.authority, broker, tmp_path / "recovery.json", clock=executor.clock
|
||||
)
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
restarted.execute(job.job_id)
|
||||
|
||||
recovered = executor.reconcile(job.job_id)
|
||||
assert recovered["state"] == JobState.COMPLETED.value
|
||||
assert broker.mutations == 1
|
||||
assert executor.execute(job.job_id)["state"] == JobState.COMPLETED.value
|
||||
|
||||
|
||||
def test_unsupported_exact_readback_reports_unknown_without_fake_success_or_effect(tmp_path, monkeypatch):
|
||||
_clock, controller, job, _authority, broker, executor = make_executor(
|
||||
tmp_path, monkeypatch, "PUBLISH_CHECK"
|
||||
)
|
||||
result = executor.execute(job.job_id)
|
||||
assert result["state"] == JobState.RECONCILIATION_REQUIRED.value
|
||||
assert result["receipt"]["verification"] == "UNKNOWN"
|
||||
assert result["receipt"]["blocker"] == "EXACT_READBACK_UNSUPPORTED"
|
||||
assert broker.mutations == 0
|
||||
again = executor.execute(job.job_id)
|
||||
assert again["state"] == JobState.RECONCILIATION_REQUIRED.value
|
||||
assert broker.mutations == 0
|
||||
|
||||
|
||||
def test_subprocess_crash_after_durable_sink_success_recovers_without_repeat(tmp_path):
|
||||
"""AT-22: process death after effect durability cannot repeat the effect."""
|
||||
job_root = tmp_path / "jobs"
|
||||
sink = tmp_path / "effect-sink.json"
|
||||
recovery_path = tmp_path / "recovery.json"
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
import aios_gitops_job_executor as m
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
|
||||
OLD = {OLD!r}; NEW = {NEW!r}
|
||||
sink = Path({str(sink)!r})
|
||||
root = Path({str(job_root)!r})
|
||||
m.load_recovered_authorization = lambda _path: {{
|
||||
'digest': m.AUTHORIZATION_DIGEST, 'campaign_id': m.CAMPAIGN_ID,
|
||||
'task_id': m.TASK_ID, 'repository': m.REPOSITORY,
|
||||
'branch': m.BRANCH, 'expected_branch_oid': OLD,
|
||||
}}
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, _ref): return NEW
|
||||
def ancestry_check(self, older, newer): return older == newer or (older == OLD and newer == NEW)
|
||||
|
||||
class Broker:
|
||||
def verify_identity(self): return {{'login':'aios-autonomy-agent','repository':m.REPOSITORY}}
|
||||
def readback(self):
|
||||
if sink.exists():
|
||||
if os.environ.get('CRASH_AFTER_EFFECT') == '1': os._exit(77)
|
||||
return json.loads(sink.read_text())['oid']
|
||||
return OLD
|
||||
def push_branch(self, oid):
|
||||
previous = json.loads(sink.read_text()) if sink.exists() else {{'count': 0}}
|
||||
payload = {{'count': previous['count'] + 1, 'oid': oid}}
|
||||
with sink.open('w') as stream:
|
||||
json.dump(payload, stream); stream.flush(); os.fsync(stream.fileno())
|
||||
return oid
|
||||
|
||||
controller = GitOpsJobController(JobStore(root))
|
||||
request = {campaign_request()!r}
|
||||
job = controller.submit(request)
|
||||
executor = m.DurableJobExecutor(controller, Authority(), Broker(), Path({str(recovery_path)!r}))
|
||||
if os.environ['ACTION'] == 'execute':
|
||||
executor.execute(job.job_id)
|
||||
else:
|
||||
print(json.dumps(executor.reconcile(job.job_id), sort_keys=True))
|
||||
"""
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
environment["ACTION"] = "execute"
|
||||
environment["CRASH_AFTER_EFFECT"] = "1"
|
||||
crashed = subprocess.run([sys.executable, "-c", script], env=environment, check=False)
|
||||
assert crashed.returncode == 77
|
||||
assert json.loads(sink.read_text()) == {"count": 1, "oid": NEW}
|
||||
|
||||
environment["ACTION"] = "recover"
|
||||
environment["CRASH_AFTER_EFFECT"] = "0"
|
||||
recovered = subprocess.run(
|
||||
[sys.executable, "-c", script], env=environment, check=True,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(recovered.stdout)["state"] == JobState.COMPLETED.value
|
||||
assert json.loads(sink.read_text()) == {"count": 1, "oid": NEW}
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from aios_gitops_job_controller import JobStore
|
||||
from aios_baseline.authorization import AuthorizationService, sign_owner_delegation_grant
|
||||
from aios_baseline.host import SystemdHost
|
||||
from aios_baseline.lineage import LineageLedger
|
||||
from aios_baseline.operations import OperationRegistry
|
||||
from aios_baseline.service import BaselineService, Identity, ServiceError
|
||||
from aios_baseline.worker_policy import WorkerProfile
|
||||
|
||||
|
||||
def fixture_service(tmp_path):
|
||||
owner = Ed25519PrivateKey.generate()
|
||||
machine = Ed25519PrivateKey.generate()
|
||||
auth = AuthorizationService(tmp_path/'auth.db', {'owner':owner.public_key()}, machine, 'machine',
|
||||
owner_key_scopes={'owner':{'tenant_ids':['tenant-a'],'subject_ids':['user-a']}})
|
||||
auth.register_grant(sign_owner_delegation_grant(owner,'owner',grant_id='grant-a',generation=1,
|
||||
tenant_id='tenant-a',subject_id='user-a',mission_ids=('mission-a',),task_ids=('*',),
|
||||
operations=('service-status',),component_ids=('sample',),environments=('staging',),
|
||||
issued_at=int(time.time())-1,validity_mode='UNTIL_REVOKED',max_effects=10))
|
||||
store=TaskPlaneStore(str(tmp_path/'tasks.db'),high_assurance=True)
|
||||
worker=WorkerProfile.from_dict(dict(worker_id='worker-a',identity='machine-a',tenant_scope=['tenant-a'],
|
||||
subject_scope=['user-a'],capabilities=['service-status'],priority=10,fallback_executor=False,
|
||||
heartbeat_seconds=15,max_concurrency=1))
|
||||
program=tmp_path/'systemctl-fixture'
|
||||
program.write_text('#!'+sys.executable+'\nprint("Id=sample.service\\nActiveState=active\\nSubState=running\\nMainPID=123\\nInvocationID=abc")\n')
|
||||
program.chmod(0o700)
|
||||
host=SystemdHost({'tenant-a:staging:sample':'sample.service'},executable=str(program))
|
||||
operation=dict(operation_id='service-status',version=1,effect_class='READ_ONLY',handler_ref='host.service_status',
|
||||
input_schema='aios.operation.service-status.input.v1',required_permissions=['service-status'],timeout_seconds=10,
|
||||
readback={'mode':'NONE','required':False},retry={'mode':'SAFE','max_attempts':2},
|
||||
reconcile={'mode':'NONE'},rollback={'mode':'NONE'},owner_boundary={'mode':'WITHIN_GRANT'})
|
||||
contract=dict(contract_schema='aios.runtime-contract.v1',contract_revision=1,component_id='sample',
|
||||
environment='staging',tenant_scope='tenant-a',lifecycle='active',
|
||||
entrypoints={'host':{'component_id':'sample','environment':'staging','resolver_ref':'service://sample/status'}},
|
||||
bindings={},resource_refs={},credential_refs={},operations=['service-status'])
|
||||
service=BaselineService(store, tmp_path/'jobs', auth, [contract], OperationRegistry([operation]),
|
||||
{'worker-a':worker},host,LineageLedger(tmp_path/'lineage.db',store,JobStore(tmp_path/'jobs').read),machine)
|
||||
return service
|
||||
|
||||
|
||||
USER=Identity('tenant-a','user-a','user')
|
||||
WORKER=Identity('tenant-a','machine-a','worker','worker-a')
|
||||
REQUEST=dict(mission_id='mission-a',grant_id='grant-a',operation='service-status',component_id='sample',
|
||||
environment='staging',inputs={},intent='检查 staging 服务并形成诊断结果')
|
||||
|
||||
|
||||
def test_real_local_task_goes_through_claim_job_receipt_and_lineage(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='request-a')
|
||||
duplicate=service.submit_goal(USER,REQUEST,idempotency_key='request-a')
|
||||
assert duplicate['task_id']==created['task_id']
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
assert claim['task_id']==created['task_id']
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
assert result['status']=='COMPLETED'
|
||||
assert result['receipt']['observation']['properties']['MainPID']=='123'
|
||||
assert service.get_task(USER,created['task_id'])['status']=='COMPLETED'
|
||||
assert service.explain(USER,'mission-a')['mission_id']=='mission-a'
|
||||
assert service.claim_next(WORKER) is None
|
||||
|
||||
|
||||
def test_spoofed_tenant_cross_subject_and_worker_results_are_denied(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
with pytest.raises(ServiceError): service.submit_goal(USER,{**REQUEST,'tenant_id':'tenant-b'},idempotency_key='a')
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='a')
|
||||
for outsider in (Identity('tenant-b','user-a','user'),Identity('tenant-a','user-b','user')):
|
||||
with pytest.raises(ServiceError): service.get_task(outsider,created['task_id'])
|
||||
with pytest.raises(ServiceError): service.cancel_task(outsider,created['task_id'])
|
||||
with pytest.raises(ServiceError): service.register_worker(Identity('tenant-b','machine-a','worker','worker-a'))
|
||||
with pytest.raises(ServiceError): service.execute_claim(USER,created['task_id'],'invented')
|
||||
|
||||
|
||||
def test_changed_payload_with_same_request_key_cannot_change_task(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='a')
|
||||
with pytest.raises(ServiceError): service.submit_goal(USER,{**REQUEST,'intent':'different goal'},idempotency_key='a')
|
||||
|
||||
|
||||
def test_cancelled_expired_claim_is_not_resurrected_by_recovery(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='cancel')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
service.cancel_task(USER,created['task_id'])
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE claims SET lease_until=? WHERE claim_id=?',(time.time()-1,claim['claim_id']))
|
||||
service.recover()
|
||||
assert service.get_task(USER,created['task_id'])['status']=='CANCELLED'
|
||||
assert service.claim_next(WORKER) is None
|
||||
|
||||
|
||||
def test_multiple_tasks_share_monotonic_resource_fence_without_sharing_state(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.register_worker(WORKER)
|
||||
generations=[]
|
||||
for request_key in ('first','second'):
|
||||
service.submit_goal(USER,REQUEST,idempotency_key=request_key)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
assert result['status']=='COMPLETED'
|
||||
generations.append(result['receipt']['generation'])
|
||||
assert generations==[1,2]
|
||||
|
||||
|
||||
def test_persisted_receipt_closes_expired_task_without_repeating_host_diagnostic(tmp_path,monkeypatch):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='recover')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
original=service._close_task
|
||||
def lose_power(*args): raise OSError('simulated process exit before Task acknowledgment')
|
||||
monkeypatch.setattr(service,'_close_task',lose_power)
|
||||
with pytest.raises(ServiceError): service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE claims SET lease_until=? WHERE claim_id=?',(time.time()-1,claim['claim_id']))
|
||||
monkeypatch.setattr(service,'_close_task',original)
|
||||
def must_not_execute(*args,**kwargs): raise AssertionError('must use durable receipt')
|
||||
monkeypatch.setattr(service.host,'status',must_not_execute)
|
||||
service.recover()
|
||||
assert service.get_task(USER,claim['task_id'])['status']=='COMPLETED'
|
||||
|
||||
|
||||
def test_wrong_signed_lineage_cannot_be_attached_to_real_task(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='scope')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
task=service.store.get_task(claim['task_id'])
|
||||
assert service._receipt_valid(task,service.signer.sign(result['receipt']))
|
||||
for field in ('job_id','mission_id','grant_id','claim_id','generation','operation','input_digest','resource_id'):
|
||||
forged=service.signer.sign({**result['receipt'],field:'wrong'})
|
||||
assert not service._receipt_valid(task,forged),field
|
||||
|
||||
|
||||
def test_receipt_key_rotation_preserves_old_evidence_only_with_explicit_trust(tmp_path):
|
||||
from aios_baseline.evidence import ReceiptSigner
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='rotation')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
old=service.signer
|
||||
envelope=old.sign(result['receipt'])
|
||||
new_key=Ed25519PrivateKey.generate()
|
||||
assert not ReceiptSigner(new_key,key_id='next').verify(envelope)
|
||||
rotated=ReceiptSigner(new_key,key_id='next',trusted_public_keys={old.key_id:old.public_key})
|
||||
service.signer=rotated
|
||||
assert service._receipt_valid(service.store.get_task(claim['task_id']),envelope)
|
||||
|
||||
|
||||
def test_fallback_is_normal_worker_eligibility_with_priority_and_same_claim_protocol(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
task=service.submit_goal(USER,REQUEST,idempotency_key='fallback')
|
||||
service.register_worker(WORKER)
|
||||
profile=service.worker_profiles['worker-a']
|
||||
fallback=WorkerProfile.from_dict({**profile.__dict__,'worker_id':'worker-fallback','identity':'machine-fallback',
|
||||
'fallback_executor':True,'priority':100})
|
||||
service.worker_profiles[fallback.worker_id]=fallback
|
||||
identity=Identity('tenant-a','machine-fallback','worker','worker-fallback')
|
||||
service.register_worker(identity)
|
||||
assert service.claim_next(identity) is None
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE baseline_task_runtime SET ready_at=? WHERE task_id=?',(time.time()-601,task['task_id']))
|
||||
assert service.claim_next(WORKER) is None
|
||||
claim=service.claim_next(identity)
|
||||
assert claim['task_id']==task['task_id']
|
||||
assert service.execute_claim(identity,claim['task_id'],claim['claim_id'])['status']=='COMPLETED'
|
||||
|
||||
|
||||
def test_deadline_expiring_while_waiting_for_executor_never_starts_host(tmp_path,monkeypatch):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='deadline')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
def unexpected(*args,**kwargs): raise AssertionError('expired call executed')
|
||||
monkeypatch.setattr(service.host,'status',unexpected)
|
||||
service._execute_lock.acquire()
|
||||
try:
|
||||
with pytest.raises(ServiceError,match='DEADLINE'):
|
||||
service.execute_claim(WORKER,claim['task_id'],claim['claim_id'],deadline=time.time()+0.05)
|
||||
finally: service._execute_lock.release()
|
||||
assert service.get_task(USER,claim['task_id'])['job_id'] is None
|
||||
@@ -0,0 +1,435 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.optimization import (
|
||||
OptimizationController,
|
||||
OptimizationEnvelope,
|
||||
OptimizationError,
|
||||
canonical_json_bytes,
|
||||
validate_proposal,
|
||||
)
|
||||
|
||||
|
||||
def _signed_envelope(**overrides):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
document = {
|
||||
"schema": "aios.optimization-envelope.v1",
|
||||
"envelope_id": "opt-envelope-1",
|
||||
"tenant_id": "tenant-a",
|
||||
"allowed_parameters": {
|
||||
"retry.max_attempts": {"minimum": 1, "maximum": 5, "integer": True},
|
||||
"timeout.operation_seconds": {"minimum": 5, "maximum": 60},
|
||||
"evidence_reuse.max_age_seconds": {
|
||||
"minimum": 0,
|
||||
"maximum": 600,
|
||||
"integer": True,
|
||||
},
|
||||
},
|
||||
"allowed_components": ["task-worker"],
|
||||
"allowed_environments": ["staging"],
|
||||
"max_change_size": 2,
|
||||
"max_compute_budget": 20,
|
||||
"quality_floor": {
|
||||
"success_rate": {"direction": "higher", "threshold": 0.95},
|
||||
"error_rate": {"direction": "lower", "threshold": 0.05},
|
||||
},
|
||||
"rollback_requirement": True,
|
||||
"promotion_rules": {
|
||||
"objective_metric": "success_rate",
|
||||
"minimum_improvement": 0.01,
|
||||
"mandatory_tests": ["test_worker_safety"],
|
||||
"mandatory_quality_gates": ["no_tenant_leak"],
|
||||
"required_receipt_schema": "aios.governed-task-receipt.v1",
|
||||
},
|
||||
"source_config_digest": "sha256:" + "a" * 64,
|
||||
"key_id": "optimization-config-key-1",
|
||||
}
|
||||
document.update(overrides)
|
||||
signature = private_key.sign(canonical_json_bytes(document))
|
||||
document["signature"] = base64.b64encode(signature).decode("ascii")
|
||||
|
||||
def verify(key_id, payload, encoded_signature):
|
||||
assert key_id == "optimization-config-key-1"
|
||||
try:
|
||||
private_key.public_key().verify(base64.b64decode(encoded_signature), payload)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
return document, verify
|
||||
|
||||
|
||||
def _envelope():
|
||||
document, verifier = _signed_envelope()
|
||||
return OptimizationEnvelope.from_dict(document, verify_envelope=verifier)
|
||||
|
||||
|
||||
def _context(**overrides):
|
||||
value = {
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "optimizer-service",
|
||||
"grant_id": "grant-123",
|
||||
"mission_id": "mission-456",
|
||||
"service_identity": "aios-root-service",
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _proposal(**overrides):
|
||||
value = {
|
||||
"schema": "aios.optimization-proposal.v1",
|
||||
"proposal_id": "proposal-1",
|
||||
"envelope_id": "opt-envelope-1",
|
||||
"tenant_id": "tenant-a",
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
"changes": [
|
||||
{"path": "retry.max_attempts", "value": 4},
|
||||
{"path": "timeout.operation_seconds", "value": 30},
|
||||
],
|
||||
"compute_budget": 10,
|
||||
"tests": ["test_worker_safety", "test_candidate_latency"],
|
||||
"quality_gates": ["no_tenant_leak"],
|
||||
"baseline_metrics": {"success_rate": 0.95, "error_rate": 0.04},
|
||||
"rollback_plan": {
|
||||
"strategy": "restore_previous_values",
|
||||
"reference": "baseline-config-17",
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _signed_receipt_verifier():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
|
||||
def sign(receipt):
|
||||
signed = dict(receipt)
|
||||
signed["key_id"] = "governed-receipt-key-1"
|
||||
signed["signature"] = base64.b64encode(
|
||||
private_key.sign(canonical_json_bytes(signed))
|
||||
).decode("ascii")
|
||||
return signed
|
||||
|
||||
def verify(receipt):
|
||||
unsigned = dict(receipt)
|
||||
signature = unsigned.pop("signature", "")
|
||||
if unsigned.get("key_id") != "governed-receipt-key-1":
|
||||
return False
|
||||
try:
|
||||
private_key.public_key().verify(
|
||||
base64.b64decode(signature), canonical_json_bytes(unsigned)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
return sign, verify
|
||||
|
||||
|
||||
def _controller(tmp_path: Path):
|
||||
submitted = []
|
||||
sign, verify = _signed_receipt_verifier()
|
||||
|
||||
def submit_task(task):
|
||||
submitted.append(task)
|
||||
return {"task_id": "evaluation-task-1", "state": "QUEUED"}
|
||||
|
||||
controller = OptimizationController(
|
||||
tmp_path / "optimization.sqlite3",
|
||||
_envelope(),
|
||||
submit_task,
|
||||
verify_receipt=verify,
|
||||
)
|
||||
return controller, submitted, sign
|
||||
|
||||
|
||||
def _evaluation(proposal_record, sign, **overrides):
|
||||
result = {
|
||||
"candidate_metrics": {"success_rate": 0.97, "error_rate": 0.03},
|
||||
"compute_used": 8,
|
||||
"tests_passed": ["test_worker_safety", "test_candidate_latency"],
|
||||
"quality_gates_passed": ["no_tenant_leak"],
|
||||
}
|
||||
result.update(overrides)
|
||||
receipt = sign(
|
||||
{
|
||||
"schema": "aios.governed-task-receipt.v1",
|
||||
"task_id": proposal_record["evaluation_task_id"],
|
||||
"proposal_id": proposal_record["proposal_id"],
|
||||
"proposal_digest": proposal_record["proposal_digest"],
|
||||
"action": "evaluate",
|
||||
"success": True,
|
||||
"result_digest": "sha256:"
|
||||
+ hashlib.sha256(canonical_json_bytes(result)).hexdigest(),
|
||||
}
|
||||
)
|
||||
return {**result, "receipt": receipt}
|
||||
|
||||
|
||||
def test_envelope_requires_a_valid_signature_over_the_exact_payload():
|
||||
document, verifier = _signed_envelope()
|
||||
document["max_compute_budget"] = 10_000
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=verifier)
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_ENVELOPE_VERIFIER_REQUIRED"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"authorization.accept_without_grant",
|
||||
"trust_root.key",
|
||||
"tenant_isolation.enabled",
|
||||
"acceptance.threshold",
|
||||
"destructive_capability.enabled",
|
||||
"owner_grant.semantics",
|
||||
"production_migration.auto_apply",
|
||||
"code.patch",
|
||||
],
|
||||
)
|
||||
def test_envelope_rejects_forbidden_or_generic_parameter_paths(path):
|
||||
document, verifier = _signed_envelope(
|
||||
allowed_parameters={path: {"minimum": 0, "maximum": 1}}
|
||||
)
|
||||
# Re-sign the changed trusted document so this reaches semantic validation.
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
unsigned = dict(document)
|
||||
unsigned.pop("signature")
|
||||
document["signature"] = base64.b64encode(
|
||||
private_key.sign(canonical_json_bytes(unsigned))
|
||||
).decode("ascii")
|
||||
|
||||
def changed_verifier(_key_id, payload, signature):
|
||||
try:
|
||||
private_key.public_key().verify(base64.b64decode(signature), payload)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_PARAMETER_PATH_FORBIDDEN"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=changed_verifier)
|
||||
|
||||
|
||||
def test_proposal_is_bounded_and_cannot_remove_mandatory_checks():
|
||||
envelope = _envelope()
|
||||
normalized = validate_proposal(envelope, _proposal())
|
||||
assert normalized["changes"][0] == {"path": "retry.max_attempts", "value": 4}
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_PARAMETER_OUT_OF_RANGE"):
|
||||
validate_proposal(
|
||||
envelope,
|
||||
_proposal(changes=[{"path": "retry.max_attempts", "value": 99}]),
|
||||
)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_MANDATORY_TEST_MISSING"):
|
||||
validate_proposal(envelope, _proposal(tests=["test_candidate_latency"]))
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_MANDATORY_GATE_MISSING"):
|
||||
validate_proposal(envelope, _proposal(quality_gates=[]))
|
||||
|
||||
|
||||
def test_evidence_reuse_cannot_widen_tenant_component_or_environment_scope():
|
||||
envelope = _envelope()
|
||||
proposal = _proposal(
|
||||
changes=[{"path": "evidence_reuse.max_age_seconds", "value": 300}],
|
||||
evidence_reuse={
|
||||
"scope": {
|
||||
"tenant_id": "tenant-b",
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
},
|
||||
"evidence_ids": ["evidence-1"],
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_EVIDENCE_SCOPE_MISMATCH"):
|
||||
validate_proposal(envelope, proposal)
|
||||
|
||||
|
||||
def test_propose_only_persists_a_record_and_submits_an_identity_bound_task(tmp_path):
|
||||
controller, submitted, _ = _controller(tmp_path)
|
||||
record = controller.propose(_proposal(), authenticated_context=_context())
|
||||
|
||||
assert record["status"] == "EVALUATION_SUBMITTED"
|
||||
assert record["evaluation_task_id"] == "evaluation-task-1"
|
||||
assert submitted == [
|
||||
{
|
||||
"schema": "aios.optimization-task-request.v1",
|
||||
"task_id": "optimization-evaluate-proposal-1",
|
||||
"action": "evaluate",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "optimizer-service",
|
||||
"grant_id": "grant-123",
|
||||
"mission_id": "mission-456",
|
||||
"proposal_id": "proposal-1",
|
||||
"proposal_digest": record["proposal_digest"],
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
"compute_budget": 10.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_candidate_is_stopped_without_task_submission(tmp_path):
|
||||
controller, submitted, _ = _controller(tmp_path)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_COMPONENT_FORBIDDEN"):
|
||||
controller.propose(
|
||||
_proposal(component="authorization-service"),
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
|
||||
stopped = controller.get_proposal("proposal-1", authenticated_context=_context())
|
||||
assert stopped["status"] == "STOPPED_INVALID"
|
||||
assert submitted == []
|
||||
|
||||
|
||||
def test_cross_tenant_or_subject_access_is_denied(tmp_path):
|
||||
controller, _, _ = _controller(tmp_path)
|
||||
controller.propose(_proposal(), authenticated_context=_context())
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_CONTEXT_TENANT_MISMATCH"):
|
||||
controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context(tenant_id="tenant-b")
|
||||
)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_CONTEXT_SUBJECT_MISMATCH"):
|
||||
controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context(subject_id="other-service")
|
||||
)
|
||||
|
||||
|
||||
def test_forged_or_wrong_task_receipt_is_rejected(tmp_path):
|
||||
controller, _, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(proposal, sign)
|
||||
evaluation["receipt"]["task_id"] = "some-other-task"
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_RECEIPT_UNTRUSTED"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
|
||||
validly_signed_wrong_binding = _evaluation(proposal, sign)
|
||||
unsigned = dict(validly_signed_wrong_binding["receipt"])
|
||||
unsigned["task_id"] = "some-other-task"
|
||||
unsigned.pop("signature")
|
||||
validly_signed_wrong_binding["receipt"] = sign(unsigned)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_RECEIPT_TASK_MISMATCH"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", validly_signed_wrong_binding, authenticated_context=_context()
|
||||
)
|
||||
|
||||
|
||||
def test_no_improvement_stops_promotion(tmp_path):
|
||||
controller, submitted, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(
|
||||
proposal,
|
||||
sign,
|
||||
candidate_metrics={"success_rate": 0.95, "error_rate": 0.04},
|
||||
)
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
|
||||
assert result["status"] == "STOPPED_NO_IMPROVEMENT"
|
||||
assert result["effect_comparison"] == {
|
||||
"error_rate": 0.0,
|
||||
"success_rate": 0.0,
|
||||
}
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_NOT_PROMOTABLE"):
|
||||
controller.promote(
|
||||
"proposal-1",
|
||||
{
|
||||
"evaluation_task_id": "evaluation-task-1",
|
||||
"proposal_digest": proposal["proposal_digest"],
|
||||
},
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert len(submitted) == 1
|
||||
|
||||
|
||||
def test_quality_floor_or_baseline_regression_stops_promotion(tmp_path):
|
||||
controller, _, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(
|
||||
proposal,
|
||||
sign,
|
||||
candidate_metrics={"success_rate": 0.97, "error_rate": 0.05},
|
||||
)
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
assert result["status"] == "STOPPED_QUALITY_REGRESSION"
|
||||
assert result["effect_comparison"]["error_rate"] == pytest.approx(-0.01)
|
||||
|
||||
improved = _evaluation(proposal, sign)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_EVALUATION_STATE_INVALID"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", improved, authenticated_context=_context()
|
||||
)
|
||||
assert controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context()
|
||||
)["status"] == "STOPPED_QUALITY_REGRESSION"
|
||||
|
||||
|
||||
def test_eligible_candidate_promotion_is_another_governed_task_only(tmp_path):
|
||||
controller, submitted, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1",
|
||||
_evaluation(proposal, sign),
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert result["status"] == "ELIGIBLE"
|
||||
|
||||
promoted = controller.promote(
|
||||
"proposal-1",
|
||||
{
|
||||
"evaluation_task_id": "evaluation-task-1",
|
||||
"proposal_digest": proposal["proposal_digest"],
|
||||
},
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert promoted["status"] == "PROMOTION_SUBMITTED"
|
||||
assert len(submitted) == 2
|
||||
promotion_task = submitted[-1]
|
||||
assert promotion_task["action"] == "promote"
|
||||
assert promotion_task["tenant_id"] == "tenant-a"
|
||||
assert promotion_task["grant_id"] == "grant-123"
|
||||
assert promotion_task["mission_id"] == "mission-456"
|
||||
assert "changes" not in promotion_task
|
||||
|
||||
|
||||
def test_restart_reads_persisted_baseline_candidate_and_effect(tmp_path):
|
||||
path = tmp_path / "optimization.sqlite3"
|
||||
submitted = []
|
||||
sign, verify = _signed_receipt_verifier()
|
||||
|
||||
def submit_task(task):
|
||||
submitted.append(task)
|
||||
return {"task_id": "evaluation-task-1"}
|
||||
|
||||
first = OptimizationController(path, _envelope(), submit_task, verify_receipt=verify)
|
||||
proposal = first.propose(_proposal(), authenticated_context=_context())
|
||||
first.record_evaluation(
|
||||
"proposal-1", _evaluation(proposal, sign), authenticated_context=_context()
|
||||
)
|
||||
|
||||
restarted = OptimizationController(path, _envelope(), submit_task, verify_receipt=verify)
|
||||
restored = restarted.get_proposal("proposal-1", authenticated_context=_context())
|
||||
assert restored["baseline_metrics"] == {"error_rate": 0.04, "success_rate": 0.95}
|
||||
assert restored["candidate_metrics"] == {"error_rate": 0.03, "success_rate": 0.97}
|
||||
assert restored["effect_comparison"] == {
|
||||
"error_rate": pytest.approx(0.01),
|
||||
"success_rate": pytest.approx(0.02),
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Exercise package installation against real temporary Git repositories."""
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location("baseline_bundle", ROOT / "tools/baseline_v1_1/bundle.py")
|
||||
bundle = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(bundle)
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
git(source, "init", "--quiet")
|
||||
(source / "app.py").write_text("print('before')\n")
|
||||
(source / "retired.txt").write_text("historical\n")
|
||||
git(source, "add", ".")
|
||||
git(source, "-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "--quiet", "-m", "base")
|
||||
revision = git(source, "rev-parse", "HEAD")
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", "--quiet", str(source), str(work)], check=True)
|
||||
(work / "app.py").write_text("print('after')\n")
|
||||
(work / "retired.txt").unlink()
|
||||
(work / "new").mkdir()
|
||||
(work / "new/tool.py").write_text("print('new')\n")
|
||||
artifact = tmp_path / "change.zip"
|
||||
bundle.create_bundle(work, revision, artifact)
|
||||
return source, work, revision, artifact
|
||||
|
||||
|
||||
def test_stage_dry_run_install_and_conflict_safe_rollback(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
result = bundle.stage_bundle(artifact, source, target, dry_run=True)
|
||||
assert result["status"] == "DRY_RUN"
|
||||
assert not target.exists()
|
||||
assert (source / "app.py").read_text() == "print('before')\n"
|
||||
result = bundle.stage_bundle(artifact, source, target)
|
||||
assert result["status"] == "STAGED"
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
assert not (target / "retired.txt").exists()
|
||||
assert (target / "new/tool.py").is_file()
|
||||
assert git(target, "rev-parse", "HEAD") == revision
|
||||
with pytest.raises(bundle.PackageError, match="exists"):
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
(target / "app.py").write_text("owner edited\n")
|
||||
with pytest.raises(bundle.PackageError, match="changed"):
|
||||
bundle.rollback(target)
|
||||
assert (target / "app.py").read_text() == "owner edited\n"
|
||||
assert (target / "new/tool.py").exists()
|
||||
(target / "app.py").write_text("print('after')\n")
|
||||
assert bundle.rollback(target, dry_run=True)["status"] == "DRY_RUN"
|
||||
assert bundle.rollback(target)["status"] == "ROLLED_BACK"
|
||||
assert not (target / "new/tool.py").exists()
|
||||
assert (target / "retired.txt").read_text() == "historical\n"
|
||||
assert not git(target, "status", "--porcelain")
|
||||
|
||||
|
||||
def test_dirty_wrong_base_and_unknown_destination_refused(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
(source / "local.txt").write_text("do not overwrite")
|
||||
with pytest.raises(bundle.PackageError, match="clean"):
|
||||
bundle.stage_bundle(artifact, source, tmp_path / "one")
|
||||
(source / "local.txt").unlink()
|
||||
(source / "app.py").write_text("another base")
|
||||
git(source, "add", ".")
|
||||
git(source, "-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "--quiet", "-m", "different")
|
||||
with pytest.raises(bundle.PackageError, match="baseline"):
|
||||
bundle.stage_bundle(artifact, source, tmp_path / "two")
|
||||
assert not (tmp_path / "two").exists()
|
||||
|
||||
|
||||
def test_tamper_traversal_extra_entries_and_symlink_refused(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
with zipfile.ZipFile(artifact) as z:
|
||||
entries = {name: z.read(name) for name in z.namelist()}
|
||||
for index, change in enumerate((
|
||||
{"new/app.py": b"tampered"},
|
||||
{"../escape": b"bad"},
|
||||
{"unlisted.py": b"bad"},
|
||||
)):
|
||||
bad = tmp_path / f"bad-{index}.zip"
|
||||
with zipfile.ZipFile(bad, "w") as z:
|
||||
for name, body in (entries | change).items():
|
||||
z.writestr(name, body)
|
||||
with pytest.raises(bundle.PackageError):
|
||||
bundle.verify_bundle(bad)
|
||||
(work / "escape.py").symlink_to(source / "app.py")
|
||||
with pytest.raises(bundle.PackageError, match="symlink"):
|
||||
bundle.create_bundle(work, revision, tmp_path / "links.zip")
|
||||
|
||||
|
||||
def test_rollback_refuses_added_file_symlink_without_touching_referent(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_text("owner data")
|
||||
(target / "new/tool.py").unlink()
|
||||
(target / "new/tool.py").symlink_to(outside)
|
||||
with pytest.raises(bundle.PackageError, match="symlink"):
|
||||
bundle.rollback(target)
|
||||
assert outside.read_text() == "owner data"
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
|
||||
|
||||
def test_manifest_semantic_tampering_is_rejected(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
with zipfile.ZipFile(artifact) as z:
|
||||
entries = {name: z.read(name) for name in z.namelist()}
|
||||
document = json.loads(entries["manifest.json"])
|
||||
document["changes"][0]["before_sha256"] = "0" * 64
|
||||
entries["manifest.json"] = json.dumps(document).encode()
|
||||
bad = tmp_path / "bad-manifest.zip"
|
||||
with zipfile.ZipFile(bad, "w") as z:
|
||||
for name, body in entries.items():
|
||||
z.writestr(name, body)
|
||||
with pytest.raises(bundle.PackageError, match="before|base"):
|
||||
bundle.verify_bundle(bad)
|
||||
|
||||
|
||||
def test_rollback_preserves_user_staged_changes(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
git(target, "add", "app.py")
|
||||
with pytest.raises(bundle.PackageError, match="index changed"):
|
||||
bundle.rollback(target)
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
assert git(target, "diff", "--cached", "--name-only") == "app.py"
|
||||
@@ -0,0 +1,583 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.lineage import (
|
||||
LineageConflict,
|
||||
LineageLedger,
|
||||
LineageVerificationError,
|
||||
VerifiedAcceptanceEvidence,
|
||||
)
|
||||
from aios_baseline.release import (
|
||||
ReleaseAuthorizationError,
|
||||
ReleaseConflict,
|
||||
ReleaseLedger,
|
||||
ReleaseReconciliationRequired,
|
||||
ReleaseVerificationError,
|
||||
VerifiedReceipt,
|
||||
)
|
||||
from aios_baseline.verifiers import (
|
||||
ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
RELEASE_RECEIPT_DOMAIN,
|
||||
SignatureVerificationError,
|
||||
ed25519_acceptance_evidence_verifier,
|
||||
ed25519_release_receipt_verifier,
|
||||
)
|
||||
|
||||
|
||||
class TaskAuthority:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
self.jobs = {}
|
||||
|
||||
def add(self, task_id, tenant_id="tenant-a", subject_id="user-a", **metadata):
|
||||
metadata.setdefault("component_id", "component-a")
|
||||
self.tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"plan": {
|
||||
"issuer": {"tenant_id": tenant_id, "subject_id": subject_id},
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
|
||||
def get_task(self, task_id):
|
||||
return self.tasks[task_id]
|
||||
|
||||
def add_job(
|
||||
self, job_id, task_id, effect_id, tenant_id="tenant-a", subject_id="user-a",
|
||||
mission_id="mission-1", grant_id="grant-1",
|
||||
):
|
||||
self.jobs[job_id] = {
|
||||
"job_id": job_id,
|
||||
"request": {
|
||||
"tenant_id": tenant_id, "subject_id": subject_id,
|
||||
"mission_id": mission_id, "grant_id": grant_id,
|
||||
"task_id": task_id, "effect_id": effect_id,
|
||||
},
|
||||
}
|
||||
|
||||
def read(self, job_id):
|
||||
return self.jobs[job_id]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authority():
|
||||
value = TaskAuthority()
|
||||
value.add("task-1", mission_id="mission-1", grant_id="grant-1")
|
||||
value.add("task-2", mission_id="mission-1", grant_id="grant-1")
|
||||
value.add_job("job-1", "task-1", "effect-1")
|
||||
value.add_job("job-2", "task-2", "effect-1")
|
||||
return value
|
||||
|
||||
|
||||
def build_lineage(path, authority):
|
||||
ledger = LineageLedger(path, authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1"
|
||||
)
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1", {"sha256": "abc"}
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def test_lineage_is_durable_and_explains_only_exact_mission(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
ledger = build_lineage(db, authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
ledger.close()
|
||||
|
||||
reopened = LineageLedger(db, authority, authority)
|
||||
graph = reopened.explain("tenant-a", "user-a", "mission-1")
|
||||
assert [item["task_id"] for item in graph["tasks"]] == ["task-1"]
|
||||
assert graph["effects"][0]["job_id"] == "job-1"
|
||||
assert graph["releases"][0]["source_task_id"] == "task-1"
|
||||
assert graph["deployments"][0]["release_id"] == "release-1"
|
||||
assert reopened.explain("tenant-a", "user-a", "mission-10")["tasks"] == []
|
||||
|
||||
|
||||
def test_task_authority_and_global_within_tenant_identity_are_enforced(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
with pytest.raises(LineageVerificationError, match="task_authority"):
|
||||
ledger.link_task("tenant-a", "intruder", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_task("tenant-a", "other-user", "other", "other", "task-1")
|
||||
|
||||
# A tenant boundary permits the same opaque ID without leaking either graph.
|
||||
authority.add("shared", tenant_id="tenant-a", subject_id="user-a")
|
||||
ledger.link_task("tenant-a", "user-a", "m-a", "g-a", "shared")
|
||||
authority.add("shared", tenant_id="tenant-b", subject_id="user-b")
|
||||
ledger.link_task("tenant-b", "user-b", "m-b", "g-b", "shared")
|
||||
assert len(ledger.explain("tenant-a", "user-a", "m-a")["tasks"]) == 1
|
||||
assert len(ledger.explain("tenant-b", "user-b", "m-b")["tasks"]) == 1
|
||||
|
||||
|
||||
def test_job_effect_and_release_bindings_are_immutable(tmp_path, authority):
|
||||
ledger = build_lineage(tmp_path / "lineage.sqlite", authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-2")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-2", "effect-2", "job-1"
|
||||
)
|
||||
with pytest.raises(LineageConflict, match="release-producing effect"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-2", {"sha256": "def"}
|
||||
)
|
||||
with pytest.raises(LineageConflict, match="effect_id"):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-2", "effect-1", "job-2"
|
||||
)
|
||||
|
||||
authority.add("task-other-subject", tenant_id="tenant-a", subject_id="user-b")
|
||||
ledger.link_task("tenant-a", "user-b", "mission-b", "grant-b", "task-other-subject")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-b", "mission-b", "task-other-subject", "effect-b", "job-1"
|
||||
)
|
||||
|
||||
|
||||
def _acceptance_verifier(raw, expected):
|
||||
if raw != {"proof": "trusted"}:
|
||||
raise ValueError("untrusted")
|
||||
return VerifiedAcceptanceEvidence(
|
||||
acceptance_id=expected["acceptance_id"],
|
||||
tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"],
|
||||
deployment_id=expected["deployment_id"],
|
||||
release_id=expected["release_id"],
|
||||
component_id=expected["component_id"],
|
||||
artifact=expected["artifact"],
|
||||
accepted=True,
|
||||
evidence_id="evidence-1",
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_rejects_wrong_deployment_release_or_artifact(tmp_path, authority):
|
||||
ledger = build_lineage(tmp_path / "lineage.sqlite", authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
for deployment, release, artifact in (
|
||||
("deployment-x", "release-1", {"sha256": "abc"}),
|
||||
("deployment-1", "release-x", {"sha256": "abc"}),
|
||||
("deployment-1", "release-1", {"sha256": "wrong"}),
|
||||
):
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-x",
|
||||
deployment, release, artifact, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
accepted = ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-1",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
assert accepted["acceptance_id"] == "acceptance-1"
|
||||
with pytest.raises(LineageConflict, match="component"):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-b", "acceptance-component",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
|
||||
def wrong_binding(raw, expected):
|
||||
verified = _acceptance_verifier(raw, expected)
|
||||
return VerifiedAcceptanceEvidence(
|
||||
**{**verified.__dict__, "deployment_id": "deployment-other"}
|
||||
)
|
||||
|
||||
with pytest.raises(LineageVerificationError, match="binding"):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-2",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=wrong_binding,
|
||||
)
|
||||
|
||||
|
||||
def _receipt_verifier(raw, expected):
|
||||
if raw.get("signed_by") != "configured-sink":
|
||||
raise ValueError("untrusted receipt")
|
||||
return VerifiedReceipt(
|
||||
receipt_id=raw["receipt_id"],
|
||||
tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"],
|
||||
component_id=expected["component_id"],
|
||||
task_id=expected["task_id"],
|
||||
source_task_id=expected["source_task_id"],
|
||||
source_effect_id=expected["source_effect_id"],
|
||||
grant_id=expected["grant_id"],
|
||||
job_id=expected["job_id"],
|
||||
effect_id=raw["effect_id"],
|
||||
operation_id=expected["operation_id"],
|
||||
deployment_id=expected["deployment_id"],
|
||||
release_id=expected["release_id"],
|
||||
artifact=expected["artifact"],
|
||||
observed={"healthy": True},
|
||||
)
|
||||
|
||||
|
||||
def _target_authorizer(tenant_id, subject_id, component_id, environment, action):
|
||||
return (
|
||||
tenant_id == "tenant-a"
|
||||
and subject_id in {"user-a", "operator-a"}
|
||||
and component_id in {"component-a", "component-b"}
|
||||
and environment == "staging"
|
||||
and action in {"read", "write", "deploy", "accept"}
|
||||
)
|
||||
|
||||
|
||||
class GovernedAdapter:
|
||||
def __init__(self, authority, execute, reconcile=None):
|
||||
self.authority = authority
|
||||
self._execute = execute
|
||||
if reconcile is not None:
|
||||
self.reconcile = reconcile
|
||||
|
||||
def prepare(self, operation):
|
||||
job_id = f"real-job:{operation['saga_id']}:{operation['stage_index']}"
|
||||
self.authority.add_job(
|
||||
job_id, operation["task_id"], operation["effect_id"],
|
||||
tenant_id=operation["tenant_id"], subject_id=operation["subject_id"],
|
||||
mission_id=operation["mission_id"], grant_id=operation["grant_id"],
|
||||
)
|
||||
return {"job_id": job_id}
|
||||
|
||||
def execute(self, operation):
|
||||
return self._execute(operation)
|
||||
|
||||
|
||||
def _release_ledger(path, lineage):
|
||||
release = ReleaseLedger(
|
||||
path, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
release.register_recipe(
|
||||
"recipe-1",
|
||||
[
|
||||
{"stage_id": "install", "operation_id": "deploy.install"},
|
||||
{"stage_id": "health", "operation_id": "deploy.health"},
|
||||
],
|
||||
)
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=None, stage="STAGED", source_revision="source-5",
|
||||
observed_main_revision="main-9", rollback_compatible=True,
|
||||
)
|
||||
release.start_saga(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "saga-1", "release-1",
|
||||
"deployment-1", "staging", {"sha256": "abc"}, "recipe-1",
|
||||
expected_target_revision=1,
|
||||
)
|
||||
return release
|
||||
|
||||
|
||||
def test_release_target_is_persistent_cas_and_allows_main_ahead(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
target = release.get_target("tenant-a", "user-a", "component-a", "staging")
|
||||
assert target.revision == 1
|
||||
assert target.source_revision == "source-5"
|
||||
assert target.observed_main_revision == "main-9" # main may be ahead while staged
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=0,
|
||||
)
|
||||
reopened = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
assert reopened.get_target("tenant-a", "user-a", "component-a", "staging").revision == 1
|
||||
assert reopened.get_saga("tenant-a", "user-a", "saga-1")["stage_id"] == "install"
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
reopened.get_saga("tenant-a", "intruder", "saga-1")
|
||||
|
||||
|
||||
def test_targets_are_component_scoped_and_subject_is_authorized_actor(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
authority.add("task-b", component_id="component-b", mission_id="mission-b", grant_id="grant-b")
|
||||
authority.add_job("job-b", "task-b", "effect-b", mission_id="mission-b", grant_id="grant-b")
|
||||
lineage.link_task("tenant-a", "user-a", "mission-b", "grant-b", "task-b")
|
||||
lineage.link_effect("tenant-a", "user-a", "mission-b", "task-b", "effect-b", "job-b")
|
||||
lineage.link_release(
|
||||
"tenant-a", "user-a", "mission-b", "effect-b", "release-b", {"sha256": "bbb"}
|
||||
)
|
||||
release = _release_ledger(db, lineage)
|
||||
second = release.set_target(
|
||||
"tenant-a", "user-a", "component-b", "staging", "release-b", {"sha256": "bbb"},
|
||||
expected_revision=None,
|
||||
)
|
||||
assert second.revision == 1
|
||||
assert release.get_target("tenant-a", "user-a", "component-a", "staging").release_id == "release-1"
|
||||
assert release.get_target("tenant-a", "user-a", "component-b", "staging").release_id == "release-b"
|
||||
|
||||
shared = release.set_target(
|
||||
"tenant-a", "operator-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1,
|
||||
)
|
||||
assert shared.revision == 2
|
||||
assert shared.updated_by_subject_id == "operator-a"
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1,
|
||||
)
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
release.get_target("tenant-a", "intruder", "component-a", "staging")
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
release.set_target(
|
||||
"tenant-a", "intruder", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=2,
|
||||
)
|
||||
|
||||
|
||||
def test_unversioned_candidate_release_schema_is_rejected(tmp_path, authority):
|
||||
db = tmp_path / "legacy-candidate.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
with sqlite3.connect(db) as connection:
|
||||
connection.execute("CREATE TABLE release_targets(tenant_id TEXT, environment TEXT)")
|
||||
with pytest.raises(ReleaseConflict, match="unversioned"):
|
||||
ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
|
||||
|
||||
def test_target_rollback_requires_the_persisted_compatible_binding(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
with pytest.raises(ReleaseConflict, match="rollback target"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1, stage="ROLLBACK_STAGED",
|
||||
)
|
||||
|
||||
|
||||
def test_outer_artifact_manifest_must_not_hash_itself(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="self hash"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1",
|
||||
{"files": [], "manifest_sha256": "recursive-impossibility"},
|
||||
)
|
||||
|
||||
|
||||
def test_release_saga_reuses_effect_after_crash_and_advances_only_verified_receipt(
|
||||
tmp_path, authority
|
||||
):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
seen = []
|
||||
|
||||
def crash(operation):
|
||||
seen.append(operation["effect_id"])
|
||||
raise RuntimeError("process crashed after durable external sink write")
|
||||
|
||||
with pytest.raises(RuntimeError, match="crashed"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, crash))
|
||||
assert release.get_saga("tenant-a", "user-a", "saga-1")["revision"] == 0
|
||||
|
||||
# A new process/object observes the intent and keeps its stable effect fence.
|
||||
release = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
with pytest.raises(ReleaseReconciliationRequired, match="RECONCILIATION_REQUIRED"):
|
||||
release.advance_saga(
|
||||
"tenant-a", "user-a", "saga-1", 0,
|
||||
GovernedAdapter(authority, lambda _operation: pytest.fail("unknown effect repeated")),
|
||||
)
|
||||
|
||||
def resume(operation):
|
||||
seen.append(operation["effect_id"])
|
||||
return {
|
||||
"signed_by": "configured-sink",
|
||||
"receipt_id": "receipt-install",
|
||||
"effect_id": operation["effect_id"],
|
||||
}
|
||||
|
||||
adapter=GovernedAdapter(authority, lambda _operation: pytest.fail("unknown effect repeated"), reconcile=resume)
|
||||
advanced = release.advance_saga("tenant-a", "user-a", "saga-1", 0, adapter)
|
||||
assert seen[0] == seen[1]
|
||||
assert advanced["stage_id"] == "health"
|
||||
assert advanced["revision"] == 1
|
||||
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, adapter)
|
||||
|
||||
|
||||
def test_release_rejects_receipt_for_wrong_effect(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
|
||||
calls = []
|
||||
|
||||
def wrong(operation):
|
||||
calls.append(operation["effect_id"])
|
||||
return {
|
||||
"signed_by": "configured-sink",
|
||||
"receipt_id": "receipt-wrong",
|
||||
"effect_id": "different-effect",
|
||||
}
|
||||
|
||||
with pytest.raises(ReleaseVerificationError, match="effect_id"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, wrong))
|
||||
with pytest.raises(ReleaseVerificationError, match="effect_id"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, wrong))
|
||||
assert len(calls) == 1 # the durable unverified receipt prevents effect replay
|
||||
assert release.get_saga("tenant-a", "user-a", "saga-1")["revision"] == 0
|
||||
|
||||
|
||||
def test_release_rejects_verified_receipt_for_other_component(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
|
||||
def wrong_component(raw, expected):
|
||||
return VerifiedReceipt(
|
||||
receipt_id="receipt-wrong-component", tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"], component_id="component-b",
|
||||
task_id=expected["task_id"],source_task_id=expected["source_task_id"],
|
||||
source_effect_id=expected["source_effect_id"],grant_id=expected["grant_id"],job_id=expected["job_id"],
|
||||
effect_id=expected["effect_id"], operation_id=expected["operation_id"],
|
||||
deployment_id=expected["deployment_id"], release_id=expected["release_id"],
|
||||
artifact=expected["artifact"], observed={"healthy": True},
|
||||
)
|
||||
|
||||
release = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=wrong_component,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
release.register_recipe("recipe", [{"stage_id": "install", "operation_id": "deploy.install"}])
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=None,
|
||||
)
|
||||
release.start_saga(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "saga", "release-1",
|
||||
"deployment", "staging", {"sha256": "abc"}, "recipe", expected_target_revision=1,
|
||||
)
|
||||
with pytest.raises(ReleaseVerificationError, match="component_id"):
|
||||
release.advance_saga(
|
||||
"tenant-a", "user-a", "saga", 0,
|
||||
GovernedAdapter(authority, lambda operation: {"effect_id": operation["effect_id"]}),
|
||||
)
|
||||
|
||||
|
||||
def test_outer_artifact_manifest_cannot_hash_itself(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect("tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1")
|
||||
with pytest.raises(ValueError, match="self hash"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1",
|
||||
{"sha256": "abc", "manifest_sha256": "self"},
|
||||
)
|
||||
|
||||
|
||||
def _signed_envelope(private_key, domain, schema, payload, key_id="configured-key"):
|
||||
canonical = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
).encode()
|
||||
signature = private_key.sign(domain + b"\x00" + canonical)
|
||||
return {
|
||||
"schema": schema,
|
||||
"key_id": key_id,
|
||||
"payload": payload,
|
||||
"signature": base64.urlsafe_b64encode(signature).rstrip(b"=").decode(),
|
||||
}
|
||||
|
||||
|
||||
def test_ed25519_release_verifier_authenticates_domain_and_every_binding():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
verifier = ed25519_release_receipt_verifier(
|
||||
{"configured-key": private_key.public_key()}
|
||||
)
|
||||
expected = {
|
||||
"tenant_id": "tenant-a", "subject_id": "user-a", "effect_id": "effect-a",
|
||||
"component_id": "component-a", "task_id": "task-a", "source_task_id": "task-a",
|
||||
"source_effect_id": "source-effect-a", "grant_id": "grant-a", "job_id": "job-a",
|
||||
"operation_id": "deploy.install", "deployment_id": "deployment-a",
|
||||
"release_id": "release-a", "component_id": "component-a",
|
||||
"artifact": {"sha256": "abc"},
|
||||
}
|
||||
payload = {
|
||||
**expected, "receipt_id": "receipt-a", "outcome": "SUCCEEDED",
|
||||
"observed": {"installed": True}, "observed_at": "2026-09-07T20:00:00Z",
|
||||
}
|
||||
envelope = _signed_envelope(
|
||||
private_key, RELEASE_RECEIPT_DOMAIN,
|
||||
"aios.release-operation-receipt-envelope.v1", payload,
|
||||
)
|
||||
assert verifier(envelope, expected).effect_id == "effect-a"
|
||||
|
||||
tampered = {**envelope, "payload": {**payload, "deployment_id": "other"}}
|
||||
with pytest.raises(SignatureVerificationError, match="signature"):
|
||||
verifier(tampered, expected)
|
||||
cross_domain = _signed_envelope(
|
||||
private_key, ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
"aios.release-operation-receipt-envelope.v1", payload,
|
||||
)
|
||||
with pytest.raises(SignatureVerificationError, match="signature"):
|
||||
verifier(cross_domain, expected)
|
||||
with pytest.raises(SignatureVerificationError, match="fields"):
|
||||
verifier({**envelope, "public_key": "caller-controlled"}, expected)
|
||||
|
||||
|
||||
def test_ed25519_acceptance_verifier_binds_decision_and_deployment(tmp_path, authority):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
verifier = ed25519_acceptance_evidence_verifier(
|
||||
{"acceptance-key": private_key.public_key()}
|
||||
)
|
||||
expected = {
|
||||
"acceptance_id": "acceptance-crypto", "tenant_id": "tenant-a",
|
||||
"subject_id": "user-a", "deployment_id": "deployment-1",
|
||||
"release_id": "release-1", "component_id": "component-a",
|
||||
"artifact": {"sha256": "abc"}, "accepted": True,
|
||||
}
|
||||
payload = {
|
||||
**expected, "evidence_id": "evidence-a", "observed": {"healthy": True},
|
||||
"observed_at": "2026-09-07T20:00:00Z",
|
||||
}
|
||||
envelope = _signed_envelope(
|
||||
private_key, ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
"aios.release-acceptance-evidence-envelope.v1", payload, key_id="acceptance-key",
|
||||
)
|
||||
assert verifier(envelope, expected).accepted is True
|
||||
ledger = build_lineage(tmp_path / "signed-acceptance.sqlite", authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
stored = ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-crypto",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence=envelope, verifier=verifier,
|
||||
)
|
||||
assert stored["evidence_id"] == "evidence-a"
|
||||
with pytest.raises(SignatureVerificationError, match="accepted"):
|
||||
verifier(envelope, {**expected, "accepted": False})
|
||||
with pytest.raises(SignatureVerificationError, match="deployment_id"):
|
||||
verifier(envelope, {**expected, "deployment_id": "deployment-other"})
|
||||
with pytest.raises(SignatureVerificationError, match="component_id"):
|
||||
verifier(envelope, {**expected, "component_id": "component-b"})
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_baseline.service import Identity
|
||||
from aios_baseline.transport import HttpTransport, TransportError, serve
|
||||
from aios_baseline.worker import OperationWorker
|
||||
from tests.baseline_v1_1.test_loop import REQUEST, USER, WORKER, fixture_service
|
||||
|
||||
|
||||
def _running_server(service):
|
||||
server = serve(
|
||||
service,
|
||||
{"user-token-012345678901234567890123456789": USER, "worker-token-012345678901234567890123456789": WORKER},
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, thread
|
||||
|
||||
|
||||
def _stop(server, thread):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_real_http_goal_worker_receipt_query_and_explain(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
worker_transport = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
created = user.submit_goal(REQUEST, idempotency_key="transport-a")
|
||||
|
||||
worker = OperationWorker(worker_transport, poll_seconds=0.01)
|
||||
completed = worker.run_once()
|
||||
|
||||
assert completed["task_id"] == created["task_id"]
|
||||
assert completed["status"] == "COMPLETED"
|
||||
assert completed["receipt"]["observation"]["properties"]["MainPID"] == "123"
|
||||
assert user.get_task(created["task_id"])["status"] == "COMPLETED"
|
||||
explained = user.explain("mission-a")
|
||||
assert explained["mission_id"] == "mission-a"
|
||||
assert [item["task_id"] for item in explained["tasks_runtime"]] == [created["task_id"]]
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_cancel_and_worker_claim_have_no_client_completion_or_receipt_route(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
worker = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
created = user.submit_goal(REQUEST, idempotency_key="transport-cancel")
|
||||
cancelled = user.cancel_task(created["task_id"])
|
||||
worker.register_worker()
|
||||
worker.heartbeat()
|
||||
assert cancelled["status"] == "CANCELLED"
|
||||
assert worker.claim_next() is None
|
||||
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
body = json.dumps({"task_id": created["task_id"], "receipt": {"success": True}})
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/workers/complete",
|
||||
body=body,
|
||||
headers={
|
||||
"Authorization": "Bearer worker-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body.encode())),
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
connection.close()
|
||||
assert response.status == 404
|
||||
assert user.get_task(created["task_id"])["status"] == "CANCELLED"
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_server_uses_token_identity_and_rejects_body_scope_claims(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_IDENTITY_FIELD_FORBIDDEN") as exc:
|
||||
user.submit_goal({**REQUEST, "tenant_id": "tenant-b"}, idempotency_key="spoof")
|
||||
assert exc.value.status == 400
|
||||
|
||||
worker = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_IDENTITY_FIELD_FORBIDDEN"):
|
||||
worker._request("POST", "/v1/workers/register", {"tenant_id": "tenant-b"})
|
||||
|
||||
outsider = HttpTransport(base_url, "not-a-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_UNAUTHORIZED") as unauthorized:
|
||||
outsider.submit_goal(REQUEST, idempotency_key="unauthorized")
|
||||
assert unauthorized.value.status == 401
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_http_boundary_rejects_large_malformed_or_expired_requests(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
try:
|
||||
def post(raw, *, deadline):
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/goals/submit",
|
||||
body=raw,
|
||||
headers={
|
||||
"Authorization": "Bearer user-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(raw)),
|
||||
"X-AIOS-Deadline": deadline,
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
connection.close()
|
||||
return response.status, payload
|
||||
|
||||
status, payload = post(b"{" + b"x" * 70_000, deadline=str(time.time() + 3))
|
||||
assert status == 413
|
||||
assert b"HTTP_BODY_TOO_LARGE" in payload
|
||||
|
||||
status, payload = post(b"not-json", deadline=str(time.time() + 3))
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_INVALID" in payload
|
||||
|
||||
status, payload = post(
|
||||
b'{"request":{},"request":{},"idempotency_key":"duplicate"}',
|
||||
deadline=str(time.time() + 3),
|
||||
)
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_DUPLICATE_KEY" in payload
|
||||
|
||||
status, payload = post(
|
||||
b'{"request":{},"idempotency_key":NaN}', deadline=str(time.time() + 3)
|
||||
)
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_NON_FINITE" in payload
|
||||
|
||||
status, payload = post(b"{}", deadline=str(time.time() - 1))
|
||||
assert status == 408
|
||||
assert b"HTTP_DEADLINE_EXPIRED" in payload
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_internal_errors_do_not_return_tracebacks_exception_text_or_tokens(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
original = service.get_task
|
||||
|
||||
def fail(_identity, _task_id):
|
||||
raise RuntimeError("user-token-012345678901234567890123456789 super-secret traceback bait")
|
||||
|
||||
service.get_task = fail
|
||||
server, thread = _running_server(service)
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"GET",
|
||||
"/v1/tasks/task-example",
|
||||
headers={
|
||||
"Authorization": "Bearer user-token-012345678901234567890123456789",
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read().decode()
|
||||
connection.close()
|
||||
assert response.status == 500
|
||||
assert json.loads(payload) == {"error": {"code": "HTTP_INTERNAL_ERROR"}}
|
||||
assert "secret" not in payload.lower()
|
||||
assert "traceback" not in payload.lower()
|
||||
assert "user-token-012345678901234567890123456789" not in payload
|
||||
finally:
|
||||
service.get_task = original
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_transport_is_loopback_only_and_rejects_redirects():
|
||||
with pytest.raises(TransportError, match="TRANSPORT_LOOPBACK_REQUIRED"):
|
||||
HttpTransport("http://example.com:8080", "token-012345678901234567890123456789")
|
||||
|
||||
aios_server = serve(object(), {"worker-token-012345678901234567890123456789": WORKER}, port=0)
|
||||
aios_thread = threading.Thread(target=aios_server.serve_forever, daemon=True)
|
||||
aios_thread.start()
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", aios_server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"PUT",
|
||||
"/v1/workers/execute",
|
||||
body=b"{}",
|
||||
headers={
|
||||
"Authorization": "Bearer worker-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
connection.close()
|
||||
assert response.status == 405
|
||||
assert json.loads(payload) == {"error": {"code": "HTTP_METHOD_NOT_ALLOWED"}}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
finally:
|
||||
_stop(aios_server, aios_thread)
|
||||
|
||||
class Redirect(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/elsewhere")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *_args):
|
||||
return
|
||||
|
||||
redirector = ThreadingHTTPServer(("127.0.0.1", 0), Redirect)
|
||||
thread = threading.Thread(target=redirector.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
transport = HttpTransport(f"http://127.0.0.1:{redirector.server_port}", "token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="TRANSPORT_REDIRECT_REJECTED"):
|
||||
transport.get_task("task-a")
|
||||
finally:
|
||||
_stop(redirector, thread)
|
||||
|
||||
|
||||
def test_worker_run_forever_uses_stop_event_wait_and_stops(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, server_thread = _running_server(service)
|
||||
stop = threading.Event()
|
||||
worker = OperationWorker(
|
||||
HttpTransport(f"http://127.0.0.1:{server.server_port}", "worker-token-012345678901234567890123456789"),
|
||||
poll_seconds=0.01,
|
||||
stop_event=stop,
|
||||
)
|
||||
thread = threading.Thread(target=worker.run_forever, daemon=True)
|
||||
try:
|
||||
thread.start()
|
||||
deadline = time.time() + 2
|
||||
while not worker.registered and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert worker.registered is True
|
||||
stop.set()
|
||||
thread.join(timeout=1)
|
||||
assert not thread.is_alive()
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=1)
|
||||
_stop(server, server_thread)
|
||||
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from aios_baseline.worker_policy import WorkerProfile, eligible
|
||||
from aios_baseline.host import SystemdHost, HostError
|
||||
|
||||
|
||||
def profile(**changes):
|
||||
value = dict(worker_id='worker-a', identity='machine-a', tenant_scope=['tenant-a'],
|
||||
subject_scope=['user-a'], capabilities=['service-status'], priority=10,
|
||||
fallback_executor=True, heartbeat_seconds=15, max_concurrency=1)
|
||||
value.update(changes)
|
||||
return WorkerProfile.from_dict(value)
|
||||
|
||||
|
||||
def test_fallback_expands_only_after_ready_deadline():
|
||||
task = dict(tenant_id='tenant-a', subject_id='user-a', operation='service-status', ready_at=100)
|
||||
assert not eligible(profile(), task, now=699, last_heartbeat=699)
|
||||
assert eligible(profile(), task, now=700, last_heartbeat=700)
|
||||
assert eligible(profile(fallback_executor=False), task, now=101, last_heartbeat=101)
|
||||
assert not eligible(profile(), task, now=700, last_heartbeat=640)
|
||||
|
||||
|
||||
def test_fallback_cannot_expand_tenant_subject_or_capability():
|
||||
task = dict(tenant_id='tenant-a', subject_id='user-b', operation='service-status', ready_at=100)
|
||||
assert not eligible(profile(), task, now=800, last_heartbeat=800)
|
||||
assert not eligible(profile(), {**task, 'subject_id':'user-a','tenant_id':'tenant-b'}, now=800, last_heartbeat=800)
|
||||
assert not eligible(profile(), {**task, 'subject_id':'user-a','operation':'service-restart'}, now=800, last_heartbeat=800)
|
||||
|
||||
|
||||
def systemctl(tmp_path, body):
|
||||
path = tmp_path / 'systemctl-fixture'
|
||||
path.write_text('#!' + sys.executable + '\n' + body)
|
||||
path.chmod(0o700)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_host_status_uses_real_bounded_process_and_no_ambient_credentials(tmp_path, monkeypatch):
|
||||
executable = systemctl(tmp_path, "import os,sys\nassert 'AIOS_ADMIN_TOKEN' not in os.environ\nassert sys.argv[-1] == 'sample.service'\nprint('Id=sample.service\\nActiveState=active\\nSubState=running\\nMainPID=123\\nInvocationID=abc')\n")
|
||||
monkeypatch.setenv('AIOS_ADMIN_TOKEN','must-not-leak')
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable)
|
||||
result = host.status('sample', 'staging',tenant_id='tenant-a')
|
||||
assert result['healthy'] is True
|
||||
assert result['properties']['MainPID'] == '123'
|
||||
assert result['unit'] == 'sample.service'
|
||||
assert 'must-not-leak' not in str(result)
|
||||
with pytest.raises(HostError): host.status('sample; rm -rf /', 'staging',tenant_id='tenant-a')
|
||||
|
||||
|
||||
def test_host_nonzero_missing_properties_and_timeout_never_healthy(tmp_path):
|
||||
executable = systemctl(tmp_path, "import sys\nprint('Id=sample.service')\nsys.exit(1)\n")
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable)
|
||||
with pytest.raises(HostError): host.status('sample','staging',tenant_id='tenant-a')
|
||||
executable = systemctl(tmp_path, "print('Id=sample.service')\n")
|
||||
with pytest.raises(HostError): host.status('sample','staging',tenant_id='tenant-a')
|
||||
executable = systemctl(tmp_path, "import time\ntime.sleep(5)\n")
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable, timeout_seconds=0.1)
|
||||
start = time.monotonic()
|
||||
with pytest.raises(HostError, match='TIMEOUT'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
assert time.monotonic()-start < 2
|
||||
|
||||
|
||||
def test_host_kills_output_flood_and_rejects_changed_executable(tmp_path):
|
||||
executable=systemctl(tmp_path,"import sys\nsys.stdout.write('X'*200000)\n")
|
||||
host=SystemdHost({'tenant-a:staging:sample':'sample.service'},executable=executable)
|
||||
with pytest.raises(HostError,match='TOO_LARGE'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
Path(executable).write_text('#!'+sys.executable+'\nraise Exception("new unexpected program")\n')
|
||||
with pytest.raises(HostError,match='IDENTITY_CHANGED'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import aios_agent_task_api_lineage_supersession as api
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, db_path, verifier, **kwargs):
|
||||
self.db_path = db_path
|
||||
|
||||
def supersede(self, body):
|
||||
if body.get("fail"):
|
||||
raise api.TaskLineageSupersessionError("SOURCE_TASK_EXACT_CAS_MISMATCH", 409)
|
||||
return {"status": "SUPERSEDED", "idempotent": bool(body.get("replay"))}
|
||||
|
||||
|
||||
def test_endpoint_authenticates_before_controller(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(api, "TaskLineageSupersessionController", FakeController)
|
||||
endpoint = api.LineageSupersessionEndpoint(
|
||||
"unused.sqlite3", object(), lambda headers: calls.append(dict(headers))
|
||||
)
|
||||
status, result = endpoint.post({"Authorization": "Bearer control"}, {})
|
||||
assert status == 201
|
||||
assert result["status"] == "SUPERSEDED"
|
||||
assert calls == [{"Authorization": "Bearer control"}]
|
||||
assert endpoint.post({}, {"replay": True})[0] == 200
|
||||
assert endpoint.post({}, {"fail": True}) == (
|
||||
409, {"error": "SOURCE_TASK_EXACT_CAS_MISMATCH"}
|
||||
)
|
||||
|
||||
|
||||
def test_authentication_failure_prevents_controller(monkeypatch):
|
||||
called = False
|
||||
|
||||
class MustNotRun(FakeController):
|
||||
def supersede(self, body):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(api, "TaskLineageSupersessionController", MustNotRun)
|
||||
|
||||
def deny(_headers):
|
||||
raise PermissionError("denied")
|
||||
|
||||
endpoint = api.LineageSupersessionEndpoint("unused", object(), deny)
|
||||
with pytest.raises(PermissionError, match="denied"):
|
||||
endpoint.post({}, {})
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_runtime_binding_is_lazy_and_fails_closed_when_overlay_missing(monkeypatch):
|
||||
for name in (
|
||||
"aios_agent_task_api", "aios_agent_task_api_p0_expired_recovery",
|
||||
"aios_agent_task_protocol", "aios_owner_approval_verifier",
|
||||
):
|
||||
monkeypatch.delitem(sys.modules, name, raising=False)
|
||||
original = __import__
|
||||
|
||||
def blocked(name, *args, **kwargs):
|
||||
if name == "aios_agent_task_api_p0_expired_recovery":
|
||||
raise ImportError(name)
|
||||
return original(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", blocked)
|
||||
with pytest.raises(RuntimeError, match="RUNTIME_OVERLAY_UNAVAILABLE"):
|
||||
api.build_runtime()
|
||||
|
||||
|
||||
def test_error_status_is_not_inferred_from_untrusted_text():
|
||||
assert api._status(api.TaskLineageSupersessionError("anything", 403)) == 403
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_gitops_job_identity import ( # noqa: E402
|
||||
JobIdentityError,
|
||||
legacy_task_storage_key,
|
||||
task_storage_key,
|
||||
)
|
||||
|
||||
|
||||
def test_collision_resistant_key_separates_historical_lossy_collision() -> None:
|
||||
first = "TASK/A"
|
||||
second = "TASK:A"
|
||||
|
||||
assert legacy_task_storage_key(first) == legacy_task_storage_key(second) == "TASK-A"
|
||||
assert task_storage_key(first).startswith("TASK-A-")
|
||||
assert task_storage_key(second).startswith("TASK-A-")
|
||||
assert task_storage_key(first) != task_storage_key(second)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", " task", "task ", "x" * 513, None])
|
||||
def test_task_storage_key_rejects_noncanonical_task_identity(value: object) -> None:
|
||||
with pytest.raises(JobIdentityError, match="TASK_ID_INVALID"):
|
||||
task_storage_key(value) # type: ignore[arg-type]
|
||||
@@ -102,6 +102,10 @@ def test_http_route_contract_is_complete():
|
||||
"/v2/release/execute",
|
||||
"/v2/release/consistency",
|
||||
"/v2/release/acceptance",
|
||||
"/v2/repository/tree",
|
||||
"/v2/repository/search",
|
||||
"/v2/repository/diff-index",
|
||||
"/v2/repository/file",
|
||||
"/v2/task-admission/envelope",
|
||||
"/v2/task-admission/authorize",
|
||||
"/v2/task-admission/status",
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -20,7 +21,9 @@ COMMIT = "1" * 40
|
||||
|
||||
def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
store = JobStore(tmp_path / "job-root")
|
||||
controller = GitOpsJobController(store)
|
||||
controller = GitOpsJobController(
|
||||
store, clock=lambda: datetime(2026, 9, 8, tzinfo=timezone.utc)
|
||||
)
|
||||
request = {
|
||||
"idempotency_key": "v7-replay",
|
||||
"campaign_id": TASK,
|
||||
@@ -50,7 +53,7 @@ def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=2,
|
||||
lease_expires_at="2099-01-01T00:00:00Z",
|
||||
lease_expires_at="2026-09-08T00:01:00Z",
|
||||
expected_revision=row["revision"],
|
||||
)
|
||||
with store._connect() as connection:
|
||||
@@ -295,12 +298,25 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
|
||||
observed = {}
|
||||
class Adapter:
|
||||
def getRuntimeStatus(self):
|
||||
return {
|
||||
"governed_writes_enabled": True,
|
||||
"legacy_global_writes_enabled": False,
|
||||
"task_principal_writes_enabled": True,
|
||||
}
|
||||
|
||||
def commitFilesGoverned(self, body):
|
||||
observed.update(body)
|
||||
return {"job_id": body["job_id"], "current_head_oid": COMMIT}
|
||||
|
||||
def verify_principal(transport, request):
|
||||
assert transport["operation"] == "commitFilesGoverned"
|
||||
assert transport["headers"]["authorization"] == "Bearer fixture-principal"
|
||||
return {"task_id": request["task_id"], "tenant_id": "fixture"}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = Adapter()
|
||||
BridgeHandler.principal_verifier = verify_principal
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
body = {
|
||||
@@ -317,7 +333,15 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
}
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||||
connection.request("POST", "/v2/commit-files", body=json.dumps(body), headers={"Content-Type": "application/json"})
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v2/commit-files",
|
||||
body=json.dumps(body),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer fixture-principal",
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = json.loads(response.read().decode())
|
||||
connection.close()
|
||||
@@ -326,6 +350,7 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = None
|
||||
BridgeHandler.principal_verifier = None
|
||||
assert response.status == 200
|
||||
assert observed["job_id"] == EXTERNAL
|
||||
assert observed["source_lease_epoch"] == 3
|
||||
|
||||
@@ -218,10 +218,14 @@ def test_revision_cas_and_receipt_are_transactional():
|
||||
|
||||
def test_job_lease_mutation_is_transactional_and_revision_bound():
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
controller = GitOpsJobController(JobStore(temp))
|
||||
controller = GitOpsJobController(
|
||||
JobStore(temp),
|
||||
clock=lambda: datetime(2026, 8, 7, 23, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
req = {"campaign_id": "campaign", "repository": "aios/aios", "operation": "commit-file", "branch": request().target_branch, "expected_branch_oid": CAMPAIGN_HEAD, "payload_digest": "b" * 64}
|
||||
job = controller.submit(req)
|
||||
leased = controller.lease_job(job.job_id, lease_id="lease-1", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=CAMPAIGN_HEAD, generation=1, lease_expires_at="2026-08-08T00:00:00Z", expected_revision=0)
|
||||
authorized = controller.advance(job.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_job(job.job_id, lease_id="lease-1", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=CAMPAIGN_HEAD, generation=1, lease_expires_at="2026-08-08T00:00:00Z", expected_revision=authorized.revision)
|
||||
assert leased.state == JobState.LEASED
|
||||
controller.update_lease("lease-1", expected_revision=0, current_head_oid="c" * 40, generation=1)
|
||||
with pytest.raises(JobError, match="LEASE_REVISION_CAS_CONFLICT"):
|
||||
|
||||
@@ -229,6 +229,10 @@ class ExactPathPolicyTests(unittest.TestCase):
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "other.service"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy.grp,
|
||||
"getgrnam",
|
||||
return_value=mock.Mock(gr_gid=os.getgid()),
|
||||
), mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
@@ -242,6 +246,10 @@ class ExactPathPolicyTests(unittest.TestCase):
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/other"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy.grp,
|
||||
"getgrnam",
|
||||
return_value=mock.Mock(gr_gid=os.getgid()),
|
||||
), mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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
|
||||
@@ -162,3 +164,77 @@ def test_machine_transport_branch_cas_is_not_force_push(monkeypatch):
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_owner_merge_authorization_server import OwnerMergeApprovalService
|
||||
from aios_single_host_authority import (
|
||||
RegistryOwnerApprovalVerifier,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
|
||||
|
||||
NOW = 2_000_000_000
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_env(tmp_path: Path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
owner_path = tmp_path / "owner.key"
|
||||
_write_key(owner_path, owner_key)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
"public_key": ssh_public_line(owner_key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository="aios/aios",
|
||||
pr_number=75,
|
||||
pr_head_oid="a" * 40,
|
||||
expected_main_oid="b" * 40,
|
||||
operation="merge.pull-request",
|
||||
required_check_contract_digest="c" * 64,
|
||||
required_checks=("contract", "security"),
|
||||
owner_identity="owner:aios-single-host",
|
||||
audit_lineage_digest="d" * 64,
|
||||
maximum_ttl_seconds=300,
|
||||
)
|
||||
policy_path = tmp_path / "policy.json"
|
||||
policy_path.write_text(json.dumps(policy.as_dict(), sort_keys=True) + "\n")
|
||||
os.chmod(policy_path, 0o600)
|
||||
return {
|
||||
"policy": policy,
|
||||
"policy_path": policy_path,
|
||||
"registry_path": registry_path,
|
||||
"owner_path": owner_path,
|
||||
"ledger_path": tmp_path / "issuance.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def _service(env):
|
||||
return OwnerMergeApprovalService(
|
||||
policy_path=env["policy_path"],
|
||||
registry_path=env["registry_path"],
|
||||
owner_key_path=env["owner_path"],
|
||||
issuance_ledger_path=env["ledger_path"],
|
||||
allowed_uids=(1000,),
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "server-generated-nonce",
|
||||
)
|
||||
|
||||
|
||||
def test_service_issues_exact_owner_approval_and_exact_replay_is_audit_idempotent(service_env):
|
||||
service = _service(service_env)
|
||||
request = {"operation": "issue_owner_merge_approval", "payload": {}}
|
||||
|
||||
first = service.handle(request, peer_uid=1000)
|
||||
second = _service(service_env).handle(request, peer_uid=1000)
|
||||
|
||||
assert first["status"] == "PASS"
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["artifact"] == first["artifact"]
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=RegistryOwnerApprovalVerifier(
|
||||
service_env["registry_path"], require_root_owner=False
|
||||
),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
payload, proof_path = verifier.verify(first["artifact"], service_env["policy"])
|
||||
assert proof_path == "primary"
|
||||
assert payload["repository"] == "aios/aios"
|
||||
assert payload["pr_number"] == 75
|
||||
|
||||
|
||||
def test_expired_approval_is_retained_for_audit_but_reissued_with_a_new_nonce(service_env):
|
||||
clock = [NOW]
|
||||
token_values = iter(("approval-one", "nonce-one", "approval-two", "nonce-two"))
|
||||
service = OwnerMergeApprovalService(
|
||||
policy_path=service_env["policy_path"],
|
||||
registry_path=service_env["registry_path"],
|
||||
owner_key_path=service_env["owner_path"],
|
||||
issuance_ledger_path=service_env["ledger_path"],
|
||||
allowed_uids=(1000,),
|
||||
require_root_owner=False,
|
||||
clock=lambda: clock[0],
|
||||
token_urlsafe=lambda _size: next(token_values),
|
||||
)
|
||||
request = {"operation": "issue_owner_merge_approval", "payload": {}}
|
||||
|
||||
first = service.handle(request, peer_uid=1000)
|
||||
clock[0] += service_env["policy"].maximum_ttl_seconds
|
||||
second = service.handle(request, peer_uid=1000)
|
||||
|
||||
assert first["idempotent"] is False
|
||||
assert second["idempotent"] is False
|
||||
assert second["artifact"]["payload"]["nonce"] != first["artifact"]["payload"]["nonce"]
|
||||
assert second["artifact"]["payload"]["issued_at"] == clock[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_request",
|
||||
[
|
||||
{"operation": "issue_owner_merge_approval", "payload": {"nonce": "caller-nonce"}},
|
||||
{"operation": "issue_owner_merge_approval", "payload": {"pr_head_oid": "f" * 40}},
|
||||
{"operation": "sign", "payload": {}},
|
||||
{"operation": "sign_bytes", "payload": {}},
|
||||
],
|
||||
)
|
||||
def test_service_rejects_caller_selected_scope_and_generic_signing(service_env, raw_request):
|
||||
service = _service(service_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_ISSUER_"):
|
||||
service.handle(raw_request, peer_uid=1000)
|
||||
|
||||
|
||||
def test_service_rejects_untrusted_peer_before_issuing_anything(service_env):
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN"):
|
||||
_service(service_env).handle(
|
||||
{"operation": "issue_owner_merge_approval", "payload": {}},
|
||||
peer_uid=2000,
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
GovernedOwnerMergeAdmission,
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
OwnerMergeLiveState,
|
||||
OwnerMergeOidReadback,
|
||||
SQLiteOwnerMergeNonceLedger,
|
||||
)
|
||||
from aios_single_host_authority import (
|
||||
RegistryOwnerApprovalVerifier,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
|
||||
|
||||
NOW = 2_000_000_000
|
||||
REPOSITORY = "aios/aios"
|
||||
PR_NUMBER = 75
|
||||
HEAD = "a" * 40
|
||||
BASE = "b" * 40
|
||||
MERGED = "c" * 40
|
||||
CHECKS = ("contract", "security", "unit")
|
||||
CHECK_CONTRACT = "d" * 64
|
||||
LINEAGE = "e" * 64
|
||||
|
||||
|
||||
def _write_key(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class RecordingExecutor:
|
||||
def __init__(self, result: str = MERGED) -> None:
|
||||
self.result = result
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def execute(self, approval: dict[str, object]) -> str:
|
||||
self.calls.append(dict(approval))
|
||||
return self.result
|
||||
|
||||
|
||||
class RecordingReconciler:
|
||||
def __init__(self, oid: str = MERGED) -> None:
|
||||
self.oid = oid
|
||||
self.calls: list[str] = []
|
||||
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
self.calls.append(merge_oid)
|
||||
return OwnerMergeOidReadback(
|
||||
authority_main_oid=self.oid,
|
||||
gitea_main_oid=self.oid,
|
||||
api_main_oid=self.oid,
|
||||
)
|
||||
|
||||
|
||||
class UnavailableProofVerifier:
|
||||
def verify(self, **_kwargs: object) -> bool:
|
||||
raise RuntimeError("default signer/verifier unavailable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approval_env(tmp_path: Path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
owner_path = tmp_path / "owner.key"
|
||||
_write_key(owner_path, owner_key)
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(owner_key.public_key()),
|
||||
"public_key": ssh_public_line(owner_key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://owner",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
os.chmod(registry_path, 0o644)
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=REPOSITORY,
|
||||
pr_number=PR_NUMBER,
|
||||
pr_head_oid=HEAD,
|
||||
expected_main_oid=BASE,
|
||||
operation="merge.pull-request",
|
||||
required_check_contract_digest=CHECK_CONTRACT,
|
||||
required_checks=CHECKS,
|
||||
owner_identity="owner:aios-single-host",
|
||||
audit_lineage_digest=LINEAGE,
|
||||
maximum_ttl_seconds=300,
|
||||
)
|
||||
authority = OwnerMergeApprovalAuthority(
|
||||
owner_path,
|
||||
policy,
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "fixed-nonce-material",
|
||||
)
|
||||
artifact = authority.issue()
|
||||
registry_verifier = RegistryOwnerApprovalVerifier(
|
||||
registry_path,
|
||||
require_root_owner=False,
|
||||
)
|
||||
return {
|
||||
"policy": policy,
|
||||
"artifact": artifact,
|
||||
"registry_verifier": registry_verifier,
|
||||
"registry_path": registry_path,
|
||||
"owner_path": owner_path,
|
||||
"nonce_path": tmp_path / "nonces.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def _live_state(**updates: object) -> OwnerMergeLiveState:
|
||||
raw = {
|
||||
"repository": REPOSITORY,
|
||||
"pr_number": PR_NUMBER,
|
||||
"pr_head_oid": HEAD,
|
||||
"main_oid": BASE,
|
||||
"required_check_contract_digest": CHECK_CONTRACT,
|
||||
"check_statuses": {name: True for name in CHECKS},
|
||||
}
|
||||
raw.update(updates)
|
||||
return OwnerMergeLiveState(**raw)
|
||||
|
||||
|
||||
def _admission(env, *, live: OwnerMergeLiveState | None = None, primary=None, recovery=None, reconciler=None):
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=primary or env["registry_verifier"],
|
||||
recovery=recovery,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
executor = RecordingExecutor()
|
||||
reconciler = reconciler or RecordingReconciler()
|
||||
admission = GovernedOwnerMergeAdmission(
|
||||
policy=env["policy"],
|
||||
verifier=verifier,
|
||||
live_state_reader=lambda: live or _live_state(),
|
||||
executor=executor,
|
||||
reconciler=reconciler,
|
||||
nonce_ledger=SQLiteOwnerMergeNonceLedger(env["nonce_path"]),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
return admission, executor, reconciler
|
||||
|
||||
|
||||
def test_valid_owner_approval_with_exact_live_binding_enters_governed_merge(approval_env):
|
||||
admission, executor, reconciler = _admission(approval_env)
|
||||
|
||||
receipt = admission.admit(approval_env["artifact"])
|
||||
|
||||
assert receipt.merge_oid == MERGED
|
||||
assert receipt.owner_identity == "owner:aios-single-host"
|
||||
assert len(executor.calls) == 1
|
||||
assert reconciler.calls == [MERGED]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("live", "code"),
|
||||
[
|
||||
(_live_state(pr_head_oid="f" * 40), "OWNER_MERGE_HEAD_DRIFT"),
|
||||
(_live_state(main_oid="f" * 40), "OWNER_MERGE_BASE_DRIFT"),
|
||||
],
|
||||
)
|
||||
def test_head_or_main_drift_invalidates_owner_approval_before_execution(approval_env, live, code):
|
||||
admission, executor, _reconciler = _admission(approval_env, live=live)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match=code):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_expired_owner_approval_is_rejected_before_execution(approval_env):
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=approval_env["registry_verifier"],
|
||||
clock=lambda: NOW + 301,
|
||||
)
|
||||
executor = RecordingExecutor()
|
||||
admission = GovernedOwnerMergeAdmission(
|
||||
policy=approval_env["policy"],
|
||||
verifier=verifier,
|
||||
live_state_reader=_live_state,
|
||||
executor=executor,
|
||||
reconciler=RecordingReconciler(),
|
||||
nonce_ledger=SQLiteOwnerMergeNonceLedger(approval_env["nonce_path"]),
|
||||
clock=lambda: NOW + 301,
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_EXPIRED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_changed_policy_scope_requires_a_formal_owner_rebind(approval_env):
|
||||
replacement = replace(approval_env["policy"], expected_main_oid="f" * 40)
|
||||
verifier = OwnerMergeApprovalVerifier(
|
||||
primary=approval_env["registry_verifier"],
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID"):
|
||||
verifier.verify(approval_env["artifact"], replacement)
|
||||
|
||||
|
||||
def test_nonce_replay_cannot_create_a_second_merge_side_effect(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_non_owner_proof_is_rejected_even_when_payload_claims_owner_identity(approval_env):
|
||||
attacker_path = approval_env["owner_path"].parent / "attacker.key"
|
||||
_write_key(attacker_path, Ed25519PrivateKey.generate())
|
||||
attacker = OwnerMergeApprovalAuthority(
|
||||
attacker_path,
|
||||
approval_env["policy"],
|
||||
require_root_owner=False,
|
||||
clock=lambda: NOW,
|
||||
token_urlsafe=lambda _size: "attacker-nonce-material",
|
||||
)
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_SIGNATURE_INVALID"):
|
||||
admission.admit(attacker.issue())
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_recovery_verifier_can_prove_existing_owner_approval_when_primary_is_unavailable(approval_env):
|
||||
recovery = approval_env["registry_verifier"]
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
primary=UnavailableProofVerifier(),
|
||||
recovery=recovery,
|
||||
)
|
||||
|
||||
receipt = admission.admit(approval_env["artifact"])
|
||||
|
||||
assert receipt.proof_path == "recovery"
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_missing_trusted_owner_proof_fails_closed(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_APPROVAL_ARTIFACT_INVALID"):
|
||||
admission.admit({})
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_owner_approval_never_converts_failed_technical_checks_to_pass(approval_env):
|
||||
statuses = {name: True for name in CHECKS}
|
||||
statuses["security"] = False
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
live=_live_state(check_statuses=statuses),
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_REQUIRED_CHECKS_FAILED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
def test_post_merge_oid_disagreement_is_not_reported_as_success_and_is_not_retried(approval_env):
|
||||
admission, executor, _reconciler = _admission(
|
||||
approval_env,
|
||||
reconciler=RecordingReconciler(oid="f" * 40),
|
||||
)
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_OID_RECONCILIATION_REQUIRED"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
def test_malformed_executor_result_is_unknown_and_cannot_be_retried(approval_env):
|
||||
admission, executor, _reconciler = _admission(approval_env)
|
||||
executor.result = "not-an-oid"
|
||||
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_EXECUTOR_RESULT_INVALID"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
with pytest.raises(OwnerMergeApprovalError, match="OWNER_MERGE_NONCE_REPLAY"):
|
||||
admission.admit(approval_env["artifact"])
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
import ast
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -26,7 +27,19 @@ def test_bridge_unit_keeps_strict_sandbox_and_required_projection():
|
||||
).read_text()
|
||||
assert "ProtectSystem=strict" in text
|
||||
assert "ProtectHome=true" in text
|
||||
assert (
|
||||
"LoadCredential=gitea_token:"
|
||||
) in text
|
||||
assert "Environment=AIOS_P0_TASK_PRINCIPAL_WRITES_ENABLED=true" in text
|
||||
projection = re.search(
|
||||
r"^LoadCredentialEncrypted=([^:]+):/etc/credstore\.encrypted/([^\n]+)$",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
consumer = re.search(
|
||||
r"^Environment=AIOS_GITEA_TOKEN_FILE=%d/([^\n]+)$",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert projection is not None
|
||||
assert consumer is not None
|
||||
assert projection.group(1) == projection.group(2) == consumer.group(1)
|
||||
assert "/run/credentials/aios-governance-executor.service/" not in text
|
||||
assert "/data/aios-gitops/repo.git/worktrees" in text
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from release_source_admission import ( # noqa: E402
|
||||
ReleaseSourceAdmissionError,
|
||||
ReleaseSourceAdmissionStore,
|
||||
ReleaseSourceAdmitter,
|
||||
)
|
||||
|
||||
|
||||
MAIN = "1" * 40
|
||||
SOURCE = "2" * 40
|
||||
TREE = "3" * 40
|
||||
POLICY = "4" * 64
|
||||
REF = "refs/heads/agent/baseline-1.1-principal-recovery-20260908"
|
||||
VERSION = "V2R2C4"
|
||||
TASK = "AIOS-BASELINE-1.1-PRODUCTION-CLOSURE"
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
remote: str = SOURCE,
|
||||
fetched: str = SOURCE,
|
||||
tree: str = TREE,
|
||||
main: str = MAIN,
|
||||
lineage: bool = True,
|
||||
) -> None:
|
||||
self.remote = remote
|
||||
self.fetched = fetched
|
||||
self.tree = tree
|
||||
self.main = main
|
||||
self.lineage = lineage
|
||||
self.fetch_calls: list[tuple[str, str]] = []
|
||||
self.lineage_calls: list[tuple[str, str]] = []
|
||||
|
||||
def authoritative_main_oid(self) -> str:
|
||||
return self.main
|
||||
|
||||
def remote_ref_oid(self, source_ref: str) -> str:
|
||||
assert source_ref == REF
|
||||
return self.remote
|
||||
|
||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str:
|
||||
self.fetch_calls.append((source_ref, source_commit))
|
||||
return self.fetched
|
||||
|
||||
def tree_oid(self, source_commit: str) -> str:
|
||||
assert source_commit == SOURCE
|
||||
return self.tree
|
||||
|
||||
def require_lineage(self, base_oid: str, source_commit: str) -> None:
|
||||
self.lineage_calls.append((base_oid, source_commit))
|
||||
if not self.lineage:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_LINEAGE_MISMATCH")
|
||||
|
||||
|
||||
def make_store(tmp_path: Path, *, writable: bool = True) -> ReleaseSourceAdmissionStore:
|
||||
owner = os.geteuid() if hasattr(os, "geteuid") else None
|
||||
return ReleaseSourceAdmissionStore(
|
||||
tmp_path / "admissions",
|
||||
repository="aios/aios",
|
||||
policy_sha256=POLICY,
|
||||
hmac_key=b"h" * 32,
|
||||
expected_owner_uid=owner,
|
||||
writable=writable,
|
||||
)
|
||||
|
||||
|
||||
def test_admit_binds_remote_ref_fetch_tree_main_lineage_and_recipe(tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
backend = FakeBackend()
|
||||
|
||||
record = ReleaseSourceAdmitter(store, backend).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
|
||||
assert record["schema"] == "aios.release-source-admission.v1"
|
||||
assert record["repository"] == "aios/aios"
|
||||
assert record["source_commit"] == SOURCE
|
||||
assert record["remote_ref_oid"] == SOURCE
|
||||
assert record["source_ref"] == REF
|
||||
assert record["base_oid"] == MAIN
|
||||
assert record["tree_oid"] == TREE
|
||||
assert record["version"] == VERSION
|
||||
assert record["task_id"] == TASK
|
||||
assert record["candidate_id"] == "rc-v2r2c4-" + SOURCE[:12]
|
||||
assert backend.fetch_calls == [(REF, SOURCE)]
|
||||
assert backend.lineage_calls == [(MAIN, SOURCE)]
|
||||
|
||||
recipe = store.resolve_recipe(VERSION, None, SOURCE)
|
||||
assert recipe == {
|
||||
"mode": "governed-source-admission",
|
||||
"task_id": TASK,
|
||||
"candidate_id": record["candidate_id"],
|
||||
"base_oid": MAIN,
|
||||
"source_commit": SOURCE,
|
||||
"version": VERSION,
|
||||
"purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
}
|
||||
|
||||
|
||||
def test_repeated_exact_admission_is_idempotent(tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
admitter = ReleaseSourceAdmitter(store, FakeBackend())
|
||||
|
||||
first = admitter.admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
second = admitter.admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
|
||||
assert second == first
|
||||
assert len(list((tmp_path / "admissions").glob("*.json"))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backend", "error"),
|
||||
(
|
||||
(FakeBackend(remote="5" * 40), "SOURCE_REF_MOVED"),
|
||||
(FakeBackend(fetched="5" * 40), "SOURCE_FETCH_MISMATCH"),
|
||||
(FakeBackend(lineage=False), "SOURCE_LINEAGE_MISMATCH"),
|
||||
),
|
||||
)
|
||||
def test_admission_rejects_moved_fetch_mismatch_or_non_descendant(
|
||||
tmp_path: Path,
|
||||
backend: FakeBackend,
|
||||
error: str,
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ReleaseSourceAdmissionError, match=error):
|
||||
ReleaseSourceAdmitter(store, backend).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
assert not list((tmp_path / "admissions").glob("*.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source_ref",
|
||||
(
|
||||
"agent/bad",
|
||||
"refs/tags/v1",
|
||||
"refs/heads/../main",
|
||||
"refs/heads/agent//bad",
|
||||
"refs/heads/agent/bad.lock",
|
||||
),
|
||||
)
|
||||
def test_admission_rejects_unsafe_or_non_head_refs(
|
||||
tmp_path: Path, source_ref: str
|
||||
) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ReleaseSourceAdmissionError, match="SOURCE_REF_INVALID"):
|
||||
ReleaseSourceAdmitter(store, FakeBackend()).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=source_ref,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
|
||||
|
||||
def test_store_rejects_caller_candidate_rebinding(tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
record = ReleaseSourceAdmitter(store, FakeBackend()).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
with pytest.raises(
|
||||
ReleaseSourceAdmissionError,
|
||||
match="SOURCE_ADMISSION_CANDIDATE_MISMATCH",
|
||||
):
|
||||
store.resolve_recipe(VERSION, "rc-attacker", SOURCE)
|
||||
assert record["candidate_id"] != "rc-attacker"
|
||||
|
||||
|
||||
def test_store_rejects_policy_change_and_record_tamper(tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
record = ReleaseSourceAdmitter(store, FakeBackend()).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
path = tmp_path / "admissions" / f"{SOURCE}.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["task_id"] = "other-task"
|
||||
path.write_text(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ReleaseSourceAdmissionError,
|
||||
match="SOURCE_ADMISSION_MAC_INVALID",
|
||||
):
|
||||
store.lookup_source(SOURCE)
|
||||
|
||||
owner = os.geteuid() if hasattr(os, "geteuid") else None
|
||||
other_policy = ReleaseSourceAdmissionStore(
|
||||
tmp_path / "admissions",
|
||||
repository="aios/aios",
|
||||
policy_sha256="6" * 64,
|
||||
hmac_key=b"h" * 32,
|
||||
expected_owner_uid=owner,
|
||||
writable=False,
|
||||
)
|
||||
with pytest.raises(
|
||||
ReleaseSourceAdmissionError,
|
||||
match="SOURCE_ADMISSION_POLICY_MISMATCH",
|
||||
):
|
||||
other_policy.lookup_source(SOURCE)
|
||||
|
||||
|
||||
def test_store_rejects_group_writable_record(tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
ReleaseSourceAdmitter(store, FakeBackend()).admit(
|
||||
version=VERSION,
|
||||
task_id=TASK,
|
||||
source_ref=REF,
|
||||
source_commit=SOURCE,
|
||||
)
|
||||
path = tmp_path / "admissions" / f"{SOURCE}.json"
|
||||
os.chmod(path, 0o660)
|
||||
with pytest.raises(
|
||||
ReleaseSourceAdmissionError,
|
||||
match="SOURCE_ADMISSION_PERMISSIONS_INVALID",
|
||||
):
|
||||
store.lookup_source(SOURCE)
|
||||
@@ -0,0 +1,514 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_repository_discovery import ( # noqa: E402
|
||||
RepositoryDiscovery,
|
||||
RepositoryDiscoveryError,
|
||||
RepositoryDiscoveryNotFound,
|
||||
get_diff_index,
|
||||
list_repository_tree,
|
||||
read_repository_file,
|
||||
search_repository_source,
|
||||
)
|
||||
|
||||
|
||||
REPOSITORY = "aios/aios"
|
||||
SECRET = b"repository-discovery-test-secret"
|
||||
|
||||
|
||||
def git(*args: str, cwd: Path | None = None) -> str:
|
||||
return subprocess.run(
|
||||
["git", *( ["-C", str(cwd)] if cwd is not None else []), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repository(tmp_path: Path):
|
||||
bare = tmp_path / "authoritative.git"
|
||||
seed = tmp_path / "seed"
|
||||
git("init", "--bare", str(bare))
|
||||
git("init", "-b", "main", str(seed))
|
||||
git("config", "user.name", "Discovery Test", cwd=seed)
|
||||
git("config", "user.email", "discovery@example.invalid", cwd=seed)
|
||||
(seed / "README.md").write_text("first immutable snapshot\n", encoding="utf-8")
|
||||
(seed / "00-large.txt").write_text("x" * 512, encoding="utf-8")
|
||||
(seed / "mystery" / "jobstore").mkdir(parents=True)
|
||||
(seed / "mystery" / "jobstore" / "recovery.py").write_text(
|
||||
"def locate():\n return 'JobStore.for_existing_readonly recovery marker'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for index in range(3):
|
||||
(seed / "search" / f"match-{index}.py").parent.mkdir(exist_ok=True)
|
||||
(seed / "search" / f"match-{index}.py").write_text(
|
||||
f"P0_SEARCH_MARKER line {index}\n", encoding="utf-8"
|
||||
)
|
||||
(seed / "binary.bin").write_bytes(b"binary-marker\x00not-source\n")
|
||||
(seed / "nested" / "a" / "b").mkdir(parents=True)
|
||||
(seed / "nested" / "a" / "b" / "value.txt").write_text("deep value\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "initial", cwd=seed)
|
||||
main_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("tag", "v1.0.0", cwd=seed)
|
||||
git("remote", "add", "origin", str(bare), cwd=seed)
|
||||
git("push", "origin", "main", "--tags", cwd=seed)
|
||||
git("--git-dir", str(bare), "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
|
||||
git("checkout", "-b", "feature/discovery", cwd=seed)
|
||||
(seed / "docs").mkdir()
|
||||
git("mv", "README.md", "docs/README.md", cwd=seed)
|
||||
for index in range(18):
|
||||
changed = seed / "zz-diff-index" / f"changed-{index:02}.txt"
|
||||
changed.parent.mkdir(exist_ok=True)
|
||||
changed.write_text(f"change {index}\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "large indexed diff", cwd=seed)
|
||||
feature_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("push", "origin", "feature/discovery", cwd=seed)
|
||||
git("--git-dir", str(bare), "config", "remote.gitea.url", "https://g.example.invalid/git/aios/aios.git")
|
||||
git("branch", "ambiguous", feature_oid, cwd=seed)
|
||||
git("tag", "ambiguous", main_oid, cwd=seed)
|
||||
git("push", "origin", "refs/heads/ambiguous:refs/heads/ambiguous", cwd=seed)
|
||||
git("push", "origin", "refs/tags/ambiguous", cwd=seed)
|
||||
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY,), git_dir=bare, cursor_secret=SECRET,
|
||||
)
|
||||
return {"bare": bare, "seed": seed, "main_oid": main_oid, "feature_oid": feature_oid, "discovery": discovery}
|
||||
|
||||
|
||||
def test_exact_allowlist_and_verified_identity_reject_other_repositories(repository):
|
||||
discovery = repository["discovery"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="REPOSITORY_NOT_ALLOWED"):
|
||||
discovery.list_tree(repository="other/repo", revision="HEAD")
|
||||
|
||||
multiple = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY, "other/repo"), git_dir=repository["bare"], cursor_secret=SECRET,
|
||||
)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="REPOSITORY_IDENTITY_MISMATCH"):
|
||||
multiple.list_tree(repository="other/repo", revision="HEAD")
|
||||
|
||||
|
||||
def test_head_branch_tag_and_full_oid_resolve_to_one_immutable_commit(repository):
|
||||
discovery = repository["discovery"]
|
||||
expected = repository["main_oid"]
|
||||
assert discovery.resolve_revision(REPOSITORY, "HEAD") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, "main") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, "v1.0.0") == expected
|
||||
assert discovery.resolve_revision(REPOSITORY, expected) == expected
|
||||
for dangerous in ("HEAD~1", "main^{tree}", "--help", "../main", "refs/remotes/origin/main"):
|
||||
with pytest.raises(RepositoryDiscoveryError, match="INVALID_REVISION"):
|
||||
discovery.resolve_revision(REPOSITORY, dangerous)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="AMBIGUOUS_REVISION"):
|
||||
discovery.resolve_revision(REPOSITORY, "ambiguous")
|
||||
|
||||
|
||||
def test_resolved_oid_remains_immutable_after_floating_head_moves(repository):
|
||||
discovery = repository["discovery"]
|
||||
original = discovery.read_file(
|
||||
repository=REPOSITORY,
|
||||
revision=repository["main_oid"],
|
||||
file_path="README.md",
|
||||
)
|
||||
assert original["resolved_oid"] == repository["main_oid"]
|
||||
|
||||
git(
|
||||
"--git-dir",
|
||||
str(repository["bare"]),
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
repository["feature_oid"],
|
||||
)
|
||||
moved = discovery.list_tree(
|
||||
repository=REPOSITORY, revision="main", prefix="docs", limit=10
|
||||
)
|
||||
assert moved["resolved_oid"] == repository["feature_oid"]
|
||||
with pytest.raises(RepositoryDiscoveryNotFound, match="FILE_NOT_FOUND"):
|
||||
discovery.read_file(
|
||||
repository=REPOSITORY, revision="main", file_path="README.md"
|
||||
)
|
||||
|
||||
still_original = discovery.read_file(
|
||||
repository=REPOSITORY,
|
||||
revision=original["resolved_oid"],
|
||||
file_path="README.md",
|
||||
)
|
||||
pinned_tree = discovery.list_tree(
|
||||
repository=REPOSITORY, revision=original["resolved_oid"], limit=100
|
||||
)
|
||||
pinned_search = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision=original["resolved_oid"],
|
||||
query="JobStore.for_existing_readonly",
|
||||
path_globs=("mystery/**/*.py",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
pinned_diff = discovery.diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision=original["resolved_oid"],
|
||||
to_revision=repository["feature_oid"],
|
||||
limit=1,
|
||||
)
|
||||
assert pinned_tree["resolved_oid"] == repository["main_oid"]
|
||||
assert any(item["path"] == "README.md" for item in pinned_tree["entries"])
|
||||
assert pinned_search["resolved_oid"] == repository["main_oid"]
|
||||
assert pinned_search["matches"]
|
||||
assert pinned_diff["resolved_from_oid"] == repository["main_oid"]
|
||||
assert pinned_diff["resolved_to_oid"] == repository["feature_oid"]
|
||||
assert still_original["resolved_oid"] == repository["main_oid"]
|
||||
assert still_original["blob_oid"] == original["blob_oid"]
|
||||
assert still_original["content"] == original["content"]
|
||||
|
||||
|
||||
def test_tree_pagination_cursor_is_signed_and_query_bound(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.list_tree(repository=REPOSITORY, revision="main", limit=2)
|
||||
assert first["resolved_oid"] == repository["main_oid"]
|
||||
assert len(first["entries"]) == 2
|
||||
assert first["truncated"] is True
|
||||
cursor = first["next_cursor"]
|
||||
assert isinstance(cursor, str)
|
||||
second = discovery.list_tree(repository=REPOSITORY, revision=repository["main_oid"], limit=2, cursor=cursor)
|
||||
assert {item["path"] for item in first["entries"]}.isdisjoint({item["path"] for item in second["entries"]})
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", limit=3, cursor=cursor)
|
||||
tampered_cursor = cursor[:-1] + ("0" if cursor[-1] != "0" else "1")
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", limit=2, cursor=tampered_cursor)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("../secret", "/etc/passwd", "nested/../a", "--output", "nested\\a", ":(glob)**", "nested/*.txt"))
|
||||
def test_path_traversal_and_git_option_like_paths_are_rejected(repository, value):
|
||||
discovery = repository["discovery"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="PATH_INVALID"):
|
||||
discovery.list_tree(repository=REPOSITORY, revision="main", prefix=value)
|
||||
with pytest.raises(RepositoryDiscoveryError, match="PATH_INVALID"):
|
||||
discovery.read_file(repository=REPOSITORY, revision="main", file_path=value)
|
||||
|
||||
|
||||
def test_source_search_finds_unknown_jobstore_path_with_literal_globs_and_bounds(repository):
|
||||
discovery = repository["discovery"]
|
||||
result = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="JobStore.for_existing_readonly",
|
||||
path_globs=("mystery/**/*.py",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert result["resolved_oid"] == repository["main_oid"]
|
||||
assert result["matches"]
|
||||
match = result["matches"][0]
|
||||
assert match["path"] == "mystery/jobstore/recovery.py"
|
||||
assert match["line_start"] == 2
|
||||
assert match["line_end"] == 2
|
||||
assert len(match["blob_oid"]) == 40
|
||||
assert "recovery marker" in match["snippet"]
|
||||
|
||||
bounded = discovery.search_source(
|
||||
repository=REPOSITORY, revision="main", query="x", max_matches=1, max_bytes=8,
|
||||
)
|
||||
assert bounded["scanned_bytes"] <= 8
|
||||
assert bounded["truncated"] is True
|
||||
|
||||
|
||||
def test_source_search_cursor_is_signed_and_binary_blobs_are_skipped(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="P0_SEARCH_MARKER",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert len(first["matches"]) == 1
|
||||
assert first["truncated"] is True
|
||||
assert isinstance(first["next_cursor"], str)
|
||||
second = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="P0_SEARCH_MARKER",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
cursor=first["next_cursor"],
|
||||
)
|
||||
assert second["matches"]
|
||||
assert first["matches"][0]["path"] != second["matches"][0]["path"]
|
||||
with pytest.raises(RepositoryDiscoveryError, match="CURSOR_INVALID"):
|
||||
discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="different",
|
||||
path_globs=("search/*.py",),
|
||||
max_matches=1,
|
||||
max_bytes=4096,
|
||||
cursor=first["next_cursor"],
|
||||
)
|
||||
binary = discovery.search_source(
|
||||
repository=REPOSITORY,
|
||||
revision="main",
|
||||
query="binary-marker",
|
||||
path_globs=("binary.bin",),
|
||||
max_matches=5,
|
||||
max_bytes=4096,
|
||||
)
|
||||
assert binary["matches"] == []
|
||||
assert binary["binary_blobs_skipped"] == 1
|
||||
|
||||
|
||||
def test_diff_index_is_paginated_rename_aware_and_never_returns_patch(repository):
|
||||
discovery = repository["discovery"]
|
||||
first = discovery.diff_index(
|
||||
repository=REPOSITORY, from_revision="main", to_revision="feature/discovery", limit=5,
|
||||
)
|
||||
assert first["resolved_from_oid"] == repository["main_oid"]
|
||||
assert first["resolved_to_oid"] == repository["feature_oid"]
|
||||
assert len(first["entries"]) == 5
|
||||
assert first["truncated"] is True
|
||||
assert "patch" not in first
|
||||
assert all("additions" in item and "deletions" in item for item in first["entries"])
|
||||
assert any(item["old_path"] == "README.md" and item["path"] == "docs/README.md" for item in first["entries"])
|
||||
second = discovery.diff_index(
|
||||
repository=REPOSITORY, from_revision="main", to_revision="feature/discovery", limit=5, cursor=first["next_cursor"],
|
||||
)
|
||||
assert {item["path"] for item in first["entries"]}.isdisjoint({item["path"] for item in second["entries"]})
|
||||
scoped = discovery.diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision="main",
|
||||
to_revision="feature/discovery",
|
||||
prefix="zz-diff-index",
|
||||
limit=100,
|
||||
)
|
||||
assert scoped["entries"]
|
||||
assert all(item["path"].startswith("zz-diff-index/") for item in scoped["entries"])
|
||||
assert "resolved_from_oid" in scoped and "resolved_to_oid" in scoped
|
||||
|
||||
|
||||
def test_read_file_returns_resolved_and_blob_identity_with_bounded_content(repository):
|
||||
discovery = repository["discovery"]
|
||||
result = discovery.read_file(
|
||||
repository=REPOSITORY, revision="main", file_path="README.md", max_bytes=5,
|
||||
)
|
||||
assert result["resolved_oid"] == repository["main_oid"]
|
||||
assert len(result["blob_oid"]) == 40
|
||||
assert result["content"] == "first"
|
||||
assert result["content_bytes"] == 5
|
||||
assert result["truncated"] is True
|
||||
with pytest.raises(RepositoryDiscoveryNotFound, match="FILE_NOT_FOUND"):
|
||||
discovery.read_file(repository=REPOSITORY, revision="main", file_path="missing.txt")
|
||||
|
||||
|
||||
def test_mapping_adapters_and_module_helpers_return_dicts_with_status_codes(repository):
|
||||
discovery = repository["discovery"]
|
||||
tree = discovery.list_repository_tree({"repository": REPOSITORY, "revision": "main", "limit": 2})
|
||||
assert isinstance(tree, dict)
|
||||
source = search_repository_source(
|
||||
{"repository": REPOSITORY, "revision": "main", "query": "recovery marker"}, discovery=discovery,
|
||||
)
|
||||
assert source["matches"]
|
||||
index = get_diff_index(
|
||||
{"repository": REPOSITORY, "from": "main", "to": "feature/discovery", "limit": 2}, discovery=discovery,
|
||||
)
|
||||
assert index["entries"]
|
||||
blob = read_repository_file(
|
||||
{"repository": REPOSITORY, "revision": "main", "path": "README.md", "max_bytes": 8}, discovery=discovery,
|
||||
)
|
||||
assert blob["path"] == "README.md"
|
||||
assert list_repository_tree({"repository": REPOSITORY, "revision": "main", "limit": 1}, discovery=discovery)["entries"]
|
||||
with pytest.raises(RepositoryDiscoveryError) as failure:
|
||||
discovery.read_repository_file({"repository": REPOSITORY, "revision": "main", "path": "../secret"})
|
||||
assert str(failure.value) == "REPOSITORY_DISCOVERY_PATH_INVALID"
|
||||
assert failure.value.status_code == 400
|
||||
|
||||
|
||||
def test_public_request_names_pin_ref_and_return_required_identity_fields(repository):
|
||||
discovery = repository["discovery"]
|
||||
tree = discovery.list_repository_tree(
|
||||
{"repository": REPOSITORY, "ref": "feature/discovery", "limit": 1}
|
||||
)
|
||||
assert tree["resolved_oid"] == repository["feature_oid"]
|
||||
assert {"path", "type", "object_oid"} <= set(tree["entries"][0])
|
||||
source = discovery.search_repository_source(
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"ref": "main",
|
||||
"query": "P0_SEARCH_MARKER",
|
||||
"path_globs": ["search/*.py"],
|
||||
"max_matches": 2,
|
||||
"max_bytes": 4096,
|
||||
}
|
||||
)
|
||||
assert source["matches"][0]["line_start"] == source["matches"][0]["line_end"] == 1
|
||||
blob = discovery.read_repository_file(
|
||||
{"repository": REPOSITORY, "ref": "main", "path": "README.md", "max_bytes": 8}
|
||||
)
|
||||
assert blob["content_base64"]
|
||||
assert len(blob["blob_oid"]) == 40
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def large_diff_repository(tmp_path: Path):
|
||||
bare = tmp_path / "large.git"
|
||||
seed = tmp_path / "seed"
|
||||
git("init", "--bare", str(bare))
|
||||
git("init", "-b", "main", str(seed))
|
||||
git("config", "user.name", "Discovery Test", cwd=seed)
|
||||
git("config", "user.email", "discovery@example.invalid", cwd=seed)
|
||||
(seed / "README.md").write_text("base\n", encoding="utf-8")
|
||||
git("add", ".", cwd=seed)
|
||||
git("commit", "-m", "base", cwd=seed)
|
||||
main_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("remote", "add", "origin", str(bare), cwd=seed)
|
||||
git("push", "origin", "main", cwd=seed)
|
||||
git("--git-dir", str(bare), "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
for index in range(18_000):
|
||||
filename = "long-prefix-" + ("x" * 42) + f"-{index:05}.txt"
|
||||
(seed / "bulk" / filename).parent.mkdir(exist_ok=True)
|
||||
(seed / "bulk" / filename).write_text("large diff\n", encoding="utf-8")
|
||||
git("add", "bulk", cwd=seed)
|
||||
git("commit", "-m", "large diff", cwd=seed)
|
||||
feature_oid = git("rev-parse", "HEAD", cwd=seed)
|
||||
git("push", "origin", "main", cwd=seed)
|
||||
git("--git-dir", str(bare), "config", "remote.gitea.url", "https://g.example.invalid/git/aios/aios.git")
|
||||
return {
|
||||
"bare": bare,
|
||||
"main_oid": main_oid,
|
||||
"feature_oid": feature_oid,
|
||||
"discovery": RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY,), git_dir=bare, cursor_secret=SECRET
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_large_diff_index_is_streamed_and_page_bounded(large_diff_repository):
|
||||
result = large_diff_repository["discovery"].diff_index(
|
||||
repository=REPOSITORY,
|
||||
from_revision=large_diff_repository["main_oid"],
|
||||
to_revision=large_diff_repository["feature_oid"],
|
||||
limit=1,
|
||||
)
|
||||
assert len(result["entries"]) == 1
|
||||
assert result["entries"][0]["path"].startswith("bulk/")
|
||||
assert result["entries"][0]["additions"] == 1
|
||||
assert result["entries"][0]["deletions"] == 0
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
def test_bridge_http_discovery_routes_are_get_only_and_do_not_enter_write_handlers(repository):
|
||||
pytest.importorskip(
|
||||
"fcntl",
|
||||
reason="Bridge imports POSIX-only fcntl; Windows integration is unverified",
|
||||
)
|
||||
from http.client import HTTPConnection
|
||||
from http.server import ThreadingHTTPServer
|
||||
from threading import Thread
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler
|
||||
|
||||
discovery = repository["discovery"]
|
||||
calls: list[str] = []
|
||||
|
||||
def invoke(operation: str, method: str):
|
||||
def handler(request):
|
||||
calls.append(operation)
|
||||
return getattr(discovery, method)(request)
|
||||
|
||||
return handler
|
||||
|
||||
adapter = BridgeV2Adapter(
|
||||
runtime_status={"deployment_state": "STAGING"},
|
||||
handlers={
|
||||
"listRepositoryTree": invoke("listRepositoryTree", "list_repository_tree"),
|
||||
"searchRepositorySource": invoke(
|
||||
"searchRepositorySource", "search_repository_source"
|
||||
),
|
||||
"getDiffIndex": invoke("getDiffIndex", "get_diff_index"),
|
||||
"readRepositoryFile": invoke("readRepositoryFile", "read_repository_file"),
|
||||
},
|
||||
)
|
||||
previous_adapter = BridgeHandler.adapter
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = adapter
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def request(method: str, path: str, body: str | None = None):
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=10)
|
||||
headers = {"Content-Type": "application/json"} if body is not None else {}
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
raw = response.read()
|
||||
status = response.status
|
||||
connection.close()
|
||||
return status, raw
|
||||
|
||||
try:
|
||||
cases = [
|
||||
(
|
||||
"/v2/repository/tree",
|
||||
{"repository": REPOSITORY, "ref": "main", "limit": 2},
|
||||
),
|
||||
(
|
||||
"/v2/repository/search",
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"ref": "main",
|
||||
"query": "recovery marker",
|
||||
"max_matches": 2,
|
||||
"max_bytes": 4096,
|
||||
},
|
||||
),
|
||||
(
|
||||
"/v2/repository/diff-index",
|
||||
{
|
||||
"repository": REPOSITORY,
|
||||
"from": "main",
|
||||
"to": "feature/discovery",
|
||||
"limit": 2,
|
||||
},
|
||||
),
|
||||
(
|
||||
"/v2/repository/file",
|
||||
{"repository": REPOSITORY, "ref": "main", "path": "README.md"},
|
||||
),
|
||||
]
|
||||
for path, values in cases:
|
||||
status, raw = request("GET", f"{path}?{urlencode(values)}")
|
||||
assert status == 200, raw.decode("utf-8", errors="replace")
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
assert payload["repository"] == REPOSITORY
|
||||
assert "resolved_oid" in payload or "resolved_to_oid" in payload
|
||||
assert len(calls) == 4
|
||||
|
||||
status, _ = request("POST", "/v2/repository/tree", "{}")
|
||||
assert status == 405
|
||||
assert len(calls) == 4
|
||||
|
||||
duplicate = (
|
||||
"/v2/repository/tree?repository=aios%2Faios&ref=main&ref=main&limit=1"
|
||||
)
|
||||
status, _ = request("GET", duplicate)
|
||||
assert status == 400
|
||||
assert len(calls) == 4
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = previous_adapter
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
BridgeV2Adapter,
|
||||
BridgeV2Error,
|
||||
OPERATION_OPTIONAL_FIELDS,
|
||||
OPERATION_REQUIRED_FIELDS,
|
||||
READ_ONLY_OPERATIONS,
|
||||
operation_contracts,
|
||||
)
|
||||
|
||||
|
||||
DISCOVERY_CONTRACTS = {
|
||||
"listRepositoryTree": {
|
||||
"path": "/v2/repository/tree",
|
||||
"legacy_path": "/api/repository/tree",
|
||||
},
|
||||
"searchRepositorySource": {
|
||||
"path": "/v2/repository/search",
|
||||
"legacy_path": "/api/repository/search",
|
||||
},
|
||||
"getDiffIndex": {
|
||||
"path": "/v2/repository/diff-index",
|
||||
"legacy_path": "/api/repository/diff-index",
|
||||
},
|
||||
"readRepositoryFile": {
|
||||
"path": "/v2/repository/file",
|
||||
"legacy_path": "/api/repository/file",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _openapi_path_block(document: str, path: str) -> str:
|
||||
marker = f" {path}:\n"
|
||||
start = document.index(marker)
|
||||
end = document.find("\n /", start + len(marker))
|
||||
return document[start:] if end < 0 else document[start:end]
|
||||
|
||||
|
||||
def _assignment(source: str, name: str) -> ast.AST:
|
||||
module = ast.parse(source)
|
||||
for node in module.body:
|
||||
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
if any(isinstance(target, ast.Name) and target.id == name for target in targets):
|
||||
return node.value
|
||||
raise AssertionError(f"assignment {name!r} not found")
|
||||
|
||||
|
||||
def _literal_assignment(source: str, name: str):
|
||||
value = _assignment(source, name)
|
||||
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name):
|
||||
assert value.func.id == "frozenset"
|
||||
assert len(value.args) == 1
|
||||
return set(ast.literal_eval(value.args[0]))
|
||||
return ast.literal_eval(value)
|
||||
|
||||
|
||||
def test_discovery_runtime_bridge_and_openapi_contracts_are_aligned():
|
||||
document = (ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
bridge_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_bridge_v2_server.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
actions_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
routes = _literal_assignment(bridge_source, "ROUTES")
|
||||
discovery_actions = _literal_assignment(actions_source, "_DISCOVERY_ACTIONS")
|
||||
read_only_paths = _literal_assignment(actions_source, "READ_ONLY_GET_PATHS")
|
||||
runtime = operation_contracts()
|
||||
|
||||
for operation, expected in DISCOVERY_CONTRACTS.items():
|
||||
assert runtime[operation] == {
|
||||
"method": "GET",
|
||||
"path": expected["path"],
|
||||
"required_fields": list(OPERATION_REQUIRED_FIELDS[operation]),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS[operation]),
|
||||
"read_only": True,
|
||||
}
|
||||
assert operation in READ_ONLY_OPERATIONS
|
||||
assert routes[expected["path"]] == operation
|
||||
assert expected["legacy_path"] in read_only_paths
|
||||
assert expected["legacy_path"] in discovery_actions
|
||||
|
||||
block = _openapi_path_block(document, expected["path"])
|
||||
assert block.count(f"operationId: {operation}") == 1
|
||||
assert " get:\n" in block
|
||||
assert " security: []" in block
|
||||
assert " x-openai-isConsequential: false" in block
|
||||
assert " x-aios-read-only: true" in block
|
||||
assert " x-aios-bounded: true" in block
|
||||
assert " x-aios-repository-scoped: true" in block
|
||||
assert " x-aios-no-shell: true" in block
|
||||
assert f" x-aios-legacy-path: {expected['legacy_path']}" in block
|
||||
parameter_names = {
|
||||
line.split("name:", 1)[1].split(",", 1)[0].strip()
|
||||
for line in block.splitlines()
|
||||
if "name:" in line and "in: query" in line
|
||||
}
|
||||
assert parameter_names == set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
||||
OPERATION_OPTIONAL_FIELDS[operation]
|
||||
)
|
||||
|
||||
for operation in DISCOVERY_CONTRACTS:
|
||||
assert document.count(f"operationId: {operation}") == 1
|
||||
|
||||
class_nodes = [
|
||||
node
|
||||
for node in ast.parse(actions_source).body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "H"
|
||||
]
|
||||
assert len(class_nodes) == 1
|
||||
post_methods = [
|
||||
node for node in class_nodes[0].body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "do_POST"
|
||||
]
|
||||
assert post_methods
|
||||
effective_post = ast.get_source_segment(actions_source, post_methods[-1]) or ""
|
||||
assert "method_not_allowed" in effective_post
|
||||
assert "max_num_fields=64" in actions_source
|
||||
assert "query_too_large" in actions_source
|
||||
|
||||
|
||||
def test_discovery_adapter_rejects_task_principal_and_write_identity_fields():
|
||||
called = False
|
||||
|
||||
def handler(_request):
|
||||
nonlocal called
|
||||
called = True
|
||||
return {}
|
||||
|
||||
adapter = BridgeV2Adapter(
|
||||
runtime_status={}, handlers={"listRepositoryTree": handler}
|
||||
)
|
||||
request = {
|
||||
"repository": "aios/aios",
|
||||
"ref": "main",
|
||||
"limit": 1,
|
||||
"task_id": "must-not-enter-read-route",
|
||||
"principal": "must-not-enter-read-route",
|
||||
"writer_lease": "must-not-enter-read-route",
|
||||
}
|
||||
with pytest.raises(BridgeV2Error, match="BRIDGE_REQUEST_FIELDS_UNKNOWN"):
|
||||
adapter.listRepositoryTree(request)
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_discovery_source_is_read_only_and_staging_freeze_remains_intact():
|
||||
source = (ROOT / "active-runtime" / "app" / "aios_repository_discovery.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})' in source
|
||||
assert "shell=True" not in source
|
||||
assert "os.system" not in source
|
||||
assert "update-ref" not in source
|
||||
assert "git push" not in source
|
||||
|
||||
unit = (
|
||||
ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-gitops-model-api-v2r2c1-staging.service"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED=false" in unit
|
||||
assert "AIOS_GITOPS_PORT=18893" in unit
|
||||
assert "AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false" in unit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "query"),
|
||||
[
|
||||
("/api/repository/tree", "repository=aios%2Faios&ref=main&limit=1"),
|
||||
(
|
||||
"/api/repository/search",
|
||||
"repository=aios%2Faios&ref=main&query=needle&max_matches=1&max_bytes=16",
|
||||
),
|
||||
(
|
||||
"/api/repository/diff-index",
|
||||
"repository=aios%2Faios&from=main&to=main&limit=1",
|
||||
),
|
||||
(
|
||||
"/api/repository/file",
|
||||
"repository=aios%2Faios&ref=main&path=README.md",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_actions_discovery_get_route_dispatches_and_post_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch, path: str, query: str
|
||||
):
|
||||
if sys.platform == "win32":
|
||||
pytest.skip("Actions adapter imports POSIX-only fcntl; Windows integration is unverified")
|
||||
import aios_chatgpt_actions_api as actions_api
|
||||
observed: list[tuple[str, object]] = []
|
||||
|
||||
def fake_discovery_request(route: str, values: dict[str, list[str]]):
|
||||
observed.append((route, values))
|
||||
return {"route": route}
|
||||
|
||||
monkeypatch.setattr(actions_api, "repository_discovery_request", fake_discovery_request)
|
||||
handler = object.__new__(actions_api.H)
|
||||
responses: list[tuple[object, int]] = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
handler.path = f"{path}?{query}"
|
||||
|
||||
actions_api.H.do_GET(handler)
|
||||
assert observed[-1][0] == path
|
||||
assert responses[-1] == ({"route": path}, 200)
|
||||
|
||||
handler.path = path
|
||||
actions_api.H.do_POST(handler)
|
||||
assert responses[-1] == ({"error": "method_not_allowed"}, 405)
|
||||
@@ -726,3 +726,69 @@ def test_systemd_shadow_unit_has_both_gates_and_no_secret_environment():
|
||||
assert "PRIVATE_KEY=" not in unit
|
||||
assert "TOKEN=" not in unit
|
||||
assert "PASSWORD=" not in unit
|
||||
|
||||
|
||||
def test_release_intent_derives_version_from_governed_source_admission(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
service.source_admission_resolver = lambda source_commit: {
|
||||
"schema": "aios.release-source-admission.v1",
|
||||
"admission_id": "source-admission-test-001",
|
||||
"source_commit": source_commit,
|
||||
"version": "V2R2C4",
|
||||
}
|
||||
|
||||
result = service.create_release_intent(
|
||||
{"event_id": "webhook-admitted-source-001", "source_commit": COMMIT}
|
||||
)
|
||||
|
||||
assert result["version"] == "V2R2C4"
|
||||
assert result["source_admission_id"] == "source-admission-test-001"
|
||||
intent_path = service.intents_root / f"{result['intent_id']}.json"
|
||||
persisted = json.loads(intent_path.read_text(encoding="utf-8"))
|
||||
assert persisted["version"] == "V2R2C4"
|
||||
assert persisted["source_admission_id"] == "source-admission-test-001"
|
||||
|
||||
|
||||
def test_release_intent_rejects_version_rebinding_against_source_admission(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
service.source_admission_resolver = lambda source_commit: {
|
||||
"schema": "aios.release-source-admission.v1",
|
||||
"admission_id": "source-admission-test-002",
|
||||
"source_commit": source_commit,
|
||||
"version": "V2R2C4",
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
CapabilityAuthorizationError,
|
||||
match="release source admission version mismatch",
|
||||
):
|
||||
service.create_release_intent(
|
||||
{
|
||||
"event_id": "webhook-admitted-source-002",
|
||||
"source_commit": COMMIT,
|
||||
"version": "V2R2C5",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_release_intent_surfaces_source_admission_required_as_typed_blocker(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
|
||||
class AdmissionRequiredBuilder:
|
||||
def build(self, _payload):
|
||||
raise CapabilityAuthorizationError(
|
||||
"release source admission required"
|
||||
)
|
||||
|
||||
service.candidate_builder = AdmissionRequiredBuilder()
|
||||
result = service.create_release_intent(
|
||||
{
|
||||
"event_id": "webhook-source-admission-required-001",
|
||||
"source_commit": COMMIT,
|
||||
}
|
||||
)
|
||||
|
||||
assert result["status"] == "SOURCE_ADMISSION_REQUIRED"
|
||||
assert result["failure_code"] == "SOURCE_ADMISSION_REQUIRED"
|
||||
assert result["retryable"] is False
|
||||
assert result["side_effects_executed"] is False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user