Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c320aeb89 | ||
|
|
beabd05396 | ||
|
|
5a4e952b39 | ||
|
|
b70c89217b | ||
|
|
dd2bd8fa08 | ||
|
|
4642b74af6 | ||
|
|
34395c7100 | ||
|
|
623fd61042 | ||
|
|
691bf76aec |
+10
-3
@@ -4,7 +4,14 @@ title = "AIOS repository secret scan policy"
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Exact non-secret Task Admission test idempotency fixture in the historical PR commit"
|
||||
description = "Exact non-secret fixtures: Task Admission idempotency test fixture and release-freeze git commit OIDs (generic-api-key false positive on 40-hex git hashes)"
|
||||
condition = "AND"
|
||||
paths = ['''tests/test_task_admission_bootstrap_http\.py$''']
|
||||
regexes = ['''idempotency-1''']
|
||||
paths = [
|
||||
'''tests/test_task_admission_bootstrap_http\.py$''',
|
||||
'''docs/V2R2C2-P0-convergence-20260909\.md$''',
|
||||
'''artifacts/round3-provider-auth/ledger\.json$''',
|
||||
]
|
||||
regexes = [
|
||||
'''idempotency-1''',
|
||||
'''f7f53d0568767d6deec2c47008a89f4d8413f93d''',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -157,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"
|
||||
|
||||
@@ -38,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[
|
||||
@@ -109,6 +110,77 @@ 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
|
||||
@@ -919,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 {
|
||||
@@ -1204,6 +1279,7 @@ 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",
|
||||
@@ -1258,27 +1334,33 @@ def main(
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
PLACEHOLDER_PATTERNS = (
|
||||
re.compile(r"(?im)^\s*(?:todo|fixme|tbd)\b"),
|
||||
re.compile(r"(?im)^\s*pass\s*(?:#.*)?$"),
|
||||
re.compile(r"(?im)\bnot\s+implemented\b"),
|
||||
re.compile(r"(?im)\bplaceholder\b"),
|
||||
re.compile(r"(?im)^\s*\.\.\.\s*$"),
|
||||
)
|
||||
|
||||
class Severity(str, Enum):
|
||||
BLOCKER = "BLOCKER"
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
INFO = "INFO"
|
||||
|
||||
class TruthState(str, Enum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
ADMITTED_NOT_PROJECTED = "ADMITTED_NOT_PROJECTED"
|
||||
WAITING_CAPACITY = "WAITING_CAPACITY"
|
||||
QUEUED = "QUEUED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
TERMINAL_UNPROVEN = "TERMINAL_UNPROVEN"
|
||||
COMPLETE = "COMPLETE"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
code: str
|
||||
severity: Severity
|
||||
message: str
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
remediation: str | None = None
|
||||
|
||||
@dataclass
|
||||
class AdmissionSnapshot:
|
||||
task_id: str
|
||||
envelope_id: str | None = None
|
||||
admission_status: str | None = None
|
||||
grant_id: str | None = None
|
||||
grant_expires_at: int | None = None
|
||||
one_time_use: bool | None = None
|
||||
authority_base_oid: str | None = None
|
||||
execution_digest: str | None = None
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
file_allowlist: tuple[str, ...] = ()
|
||||
admitted_at: int | None = None
|
||||
|
||||
@dataclass
|
||||
class TaskSnapshot:
|
||||
task_id: str
|
||||
status: str | None = None
|
||||
claimed_by: str | None = None
|
||||
lease_expires_at: int | None = None
|
||||
attempt: int | None = None
|
||||
revision: int | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
deadline: str | None = None
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class WorkerSnapshot:
|
||||
worker_id: str
|
||||
status: str | None = None
|
||||
last_seen_epoch: int | None = None
|
||||
capabilities: tuple[str, ...] = ()
|
||||
scope_task_id: str | None = None
|
||||
scope_job_id: str | None = None
|
||||
scope_branch: str | None = None
|
||||
scope_execution_digest: str | None = None
|
||||
|
||||
@dataclass
|
||||
class LeaseSnapshot:
|
||||
task_id: str
|
||||
holder: str | None = None
|
||||
expires_at: int | None = None
|
||||
state: str | None = None
|
||||
|
||||
@dataclass
|
||||
class GitSnapshot:
|
||||
branch: str
|
||||
current_oid: str | None = None
|
||||
expected_oid: str | None = None
|
||||
commit_oid: str | None = None
|
||||
post_oid: str | None = None
|
||||
|
||||
@dataclass
|
||||
class EvidenceSnapshot:
|
||||
commit_receipt: dict[str, Any] | None = None
|
||||
runtime_readback: dict[str, Any] | None = None
|
||||
external_projection_readback: dict[str, Any] | None = None
|
||||
output_hashes: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def parse_iso_epoch(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime
|
||||
try:
|
||||
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def worker_is_healthy(worker: WorkerSnapshot, now: int, heartbeat_ttl: int = 180) -> bool:
|
||||
return bool(
|
||||
worker.status == "ACTIVE"
|
||||
and worker.last_seen_epoch is not None
|
||||
and now - worker.last_seen_epoch <= heartbeat_ttl
|
||||
)
|
||||
|
||||
def worker_scope_matches(worker: WorkerSnapshot, task: TaskSnapshot) -> bool:
|
||||
meta = task.metadata or {}
|
||||
plan = meta.get("execution_plan") or meta.get("plan") or meta
|
||||
job_id = plan.get("external_job_id") if isinstance(plan, dict) else None
|
||||
branch = plan.get("target_branch") if isinstance(plan, dict) else None
|
||||
digest = plan.get("execution_digest") if isinstance(plan, dict) else None
|
||||
if worker.scope_task_id and worker.scope_task_id != task.task_id:
|
||||
return False
|
||||
if worker.scope_job_id and job_id and worker.scope_job_id != job_id:
|
||||
return False
|
||||
if worker.scope_branch and branch and worker.scope_branch != branch:
|
||||
return False
|
||||
if worker.scope_execution_digest and digest and worker.scope_execution_digest != digest:
|
||||
return False
|
||||
return True
|
||||
|
||||
def worker_has_capabilities(worker: WorkerSnapshot, required: Sequence[str]) -> bool:
|
||||
have = set(worker.capabilities)
|
||||
return set(required).issubset(have)
|
||||
|
||||
def eligible_workers(task: TaskSnapshot, workers: Sequence[WorkerSnapshot], now: int, heartbeat_ttl: int = 180) -> list[WorkerSnapshot]:
|
||||
return [
|
||||
w for w in workers
|
||||
if worker_is_healthy(w, now, heartbeat_ttl)
|
||||
and worker_has_capabilities(w, task.required_capabilities)
|
||||
and worker_scope_matches(w, task)
|
||||
]
|
||||
|
||||
def derive_truth(
|
||||
admission: AdmissionSnapshot | None,
|
||||
task: TaskSnapshot | None,
|
||||
workers: Sequence[WorkerSnapshot],
|
||||
lease: LeaseSnapshot | None,
|
||||
git: GitSnapshot | None,
|
||||
evidence: EvidenceSnapshot | None,
|
||||
now: int | None = None,
|
||||
queue_claim_threshold: int = 600,
|
||||
heartbeat_ttl: int = 180,
|
||||
) -> tuple[TruthState, list[Finding]]:
|
||||
now = int(time.time()) if now is None else now
|
||||
findings: list[Finding] = []
|
||||
|
||||
if admission and admission.admission_status == "ADMITTED" and not task:
|
||||
findings.append(Finding(
|
||||
"DUAL_PLANE_ADMITTED_TASK_MISSING",
|
||||
Severity.BLOCKER,
|
||||
"Admission plane says ADMITTED but TaskPlane has no task.",
|
||||
{"task_id": admission.task_id, "envelope_id": admission.envelope_id},
|
||||
"Use durable projection/outbox + exact readback; Admission must not imply execution.",
|
||||
))
|
||||
return TruthState.ADMITTED_NOT_PROJECTED, findings
|
||||
|
||||
if not task:
|
||||
return TruthState.UNKNOWN, findings
|
||||
|
||||
if admission and admission.grant_expires_at and admission.grant_expires_at <= now and task.status in {"QUEUED", "CLAIMED", "RUNNING"}:
|
||||
findings.append(Finding(
|
||||
"GRANT_EXPIRED_BEFORE_EXECUTION",
|
||||
Severity.BLOCKER,
|
||||
"Execution state depends on an expired grant.",
|
||||
{"expires_at": admission.grant_expires_at, "now": now, "task_state": task.status},
|
||||
"Mint runtime TaskPrincipal just-in-time at claim; do not burn grant TTL while waiting in queue.",
|
||||
))
|
||||
|
||||
if admission and admission.admission_status == "ADMITTED" and task.status in {"EXPIRED", "FAILED", "CANCELLED"}:
|
||||
findings.append(Finding(
|
||||
"DUAL_PLANE_TERMINAL_DRIFT",
|
||||
Severity.MAJOR,
|
||||
"Admission remains ADMITTED while TaskPlane is terminal.",
|
||||
{"admission": admission.admission_status, "task": task.status},
|
||||
"Expose admission_state and execution_state separately; never collapse both into one PASS/complete status.",
|
||||
))
|
||||
|
||||
if task.status == "QUEUED":
|
||||
elig = eligible_workers(task, workers, now, heartbeat_ttl)
|
||||
if not elig:
|
||||
findings.append(Finding(
|
||||
"QUEUED_WITHOUT_ELIGIBLE_WORKER",
|
||||
Severity.BLOCKER,
|
||||
"Task is QUEUED but no healthy, capability- and scope-matching worker exists.",
|
||||
{
|
||||
"required_capabilities": list(task.required_capabilities),
|
||||
"healthy_workers": [w.worker_id for w in workers if worker_is_healthy(w, now, heartbeat_ttl)],
|
||||
},
|
||||
"Gate TaskPlane projection on a SchedulabilityProof; keep Admission ADMITTED/WAITING_CAPACITY until claimable.",
|
||||
))
|
||||
return TruthState.WAITING_CAPACITY, findings
|
||||
created = parse_iso_epoch(task.created_at)
|
||||
if created is not None and now - created > queue_claim_threshold and not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"UNCLAIMED_TIMEOUT",
|
||||
Severity.BLOCKER,
|
||||
"QUEUED task exceeded claim SLA without a claim holder.",
|
||||
{"queued_seconds": now - created, "threshold": queue_claim_threshold},
|
||||
"Trigger scheduler diagnosis/takeover contract; do not extend TTL blindly.",
|
||||
))
|
||||
return TruthState.QUEUED, findings
|
||||
|
||||
if task.status == "CLAIMED":
|
||||
if not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"FAKE_CLAIM_STATE",
|
||||
Severity.BLOCKER,
|
||||
"Task says CLAIMED but claimed_by is empty.",
|
||||
{"task_id": task.task_id},
|
||||
"Reject state transition unless claim holder + fencing generation are persisted atomically.",
|
||||
))
|
||||
if lease is None or lease.holder != task.claimed_by or (lease.expires_at and lease.expires_at <= now):
|
||||
findings.append(Finding(
|
||||
"CLAIM_WITHOUT_VALID_LEASE",
|
||||
Severity.BLOCKER,
|
||||
"CLAIMED task has no valid matching lease.",
|
||||
{"claimed_by": task.claimed_by, "lease": asdict(lease) if lease else None},
|
||||
"Require claim+lease readback before exposing CLAIMED.",
|
||||
))
|
||||
return TruthState.CLAIMED, findings
|
||||
|
||||
if task.status == "RUNNING":
|
||||
if not task.claimed_by:
|
||||
findings.append(Finding("FAKE_RUNNING_NO_CLAIM", Severity.BLOCKER, "RUNNING without claim holder."))
|
||||
claimed_worker = next((w for w in workers if w.worker_id == task.claimed_by), None)
|
||||
if not claimed_worker or not worker_is_healthy(claimed_worker, now, heartbeat_ttl):
|
||||
findings.append(Finding(
|
||||
"FAKE_RUNNING_NO_HEARTBEAT",
|
||||
Severity.BLOCKER,
|
||||
"RUNNING task has no fresh worker heartbeat.",
|
||||
{"claimed_by": task.claimed_by},
|
||||
"Transition to recovery only through lease/fencing rules; never report RUNNING from a stale projection.",
|
||||
))
|
||||
if lease is None or (lease.expires_at and lease.expires_at <= now):
|
||||
findings.append(Finding("RUNNING_WITHOUT_VALID_LEASE", Severity.BLOCKER, "RUNNING without valid lease."))
|
||||
return TruthState.RUNNING, findings
|
||||
|
||||
if task.status in {"SUCCEEDED", "COMPLETE"}:
|
||||
ev = evidence or EvidenceSnapshot()
|
||||
commit = ev.commit_receipt or {}
|
||||
before = git.expected_oid if git else None
|
||||
post = (git.post_oid if git else None) or commit.get("post_oid")
|
||||
commit_oid = (git.commit_oid if git else None) or commit.get("commit_oid")
|
||||
if not commit_oid or not post or (before and post == before):
|
||||
findings.append(Finding(
|
||||
"TERMINAL_SUCCESS_UNPROVEN",
|
||||
Severity.BLOCKER,
|
||||
"Task reports success without a proven branch mutation receipt.",
|
||||
{"commit_oid": commit_oid, "post_oid": post, "pre_oid": before},
|
||||
"SUCCEEDED requires lease id, commit receipt, pre/post OID change, output hashes, and runtime/projection readback.",
|
||||
))
|
||||
return TruthState.TERMINAL_UNPROVEN, findings
|
||||
if not ev.runtime_readback:
|
||||
findings.append(Finding("SUCCESS_WITHOUT_RUNTIME_READBACK", Severity.MAJOR, "Commit exists but runtime readback is absent."))
|
||||
return TruthState.COMPLETE if not findings else TruthState.TERMINAL_UNPROVEN, findings
|
||||
|
||||
if task.status == "EXPIRED":
|
||||
if task.attempt in (None, 0) and not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"EXPIRED_WITHOUT_ANY_CLAIM",
|
||||
Severity.MAJOR,
|
||||
"Task expired without a single execution attempt.",
|
||||
{"attempt": task.attempt, "claimed_by": task.claimed_by},
|
||||
"Treat as schedulability failure, not execution failure; fix worker/principal supply before recovery.",
|
||||
))
|
||||
return TruthState.EXPIRED, findings
|
||||
|
||||
return TruthState.UNKNOWN, findings
|
||||
|
||||
def inspect_artifact(path: Path, expected_sha256: str | None = None, min_bytes: int = 8) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
if not path.is_file():
|
||||
return [Finding("ARTIFACT_MISSING", Severity.BLOCKER, f"Artifact missing: {path}")]
|
||||
data = path.read_bytes()
|
||||
if len(data) < min_bytes:
|
||||
findings.append(Finding("ARTIFACT_TOO_SMALL", Severity.BLOCKER, f"Artifact is too small: {path}", {"bytes": len(data)}))
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if expected_sha256 and digest != expected_sha256:
|
||||
findings.append(Finding("ARTIFACT_HASH_MISMATCH", Severity.BLOCKER, f"Artifact hash mismatch: {path}", {"expected": expected_sha256, "actual": digest}))
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = ""
|
||||
if text and any(p.search(text) for p in PLACEHOLDER_PATTERNS):
|
||||
findings.append(Finding(
|
||||
"PLACEHOLDER_ARTIFACT",
|
||||
Severity.BLOCKER,
|
||||
f"Artifact contains placeholder/stub markers: {path}",
|
||||
remediation="Reject plan admission until actual bytes exist and match the declared digest.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def operation_ids_from_openapi(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PyYAML is required for OpenAPI parity checks") from exc
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
|
||||
result: dict[str, str] = {}
|
||||
for route, methods in (doc.get("paths") or {}).items():
|
||||
if not isinstance(methods, dict):
|
||||
continue
|
||||
for _, spec in methods.items():
|
||||
if isinstance(spec, dict) and spec.get("operationId"):
|
||||
result[str(spec["operationId"])] = str(route)
|
||||
return result
|
||||
|
||||
ROUTE_PAIR_RE = re.compile(r'["\'](?P<route>/v\d+/[^"\']+)["\']\s*:\s*["\'](?P<op>[A-Za-z0-9_]+)["\']')
|
||||
|
||||
def operation_ids_from_server_source(path: Path) -> dict[str, str]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
out: dict[str, str] = {}
|
||||
for m in ROUTE_PAIR_RE.finditer(text):
|
||||
out[m.group("op")] = m.group("route")
|
||||
return out
|
||||
|
||||
def check_openapi_backend_parity(openapi_path: Path, server_path: Path, required_ops: Iterable[str] = ()) -> list[Finding]:
|
||||
oa = operation_ids_from_openapi(openapi_path)
|
||||
be = operation_ids_from_server_source(server_path)
|
||||
findings: list[Finding] = []
|
||||
for op in sorted(set(required_ops) | set(oa)):
|
||||
if op in oa and op not in be:
|
||||
findings.append(Finding(
|
||||
"OPENAPI_DEAD_ENDPOINT",
|
||||
Severity.BLOCKER,
|
||||
f"OpenAPI advertises {op} ({oa[op]}) but backend route mapping is absent.",
|
||||
remediation="Deploy projection and backend from the same source commit; reject schema-only rollout.",
|
||||
))
|
||||
for op in sorted(set(required_ops) | set(be)):
|
||||
if op in be and op not in oa:
|
||||
findings.append(Finding(
|
||||
"BACKEND_UNPROJECTED_OPERATION",
|
||||
Severity.MAJOR,
|
||||
f"Backend implements {op} ({be[op]}) but OpenAPI projection is absent.",
|
||||
remediation="Generate/validate external projection from the canonical backend route manifest.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def check_capability_aliases(required: Sequence[str], workers: Sequence[WorkerSnapshot]) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
req = set(required)
|
||||
if "governed.write.files" in req:
|
||||
for w in workers:
|
||||
caps = set(w.capabilities)
|
||||
if "governed.write.file" in caps and "governed.write.files" not in caps:
|
||||
findings.append(Finding(
|
||||
"CAPABILITY_SINGULAR_PLURAL_ASYMMETRY",
|
||||
Severity.MAJOR,
|
||||
f"Worker {w.worker_id} exposes governed.write.file but task requires governed.write.files.",
|
||||
{"worker_capabilities": sorted(caps)},
|
||||
"Do not treat names as aliases implicitly; define one canonical capability or an explicit server-side compatibility mapping with tests.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def git_ref(git_dir: Path, ref: str) -> str | None:
|
||||
cp = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "rev-parse", ref],
|
||||
text=True, capture_output=True
|
||||
)
|
||||
if cp.returncode != 0:
|
||||
return None
|
||||
value = cp.stdout.strip()
|
||||
return value if re.fullmatch(r"[0-9a-f]{40}", value) else None
|
||||
|
||||
def sqlite_rows(path: Path, query: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]:
|
||||
uri = f"file:{path}?mode=ro&immutable=1"
|
||||
con = sqlite3.connect(uri, uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
return list(con.execute(query, params))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def dump_report(path: Path, report: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
|
||||
@@ -1,7 +1,27 @@
|
||||
"""Non-sensitive control-plane snapshot assembly."""
|
||||
"""Non-sensitive control-plane snapshot assembly.
|
||||
|
||||
The base snapshot is a pure field-copy of each plane's reported state. It does
|
||||
NOT itself assert whether that state is backed by evidence — a drift_status=PASS
|
||||
or an ADMITTED status is a *claim*, not a proof alert/status change.
|
||||
|
||||
`attach_control_plane_truth` (see below) injects the read-only derived `SystemTruth`
|
||||
(states + findings) from `aios_control_plane_consistency.derive_truth` into the
|
||||
snapshot so that explain/status surfaces can expose *derived* truth alongside
|
||||
the reported state, without ever mutating the persistence planes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_control_plane_consistency import (
|
||||
AdmissionSnapshot,
|
||||
EvidenceSnapshot,
|
||||
GitSnapshot,
|
||||
LeaseSnapshot,
|
||||
TaskSnapshot,
|
||||
WorkerSnapshot,
|
||||
derive_truth,
|
||||
)
|
||||
|
||||
|
||||
def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
@@ -28,3 +48,48 @@ def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, A
|
||||
|
||||
def getControlPlaneSnapshot(**kwargs: Any) -> dict[str, Any]:
|
||||
return build_control_plane_snapshot(**kwargs)
|
||||
|
||||
|
||||
def attach_control_plane_truth(
|
||||
snapshot: Mapping[str, Any],
|
||||
*,
|
||||
admission: AdmissionSnapshot | None = None,
|
||||
task: TaskSnapshot | None = None,
|
||||
workers: Sequence[WorkerSnapshot] = (),
|
||||
lease: LeaseSnapshot | None = None,
|
||||
git: GitSnapshot | None = None,
|
||||
evidence: EvidenceSnapshot | None = None,
|
||||
now: int | None = None,
|
||||
queue_claim_threshold: int = 600,
|
||||
heartbeat_ttl: int = 180,
|
||||
) -> dict[str, Any]:
|
||||
"""Attach read-only derived SystemTruth to a control-plane snapshot.
|
||||
|
||||
This never writes to any persistence plane. It derives a ``system_truth``
|
||||
block from the already-loaded plane snapshots and adds it under
|
||||
``snapshot["derived"]``, leaving all existing keys intact.
|
||||
|
||||
The block includes the derived truth state, any findings, and a compact
|
||||
"equivalence" guard (see config/gitops/system_truth_contract.json
|
||||
``forbidden_equivalences``) so surfaces cannot silently present a local
|
||||
plane claim as end-to-end evidence.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
truth_state, findings = derive_truth(
|
||||
admission, task, workers, lease, git, evidence, now,
|
||||
queue_claim_threshold=queue_claim_threshold,
|
||||
heartbeat_ttl=heartbeat_ttl,
|
||||
)
|
||||
out = dict(snapshot)
|
||||
if "derived" not in out:
|
||||
out["derived"] = {}
|
||||
blockers = [f for f in findings if f.severity.value == "BLOCKER"]
|
||||
out["derived"]["system_truth"] = {
|
||||
"truth_state": truth_state.value,
|
||||
"findings": [asdict(f) for f in findings],
|
||||
"blocker_count": sum(1 for f in findings if f.severity.value == "BLOCKER"),
|
||||
"major_count": sum(1 for f in findings if f.severity.value == "MAJOR"),
|
||||
# Derivation is read-only; it never confirms completion on its own.
|
||||
"evidence_flow_confirmed": truth_state.value == "COMPLETE" and not blockers,
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
@@ -151,6 +155,7 @@ SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/submitSignedApproval',
|
||||
'/submitOwnerApproval',
|
||||
'/startDeployment',
|
||||
'/reopenOwnerApproval',
|
||||
})
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
@@ -253,6 +258,22 @@ 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
|
||||
if code == 'MIGRATION_PLAN_REQUIRED':
|
||||
raise CapabilityAuthorizationError(
|
||||
'migration plan required'
|
||||
) from exc
|
||||
if code == 'MIGRATION_PLAN_INVALID':
|
||||
raise CapabilityAuthorizationError(
|
||||
'migration plan invalid'
|
||||
) from exc
|
||||
raise TrustedCandidateBuilderUnavailable(
|
||||
'trusted builder request failed'
|
||||
) from exc
|
||||
@@ -290,6 +311,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 +339,33 @@ 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 (
|
||||
open_source_admission_store,
|
||||
)
|
||||
|
||||
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 = open_source_admission_store(
|
||||
Path(TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT),
|
||||
repository=builder_config.repository,
|
||||
policy=TRUSTED_BUILDER_POLICY,
|
||||
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 +414,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,
|
||||
@@ -925,6 +975,17 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(
|
||||
silent_release_service().submit_owner_approval(data)
|
||||
)
|
||||
if path == "/reopenOwnerApproval":
|
||||
a = self.need("release:approve")
|
||||
if not a:
|
||||
return
|
||||
from release_approval_reopen import ApprovalReopenError, reopen_owner_approval
|
||||
try:
|
||||
result = reopen_owner_approval(silent_release_service(), data)
|
||||
except ApprovalReopenError as exc:
|
||||
return self.sendj({"error": exc.code, "message": str(exc)}, 409)
|
||||
return self.sendj(result)
|
||||
|
||||
if path == '/startDeployment':
|
||||
a=self.need('release:deploy')
|
||||
if not a: return
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
@@ -864,10 +929,23 @@ class TaskAdmissionBootstrapService:
|
||||
trust_domain=envelope.trust_domain, policy_digest=envelope.policy_digest,
|
||||
authorization_kind=kind, owner_identity=owner_identity,
|
||||
parent_epoch_id=parent_epoch_id, issued_at=now,
|
||||
expires_at=min(
|
||||
envelope.expires_at,
|
||||
now + self.policy.max_task_ttl_seconds,
|
||||
parent_epoch.expires_at if parent_epoch is not None else envelope.expires_at,
|
||||
# V2R2C2 TTL contract: the admission envelope is a *signing
|
||||
# window* only. Once authorization succeeds (epoch verified or
|
||||
# Owner signature verified) the envelope is consumed, and the
|
||||
# derived grant's lifecycle is bounded by the task/grant policy
|
||||
# ceiling (max_task_ttl_seconds) and, for a delegation epoch,
|
||||
# by the parent epoch's own expiry. It must NOT be truncated by
|
||||
# the already-consumed envelope's remaining window, otherwise a
|
||||
# nominal 3600s policy collapses to the ~900s envelope window
|
||||
# (dual-semantic TTL defect). The envelope's temporal validity
|
||||
# still gates authorization (now < envelope.expires_at above).
|
||||
expires_at=(
|
||||
now + self.policy.max_task_ttl_seconds
|
||||
if parent_epoch is None
|
||||
else min(
|
||||
now + self.policy.max_task_ttl_seconds,
|
||||
parent_epoch.expires_at,
|
||||
)
|
||||
),
|
||||
)
|
||||
persisted_grant, _ = self.store.claim_grant(
|
||||
|
||||
@@ -257,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,
|
||||
@@ -286,6 +308,152 @@ 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"],
|
||||
@@ -840,6 +1036,17 @@ 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
|
||||
|
||||
@@ -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,8 @@ 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
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
@@ -227,14 +229,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]]:
|
||||
@@ -297,6 +348,35 @@ class ServerOwnedEvidenceSource:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
||||
return resolved
|
||||
|
||||
def assert_ready(self) -> None:
|
||||
"""Validate the server-owned evidence facility without requiring recipe evidence.
|
||||
|
||||
Readiness is an infrastructure/property check. Candidate-specific evidence
|
||||
remains mandatory for ``BuildTrustedCandidateV1`` and is validated by
|
||||
:meth:`__call__`; an empty evidence root therefore no longer makes the
|
||||
builder globally unavailable.
|
||||
"""
|
||||
|
||||
for path, code in (
|
||||
(self._authority_git_dir, "COLLECTOR_AUTHORITY_GIT_UNAVAILABLE"),
|
||||
(self._evidence_root, "COLLECTOR_EVIDENCE_ROOT_UNAVAILABLE"),
|
||||
):
|
||||
try:
|
||||
metadata = path.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise BuilderError(code) from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
||||
raise BuilderError(code)
|
||||
if path == self._evidence_root:
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and self._expected_owner_uid is not None
|
||||
and metadata.st_uid != self._expected_owner_uid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
||||
|
||||
def _digest(self, path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
@@ -436,9 +516,17 @@ class DeterministicEvidenceCollector:
|
||||
self._config = config
|
||||
self._evidence_source = evidence_source
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
def assert_ready(self) -> None:
|
||||
"""Fail closed on collector configuration, not on recipe evidence presence."""
|
||||
|
||||
if self._evidence_source is None:
|
||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||
checker = getattr(self._evidence_source, "assert_ready", None)
|
||||
if checker is not None:
|
||||
checker()
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.assert_ready()
|
||||
bundle = self._evidence_source(recipe_name, recipe)
|
||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
@@ -464,11 +552,32 @@ class DeterministicEvidenceCollector:
|
||||
try:
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
||||
from release_migration_plan import (
|
||||
MigrationPlanError,
|
||||
MigrationPlanRequired,
|
||||
)
|
||||
|
||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||
release_plan = ReleasePlan.from_dict(dict(bundle["release_plan"]))
|
||||
release_plan_payload = dict(bundle["release_plan"])
|
||||
migration_resolver = self._config.migration_plan_resolver
|
||||
if migration_resolver is not None:
|
||||
try:
|
||||
migration = migration_resolver(
|
||||
recipe_name,
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
)
|
||||
except MigrationPlanRequired as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_REQUIRED") from exc
|
||||
except MigrationPlanError as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_INVALID") from exc
|
||||
if migration is not None:
|
||||
release_plan_payload["migration"] = migration.to_dict()
|
||||
release_plan = ReleasePlan.from_dict(release_plan_payload)
|
||||
checks = dict(raw_checks)
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
except BuilderError:
|
||||
raise
|
||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
||||
@@ -622,14 +731,12 @@ class TrustedCandidateBuilder:
|
||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
||||
if {key: request.get(key) for key in bound} != bound:
|
||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
||||
candidate = bundle["candidate"]
|
||||
release_plan = bundle["release_plan"]
|
||||
checks = bundle.get("checks")
|
||||
evidence_digests = bundle["evidence_digests"]
|
||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
if request["operation"] == self.config.readiness_operation:
|
||||
self.collector.assert_ready()
|
||||
# Accessing the identity properties validates that the signer is
|
||||
# configured and usable without invoking the private key.
|
||||
builder_key_id = self.signer.key_id
|
||||
builder_key_generation = self.signer.key_generation
|
||||
result = {
|
||||
"schema": self.config.response_schema,
|
||||
"status": "READY",
|
||||
@@ -637,8 +744,8 @@ class TrustedCandidateBuilder:
|
||||
"recipe": recipe_name,
|
||||
"candidate_id": recipe["candidate_id"],
|
||||
"source_commit": recipe["source_commit"],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"builder_key_id": builder_key_id,
|
||||
"builder_key_generation": builder_key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"policy_sha256": self.config.policy_sha256,
|
||||
@@ -653,6 +760,15 @@ class TrustedCandidateBuilder:
|
||||
)
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
|
||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
||||
candidate = bundle["candidate"]
|
||||
release_plan = bundle["release_plan"]
|
||||
checks = bundle.get("checks")
|
||||
evidence_digests = bundle["evidence_digests"]
|
||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
|
||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
attestation = {
|
||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
||||
@@ -776,6 +892,50 @@ 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 open_source_admission_store
|
||||
|
||||
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 = open_source_admission_store(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=0,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_store.resolve_recipe,
|
||||
)
|
||||
migration_plan_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT", ""
|
||||
).strip()
|
||||
migration_profile_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT", ""
|
||||
).strip()
|
||||
if bool(migration_plan_root) != bool(migration_profile_root):
|
||||
raise BuilderError("MIGRATION_PLAN_CONFIGURATION_INVALID")
|
||||
if migration_plan_root:
|
||||
from release_migration_plan import FileMigrationPlanResolver
|
||||
|
||||
migration_resolver = FileMigrationPlanResolver(
|
||||
Path(migration_plan_root),
|
||||
profile_root=Path(migration_profile_root),
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
migration_plan_resolver=migration_resolver.resolve,
|
||||
)
|
||||
signer = CredentialPathEd25519Signer(
|
||||
keyring.signing_private_key_path,
|
||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Authenticated Gitea consistency provider using the real governed Git transport.
|
||||
|
||||
The provider proves Gitea consistency without inventing a REST API:
|
||||
|
||||
* local HEAD == source_oid,
|
||||
* local source_oid^{tree} == tree_oid,
|
||||
* origin identifies the governed repository,
|
||||
* exact checked-out branch exists remotely, and
|
||||
* git ls-remote reports exactly source_oid for that branch.
|
||||
|
||||
It then writes an append-only root-owned v2 provider receipt using only governed
|
||||
policy for provider identity, operation, store root, store id and modes.
|
||||
|
||||
The result explicitly records ``transport=git-ls-remote`` and
|
||||
``api_verified=false``. It must never be described as REST/API evidence.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
||||
RECEIPT_VERSION = 2
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$")
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class GiteaProviderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCommandResult:
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
GitRunner = Callable[[tuple[str, ...], Path], GitCommandResult]
|
||||
|
||||
|
||||
def canonical_json_bytes(payload: Mapping) -> bytes:
|
||||
return json.dumps(
|
||||
dict(payload),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(payload: Mapping) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
|
||||
|
||||
|
||||
def _runner(args: tuple[str, ...], cwd: Path) -> GitCommandResult:
|
||||
proc = subprocess.run(
|
||||
list(args),
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
return GitCommandResult(proc.returncode, proc.stdout, proc.stderr)
|
||||
|
||||
|
||||
def _git(run: GitRunner, cwd: Path, *args: str, error: str) -> str:
|
||||
result = run(("git", *args), cwd)
|
||||
if result.returncode != 0:
|
||||
raise GiteaProviderError(f"{error}: {result.stderr.strip()[:300]}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
if not _OID_RE.fullmatch(value):
|
||||
raise GiteaProviderError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _mode(value) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if text.isdigit():
|
||||
return int(text, 8)
|
||||
raise GiteaProviderError("PROVIDER_MODE_INVALID")
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> Path:
|
||||
raw = str(value).strip()
|
||||
p = Path(raw)
|
||||
if not raw or p.is_absolute() or any(part in ("", ".", "..") for part in p.parts):
|
||||
raise GiteaProviderError("PROVIDER_RECEIPT_REF_INVALID")
|
||||
return p
|
||||
|
||||
|
||||
def _safe_remote_url(url: str) -> str:
|
||||
url = str(url).strip()
|
||||
if "://" in url:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
port = f":{parts.port}" if parts.port else ""
|
||||
return urlunsplit((parts.scheme, host + port, parts.path, "", ""))
|
||||
if ":" in url and not url.startswith(("/", "./", "../")):
|
||||
left, right = url.split(":", 1)
|
||||
return f"{left.rsplit('@', 1)[-1]}:{right}"
|
||||
return url
|
||||
|
||||
|
||||
def repository_identity_from_remote(url: str) -> str:
|
||||
safe = _safe_remote_url(url)
|
||||
if "://" in safe:
|
||||
path = urlsplit(safe).path
|
||||
elif ":" in safe and not safe.startswith("/"):
|
||||
path = safe.split(":", 1)[1]
|
||||
else:
|
||||
path = safe
|
||||
parts = [x for x in path.strip("/").split("/") if x]
|
||||
if parts and parts[0] == "git":
|
||||
parts = parts[1:]
|
||||
if len(parts) < 2:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_UNAVAILABLE")
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
if repo.endswith(".git"):
|
||||
repo = repo[:-4]
|
||||
identity = f"{owner}/{repo}".lower()
|
||||
if not _REPO_RE.fullmatch(identity):
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_INVALID")
|
||||
return identity
|
||||
|
||||
|
||||
def load_gitea_policy(policy_path: str | Path) -> dict:
|
||||
raw = json.loads(Path(policy_path).read_text(encoding="utf-8"))
|
||||
defaults = dict(raw.get("defaults") or {})
|
||||
providers = raw.get("providers")
|
||||
if not isinstance(providers, dict) or not isinstance(providers.get("gitea"), dict):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_POLICY_MISSING")
|
||||
cfg = dict(providers["gitea"])
|
||||
return {
|
||||
"provider_identity": str(cfg["provider_identity"]),
|
||||
"store_id": str(cfg["store_id"]),
|
||||
"store_root": Path(str(cfg["store_root"])),
|
||||
"provider_operation": str(cfg["allowed_operations"][0]),
|
||||
"expected_owner_uid": int(cfg.get("expected_owner_uid", defaults.get("expected_owner_uid", 0))),
|
||||
"directory_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_directory_modes",
|
||||
defaults.get("allowed_directory_modes", ["0700", "0750"]),
|
||||
)
|
||||
),
|
||||
"receipt_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_receipt_modes",
|
||||
defaults.get("allowed_receipt_modes", ["0600", "0640"]),
|
||||
)
|
||||
),
|
||||
"authentication_kind": str(
|
||||
cfg.get(
|
||||
"authentication_kind",
|
||||
defaults.get("authentication_kind", "root-owned-store-v1"),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def collect_gitea_transport_consistency(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
remote: str = "origin",
|
||||
git_runner: GitRunner | None = None,
|
||||
) -> dict:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
if not repo_root.is_dir():
|
||||
raise GiteaProviderError("GITEA_CHECKOUT_UNAVAILABLE")
|
||||
source_oid = _oid(source_oid, "source_oid")
|
||||
tree_oid = _oid(tree_oid, "tree_oid")
|
||||
expected_repository = str(expected_repository).strip().lower()
|
||||
if not _REPO_RE.fullmatch(expected_repository):
|
||||
raise GiteaProviderError("GITEA_EXPECTED_REPOSITORY_INVALID")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._/-]+", remote) or remote.startswith(("/", "-")):
|
||||
raise GiteaProviderError("GITEA_REMOTE_NAME_INVALID")
|
||||
|
||||
run = git_runner or _runner
|
||||
|
||||
if _git(run, repo_root, "rev-parse", "--is-inside-work-tree",
|
||||
error="GITEA_LOCAL_REPOSITORY_REQUIRED").lower() != "true":
|
||||
raise GiteaProviderError("GITEA_LOCAL_REPOSITORY_REQUIRED")
|
||||
|
||||
actual_source = _oid(
|
||||
_git(run, repo_root, "rev-parse", "HEAD^{commit}",
|
||||
error="GITEA_LOCAL_SOURCE_UNAVAILABLE"),
|
||||
"actual_source_oid",
|
||||
)
|
||||
if actual_source != source_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_SOURCE_OID_MISMATCH")
|
||||
|
||||
actual_tree = _oid(
|
||||
_git(run, repo_root, "rev-parse", f"{source_oid}^{{tree}}",
|
||||
error="GITEA_LOCAL_TREE_UNAVAILABLE"),
|
||||
"actual_tree_oid",
|
||||
)
|
||||
if actual_tree != tree_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_TREE_OID_MISMATCH")
|
||||
|
||||
branch = _git(
|
||||
run, repo_root, "symbolic-ref", "--quiet", "HEAD",
|
||||
error="GITEA_GOVERNED_BRANCH_REQUIRED",
|
||||
)
|
||||
if not _REF_RE.fullmatch(branch):
|
||||
raise GiteaProviderError("GITEA_GOVERNED_BRANCH_INVALID")
|
||||
|
||||
remote_url = _git(
|
||||
run, repo_root, "remote", "get-url", remote,
|
||||
error="GITEA_REMOTE_UNAVAILABLE",
|
||||
)
|
||||
identity = repository_identity_from_remote(remote_url)
|
||||
if identity != expected_repository:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
output = _git(
|
||||
run, repo_root, "ls-remote", "--exit-code", remote, branch,
|
||||
error="GITEA_REMOTE_REF_UNAVAILABLE",
|
||||
)
|
||||
rows = []
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == branch:
|
||||
rows.append((parts[0].lower(), parts[1]))
|
||||
if len(rows) != 1:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_AMBIGUOUS")
|
||||
remote_oid = _oid(rows[0][0], "remote_ref_oid")
|
||||
if remote_oid != source_oid:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_OID_MISMATCH")
|
||||
|
||||
return {
|
||||
"consistent": True,
|
||||
"repository": identity,
|
||||
"source_commit": source_oid,
|
||||
"remote_ref_oid": remote_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"remote_ref": branch,
|
||||
"remote_url": _safe_remote_url(remote_url),
|
||||
"transport": "git-ls-remote",
|
||||
"api_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_root_directory(path: Path, mode: int, expected_uid: int) -> None:
|
||||
if os.geteuid() != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_ROOT_REQUIRED")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
st = os.lstat(path)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_INVALID")
|
||||
if st.st_uid != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_MODE_INVALID")
|
||||
|
||||
|
||||
def _write_receipt_exclusive(path: Path, payload: Mapping, mode: int) -> str:
|
||||
data = canonical_json_bytes(payload)
|
||||
if path.exists() or path.is_symlink():
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_EXISTS")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(path, flags, mode)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_WRITE_FAILED")
|
||||
view = view[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def produce_gitea_consistency_receipt(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
policy_path: str | Path,
|
||||
receipt_ref: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
) -> tuple[str, dict]:
|
||||
cfg = load_gitea_policy(policy_path)
|
||||
if cfg["authentication_kind"] != "root-owned-store-v1":
|
||||
raise GiteaProviderError("GITEA_PROVIDER_AUTH_KIND_UNSUPPORTED")
|
||||
result = collect_gitea_transport_consistency(
|
||||
repo_root=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
expected_repository=expected_repository,
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
run_id = f"gitea-git-transport-{source_oid[:12]}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
||||
rel = _safe_ref(receipt_ref)
|
||||
target = cfg["store_root"].joinpath(rel)
|
||||
root = cfg["store_root"].resolve()
|
||||
if not str(target.resolve(strict=False)).startswith(str(root) + os.sep):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_REF_ESCAPE")
|
||||
_ensure_root_directory(root, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
parent = root
|
||||
for part in rel.parts[:-1]:
|
||||
parent = parent / part
|
||||
_ensure_root_directory(parent, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
|
||||
payload = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"version": RECEIPT_VERSION,
|
||||
"provider_identity": cfg["provider_identity"],
|
||||
"provider_operation": cfg["provider_operation"],
|
||||
"operation_run_id": run_id,
|
||||
"result_reference": f"git:{result['remote_url']}#{result['remote_ref']}",
|
||||
"content_sha256": canonical_sha256(result),
|
||||
"source_oid": _oid(source_oid, "source_oid"),
|
||||
"tree_oid": _oid(tree_oid, "tree_oid"),
|
||||
"environment": "hw-isolated-linux-git-transport",
|
||||
"issued_at": now,
|
||||
"result": result,
|
||||
"authentication": {
|
||||
"kind": "root-owned-store-v1",
|
||||
"store_id": cfg["store_id"],
|
||||
},
|
||||
}
|
||||
sha = _write_receipt_exclusive(target, payload, cfg["receipt_mode"])
|
||||
return sha, payload
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from governed_provider_auth_adapter import (
|
||||
ALL_RELEASE_EVIDENCE_KINDS,
|
||||
authenticate_build_artifacts,
|
||||
gate_w_authentication_status,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence_closure(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
legacy_producer=None,
|
||||
):
|
||||
"""Production wrapper for the existing evidence producer.
|
||||
|
||||
This is the governed operation entrypoint. It refuses to call the legacy
|
||||
closure assembler until *all* final-release provider receipts are resolved
|
||||
from authenticated stores. For Gate-W, use `validate_gate_w_provider_auth`
|
||||
instead; canary/rollback remain pending until Gate-R.
|
||||
"""
|
||||
if provider_receipt_resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if legacy_producer is None:
|
||||
from governed_release_evidence_producer import produce_evidence_closure as legacy_producer
|
||||
|
||||
authenticated_request = authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
required_kinds=ALL_RELEASE_EVIDENCE_KINDS,
|
||||
)
|
||||
return legacy_producer(
|
||||
authenticated_request,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
)
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W authentication check without pretending Gate-R evidence exists."""
|
||||
return gate_w_authentication_status(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed evidence runtime registration chain (V2R2C2 P0).
|
||||
|
||||
Establishes the REAL runtime chain for the governed release evidence producer:
|
||||
|
||||
Inventory -> Resolver -> operation -> Producer -> closure
|
||||
|
||||
The Producer alone (governed_release_evidence_producer) is not "runtime
|
||||
registered" just because its OperationDefinition exists — registration only
|
||||
holds when a runtime can actually discover the operation in an inventory,
|
||||
resolve its ``handler_ref`` to the real callable, and invoke it to produce a
|
||||
closure. That full path is what this module wires and what the
|
||||
``PRODUCER_RUNTIME_REGISTERED_PASS`` verdict proves.
|
||||
|
||||
- Inventory : EvidenceOperationInventory — the set of governed evidence
|
||||
operations the authority can execute (unions the producer
|
||||
registry; extensible to future governed operations).
|
||||
- Resolver : resolve_handler(handler_ref) maps the operation's declared
|
||||
handler reference to the actual ``produce_evidence_closure``
|
||||
callable. Resolution is exact and restrictive — it never
|
||||
imports by arbitrary string eval.
|
||||
- Producer : the resolved callable executes with validated, receipt-bound
|
||||
build artifacts and materializes the evidence closure.
|
||||
- closure : the produced source_root/<version>/ closure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from governed_authenticated_evidence_operation import (
|
||||
produce_authenticated_evidence_closure,
|
||||
)
|
||||
from governed_provider_auth_adapter import gate_w_authentication_status
|
||||
from governed_release_evidence_producer import (
|
||||
EvidenceProduceError,
|
||||
GovernedBuildArtifacts,
|
||||
ProduceResult,
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
produce_evidence_closure,
|
||||
producer_operation_definition,
|
||||
producer_registry,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderPolicy,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
# Exact, restrictive handler_ref -> callable map. This is the ONLY way
|
||||
# handler_refs are resolved; no arbitrary import/eval.
|
||||
_HANDLERS: Mapping[str, Callable[..., ProduceResult]] = {
|
||||
"governed_release_evidence_producer.produce_evidence_closure": produce_evidence_closure,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF: produce_authenticated_evidence_closure,
|
||||
}
|
||||
|
||||
|
||||
class EvidenceOperationInventory:
|
||||
"""Inventory of governed evidence operations available to the authority."""
|
||||
|
||||
def __init__(self, *registries: OperationRegistry) -> None:
|
||||
definitions: list[OperationDefinition] = []
|
||||
seen: set[str] = set()
|
||||
for reg in registries:
|
||||
for definition in reg.definitions:
|
||||
if definition.operation_id in seen:
|
||||
continue
|
||||
seen.add(definition.operation_id)
|
||||
definitions.append(definition)
|
||||
self._registry = OperationRegistry(definitions)
|
||||
self._by_id = {d.operation_id: d for d in definitions}
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "EvidenceOperationInventory":
|
||||
"""The standard inventory: the governed release evidence producer."""
|
||||
return cls(producer_registry())
|
||||
|
||||
def contains(self, operation_id: str) -> bool:
|
||||
return operation_id in self._by_id
|
||||
|
||||
def operation_ids(self) -> tuple[str, ...]:
|
||||
return tuple(self._by_id)
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition | None:
|
||||
return self._by_id.get(operation_id)
|
||||
|
||||
|
||||
def resolve_handler(handler_ref: str) -> Callable[..., ProduceResult]:
|
||||
"""Resolver: turn an OperationDefinition.handler_ref into the real callable.
|
||||
|
||||
Exact lookup in a fixed allowlist. Unknown or non-producer references raise.
|
||||
"""
|
||||
handler = _HANDLERS.get(handler_ref)
|
||||
if handler is None:
|
||||
raise EvidenceProduceError("HANDLER_REF_UNRESOLVED")
|
||||
return handler
|
||||
|
||||
|
||||
def _payload_to_artifacts(payload: Mapping[str, Any]) -> GovernedBuildArtifacts:
|
||||
try:
|
||||
return GovernedBuildArtifacts(
|
||||
version=payload["version"],
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
issued_at=payload.get("issued_at", ""),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PAYLOAD_SCHEMA_INVALID") from exc
|
||||
|
||||
|
||||
def invoke_governed_produce(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
payload: Mapping[str, Any],
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Real runtime chain: Inventory -> Resolver -> operation -> Producer -> closure.
|
||||
|
||||
Returns a verdict dict. Only a genuine end-to-end invocation that reaches
|
||||
evidence production reports ``status == "PRODUCER_RUNTIME_REGISTERED_PASS"``.
|
||||
"""
|
||||
inventory = EvidenceOperationInventory.default()
|
||||
operation_id = "governed.release.evidence.produce"
|
||||
if not inventory.contains(operation_id):
|
||||
raise EvidenceProduceError("OPERATION_NOT_IN_INVENTORY")
|
||||
|
||||
definition = inventory.get(operation_id)
|
||||
assert definition is not None # guaranteed by contains()
|
||||
handler = resolve_handler(definition.handler_ref)
|
||||
|
||||
request = _payload_to_artifacts(payload)
|
||||
result = handler(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=request,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
# The resolved handler returned a real ProduceResult from a real closure;
|
||||
# the registered operation genuinely produced evidence.
|
||||
return {
|
||||
"status": "PRODUCER_RUNTIME_REGISTERED_PASS",
|
||||
"operation_id": operation_id,
|
||||
"handler_ref": definition.handler_ref,
|
||||
"handler_resolved": handler.__module__ + "." + handler.__name__,
|
||||
"evidence_types_bound": list(TRUSTED_PROVIDER_EVIDENCE_TYPES),
|
||||
"result": {
|
||||
"produce_status": result.status,
|
||||
"idempotent": result.idempotent,
|
||||
"receipt_sha256": result.receipt_sha256,
|
||||
"closure_dir": result.closure_dir,
|
||||
"evidence_digests": dict(result.evidence_digests),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V2R2C2 Round-3: Provider-Authenticated runtime chain.
|
||||
#
|
||||
# Inventory -> Resolver -> authenticated operation -> TrustedProviderReceiptResolver
|
||||
# -> Producer assembler (second-layer bound)
|
||||
#
|
||||
# The Round-3 runtime builds TrustedProviderReceiptResolver from the governed
|
||||
# evidence-provider policy (never from request-supplied store paths, provider
|
||||
# identities, key ids, or HMAC material). Gate-W authenticates only the
|
||||
# provider classes that can exist before staging (tests/Gitea/secret-scan);
|
||||
# canary/rollback remain pending until Gate-R.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sequence used for a full final release closure (Gate-R).
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
# Provider classes that are real before staging (Gate-W).
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
_DEFAULT_PROVIDER_POLICY = Path(__file__).resolve().parents[2] / "config" / "gitops" / "trusted-evidence-providers.json"
|
||||
|
||||
|
||||
def _load_provider_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping) or decoded.get("schema") != "aios.trusted-evidence-providers.v1":
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
return decoded
|
||||
|
||||
|
||||
def _policy_from_config(name: str, raw: Mapping[str, Any], defaults: Mapping[str, Any]) -> TrustedProviderPolicy:
|
||||
def _mode(value: object, field: str) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return int(value, 8)
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
|
||||
try:
|
||||
store_root = Path(str(raw["store_root"])).expanduser()
|
||||
allowed_ops = frozenset(str(op) for op in raw["allowed_operations"])
|
||||
return TrustedProviderPolicy(
|
||||
evidence_kind=name,
|
||||
provider_identity=str(raw["provider_identity"]),
|
||||
store_id=str(raw["store_id"]),
|
||||
store_root=store_root,
|
||||
allowed_operations=allowed_ops,
|
||||
expected_owner_uid=_mode(raw.get("expected_owner_uid", defaults.get("expected_owner_uid", 0)), "expected_owner_uid"),
|
||||
allowed_directory_modes=frozenset(
|
||||
_mode(v, "allowed_directory_modes")
|
||||
for v in raw.get("allowed_directory_modes", defaults.get("allowed_directory_modes", ["0700", "0750"]))
|
||||
),
|
||||
allowed_receipt_modes=frozenset(
|
||||
_mode(v, "allowed_receipt_modes")
|
||||
for v in raw.get("allowed_receipt_modes", defaults.get("allowed_receipt_modes", ["0600", "0640"]))
|
||||
),
|
||||
authentication_kind=str(raw.get("authentication_kind", defaults.get("authentication_kind", "root-owned-store-v1"))),
|
||||
max_age_seconds=raw.get("max_age_seconds", defaults.get("max_age_seconds")),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID") from exc
|
||||
|
||||
|
||||
def build_provider_receipt_resolver(
|
||||
provider_policy_path: Path = _DEFAULT_PROVIDER_POLICY,
|
||||
*,
|
||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
||||
now_fn: Callable[[], Any] | None = None,
|
||||
) -> TrustedProviderReceiptResolver:
|
||||
"""Build TrustedProviderReceiptResolver from the governed evidence-provider policy.
|
||||
|
||||
Store paths, provider identities, key ids and HMAC material come only from
|
||||
the governed policy/secret resolver — never from a request payload.
|
||||
"""
|
||||
policy = _load_provider_policy(provider_policy_path)
|
||||
defaults = dict(policy.get("defaults") or {})
|
||||
providers = policy.get("providers")
|
||||
if not isinstance(providers, Mapping) or not providers:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies: dict[str, TrustedProviderPolicy] = {}
|
||||
for name, raw in providers.items():
|
||||
if not isinstance(raw, Mapping):
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies[name] = _policy_from_config(name, raw, defaults)
|
||||
return TrustedProviderReceiptResolver(policies, hmac_key_resolver=hmac_key_resolver, now_fn=now_fn)
|
||||
|
||||
|
||||
def _resolver_guard(resolver: TrustedProviderReceiptResolver | None) -> TrustedProviderReceiptResolver:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
return resolver
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W: authenticate only the provider classes real before staging.
|
||||
|
||||
Returns PRODUCER_RUNTIME_REGISTRATION_PASS / PROVIDER_AUTH_ENFORCEMENT_PASS /
|
||||
FINAL_RELEASE_CLOSURE_PENDING_GATE_R. Canary/rollback are intentionally not
|
||||
required here — they remain pending until Gate-R.
|
||||
"""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
gate_w_authentication_status(request, resolver=resolver)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_closure_adapter(authenticated_request: GovernedBuildArtifacts, *, source_root: Path, builder_policy: Path) -> ProduceResult:
|
||||
"""Invoke the legacy closure assembler on the resolver-authenticated request.
|
||||
|
||||
The classified evidence fields now carry ``provider_receipt`` provenance;
|
||||
the producer's second-layer validator re-asserts content/OID binding on it.
|
||||
"""
|
||||
return produce_evidence_closure(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=authenticated_request,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> ProduceResult:
|
||||
"""Gate-R production path: authenticate all five final-release provider
|
||||
receipts, then invoke the legacy assembler only after authentication."""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
try:
|
||||
return produce_authenticated_evidence_closure(
|
||||
request,
|
||||
provider_receipt_resolver=resolver,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
legacy_producer=_legacy_closure_adapter,
|
||||
)
|
||||
except ProviderAuthenticationError as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import is_dataclass, replace
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
ResolvedEvidence,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
|
||||
def authenticate_evidence_request(
|
||||
evidence_kind: str,
|
||||
evidence_request: Mapping[str, Any],
|
||||
*,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, Any]:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
resolved: ResolvedEvidence = resolver.resolve_evidence(
|
||||
evidence_kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
return {
|
||||
**dict(resolved.result),
|
||||
"provider_receipt": dict(resolved.provider_receipt),
|
||||
}
|
||||
|
||||
|
||||
def authenticate_build_artifacts(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> Any:
|
||||
"""Authenticate receipt_ref-only evidence and return a replaced request.
|
||||
|
||||
The input is expected to be the existing GovernedBuildArtifacts dataclass.
|
||||
The function deliberately relies only on field names already used by the
|
||||
producer (`source_commit`, `tree_oid`, and the evidence kind fields), so it
|
||||
can be integrated without changing the deployment/release-plan contract.
|
||||
"""
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if not is_dataclass(request):
|
||||
raise TypeError("request must be a dataclass instance")
|
||||
source_oid = getattr(request, "source_commit")
|
||||
tree_oid = getattr(request, "tree_oid")
|
||||
replacements: dict[str, Any] = {}
|
||||
for kind in tuple(required_kinds):
|
||||
evidence_request = getattr(request, kind, None)
|
||||
if evidence_request is None:
|
||||
raise ProviderAuthenticationError(f"EVIDENCE_{kind.upper()}_REQUIRED")
|
||||
replacements[kind] = authenticate_evidence_request(
|
||||
kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
resolver=resolver,
|
||||
)
|
||||
return replace(request, **replacements)
|
||||
|
||||
|
||||
def gate_w_authentication_status(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Authenticate Gate-W evidence without pretending final closure exists."""
|
||||
authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=resolver,
|
||||
required_kinds=GATE_W_EVIDENCE_KINDS,
|
||||
)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed release evidence producer (V2R2C2 P0, operation governed.release.evidence.produce).
|
||||
|
||||
This module is the *producer* half of the governed evidence pipeline. It never
|
||||
invents candidate/release evidence: every digest and pass/fail flag in the closure
|
||||
is derived from real build artifacts and real verification executions supplied as
|
||||
input. It assembles a self-consistent, root-owned evidence closure below
|
||||
``source_root/<version>`` that ``TrustedBuilderEvidenceMaterializer`` (the projection
|
||||
half) can re-validate and project byte-for-byte.
|
||||
|
||||
Contract (see docs/V2R2C2-P0-convergence-20260909.md section 1):
|
||||
- operation_id : governed.release.evidence.produce
|
||||
- input : governed build artifacts + deployment conventions (no hand-write)
|
||||
- output : source_root/<version>/ full evidence_layout closure + receipt.json
|
||||
- fail-closed : mismatched secret-scan digest, tampered artifact, non-passing
|
||||
tests/canary/rollback/secret-scan, or inconsistent gitea all abort
|
||||
without writing a closure.
|
||||
- idempotent : an existing identical closure is accepted; a conflicting one fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from aios_trusted_candidate_builder import (
|
||||
load_builder_policy,
|
||||
TrustedCandidateBuilderConfig,
|
||||
)
|
||||
from release_evidence_materializer import EvidenceMaterializationError
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
PRODUCE_SCHEMA = "aios.governed-release-evidence-producer.v1"
|
||||
EVIDENCE_RECEIPT_SCHEMA = "aios.trusted-candidate-builder.evidence.v1"
|
||||
# Each governed evidence type (tests/canary/gitea/rollback/secret_scan) must be
|
||||
# bound to a trusted-provider receipt. A plain caller-supplied {"passed": true}
|
||||
# is never accepted on its own: the producer requires the receipt that proves a
|
||||
# trusted provider actually ran the check and produced the referenced result.
|
||||
TRUSTED_PROVIDER_RECEIPT_SCHEMA = "aios.trusted-provider.evidence.v1"
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
|
||||
|
||||
class EvidenceProduceError(RuntimeError):
|
||||
"""Stable, non-sensitive producer failure."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _validate_oid(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if (
|
||||
len(normalized) != 40
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_identifier(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > 255
|
||||
or normalized[0] in ".-"
|
||||
or any(
|
||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
||||
for char in normalized
|
||||
)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_sha256(value: object, name: str, *, allow_empty: bool = False) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if allow_empty and not normalized:
|
||||
return ""
|
||||
if (
|
||||
len(normalized) != 64
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedBuildArtifacts:
|
||||
"""Real build artifacts + real verification results. Producer never invents these."""
|
||||
|
||||
version: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact: bytes
|
||||
manifest: Mapping[str, Any]
|
||||
package_closure: bytes
|
||||
policy: Mapping[str, Any]
|
||||
tests: Mapping[str, Any]
|
||||
canary: Mapping[str, Any]
|
||||
gitea: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
secret_scan: Mapping[str, Any]
|
||||
# Deployment conventions (supplied by release planning, not evidence invention).
|
||||
deployment: Mapping[str, Any]
|
||||
tag: Mapping[str, Any]
|
||||
finalization: Mapping[str, Any]
|
||||
rollback_sha256: str = ""
|
||||
issued_at: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProduceResult:
|
||||
status: str
|
||||
idempotent: bool
|
||||
receipt_sha256: str
|
||||
closure_dir: str
|
||||
evidence_digests: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _require_trusted_provider_receipt(
|
||||
name: str,
|
||||
evidence: Mapping[str, Any],
|
||||
source_commit: str,
|
||||
tree_oid: str,
|
||||
) -> None:
|
||||
"""Require a trusted-provider binding for `evidence` (second-layer check).
|
||||
|
||||
A caller may never pass a bare ``{"passed": true}``: each governed evidence
|
||||
type must be bound to a trusted provider. Two accepted forms, both
|
||||
fail-closed on any missing/structurally invalid/digest-mismatched binding:
|
||||
|
||||
1. Round-2 native ``receipt`` (schema ``aios.trusted-provider.evidence.v1``)
|
||||
— a caller-carried receipt.
|
||||
2. Round-3 resolver-authenticated ``provider_receipt`` provenance (schema
|
||||
``aios.trusted-evidence-provider-receipt.v2``) — produced by
|
||||
``TrustedProviderReceiptResolver`` from an authenticated store. Its input
|
||||
is the resolver-authenticated provenance, never a caller-supplied body.
|
||||
|
||||
This helper is a *second-layer* binding validator: for the Round-3 path the
|
||||
authoritative proof is the resolver's authenticated provenance embedded as
|
||||
``provider_receipt``, and this check only re-asserts content/OID binding on
|
||||
top of it.
|
||||
"""
|
||||
receipt = evidence.get("receipt")
|
||||
if isinstance(receipt, Mapping) and receipt:
|
||||
body = {k: v for k, v in dict(evidence).items() if k != "receipt"}
|
||||
expected = _sha256_bytes(_canonical_json(body))
|
||||
if receipt.get("schema") != TRUSTED_PROVIDER_RECEIPT_SCHEMA:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("content_sha256") != expected:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_CONTENT_MISMATCH")
|
||||
for f in ("provider_identity", "operation_run_id", "result_reference",
|
||||
"environment", "issued_at"):
|
||||
if not str(receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("source_oid") not in (None, "", source_commit):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if receipt.get("tree_oid") not in (None, "", tree_oid):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
provider_receipt = evidence.get("provider_receipt")
|
||||
if isinstance(provider_receipt, Mapping) and provider_receipt:
|
||||
if (
|
||||
provider_receipt.get("schema")
|
||||
!= "aios.trusted-evidence-provider-receipt.v2"
|
||||
):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
for f in ("provider_identity", "provider_operation", "operation_run_id",
|
||||
"result_reference", "content_sha256", "source_oid", "tree_oid",
|
||||
"issued_at"):
|
||||
if not str(provider_receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if provider_receipt.get("source_oid") != source_commit:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if provider_receipt.get("tree_oid") != tree_oid:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_MISSING")
|
||||
|
||||
|
||||
def _verify_artifacts(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> dict[str, str]:
|
||||
"""Validate input consistency (fail-closed) and return the evidence digests."""
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
if version != request.version:
|
||||
raise EvidenceProduceError("VERSION_INVALID")
|
||||
|
||||
candidate_id = _validate_identifier(request.candidate_id, "candidate_id")
|
||||
source_commit = _validate_oid(request.source_commit, "source_commit")
|
||||
tree_oid = _validate_oid(request.tree_oid, "tree_oid")
|
||||
|
||||
if (
|
||||
set(request.manifest) != {
|
||||
"artifact_sha256", "package_closure_sha256",
|
||||
}
|
||||
):
|
||||
raise EvidenceProduceError("MANIFEST_SCHEMA_INVALID")
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
if request.manifest["artifact_sha256"] != artifact_sha:
|
||||
raise EvidenceProduceError("MANIFEST_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# manifest_sha256 is the digest of the manifest evidence file itself. It is
|
||||
# derived (never invented) from the manifest content; the manifest does not
|
||||
# embed its own digest to avoid a self-referential fixed point.
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
if _sha256_bytes(request.package_closure) != closure_sha:
|
||||
raise EvidenceProduceError("PACKAGE_CLOSURE_DIGEST_MISMATCH")
|
||||
|
||||
# policy_sha256 is the digest of the policy evidence file content; derived
|
||||
# here (never invented, never self-referential).
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
# Secret scan must cover the exact artifact bytes.
|
||||
secret_scan_artifact = _validate_sha256(
|
||||
request.secret_scan.get("artifact_sha256"), "secret_scan.artifact_sha256"
|
||||
)
|
||||
if secret_scan_artifact != artifact_sha:
|
||||
raise EvidenceProduceError("SECRET_SCAN_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# Every governed evidence type must be bound to a trusted-provider receipt.
|
||||
# A bare caller-supplied {"passed": true} (no receipt) fails closed here;
|
||||
# the producer never accepts an invented pass flag.
|
||||
for etype in TRUSTED_PROVIDER_EVIDENCE_TYPES:
|
||||
_require_trusted_provider_receipt(
|
||||
etype, getattr(request, etype), source_commit, tree_oid
|
||||
)
|
||||
|
||||
if request.secret_scan.get("passed") is not True:
|
||||
raise EvidenceProduceError("SECRET_SCAN_NOT_PASSED")
|
||||
|
||||
if request.tests.get("passed") is not True:
|
||||
raise EvidenceProduceError("TESTS_NOT_PASSED")
|
||||
if request.canary.get("passed") is not True:
|
||||
raise EvidenceProduceError("CANARY_NOT_PASSED")
|
||||
if request.rollback.get("ready") is not True:
|
||||
raise EvidenceProduceError("ROLLBACK_NOT_READY")
|
||||
if (
|
||||
request.gitea.get("consistent") is not True
|
||||
or request.gitea.get("repository") != config.repository
|
||||
or request.gitea.get("source_commit") != source_commit
|
||||
or request.gitea.get("remote_ref_oid") != source_commit
|
||||
or request.gitea.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise EvidenceProduceError("GITEA_INCONSISTENT")
|
||||
|
||||
digests = {
|
||||
"artifact": artifact_sha,
|
||||
"manifest": manifest_sha,
|
||||
"package_closure": closure_sha,
|
||||
"policy": policy_sha,
|
||||
"tests": _sha256_bytes(_canonical_json(dict(request.tests))),
|
||||
"canary": _sha256_bytes(_canonical_json(dict(request.canary))),
|
||||
"gitea": _sha256_bytes(_canonical_json(dict(request.gitea))),
|
||||
"rollback": _sha256_bytes(_canonical_json(dict(request.rollback))),
|
||||
"secret_scan": _sha256_bytes(_canonical_json(dict(request.secret_scan))),
|
||||
}
|
||||
return digests
|
||||
|
||||
|
||||
def _build_receipt(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Assemble the self-consistent receipt from verified artifacts."""
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState
|
||||
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
candidate_kwargs: dict[str, Any] = {}
|
||||
issued_at = str(request.issued_at or "").strip()
|
||||
if issued_at:
|
||||
# The evidence was produced at the build execution instant; bind it so the
|
||||
# same verified artifacts reproduce an identical, idempotent closure.
|
||||
candidate_kwargs["created_at"] = issued_at
|
||||
candidate_kwargs["updated_at"] = issued_at
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=request.candidate_id,
|
||||
source_commit=request.source_commit,
|
||||
tree_oid=request.tree_oid,
|
||||
artifact_sha256=artifact_sha,
|
||||
manifest_sha256=manifest_sha,
|
||||
tests=dict(request.tests),
|
||||
canary=dict(request.canary),
|
||||
rollback_ready=request.rollback.get("ready") is True,
|
||||
git_consistency={
|
||||
"consistent": True,
|
||||
"repository": config.repository,
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
},
|
||||
gitea_consistency=dict(request.gitea),
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
**candidate_kwargs,
|
||||
)
|
||||
|
||||
deployment_payload = dict(request.deployment)
|
||||
deployment_payload.update(
|
||||
{
|
||||
"service": request.deployment["service"],
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"manifest_sha256": manifest_sha,
|
||||
"package_closure_sha256": closure_sha,
|
||||
"policy_sha256": policy_sha,
|
||||
"rollback_sha256": _validate_sha256(
|
||||
request.rollback_sha256, "rollback_sha256"
|
||||
)
|
||||
or request.rollback.get("rollback_sha256", ""),
|
||||
"repository_identity": config.repository,
|
||||
"authoritative_git_dir": request.deployment["authoritative_git_dir"],
|
||||
}
|
||||
)
|
||||
if not deployment_payload["rollback_sha256"]:
|
||||
raise EvidenceProduceError("ROLLBACK_SHA256_REQUIRED")
|
||||
|
||||
from silent_release_hardening import (
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
TagPlan,
|
||||
TrustedCandidateBundle,
|
||||
)
|
||||
|
||||
release_plan = ReleasePlan(
|
||||
deployment=DeploymentPlan.from_dict(deployment_payload),
|
||||
tag=TagPlan.from_dict(dict(request.tag)),
|
||||
finalization=FinalizationPlan.from_dict(dict(request.finalization)),
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
checks = {name: True for name in config.required_checks}
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
|
||||
return {
|
||||
"schema": EVIDENCE_RECEIPT_SCHEMA,
|
||||
"version": request.version,
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _safe_target(source_root: Path, version: str) -> Path:
|
||||
relative = Path(version)
|
||||
if relative.is_absolute() or any(
|
||||
part in {"", ".", ".."} for part in relative.parts
|
||||
):
|
||||
raise EvidenceProduceError("CLOSURE_PATH_INVALID")
|
||||
candidate = Path(source_root) / version
|
||||
try:
|
||||
metadata = Path(source_root).stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise EvidenceProduceError("SOURCE_ROOT_UNAVAILABLE") from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or Path(source_root).is_symlink():
|
||||
raise EvidenceProduceError("SOURCE_ROOT_INVALID")
|
||||
return candidate
|
||||
|
||||
|
||||
def _write_durable_json(path: Path, payload: Mapping[str, Any], mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# The deterministic collector compares file digests against the canonical
|
||||
# payload hash (no trailing newline); write the exact canonical bytes so the
|
||||
# producer's closure re-validates under TrustedBuilderEvidenceMaterializer.
|
||||
raw = _canonical_json(payload)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, raw)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _write_durable_bytes(path: Path, value: bytes, mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, value)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def produce_evidence_closure(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
request: GovernedBuildArtifacts,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> ProduceResult:
|
||||
"""Produce a self-consistent, root-owned evidence closure, fail-closed and idempotent."""
|
||||
try:
|
||||
config = TrustedCandidateBuilderConfig.from_policy(
|
||||
load_builder_policy(builder_policy)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
|
||||
digests = _verify_artifacts(config, request)
|
||||
receipt = _build_receipt(config, request)
|
||||
|
||||
# Resolve layout paths from policy (aligned with evidence_layout; never invented).
|
||||
target = _safe_target(source_root, version)
|
||||
layout_paths = {
|
||||
name: target / Path(template.format(version=version)).relative_to(version)
|
||||
for name, template in config.evidence_layout.items()
|
||||
}
|
||||
|
||||
if target.exists() or target.is_symlink():
|
||||
# Idempotency: an existing identical closure (same receipt digest + same
|
||||
# artifact bytes) is accepted; anything else is a conflict.
|
||||
existing_receipt = target / "receipt.json"
|
||||
if not existing_receipt.is_file():
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
try:
|
||||
existing = json.loads(existing_receipt.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT") from exc
|
||||
if _canonical_json(dict(existing)) != _canonical_json(dict(receipt)):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
for name, path in layout_paths.items():
|
||||
if not (path.is_file() and _file_sha256(path) == digests[name]):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=True,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
temporary = source_root / (
|
||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.prod.tmp"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
raise EvidenceProduceError("PRODUCE_TEMP_CONFLICT")
|
||||
temporary.mkdir(mode=0o700)
|
||||
# The closure directory mirrors source_root/<version>/; every written file uses
|
||||
# the policy-declared basename so the layout is aligned with evidence_layout.
|
||||
try:
|
||||
_write_durable_json(temporary / "receipt.json", receipt)
|
||||
_write_durable_bytes(temporary / "artifact.tar.gz", request.artifact)
|
||||
_write_durable_json(temporary / "manifest.json", request.manifest)
|
||||
_write_durable_bytes(temporary / "checksums.sha256", request.package_closure)
|
||||
_write_durable_json(temporary / "trusted-release-policy.json", request.policy)
|
||||
_write_durable_json(temporary / "tests.json", request.tests)
|
||||
_write_durable_json(temporary / "canary.json", request.canary)
|
||||
_write_durable_json(temporary / "gitea.json", request.gitea)
|
||||
_write_durable_json(temporary / "rollback.json", request.rollback)
|
||||
_write_durable_json(temporary / "secret-scan.json", request.secret_scan)
|
||||
os.replace(temporary, target)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=False,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
|
||||
def producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed.release.evidence.produce operation (lookup-only registry).
|
||||
|
||||
This is the *legacy closure assembler* entrypoint. V2R2C2 Round-3 keeps it
|
||||
registered for backward compatibility with the existing runtime chain; the
|
||||
governed production path now resolves
|
||||
``governed_authenticated_evidence_operation.produce_authenticated_evidence_closure``
|
||||
(see ``authenticated_producer_operation_definition``).
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": "governed_release_evidence_producer.produce_evidence_closure",
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF = (
|
||||
"governed_authenticated_evidence_operation.produce_authenticated_evidence_closure"
|
||||
)
|
||||
|
||||
|
||||
def authenticated_producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed, provider-authenticated release evidence operation.
|
||||
|
||||
V2R2C2 Round-3 production entrypoint. Its ``handler_ref`` points at the
|
||||
authenticated wrapper, which refuses to invoke the legacy closure assembler
|
||||
until every final-release provider receipt has been resolved from an
|
||||
authenticated store (Gate-W only authenticates tests/Gitea/secret-scan;
|
||||
canary/rollback require Gate-R). See Round-3 Provider Authentication.
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.authenticated.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def producer_registry() -> OperationRegistry:
|
||||
"""Union of the legacy assembler and the authenticated production operation."""
|
||||
return OperationRegistry(
|
||||
[producer_operation_definition(), authenticated_producer_operation_definition()]
|
||||
)
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("produce",))
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--artifacts-json", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
try:
|
||||
payload = json.loads(arguments.artifacts_json.read_text(encoding="utf-8"))
|
||||
request = GovernedBuildArtifacts(
|
||||
version=arguments.version,
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
)
|
||||
result = produce_evidence_closure(
|
||||
builder_policy=arguments.builder_policy,
|
||||
source_root=arguments.source_root,
|
||||
request=request,
|
||||
)
|
||||
except (EvidenceProduceError, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
from dataclasses import asdict
|
||||
|
||||
print(json.dumps(asdict(result), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -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,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed, append-only reopen of one expired Owner approval cycle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
REQUEST_SCHEMA = "aios.owner-approval-reopen-request.v1"
|
||||
EVENT_NAME = "owner_approval_reopen_authorized"
|
||||
_IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ApprovalReopenError(RuntimeError):
|
||||
def __init__(self, code: str, detail: str | None = None):
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
super().__init__(code if not detail else f"{code}:{detail}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReopenRequest:
|
||||
candidate_id: str
|
||||
expected_approval_fingerprint: str
|
||||
expected_source_commit: str
|
||||
expected_tree_oid: str
|
||||
expected_artifact_sha256: str
|
||||
expected_manifest_sha256: str
|
||||
expected_release_plan_sha256: str
|
||||
expected_policy_sha256: str
|
||||
reason: str
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "ReopenRequest":
|
||||
if raw.get("schema") != REQUEST_SCHEMA:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SCHEMA_INVALID")
|
||||
allowed = {
|
||||
"schema", "candidate_id", "expected_approval_fingerprint",
|
||||
"expected_source_commit", "expected_tree_oid",
|
||||
"expected_artifact_sha256", "expected_manifest_sha256",
|
||||
"expected_release_plan_sha256", "expected_policy_sha256",
|
||||
"reason", "idempotency_key",
|
||||
}
|
||||
if set(raw) != allowed:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REQUEST_FIELDS_INVALID")
|
||||
req = cls(
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
expected_approval_fingerprint=str(raw["expected_approval_fingerprint"]),
|
||||
expected_source_commit=str(raw["expected_source_commit"]),
|
||||
expected_tree_oid=str(raw["expected_tree_oid"]),
|
||||
expected_artifact_sha256=str(raw["expected_artifact_sha256"]),
|
||||
expected_manifest_sha256=str(raw["expected_manifest_sha256"]),
|
||||
expected_release_plan_sha256=str(raw["expected_release_plan_sha256"]),
|
||||
expected_policy_sha256=str(raw["expected_policy_sha256"]),
|
||||
reason=str(raw["reason"]),
|
||||
idempotency_key=str(raw["idempotency_key"]),
|
||||
)
|
||||
if not req.candidate_id or "/" in req.candidate_id or ".." in req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_ID_INVALID")
|
||||
if not _HEX64.fullmatch(req.expected_approval_fingerprint):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_FINGERPRINT_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_source_commit):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SOURCE_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_tree_oid):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_TREE_INVALID")
|
||||
for value, code in (
|
||||
(req.expected_artifact_sha256, "APPROVAL_REOPEN_ARTIFACT_INVALID"),
|
||||
(req.expected_manifest_sha256, "APPROVAL_REOPEN_MANIFEST_INVALID"),
|
||||
(req.expected_release_plan_sha256, "APPROVAL_REOPEN_RELEASE_PLAN_INVALID"),
|
||||
(req.expected_policy_sha256, "APPROVAL_REOPEN_POLICY_INVALID"),
|
||||
):
|
||||
if not _HEX64.fullmatch(value):
|
||||
raise ApprovalReopenError(code)
|
||||
if not (1 <= len(req.reason.strip()) <= 512):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REASON_INVALID")
|
||||
if not _IDEMPOTENCY_RE.fullmatch(req.idempotency_key):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_IDEMPOTENCY_KEY_INVALID")
|
||||
return req
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _time(value: Any, code: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApprovalReopenError(code)
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ApprovalReopenError(code) from exc
|
||||
if dt.tzinfo is None:
|
||||
raise ApprovalReopenError(code)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
st = path.stat()
|
||||
if st.st_mode & 0o022:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_WRITABLE")
|
||||
if st.st_uid not in {0, os.geteuid()}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_OWNER_INVALID")
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
return obj
|
||||
|
||||
|
||||
def _fsync_dir(path: Path) -> None:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _paths(root: Path, cid: str) -> dict[str, Path]:
|
||||
return {
|
||||
"candidate": root / "candidates" / f"{cid}.json",
|
||||
"saga": root / "sagas" / f"{cid}.json",
|
||||
"plan": root / "release-plans" / f"{cid}.json",
|
||||
"acceptance": root / "acceptance" / f"{cid}.json",
|
||||
"approval": root / "approvals" / f"{cid}.json",
|
||||
}
|
||||
|
||||
|
||||
def _history(root: Path, cid: str, fp: str) -> Path:
|
||||
return root / "approval-history" / cid / f"{fp}.json"
|
||||
|
||||
|
||||
def _archive(active: Path, history: Path) -> None:
|
||||
history.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
try:
|
||||
os.chmod(history.parent, 0o750)
|
||||
except PermissionError:
|
||||
pass
|
||||
original = active.read_bytes()
|
||||
if history.exists():
|
||||
if history.is_symlink() or not history.is_file() or history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COLLISION")
|
||||
return
|
||||
try:
|
||||
os.link(active, history)
|
||||
except OSError:
|
||||
fd = os.open(history, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
active.stat().st_mode & 0o777)
|
||||
try:
|
||||
view = memoryview(original)
|
||||
while view:
|
||||
n = os.write(fd, view)
|
||||
view = view[n:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
if history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COPY_FAILED")
|
||||
_fsync_dir(history.parent)
|
||||
|
||||
|
||||
def _details(event: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
val = event.get("details")
|
||||
return val if isinstance(val, Mapping) else {}
|
||||
|
||||
|
||||
def _cycle_event(saga: Mapping[str, Any], cycle_id: str) -> Mapping[str, Any] | None:
|
||||
for event in saga.get("events") or []:
|
||||
if isinstance(event, Mapping) and event.get("event") == EVENT_NAME \
|
||||
and _details(event).get("approval_cycle_id") == cycle_id:
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _state(name: str) -> Any:
|
||||
from silent_release_hardening import SagaState # lazy: avoids import cycle
|
||||
return getattr(SagaState, name)
|
||||
|
||||
|
||||
def _cycle_id(req: ReopenRequest) -> str:
|
||||
return "approval-reopen-" + _sha(_canon({
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"candidate_id": req.candidate_id,
|
||||
"old_fingerprint": req.expected_approval_fingerprint,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"manifest_sha256": req.expected_manifest_sha256,
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"policy_sha256": req.expected_policy_sha256,
|
||||
"idempotency_key": req.idempotency_key,
|
||||
}))[:24]
|
||||
|
||||
|
||||
def _validate(req: ReopenRequest, candidate: Mapping[str, Any],
|
||||
saga: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
acceptance: Mapping[str, Any], approval: Mapping[str, Any],
|
||||
now: datetime) -> None:
|
||||
if saga.get("journal_integrity") is False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_JOURNAL_INTEGRITY_INVALID")
|
||||
if saga.get("state") != "APPROVED":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_STATE_INVALID")
|
||||
if candidate.get("candidate_id") != req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_MISMATCH")
|
||||
if candidate.get("state") not in {"READY_FOR_APPROVAL", "APPROVED"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_STATE_INVALID")
|
||||
if candidate.get("approval_state") not in {None, "PENDING"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_APPROVAL_STATE_INVALID")
|
||||
# ReleaseCandidate v1 durable records do not persist side_effects_executed.
|
||||
# Compatibility rule is intentionally fail-closed:
|
||||
# - an absent key is accepted only together with the independent Saga
|
||||
# invariant below that the accepted Owner-approval event is the final
|
||||
# Saga event;
|
||||
# - if a future durable schema persists the key, it must be exactly False;
|
||||
# - explicit True/None/number/string values are rejected.
|
||||
if "side_effects_executed" in candidate and candidate.get("side_effects_executed") is not False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SIDE_EFFECTS_ALREADY_EXECUTED")
|
||||
|
||||
checks = {
|
||||
"source": candidate.get("source_commit") == req.expected_source_commit,
|
||||
"tree": candidate.get("tree_oid") == req.expected_tree_oid,
|
||||
"artifact": candidate.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"manifest": candidate.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_fingerprint": approval.get("fingerprint") == req.expected_approval_fingerprint,
|
||||
"approval_source": approval.get("source_commit") == req.expected_source_commit,
|
||||
"approval_tree": approval.get("tree_oid") == req.expected_tree_oid,
|
||||
"approval_artifact": approval.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"approval_manifest": approval.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_release_plan": approval.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"approval_policy": approval.get("policy_sha256") == req.expected_policy_sha256,
|
||||
"acceptance": acceptance.get("status") == "ACCEPTED",
|
||||
"acceptance_release_plan": acceptance.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"plan_sha": (plan.get("sha256") or plan.get("release_plan_sha256")) == req.expected_release_plan_sha256,
|
||||
"plan_policy": plan.get("policy_sha256") == req.expected_policy_sha256,
|
||||
}
|
||||
bad = sorted(k for k, ok in checks.items() if not ok)
|
||||
if bad:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_BINDING_MISMATCH", ",".join(bad))
|
||||
|
||||
if now <= _time(approval.get("expires_at"), "APPROVAL_REOPEN_APPROVAL_EXPIRY_INVALID"):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_APPROVAL_NOT_EXPIRED")
|
||||
|
||||
events = [e for e in saga.get("events") or [] if isinstance(e, Mapping)]
|
||||
accepted = None
|
||||
for i, event in enumerate(events):
|
||||
if event.get("event") == "signed_owner_approval_accepted" \
|
||||
and _details(event).get("approval_fingerprint") == req.expected_approval_fingerprint:
|
||||
accepted = i
|
||||
if accepted is None:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACCEPTED_EVENT_MISSING")
|
||||
if accepted != len(events) - 1:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_ALREADY_ADVANCED")
|
||||
|
||||
|
||||
def reopen_owner_approval(service: Any, raw: Mapping[str, Any], *,
|
||||
state_root: Path | None = None,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
req = ReopenRequest.from_mapping(raw)
|
||||
raw_root = state_root or os.environ.get("AIOS_SILENT_RELEASE_STATE_ROOT", "")
|
||||
if not str(raw_root):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_ROOT_MISSING")
|
||||
root = Path(raw_root).expanduser().resolve()
|
||||
paths = _paths(root, req.candidate_id)
|
||||
cycle = _cycle_id(req)
|
||||
history = _history(root, req.candidate_id, req.expected_approval_fingerprint)
|
||||
saga = _secure_json(paths["saga"])
|
||||
|
||||
prior = _cycle_event(saga, cycle)
|
||||
if prior is not None:
|
||||
if saga.get("state") != "READY_FOR_APPROVAL":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CYCLE_ALREADY_CONSUMED")
|
||||
if not history.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_MISSING")
|
||||
if paths["approval"].exists():
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "IDEMPOTENT",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
}
|
||||
|
||||
candidate = _secure_json(paths["candidate"])
|
||||
plan = _secure_json(paths["plan"])
|
||||
acceptance = _secure_json(paths["acceptance"])
|
||||
approval = _secure_json(paths["approval"])
|
||||
_validate(req, candidate, saga, plan, acceptance, approval,
|
||||
(now or datetime.now(timezone.utc)).astimezone(timezone.utc))
|
||||
|
||||
_archive(paths["approval"], history)
|
||||
event_details = {
|
||||
"schema": "aios.owner-approval-reopen-event.v1",
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_approval_fingerprint": req.expected_approval_fingerprint,
|
||||
"expired_at": approval.get("expires_at"),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"reason": req.reason.strip(),
|
||||
"idempotency_digest": _sha(req.idempotency_key.encode()),
|
||||
}
|
||||
try:
|
||||
service.sagas.compare_and_set(
|
||||
req.candidate_id,
|
||||
expected_revision=saga.get("revision"),
|
||||
expected_states={_state("APPROVED")},
|
||||
target=_state("READY_FOR_APPROVAL"),
|
||||
event=EVENT_NAME,
|
||||
details=event_details,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_CAS_FAILED") from exc
|
||||
|
||||
try:
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
except ApprovalReopenError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_REMOVE_FAILED") from exc
|
||||
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "REOPENED",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed, fail-closed projection of verified release evidence into Builder storage.
|
||||
|
||||
This module never invents candidate/release evidence. A separate governed release
|
||||
producer must first write a complete, root-owned evidence closure below
|
||||
``source_root/<version>``. The materializer re-validates that closure with the
|
||||
same ``ServerOwnedEvidenceSource``/``DeterministicEvidenceCollector`` code used by
|
||||
Trusted Candidate Builder and atomically projects the exact bytes into the
|
||||
Builder-owned evidence root.
|
||||
|
||||
Readiness is deliberately orthogonal: an empty Builder evidence root does not make
|
||||
Builder infrastructure globally unavailable, but every BuildTrustedCandidateV1
|
||||
request still requires a valid materialized closure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
import subprocess
|
||||
|
||||
from aios_trusted_candidate_builder import (
|
||||
BuilderError,
|
||||
DeterministicEvidenceCollector,
|
||||
ServerOwnedEvidenceSource,
|
||||
TrustedCandidateBuilderConfig,
|
||||
load_builder_policy,
|
||||
)
|
||||
from release_migration_plan import FileMigrationPlanResolver
|
||||
from release_source_admission import (
|
||||
ReleaseSourceAdmissionStore,
|
||||
open_source_admission_store,
|
||||
source_admission_policy_sha256,
|
||||
)
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
MATERIALIZATION_SCHEMA = "aios.trusted-builder-evidence-materialization.v1"
|
||||
SOURCE_ADMISSION_CREDENTIAL = "source_admission_hmac"
|
||||
|
||||
|
||||
class EvidenceMaterializationError(RuntimeError):
|
||||
"""Stable, non-sensitive materialization failure."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _validate_oid(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if (
|
||||
len(normalized) != 40
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_identifier(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > 255
|
||||
or normalized[0] in ".-"
|
||||
or any(
|
||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
||||
for char in normalized
|
||||
)
|
||||
):
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_directory(path: Path, *, expected_owner_uid: int | None) -> Path:
|
||||
candidate = Path(path)
|
||||
try:
|
||||
metadata = candidate.stat(follow_symlinks=False)
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_UNAVAILABLE") from exc
|
||||
if candidate.is_symlink() or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_INVALID")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_PERMISSIONS_INVALID")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and expected_owner_uid is not None
|
||||
and metadata.st_uid != expected_owner_uid
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_OWNER_INVALID")
|
||||
return resolved
|
||||
|
||||
|
||||
def _safe_regular(
|
||||
root: Path,
|
||||
relative: str,
|
||||
*,
|
||||
expected_owner_uid: int | None,
|
||||
) -> Path:
|
||||
root = _safe_directory(root, expected_owner_uid=expected_owner_uid)
|
||||
relative_path = Path(relative)
|
||||
if relative_path.is_absolute() or any(
|
||||
part in {"", ".", ".."} for part in relative_path.parts
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_PATH_INVALID")
|
||||
current = root
|
||||
for part in relative_path.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise EvidenceMaterializationError("EVIDENCE_SYMLINK_DENIED")
|
||||
try:
|
||||
resolved = (root / relative_path).resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = resolved.stat(follow_symlinks=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise EvidenceMaterializationError("EVIDENCE_SPECIAL_FILE_DENIED")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_PERMISSIONS_INVALID")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and expected_owner_uid is not None
|
||||
and metadata.st_uid != expected_owner_uid
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_OWNER_INVALID")
|
||||
return resolved
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _copy_exact(source: Path, target: Path, *, mode: int = 0o640) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
descriptor = os.open(
|
||||
target,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
with source.open("rb") as src:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
os.write(descriptor, chunk)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(target, mode)
|
||||
|
||||
|
||||
def _write_json_exclusive(path: Path, payload: Mapping[str, Any]) -> str:
|
||||
raw = _canonical_json(payload) + b"\n"
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, raw)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, 0o640)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
class TrustedBuilderEvidenceMaterializer:
|
||||
"""Verify a governed evidence closure and atomically project exact bytes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
builder_policy: Path,
|
||||
authority_git_dir: Path,
|
||||
source_root: Path,
|
||||
output_root: Path,
|
||||
source_admission_root: Path | None = None,
|
||||
source_admission_hmac: bytes | None = None,
|
||||
migration_plan_root: Path | None = None,
|
||||
migration_profile_root: Path | None = None,
|
||||
expected_owner_uid: int | None = 0,
|
||||
git_runner: Callable[..., Any] = subprocess.run,
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None,
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None,
|
||||
) -> None:
|
||||
self.builder_policy = Path(builder_policy)
|
||||
self.authority_git_dir = Path(authority_git_dir)
|
||||
self.source_root = Path(source_root)
|
||||
self.output_root = Path(output_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
self.git_runner = git_runner
|
||||
|
||||
try:
|
||||
policy = load_builder_policy(self.builder_policy)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
except BuilderError as exc:
|
||||
raise EvidenceMaterializationError(str(exc)) from exc
|
||||
|
||||
self.source_admission_store: ReleaseSourceAdmissionStore | None = None
|
||||
if source_admission_resolver is not None:
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_admission_resolver,
|
||||
)
|
||||
elif source_admission_root is not None:
|
||||
if not source_admission_hmac or len(source_admission_hmac) < 32:
|
||||
raise EvidenceMaterializationError("SOURCE_ADMISSION_HMAC_UNAVAILABLE")
|
||||
self.source_admission_store = open_source_admission_store(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy=self.builder_policy,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=self.source_admission_store.resolve_recipe,
|
||||
)
|
||||
|
||||
if migration_plan_resolver is not None:
|
||||
config = replace(config, migration_plan_resolver=migration_plan_resolver)
|
||||
elif migration_plan_root is not None or migration_profile_root is not None:
|
||||
if migration_plan_root is None or migration_profile_root is None:
|
||||
raise EvidenceMaterializationError(
|
||||
"MIGRATION_PLAN_CONFIGURATION_INVALID"
|
||||
)
|
||||
resolver = FileMigrationPlanResolver(
|
||||
Path(migration_plan_root),
|
||||
profile_root=Path(migration_profile_root),
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
config = replace(config, migration_plan_resolver=resolver.resolve)
|
||||
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def policy_file_sha256(self) -> str:
|
||||
return source_admission_policy_sha256(self.builder_policy)
|
||||
|
||||
def _collector_for(self, evidence_root: Path) -> DeterministicEvidenceCollector:
|
||||
source = ServerOwnedEvidenceSource(
|
||||
self.config,
|
||||
self.authority_git_dir,
|
||||
evidence_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
git_runner=self.git_runner,
|
||||
)
|
||||
return DeterministicEvidenceCollector(
|
||||
self.config,
|
||||
evidence_source=source,
|
||||
)
|
||||
|
||||
def _relative_files(self, version: str) -> tuple[str, ...]:
|
||||
relative = [f"{version}/receipt.json"]
|
||||
relative.extend(
|
||||
template.format(version=version)
|
||||
for template in self.config.evidence_layout.values()
|
||||
)
|
||||
return tuple(dict.fromkeys(relative))
|
||||
|
||||
def _validate_existing(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
expected_bundle: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
observed = self._collector_for(self.output_root).collect(
|
||||
version,
|
||||
self.config.resolve_recipe(version, candidate_id, source_commit),
|
||||
)
|
||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
||||
if _canonical_json(dict(observed)) != _canonical_json(dict(expected_bundle)):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
record_path = self.output_root / version / "materialization.json"
|
||||
if not record_path.is_file():
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
try:
|
||||
record = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
||||
if not isinstance(record, Mapping):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
return dict(record)
|
||||
|
||||
def materialize(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
version = validate_release_version(version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceMaterializationError("VERSION_INVALID") from exc
|
||||
candidate_id = _validate_identifier(candidate_id, "candidate_id")
|
||||
source_commit = _validate_oid(source_commit, "source_commit")
|
||||
|
||||
_safe_directory(
|
||||
self.source_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
_safe_directory(
|
||||
self.output_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
|
||||
try:
|
||||
recipe = self.config.resolve_recipe(
|
||||
version,
|
||||
candidate_id,
|
||||
source_commit,
|
||||
)
|
||||
bundle = self._collector_for(self.source_root).collect(
|
||||
version,
|
||||
recipe,
|
||||
)
|
||||
except BuilderError as exc:
|
||||
raise EvidenceMaterializationError(str(exc)) from exc
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_SOURCE_INVALID") from exc
|
||||
|
||||
admission_id = ""
|
||||
if self.source_admission_store is not None:
|
||||
try:
|
||||
admission_id = str(
|
||||
self.source_admission_store.lookup_source(source_commit)[
|
||||
"admission_id"
|
||||
]
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
"SOURCE_ADMISSION_INVALID"
|
||||
) from exc
|
||||
|
||||
receipt_path = _safe_regular(
|
||||
self.source_root,
|
||||
f"{version}/receipt.json",
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
receipt_sha256 = _file_sha256(receipt_path)
|
||||
release_plan = dict(bundle["release_plan"])
|
||||
migration = release_plan.get("migration")
|
||||
migration_sha256 = ""
|
||||
if isinstance(migration, Mapping):
|
||||
migration_sha256 = str(migration.get("sha256") or "")
|
||||
|
||||
materialization_payload = {
|
||||
"schema": MATERIALIZATION_SCHEMA,
|
||||
"repository": self.config.repository,
|
||||
"version": version,
|
||||
"candidate_id": candidate_id,
|
||||
"source_commit": source_commit,
|
||||
"tree_oid": str(bundle["candidate"]["tree_oid"]),
|
||||
"source_admission_id": admission_id,
|
||||
"builder_policy_file_sha256": self.policy_file_sha256,
|
||||
"receipt_sha256": receipt_sha256,
|
||||
"migration_sha256": migration_sha256,
|
||||
"evidence_digests": dict(bundle["evidence_digests"]),
|
||||
}
|
||||
materialization_id = hashlib.sha256(
|
||||
_canonical_json(materialization_payload)
|
||||
).hexdigest()
|
||||
materialization_payload["materialization_id"] = (
|
||||
"evidence-materialization-" + materialization_id[:24]
|
||||
)
|
||||
|
||||
target = self.output_root / version
|
||||
if target.exists() or target.is_symlink():
|
||||
existing = self._validate_existing(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
expected_bundle=bundle,
|
||||
)
|
||||
if (
|
||||
existing.get("materialization_id")
|
||||
!= materialization_payload["materialization_id"]
|
||||
):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_DESTINATION_CONFLICT"
|
||||
)
|
||||
return {
|
||||
**materialization_payload,
|
||||
"status": "EVIDENCE_MATERIALIZED",
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
temporary = self.output_root / (
|
||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
raise EvidenceMaterializationError("EVIDENCE_TEMP_CONFLICT")
|
||||
temporary.mkdir(mode=0o750)
|
||||
try:
|
||||
for relative in self._relative_files(version):
|
||||
source = _safe_regular(
|
||||
self.source_root,
|
||||
relative,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
destination_relative = Path(relative).relative_to(version)
|
||||
destination = temporary / destination_relative
|
||||
_copy_exact(source, destination)
|
||||
if _file_sha256(destination) != _file_sha256(source):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_COPY_DIGEST_MISMATCH"
|
||||
)
|
||||
materialization_sha256 = _write_json_exclusive(
|
||||
temporary / "materialization.json",
|
||||
materialization_payload,
|
||||
)
|
||||
_fsync_directory(temporary)
|
||||
os.replace(temporary, target)
|
||||
_fsync_directory(self.output_root)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
try:
|
||||
observed = self._collector_for(self.output_root).collect(
|
||||
version,
|
||||
recipe,
|
||||
)
|
||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
||||
) from exc
|
||||
if _canonical_json(dict(observed)) != _canonical_json(dict(bundle)):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
||||
)
|
||||
|
||||
return {
|
||||
**materialization_payload,
|
||||
"materialization_sha256": materialization_sha256,
|
||||
"status": "EVIDENCE_MATERIALIZED",
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
|
||||
def _credential(name: str) -> bytes:
|
||||
root = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
if not root:
|
||||
raise EvidenceMaterializationError("CREDENTIALS_DIRECTORY_REQUIRED")
|
||||
path = Path(root) / name
|
||||
try:
|
||||
value = path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
f"{name.upper()}_UNAVAILABLE"
|
||||
) from exc
|
||||
if len(value) < 32:
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("materialize",))
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--candidate-id", required=True)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
||||
parser.add_argument("--migration-plan-root", required=True, type=Path)
|
||||
parser.add_argument("--migration-profile-root", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = TrustedBuilderEvidenceMaterializer(
|
||||
builder_policy=arguments.builder_policy,
|
||||
authority_git_dir=arguments.authority_git_dir,
|
||||
source_root=arguments.source_root,
|
||||
output_root=arguments.output_root,
|
||||
source_admission_root=arguments.source_admission_root,
|
||||
source_admission_hmac=_credential(SOURCE_ADMISSION_CREDENTIAL),
|
||||
migration_plan_root=arguments.migration_plan_root,
|
||||
migration_profile_root=arguments.migration_profile_root,
|
||||
).materialize(
|
||||
version=arguments.version,
|
||||
candidate_id=arguments.candidate_id,
|
||||
source_commit=arguments.source_commit,
|
||||
)
|
||||
except EvidenceMaterializationError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -0,0 +1,989 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed cross-code-line release migration planning.
|
||||
|
||||
The migration plan is data, not a shell script. It binds an admitted source to
|
||||
the exact currently observed staging code lines, the compatible state contract,
|
||||
and the rollback/verification contract. The plan can then be embedded in the
|
||||
existing signed ReleasePlan so Owner approval covers migration semantics without
|
||||
creating a second release authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{2,255}$")
|
||||
PLAN_SCHEMA = "aios.release-migration-plan.v1"
|
||||
REQUIREMENT_SCHEMA = "aios.release-migration-requirement.v1"
|
||||
PROFILE_SCHEMA = "aios.release-migration-profile.v1"
|
||||
ALLOWED_STATE_MODES = frozenset({"REUSE_IN_PLACE_WITH_SNAPSHOT", "PRESERVE_UNUSED"})
|
||||
REQUIRED_PRECONDITIONS = (
|
||||
"writer_disabled",
|
||||
"task_quiescent",
|
||||
"state_snapshot_verified",
|
||||
"credentials_ready",
|
||||
"rollback_ready",
|
||||
)
|
||||
REQUIRED_VERIFICATION_CONTRACTS = (
|
||||
"runtime_identity_exact",
|
||||
"task_principal_no_bearer_e2e",
|
||||
"restart_recovery",
|
||||
"credential_projection",
|
||||
"release_consistency_no_unknown",
|
||||
)
|
||||
|
||||
|
||||
class MigrationPlanError(RuntimeError):
|
||||
"""Migration evidence is missing, malformed, stale, or unsafe."""
|
||||
|
||||
|
||||
class MigrationPlanRequired(MigrationPlanError):
|
||||
"""A cross-code-line release cannot proceed without an exact migration plan."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _sha(value: object, label: str) -> str:
|
||||
text = str(value or "").lower()
|
||||
if not SHA_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _oid(value: object, label: str) -> str:
|
||||
text = str(value or "").lower()
|
||||
if not OID_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _ident(value: object, label: str) -> str:
|
||||
text = str(value or "")
|
||||
if not IDENT_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _secure_json(path: Path, *, expected_owner_uid: int | None = 0) -> dict[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise MigrationPlanError(f"secure_json_unavailable:{path}") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
raise MigrationPlanError("secure_json_regular_file_required")
|
||||
if expected_owner_uid is not None and os.name != "nt" and metadata.st_uid != expected_owner_uid:
|
||||
raise MigrationPlanError("secure_json_owner_invalid")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise MigrationPlanError("secure_json_mode_invalid")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise MigrationPlanError("secure_json_invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise MigrationPlanError("secure_json_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _durable_json(path: Path, value: Mapping[str, Any], *, mode: int = 0o640) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = _canonical_json(dict(value))
|
||||
if path.exists():
|
||||
current = path.read_bytes()
|
||||
if current != encoded:
|
||||
raise MigrationPlanError("migration_record_conflict")
|
||||
return
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
if os.name != "nt":
|
||||
os.chmod(path, mode)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _hash_files(root: Path, files: Sequence[str]) -> str:
|
||||
payload: dict[str, str] = {}
|
||||
resolved_root = root.resolve(strict=True)
|
||||
for relative in files:
|
||||
rel = Path(relative)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
raise MigrationPlanError("interface_path_invalid")
|
||||
path = (resolved_root / rel).resolve(strict=True)
|
||||
try:
|
||||
path.relative_to(resolved_root)
|
||||
except ValueError as exc:
|
||||
raise MigrationPlanError("interface_path_escape") from exc
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise MigrationPlanError("interface_file_invalid")
|
||||
payload[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
def _git_blob(git_dir: Path, source_oid: str, relative: str) -> bytes:
|
||||
rel = Path(relative)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
raise MigrationPlanError("target_interface_path_invalid")
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "show", f"{source_oid}:{relative}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise MigrationPlanError("target_interface_unavailable")
|
||||
return bytes(completed.stdout)
|
||||
|
||||
|
||||
def _hash_git_files(git_dir: Path, source_oid: str, files: Sequence[str]) -> str:
|
||||
payload = {
|
||||
relative: hashlib.sha256(_git_blob(git_dir, source_oid, relative)).hexdigest()
|
||||
for relative in files
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
def _systemd_properties(service: str) -> dict[str, Any]:
|
||||
_ident(service, "service")
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"systemctl",
|
||||
"show",
|
||||
service,
|
||||
"--property=WorkingDirectory",
|
||||
"--property=Environment",
|
||||
"--property=FragmentPath",
|
||||
"--property=ActiveState",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise MigrationPlanError("systemd_observation_unavailable")
|
||||
result: dict[str, Any] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
result[key] = value
|
||||
if result.get("ActiveState") != "active":
|
||||
raise MigrationPlanError("component_not_active")
|
||||
environment: dict[str, str] = {}
|
||||
for token in str(result.get("Environment") or "").split():
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
environment[key] = value.strip('"')
|
||||
result["environment"] = environment
|
||||
return result
|
||||
|
||||
|
||||
def _sqlite_schema(connection: sqlite3.Connection) -> dict[str, Any]:
|
||||
tables = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
]
|
||||
result: dict[str, Any] = {}
|
||||
for table in tables:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
|
||||
raise MigrationPlanError("sqlite_table_name_invalid")
|
||||
columns = []
|
||||
for row in connection.execute(f"PRAGMA table_info({table})"):
|
||||
columns.append(
|
||||
{
|
||||
"cid": int(row[0]),
|
||||
"name": str(row[1]),
|
||||
"type": str(row[2]),
|
||||
"notnull": int(row[3]),
|
||||
"default": row[4],
|
||||
"pk": int(row[5]),
|
||||
}
|
||||
)
|
||||
result[table] = {
|
||||
"columns": columns,
|
||||
"row_count": int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _schema_digest(schema: Mapping[str, Any]) -> str:
|
||||
shape = {
|
||||
table: {"columns": value["columns"]}
|
||||
for table, value in sorted(schema.items())
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(shape)).hexdigest()
|
||||
|
||||
|
||||
def _assert_additive_sqlite(before: Mapping[str, Any], after: Mapping[str, Any]) -> None:
|
||||
for table, old in before.items():
|
||||
if table not in after:
|
||||
raise MigrationPlanError("sqlite_table_removed")
|
||||
old_columns = {column["name"]: column for column in old["columns"]}
|
||||
new_columns = {column["name"]: column for column in after[table]["columns"]}
|
||||
for name, column in old_columns.items():
|
||||
if new_columns.get(name) != column:
|
||||
raise MigrationPlanError("sqlite_column_changed")
|
||||
for name, column in new_columns.items():
|
||||
if name in old_columns:
|
||||
continue
|
||||
# A new NOT NULL column is backward compatible only with a default.
|
||||
if column["notnull"] and column["default"] is None:
|
||||
raise MigrationPlanError("sqlite_new_column_unsafe")
|
||||
if int(after[table]["row_count"]) != int(old["row_count"]):
|
||||
raise MigrationPlanError("sqlite_row_count_changed_during_probe")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeComponentBinding:
|
||||
component_id: str
|
||||
service: str
|
||||
code_line_id: str
|
||||
source_oid: str
|
||||
artifact_sha256: str | None
|
||||
interface_sha256: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.component_id, "component_id")
|
||||
_ident(self.service, "service")
|
||||
_ident(self.code_line_id, "code_line_id")
|
||||
_oid(self.source_oid, "source_oid")
|
||||
if self.artifact_sha256 is not None:
|
||||
_sha(self.artifact_sha256, "artifact_sha256")
|
||||
_sha(self.interface_sha256, "interface_sha256")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "RuntimeComponentBinding":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateMigrationContract:
|
||||
state_id: str
|
||||
state_ref: str
|
||||
mode: str
|
||||
before_schema_sha256: str
|
||||
after_schema_sha256: str
|
||||
snapshot_name: str
|
||||
snapshot_sha256: str
|
||||
snapshot_required: bool
|
||||
rollback_compatible: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.state_id, "state_id")
|
||||
_ident(self.state_ref, "state_ref")
|
||||
if self.mode not in ALLOWED_STATE_MODES:
|
||||
raise MigrationPlanError("state_mode_invalid")
|
||||
_sha(self.before_schema_sha256, "before_schema_sha256")
|
||||
_sha(self.after_schema_sha256, "after_schema_sha256")
|
||||
_sha(self.snapshot_sha256, "snapshot_sha256")
|
||||
snapshot = Path(self.snapshot_name)
|
||||
if (
|
||||
snapshot.is_absolute()
|
||||
or ".." in snapshot.parts
|
||||
or not snapshot.parts
|
||||
or str(snapshot).startswith(".")
|
||||
):
|
||||
raise MigrationPlanError("snapshot_name_invalid")
|
||||
if self.snapshot_required is not True or self.rollback_compatible is not True:
|
||||
raise MigrationPlanError("state_recovery_contract_invalid")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "StateMigrationContract":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationPlan:
|
||||
migration_id: str
|
||||
version: str
|
||||
candidate_id: str
|
||||
repository_identity: str
|
||||
source_admission_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
from_components: tuple[RuntimeComponentBinding, ...]
|
||||
to_components: tuple[RuntimeComponentBinding, ...]
|
||||
state_contracts: tuple[StateMigrationContract, ...]
|
||||
activation_schema: str
|
||||
activation_sha256: str
|
||||
rollback_window_seconds: int
|
||||
preconditions: tuple[str, ...] = REQUIRED_PRECONDITIONS
|
||||
verification_contracts: tuple[str, ...] = REQUIRED_VERIFICATION_CONTRACTS
|
||||
runtime_switch_capability: str = "runtime_switch"
|
||||
rollback_contract: str = "restore_previous_runtime_and_state_snapshot.v1"
|
||||
forward_recovery_contract: str = "forward_recovery_after_verified_rollback_block.v1"
|
||||
writer_expected: bool = False
|
||||
direct_db_allowed: bool = False
|
||||
direct_runtime_overwrite_allowed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.migration_id, "migration_id")
|
||||
_ident(self.version, "version")
|
||||
_ident(self.candidate_id, "candidate_id")
|
||||
if self.repository_identity != "aios/aios":
|
||||
raise MigrationPlanError("repository_identity_invalid")
|
||||
_ident(self.source_admission_id, "source_admission_id")
|
||||
_oid(self.source_commit, "source_commit")
|
||||
_oid(self.tree_oid, "tree_oid")
|
||||
_ident(self.profile_id, "profile_id")
|
||||
_sha(self.profile_sha256, "profile_sha256")
|
||||
_ident(self.activation_schema, "activation_schema")
|
||||
_sha(self.activation_sha256, "activation_sha256")
|
||||
if not 60 <= int(self.rollback_window_seconds) <= 86400:
|
||||
raise MigrationPlanError("rollback_window_invalid")
|
||||
if tuple(self.preconditions) != REQUIRED_PRECONDITIONS:
|
||||
raise MigrationPlanError("migration_preconditions_invalid")
|
||||
if tuple(self.verification_contracts) != REQUIRED_VERIFICATION_CONTRACTS:
|
||||
raise MigrationPlanError("migration_verification_contracts_invalid")
|
||||
if self.runtime_switch_capability != "runtime_switch":
|
||||
raise MigrationPlanError("migration_capability_invalid")
|
||||
if self.writer_expected or self.direct_db_allowed or self.direct_runtime_overwrite_allowed:
|
||||
raise MigrationPlanError("migration_unsafe_authority")
|
||||
if not self.from_components or not self.to_components or not self.state_contracts:
|
||||
raise MigrationPlanError("migration_contract_incomplete")
|
||||
from_ids = {item.component_id for item in self.from_components}
|
||||
to_ids = {item.component_id for item in self.to_components}
|
||||
if from_ids != to_ids or len(from_ids) != len(self.from_components) or len(to_ids) != len(self.to_components):
|
||||
raise MigrationPlanError("migration_component_binding_invalid")
|
||||
if all(
|
||||
next(item.code_line_id for item in self.from_components if item.component_id == target.component_id)
|
||||
== target.code_line_id
|
||||
for target in self.to_components
|
||||
):
|
||||
raise MigrationPlanError("migration_not_required")
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PLAN_SCHEMA,
|
||||
"migration_id": self.migration_id,
|
||||
"version": self.version,
|
||||
"candidate_id": self.candidate_id,
|
||||
"repository_identity": self.repository_identity,
|
||||
"source_admission_id": self.source_admission_id,
|
||||
"source_commit": self.source_commit,
|
||||
"tree_oid": self.tree_oid,
|
||||
"profile_id": self.profile_id,
|
||||
"profile_sha256": self.profile_sha256,
|
||||
"from_components": [item.to_dict() for item in self.from_components],
|
||||
"to_components": [item.to_dict() for item in self.to_components],
|
||||
"state_contracts": [item.to_dict() for item in self.state_contracts],
|
||||
"activation_schema": self.activation_schema,
|
||||
"activation_sha256": self.activation_sha256,
|
||||
"rollback_window_seconds": self.rollback_window_seconds,
|
||||
"preconditions": list(self.preconditions),
|
||||
"verification_contracts": list(self.verification_contracts),
|
||||
"runtime_switch_capability": self.runtime_switch_capability,
|
||||
"rollback_contract": self.rollback_contract,
|
||||
"forward_recovery_contract": self.forward_recovery_contract,
|
||||
"writer_expected": self.writer_expected,
|
||||
"direct_db_allowed": self.direct_db_allowed,
|
||||
"direct_runtime_overwrite_allowed": self.direct_runtime_overwrite_allowed,
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {**self.canonical_payload, "sha256": self.sha256}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationPlan":
|
||||
raw = dict(value)
|
||||
if raw.pop("schema", None) != PLAN_SCHEMA:
|
||||
raise MigrationPlanError("migration_plan_schema_invalid")
|
||||
claimed = raw.pop("sha256", None)
|
||||
raw["from_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["from_components"])
|
||||
raw["to_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["to_components"])
|
||||
raw["state_contracts"] = tuple(StateMigrationContract.from_dict(item) for item in raw["state_contracts"])
|
||||
raw["preconditions"] = tuple(raw.get("preconditions") or ())
|
||||
raw["verification_contracts"] = tuple(raw.get("verification_contracts") or ())
|
||||
plan = cls(**raw)
|
||||
if claimed is not None and claimed != plan.sha256:
|
||||
raise MigrationPlanError("migration_plan_sha256_mismatch")
|
||||
return plan
|
||||
|
||||
def assert_release_binding(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
tree_oid: str,
|
||||
repository_identity: str,
|
||||
) -> None:
|
||||
expected = (
|
||||
self.version,
|
||||
self.candidate_id,
|
||||
self.source_commit,
|
||||
self.tree_oid,
|
||||
self.repository_identity,
|
||||
)
|
||||
actual = (version, candidate_id, source_commit, tree_oid, repository_identity)
|
||||
if actual != expected:
|
||||
raise MigrationPlanError("migration_release_binding_mismatch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationRequirement:
|
||||
version: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
repository_identity: str
|
||||
required: bool
|
||||
reason_code: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
migration_plan_sha256: str | None
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUIREMENT_SCHEMA,
|
||||
**asdict(self),
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {**self.canonical_payload, "sha256": self.sha256}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationRequirement":
|
||||
raw = dict(value)
|
||||
if raw.pop("schema", None) != REQUIREMENT_SCHEMA:
|
||||
raise MigrationPlanError("migration_requirement_schema_invalid")
|
||||
claimed = raw.pop("sha256", None)
|
||||
requirement = cls(**raw)
|
||||
_ident(requirement.version, "version")
|
||||
_ident(requirement.candidate_id, "candidate_id")
|
||||
_oid(requirement.source_commit, "source_commit")
|
||||
if requirement.repository_identity != "aios/aios":
|
||||
raise MigrationPlanError("migration_requirement_repository_invalid")
|
||||
_ident(requirement.reason_code, "reason_code")
|
||||
_ident(requirement.profile_id, "profile_id")
|
||||
_sha(requirement.profile_sha256, "profile_sha256")
|
||||
if requirement.required:
|
||||
_sha(requirement.migration_plan_sha256, "migration_plan_sha256")
|
||||
elif requirement.migration_plan_sha256 is not None:
|
||||
raise MigrationPlanError("unexpected_migration_plan")
|
||||
if claimed is not None and claimed != requirement.sha256:
|
||||
raise MigrationPlanError("migration_requirement_sha256_mismatch")
|
||||
return requirement
|
||||
|
||||
|
||||
class FileMigrationPlanResolver:
|
||||
"""Read-only resolver used by the trusted builder.
|
||||
|
||||
A profile declares that a release version is cross-code-line. Once a
|
||||
version is declared, absence of requirement/plan state is fail-closed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
profile_root: Path,
|
||||
expected_owner_uid: int | None = 0,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.profile_root = Path(profile_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
|
||||
def _profile_for_version(self, version: str) -> dict[str, Any] | None:
|
||||
matches = []
|
||||
if not self.profile_root.is_dir():
|
||||
return None
|
||||
for path in sorted(self.profile_root.glob("*.json")):
|
||||
profile = _secure_json(path, expected_owner_uid=self.expected_owner_uid)
|
||||
if profile.get("schema") != PROFILE_SCHEMA:
|
||||
raise MigrationPlanError("migration_profile_schema_invalid")
|
||||
if profile.get("version") == version:
|
||||
matches.append(profile)
|
||||
if len(matches) > 1:
|
||||
raise MigrationPlanError("migration_profile_ambiguous")
|
||||
return matches[0] if matches else None
|
||||
|
||||
def resolve(self, version: str, candidate_id: str, source_commit: str) -> MigrationPlan | None:
|
||||
profile = self._profile_for_version(version)
|
||||
if profile is None:
|
||||
return None
|
||||
base = self.root / source_commit
|
||||
requirement_path = base / "requirement.json"
|
||||
plan_path = base / "plan.json"
|
||||
if not requirement_path.is_file():
|
||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
||||
requirement = MigrationRequirement.from_dict(
|
||||
_secure_json(requirement_path, expected_owner_uid=self.expected_owner_uid)
|
||||
)
|
||||
expected = (version, candidate_id, source_commit, "aios/aios")
|
||||
actual = (
|
||||
requirement.version,
|
||||
requirement.candidate_id,
|
||||
requirement.source_commit,
|
||||
requirement.repository_identity,
|
||||
)
|
||||
if actual != expected:
|
||||
raise MigrationPlanError("migration_requirement_binding_mismatch")
|
||||
if requirement.profile_id != profile.get("profile_id"):
|
||||
raise MigrationPlanError("migration_profile_binding_mismatch")
|
||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
||||
if requirement.profile_sha256 != profile_sha:
|
||||
raise MigrationPlanError("migration_profile_digest_mismatch")
|
||||
if not requirement.required:
|
||||
return None
|
||||
if not plan_path.is_file():
|
||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
||||
plan = MigrationPlan.from_dict(
|
||||
_secure_json(plan_path, expected_owner_uid=self.expected_owner_uid)
|
||||
)
|
||||
plan.assert_release_binding(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
tree_oid=plan.tree_oid,
|
||||
repository_identity="aios/aios",
|
||||
)
|
||||
if requirement.migration_plan_sha256 != plan.sha256:
|
||||
raise MigrationPlanError("migration_requirement_plan_mismatch")
|
||||
return plan
|
||||
|
||||
|
||||
class MigrationPreSwitchValidator:
|
||||
"""Validates a root-owned live proof against the signed migration plan."""
|
||||
|
||||
def __init__(self, proof_resolver: Callable[[MigrationPlan], Mapping[str, Any]]):
|
||||
self.proof_resolver = proof_resolver
|
||||
|
||||
def validate(self, migration_plan: MigrationPlan | None) -> dict[str, Any]:
|
||||
if migration_plan is None:
|
||||
return {"required": False, "verified": True}
|
||||
proof = dict(self.proof_resolver(migration_plan))
|
||||
snapshot_digests = sorted(
|
||||
contract.snapshot_sha256 for contract in migration_plan.state_contracts
|
||||
)
|
||||
required = {
|
||||
"migration_plan_sha256": migration_plan.sha256,
|
||||
"migration_id": migration_plan.migration_id,
|
||||
"source_commit": migration_plan.source_commit,
|
||||
"tree_oid": migration_plan.tree_oid,
|
||||
"activation_sha256": migration_plan.activation_sha256,
|
||||
"state_snapshot_sha256s": snapshot_digests,
|
||||
"writer_disabled": True,
|
||||
"task_quiescent": True,
|
||||
"state_snapshot_verified": True,
|
||||
"credentials_ready": True,
|
||||
"rollback_ready": True,
|
||||
}
|
||||
failed = [key for key, expected in required.items() if proof.get(key) != expected]
|
||||
if failed:
|
||||
raise MigrationPlanError(
|
||||
"MIGRATION_PRE_SWITCH_PROOF_INVALID:" + ",".join(sorted(failed))
|
||||
)
|
||||
return {"required": True, "verified": True, **proof}
|
||||
|
||||
|
||||
class HostMigrationCompiler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
authority_git_dir: Path,
|
||||
profile_path: Path,
|
||||
output_root: Path,
|
||||
expected_owner_uid: int | None = 0,
|
||||
):
|
||||
self.authority_git_dir = Path(authority_git_dir)
|
||||
self.profile_path = Path(profile_path)
|
||||
self.output_root = Path(output_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
|
||||
def _profile(self) -> dict[str, Any]:
|
||||
profile = _secure_json(self.profile_path, expected_owner_uid=self.expected_owner_uid)
|
||||
required = {
|
||||
"schema",
|
||||
"profile_id",
|
||||
"version",
|
||||
"repository",
|
||||
"current_components",
|
||||
"target_components",
|
||||
"activation_file",
|
||||
"state_ref",
|
||||
"state_db",
|
||||
"state_mode",
|
||||
"rollback_window_seconds",
|
||||
}
|
||||
if set(profile) != required or profile.get("schema") != PROFILE_SCHEMA:
|
||||
raise MigrationPlanError("migration_profile_fields_invalid")
|
||||
if profile.get("repository") != "aios/aios":
|
||||
raise MigrationPlanError("migration_profile_repository_invalid")
|
||||
_ident(profile.get("profile_id"), "profile_id")
|
||||
_ident(profile.get("version"), "version")
|
||||
if profile.get("state_mode") not in ALLOWED_STATE_MODES:
|
||||
raise MigrationPlanError("migration_profile_state_mode_invalid")
|
||||
return profile
|
||||
|
||||
@staticmethod
|
||||
def _component_from_live(spec: Mapping[str, Any]) -> RuntimeComponentBinding:
|
||||
required = {
|
||||
"component_id",
|
||||
"service",
|
||||
"code_line_id",
|
||||
"expected_source_oid",
|
||||
"source_oid_env",
|
||||
"artifact_sha_env",
|
||||
"interface_files",
|
||||
"marker_files",
|
||||
}
|
||||
if set(spec) != required:
|
||||
raise MigrationPlanError("current_component_profile_invalid")
|
||||
properties = _systemd_properties(str(spec["service"]))
|
||||
environment = properties["environment"]
|
||||
source_oid = _oid(environment.get(str(spec["source_oid_env"])), "current_source_oid")
|
||||
expected_oid = _oid(spec["expected_source_oid"], "expected_source_oid")
|
||||
if source_oid != expected_oid:
|
||||
raise MigrationPlanError("CURRENT_RUNTIME_DRIFT")
|
||||
working = Path(str(properties.get("WorkingDirectory") or ""))
|
||||
if not working.is_absolute() or not working.is_dir():
|
||||
raise MigrationPlanError("current_working_directory_invalid")
|
||||
for marker in spec["marker_files"]:
|
||||
if not (working / str(marker)).is_file():
|
||||
raise MigrationPlanError("current_code_line_marker_missing")
|
||||
interface = _hash_files(working, tuple(str(item) for item in spec["interface_files"]))
|
||||
raw_artifact = environment.get(str(spec["artifact_sha_env"]))
|
||||
artifact = str(raw_artifact or "").lower()
|
||||
artifact_value = artifact if SHA_RE.fullmatch(artifact) else None
|
||||
return RuntimeComponentBinding(
|
||||
component_id=str(spec["component_id"]),
|
||||
service=str(spec["service"]),
|
||||
code_line_id=str(spec["code_line_id"]),
|
||||
source_oid=source_oid,
|
||||
artifact_sha256=artifact_value,
|
||||
interface_sha256=interface,
|
||||
)
|
||||
|
||||
def _component_from_target(
|
||||
self, spec: Mapping[str, Any], *, source_commit: str
|
||||
) -> RuntimeComponentBinding:
|
||||
required = {"component_id", "service", "code_line_id", "interface_files"}
|
||||
if set(spec) != required:
|
||||
raise MigrationPlanError("target_component_profile_invalid")
|
||||
interface_files = tuple(str(item) for item in spec["interface_files"])
|
||||
interface = _hash_git_files(self.authority_git_dir, source_commit, interface_files)
|
||||
return RuntimeComponentBinding(
|
||||
component_id=str(spec["component_id"]),
|
||||
service=str(spec["service"]),
|
||||
code_line_id=str(spec["code_line_id"]),
|
||||
source_oid=source_commit,
|
||||
artifact_sha256=None,
|
||||
interface_sha256=interface,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_compatibility(
|
||||
state_db: Path,
|
||||
state_ref: str,
|
||||
*,
|
||||
snapshot_path: Path,
|
||||
snapshot_name: str,
|
||||
) -> StateMigrationContract:
|
||||
from aios_task_admission_bootstrap import TaskAdmissionStore
|
||||
|
||||
if state_db.is_symlink() or not state_db.is_file():
|
||||
raise MigrationPlanError("state_db_invalid")
|
||||
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="aios-migration-probe-") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
consistent_snapshot = temp_root / "pre-migration.sqlite3"
|
||||
probe_path = temp_root / "schema-probe.sqlite3"
|
||||
with sqlite3.connect(
|
||||
f"file:{state_db}?mode=ro", uri=True
|
||||
) as live, sqlite3.connect(consistent_snapshot) as copy:
|
||||
live.backup(copy)
|
||||
with sqlite3.connect(consistent_snapshot) as frozen:
|
||||
before = _sqlite_schema(frozen)
|
||||
# Probe target initialization only on a second private copy. The
|
||||
# rollback snapshot remains byte-for-byte pre-migration state.
|
||||
with sqlite3.connect(consistent_snapshot) as source, sqlite3.connect(
|
||||
probe_path
|
||||
) as probe:
|
||||
source.backup(probe)
|
||||
TaskAdmissionStore(probe_path)
|
||||
with sqlite3.connect(probe_path) as migrated:
|
||||
after = _sqlite_schema(migrated)
|
||||
required_tables = {
|
||||
"task_admission_envelopes",
|
||||
"task_admission_grants",
|
||||
"task_admission_receipts",
|
||||
}
|
||||
if not required_tables.issubset(after):
|
||||
raise MigrationPlanError("INCOMPATIBLE_STATE_SCHEMA")
|
||||
_assert_additive_sqlite(before, after)
|
||||
snapshot_bytes = consistent_snapshot.read_bytes()
|
||||
snapshot_sha = hashlib.sha256(snapshot_bytes).hexdigest()
|
||||
if snapshot_path.exists():
|
||||
if snapshot_path.is_symlink() or not snapshot_path.is_file():
|
||||
raise MigrationPlanError("migration_snapshot_invalid")
|
||||
if hashlib.sha256(snapshot_path.read_bytes()).hexdigest() != snapshot_sha:
|
||||
raise MigrationPlanError("migration_snapshot_conflict")
|
||||
else:
|
||||
temporary = snapshot_path.with_name(snapshot_path.name + ".tmp")
|
||||
descriptor = os.open(
|
||||
temporary,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(snapshot_bytes)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, snapshot_path)
|
||||
if os.name != "nt":
|
||||
os.chmod(snapshot_path, 0o640)
|
||||
directory_fd = os.open(snapshot_path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return StateMigrationContract(
|
||||
state_id="task-admission",
|
||||
state_ref=state_ref,
|
||||
mode="REUSE_IN_PLACE_WITH_SNAPSHOT",
|
||||
before_schema_sha256=_schema_digest(before),
|
||||
after_schema_sha256=_schema_digest(after),
|
||||
snapshot_name=snapshot_name,
|
||||
snapshot_sha256=snapshot_sha,
|
||||
snapshot_required=True,
|
||||
rollback_compatible=True,
|
||||
)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
*,
|
||||
source_admission: Mapping[str, Any],
|
||||
candidate_id: str,
|
||||
) -> tuple[MigrationRequirement, MigrationPlan]:
|
||||
profile = self._profile()
|
||||
version = str(source_admission.get("version") or "")
|
||||
source_commit = _oid(source_admission.get("source_commit"), "source_commit")
|
||||
tree_oid = _oid(source_admission.get("tree_oid"), "tree_oid")
|
||||
admission_id = _ident(source_admission.get("admission_id"), "source_admission_id")
|
||||
if (
|
||||
version != profile["version"]
|
||||
or source_admission.get("repository") != "aios/aios"
|
||||
):
|
||||
raise MigrationPlanError("source_admission_profile_mismatch")
|
||||
_ident(candidate_id, "candidate_id")
|
||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
||||
from_components = tuple(
|
||||
self._component_from_live(item) for item in profile["current_components"]
|
||||
)
|
||||
to_components = tuple(
|
||||
self._component_from_target(item, source_commit=source_commit)
|
||||
for item in profile["target_components"]
|
||||
)
|
||||
if {item.component_id for item in from_components} != {item.component_id for item in to_components}:
|
||||
raise MigrationPlanError("migration_component_profile_mismatch")
|
||||
required = any(
|
||||
next(old.code_line_id for old in from_components if old.component_id == new.component_id)
|
||||
!= new.code_line_id
|
||||
for new in to_components
|
||||
)
|
||||
if not required:
|
||||
raise MigrationPlanError("migration_profile_does_not_require_transition")
|
||||
activation_path = Path(str(profile["activation_file"]))
|
||||
activation = _secure_json(activation_path, expected_owner_uid=self.expected_owner_uid)
|
||||
if activation.get("schema") != "aios.task-admission-host-activation.v1":
|
||||
raise MigrationPlanError("activation_schema_invalid")
|
||||
state_path = Path(str(profile["state_db"]))
|
||||
if str(activation.get("state_db") or "") != str(state_path):
|
||||
raise MigrationPlanError("activation_state_db_mismatch")
|
||||
root = self.output_root / source_commit
|
||||
snapshot_name = "snapshots/task-admission.sqlite3"
|
||||
state_contract = self._state_compatibility(
|
||||
state_path,
|
||||
str(profile["state_ref"]),
|
||||
snapshot_path=root / snapshot_name,
|
||||
snapshot_name=snapshot_name,
|
||||
)
|
||||
migration_id = (
|
||||
"migration-"
|
||||
+ hashlib.sha256(
|
||||
f"{version}\0{candidate_id}\0{source_commit}\0{profile_sha}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
)
|
||||
plan = MigrationPlan(
|
||||
migration_id=migration_id,
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
repository_identity="aios/aios",
|
||||
source_admission_id=admission_id,
|
||||
source_commit=source_commit,
|
||||
tree_oid=tree_oid,
|
||||
profile_id=str(profile["profile_id"]),
|
||||
profile_sha256=profile_sha,
|
||||
from_components=from_components,
|
||||
to_components=to_components,
|
||||
state_contracts=(state_contract,),
|
||||
activation_schema=str(activation["schema"]),
|
||||
activation_sha256=hashlib.sha256(_canonical_json(activation)).hexdigest(),
|
||||
rollback_window_seconds=int(profile["rollback_window_seconds"]),
|
||||
)
|
||||
requirement = MigrationRequirement(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
repository_identity="aios/aios",
|
||||
required=True,
|
||||
reason_code="CROSS_CODE_LINE",
|
||||
profile_id=str(profile["profile_id"]),
|
||||
profile_sha256=profile_sha,
|
||||
migration_plan_sha256=plan.sha256,
|
||||
)
|
||||
_durable_json(root / "requirement.json", requirement.to_dict())
|
||||
_durable_json(root / "plan.json", plan.to_dict())
|
||||
return requirement, plan
|
||||
|
||||
|
||||
def _verified_source_admission(
|
||||
*,
|
||||
source_admission_root: Path,
|
||||
source_commit: str,
|
||||
builder_policy: Path,
|
||||
credential_name: str = "source_admission_hmac",
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve one source admission through its existing HMAC verifier.
|
||||
|
||||
The migration planner never trusts a caller-selected admission JSON path.
|
||||
It consumes only a root-owned admission store and the independent
|
||||
``source_admission_hmac`` credential projected by systemd.
|
||||
"""
|
||||
|
||||
from release_source_admission import open_source_admission_store
|
||||
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
if not credentials:
|
||||
raise MigrationPlanError("source_admission_credentials_unavailable")
|
||||
credential_path = Path(credentials) / credential_name
|
||||
try:
|
||||
hmac_key = credential_path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise MigrationPlanError("source_admission_hmac_unavailable") from exc
|
||||
try:
|
||||
store = open_source_admission_store(
|
||||
source_admission_root,
|
||||
repository="aios/aios",
|
||||
policy=builder_policy,
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=0 if os.name != "nt" else None,
|
||||
writable=False,
|
||||
)
|
||||
record = store.lookup_source(source_commit)
|
||||
except Exception as exc:
|
||||
raise MigrationPlanError("source_admission_verification_failed") from exc
|
||||
if record is None:
|
||||
raise MigrationPlanRequired("SOURCE_ADMISSION_REQUIRED")
|
||||
return dict(record)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
||||
parser.add_argument("--profile", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--candidate-id", required=True)
|
||||
args = parser.parse_args()
|
||||
source_commit = _oid(args.source_commit, "source_commit")
|
||||
compiler = HostMigrationCompiler(
|
||||
authority_git_dir=args.authority_git_dir,
|
||||
profile_path=args.profile,
|
||||
output_root=args.output_root,
|
||||
expected_owner_uid=0 if os.name != "nt" else None,
|
||||
)
|
||||
requirement, plan = compiler.compile(
|
||||
source_admission=_verified_source_admission(
|
||||
source_admission_root=args.source_admission_root,
|
||||
source_commit=source_commit,
|
||||
builder_policy=args.builder_policy,
|
||||
),
|
||||
candidate_id=args.candidate_id,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "MIGRATION_PLAN_READY",
|
||||
"migration_plan_sha256": plan.sha256,
|
||||
"requirement_sha256": requirement.sha256,
|
||||
"source_commit": plan.source_commit,
|
||||
"version": plan.version,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "FAIL_CLOSED",
|
||||
"error": type(exc).__name__,
|
||||
"reason": str(exc),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,894 @@
|
||||
#!/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 rebind_policy(
|
||||
self,
|
||||
source_commit: str,
|
||||
*,
|
||||
previous_policy_sha256: str,
|
||||
reason: str,
|
||||
expected_admission_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebind one immutable source identity to an explicitly authorized policy.
|
||||
|
||||
Only ``policy_sha256`` and ``mac`` change in the admission record.
|
||||
``admission_id`` and every source/candidate/tree binding remain immutable.
|
||||
A separate HMAC-authenticated history record makes the temporary §8.1
|
||||
trust expansion and its restoration auditable and idempotent.
|
||||
"""
|
||||
|
||||
if not self.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
previous_policy = _digest(
|
||||
previous_policy_sha256,
|
||||
"previous_policy_sha256",
|
||||
)
|
||||
reason_value = _identifier(reason, "reason")
|
||||
path = self._path(source_commit)
|
||||
if not path.exists():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REQUIRED")
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
decoded = json.loads(raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_RECORD_INVALID"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
previous = _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=previous_policy,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
except ReleaseSourceAdmissionError:
|
||||
# Idempotent replay after a successful rebind validates under the
|
||||
# current policy instead of silently weakening the old-policy gate.
|
||||
current = _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
if expected_admission_id and current["admission_id"] != expected_admission_id:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_ID_MISMATCH"
|
||||
)
|
||||
return dict(current)
|
||||
|
||||
if expected_admission_id and previous["admission_id"] != expected_admission_id:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_ID_MISMATCH")
|
||||
rebound = {
|
||||
key: value
|
||||
for key, value in previous.items()
|
||||
if key != "mac"
|
||||
}
|
||||
rebound["policy_sha256"] = self.policy_sha256
|
||||
rebound["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(rebound),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
checked = _validate_record(
|
||||
rebound,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
if checked["admission_id"] != previous["admission_id"]:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_ID_CHANGED"
|
||||
)
|
||||
for field in (
|
||||
"repository",
|
||||
"version",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_ref",
|
||||
"source_commit",
|
||||
"remote_ref_oid",
|
||||
"tree_oid",
|
||||
"purpose",
|
||||
"task_id",
|
||||
):
|
||||
if checked[field] != previous[field]:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_REBIND_SCOPE_CHANGED"
|
||||
)
|
||||
|
||||
encoded = _canonical(checked) + b"\n"
|
||||
history_payload = {
|
||||
"schema": "aios.release-source-admission-policy-rebind.v1",
|
||||
"admission_id": checked["admission_id"],
|
||||
"source_commit": checked["source_commit"],
|
||||
"previous_policy_sha256": previous_policy,
|
||||
"policy_sha256": self.policy_sha256,
|
||||
"reason": reason_value,
|
||||
"record_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
"rebound_at": _now(),
|
||||
}
|
||||
history_payload["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(history_payload),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
history_root = self.root / "rebind-history"
|
||||
if not history_root.exists():
|
||||
history_root.mkdir(mode=0o750)
|
||||
_secure_stat(
|
||||
history_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
history_name = (
|
||||
f"{checked['source_commit']}."
|
||||
f"{previous_policy[:12]}-{self.policy_sha256[:12]}."
|
||||
f"{hashlib.sha256(reason_value.encode()).hexdigest()[:12]}.json"
|
||||
)
|
||||
history_path = history_root / history_name
|
||||
history_bytes = _canonical(history_payload) + b"\n"
|
||||
|
||||
fd, raw_name = tempfile.mkstemp(
|
||||
prefix=f".{checked['source_commit']}.rebind.",
|
||||
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())
|
||||
if history_path.exists():
|
||||
_secure_stat(
|
||||
history_path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
if history_path.read_bytes() != history_bytes:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_REBIND_HISTORY_CONFLICT"
|
||||
)
|
||||
else:
|
||||
history_fd = os.open(
|
||||
history_path,
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(history_fd, history_bytes)
|
||||
os.fsync(history_fd)
|
||||
finally:
|
||||
os.close(history_fd)
|
||||
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
|
||||
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 source_admission_policy_sha256(path: Path) -> str:
|
||||
"""Canonical Source Admission policy binding: exact policy file bytes.
|
||||
|
||||
Builder/controller readiness may use a canonical JSON digest internally,
|
||||
but Source Admission records are intentionally bound to the immutable policy
|
||||
file bytes. Every admission producer/consumer must call this helper.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _sha256_file(Path(path))
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def open_source_admission_store(
|
||||
root: str | os.PathLike[str],
|
||||
*,
|
||||
repository: str,
|
||||
policy: Path,
|
||||
hmac_key: bytes,
|
||||
expected_owner_uid: int | None,
|
||||
writable: bool,
|
||||
) -> ReleaseSourceAdmissionStore:
|
||||
"""The only supported constructor for policy-bound Source Admission stores."""
|
||||
|
||||
return ReleaseSourceAdmissionStore(
|
||||
root,
|
||||
repository=repository,
|
||||
policy_sha256=source_admission_policy_sha256(policy),
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=writable,
|
||||
)
|
||||
|
||||
|
||||
def validate_isolated_peer_policy_delta(
|
||||
previous_policy: Path,
|
||||
policy: Path,
|
||||
isolated_peer_cgroup: str,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Allow exactly one temporary isolated peer add/remove and no other drift."""
|
||||
|
||||
peer = str(isolated_peer_cgroup or "").strip()
|
||||
if (
|
||||
not peer.startswith("/system.slice/")
|
||||
or not peer.endswith(".service")
|
||||
or "*" in peer
|
||||
or ".." in peer
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
try:
|
||||
from aios_trusted_candidate_builder import load_builder_policy
|
||||
|
||||
before = dict(load_builder_policy(Path(previous_policy)))
|
||||
after = dict(load_builder_policy(Path(policy)))
|
||||
except Exception as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_INVALID") from exc
|
||||
|
||||
before_cgroups = tuple(before.get("allowed_peer_cgroups") or ())
|
||||
after_cgroups = tuple(after.get("allowed_peer_cgroups") or ())
|
||||
if len(set(before_cgroups)) != len(before_cgroups) or len(set(after_cgroups)) != len(after_cgroups):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
if any("*" in value or ".." in value for value in (*before_cgroups, *after_cgroups)):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
|
||||
before_without = dict(before)
|
||||
after_without = dict(after)
|
||||
before_without["allowed_peer_cgroups"] = []
|
||||
after_without["allowed_peer_cgroups"] = []
|
||||
if _canonical(before_without) != _canonical(after_without):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_REBIND_SCOPE_CHANGED")
|
||||
|
||||
before_set, after_set = set(before_cgroups), set(after_cgroups)
|
||||
added = after_set - before_set
|
||||
removed = before_set - after_set
|
||||
if added == {peer} and not removed:
|
||||
direction = "ADD_ISOLATED_PEER"
|
||||
elif removed == {peer} and not added:
|
||||
direction = "REMOVE_ISOLATED_PEER"
|
||||
else:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_DELTA_INVALID")
|
||||
return (
|
||||
source_admission_policy_sha256(previous_policy),
|
||||
source_admission_policy_sha256(policy),
|
||||
direction,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
rebind = subparsers.add_parser("rebind-policy")
|
||||
rebind.add_argument("--source-commit", required=True)
|
||||
rebind.add_argument("--previous-policy", required=True, type=Path)
|
||||
rebind.add_argument("--policy", required=True, type=Path)
|
||||
rebind.add_argument("--isolated-peer-cgroup", required=True)
|
||||
rebind.add_argument("--expected-admission-id", required=True)
|
||||
rebind.add_argument("--reason", required=True)
|
||||
rebind.add_argument("--store-root", required=True, type=Path)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
repository = os.environ.get("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
||||
if not repository:
|
||||
raise SystemExit("AIOS_GITOPS_REPOSITORY_IDENTITY is required")
|
||||
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
|
||||
|
||||
if arguments.command == "rebind-policy":
|
||||
try:
|
||||
previous_sha, policy_sha, direction = (
|
||||
validate_isolated_peer_policy_delta(
|
||||
arguments.previous_policy,
|
||||
arguments.policy,
|
||||
arguments.isolated_peer_cgroup,
|
||||
)
|
||||
)
|
||||
store = open_source_admission_store(
|
||||
arguments.store_root,
|
||||
repository=repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=admission_hmac,
|
||||
expected_owner_uid=(
|
||||
os.geteuid() if hasattr(os, "geteuid") else None
|
||||
),
|
||||
writable=True,
|
||||
)
|
||||
result = store.rebind_policy(
|
||||
arguments.source_commit,
|
||||
previous_policy_sha256=previous_sha,
|
||||
reason=f"{arguments.reason}:{direction}",
|
||||
expected_admission_id=arguments.expected_admission_id,
|
||||
)
|
||||
except ReleaseSourceAdmissionError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
**result,
|
||||
"rebind_direction": direction,
|
||||
"previous_policy_sha256": previous_sha,
|
||||
"policy_sha256": policy_sha,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
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 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",
|
||||
),
|
||||
)
|
||||
store = open_source_admission_store(
|
||||
arguments.store_root,
|
||||
repository=repository,
|
||||
policy=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()
|
||||
@@ -25,6 +25,12 @@ from typing import Any, Callable, Iterable, Mapping, Protocol
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from release_migration_plan import (
|
||||
MigrationPlan,
|
||||
MigrationPlanError,
|
||||
MigrationPreSwitchValidator,
|
||||
)
|
||||
|
||||
from silent_release_controller import (
|
||||
CANDIDATE_ID_RE,
|
||||
OID_RE,
|
||||
@@ -562,6 +568,7 @@ class ReleasePlan:
|
||||
tag: TagPlan
|
||||
finalization: FinalizationPlan
|
||||
policy_sha256: str
|
||||
migration: MigrationPlan | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha("policy_sha256", self.policy_sha256)
|
||||
@@ -588,16 +595,32 @@ class ReleasePlan:
|
||||
!= self.finalization.expected_previous_baseline_sha256
|
||||
):
|
||||
raise ValueError("previous baseline is inconsistent")
|
||||
if self.migration is not None:
|
||||
migration = self.migration
|
||||
if (
|
||||
migration.version != self.tag.version
|
||||
or migration.source_commit != self.deployment.source_commit
|
||||
or migration.tree_oid != self.deployment.tree_oid
|
||||
or migration.repository_identity
|
||||
!= self.deployment.repository_identity
|
||||
):
|
||||
raise ValueError("migration plan release binding is inconsistent")
|
||||
return self
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
# Legacy release-plan bytes/hashes remain unchanged when migration is
|
||||
# not required. A cross-code-line migration is an optional signed
|
||||
# sub-contract and is therefore present only when required.
|
||||
payload = {
|
||||
"deployment": self.deployment.canonical_payload,
|
||||
"finalization": self.finalization.canonical_payload,
|
||||
"policy_sha256": self.policy_sha256,
|
||||
"tag": self.tag.canonical_payload,
|
||||
}
|
||||
if self.migration is not None:
|
||||
payload["migration"] = self.migration.to_dict()
|
||||
return payload
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
@@ -612,6 +635,7 @@ class ReleasePlan:
|
||||
def from_dict(cls, payload: Mapping[str, Any]) -> "ReleasePlan":
|
||||
values = dict(payload)
|
||||
values.pop("sha256", None)
|
||||
migration_value = values.get("migration")
|
||||
return cls(
|
||||
deployment=DeploymentPlan.from_dict(values["deployment"]),
|
||||
tag=TagPlan.from_dict(values["tag"]),
|
||||
@@ -619,6 +643,11 @@ class ReleasePlan:
|
||||
values["finalization"]
|
||||
),
|
||||
policy_sha256=values["policy_sha256"],
|
||||
migration=(
|
||||
MigrationPlan.from_dict(migration_value)
|
||||
if isinstance(migration_value, Mapping)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1215,6 +1244,19 @@ class TrustedCandidateBundle:
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted candidate and release plan are inconsistent"
|
||||
)
|
||||
if plan.migration is not None:
|
||||
try:
|
||||
plan.migration.assert_release_binding(
|
||||
version=plan.tag.version,
|
||||
candidate_id=candidate.candidate_id,
|
||||
source_commit=candidate.source_commit,
|
||||
tree_oid=candidate.tree_oid,
|
||||
repository_identity=plan.deployment.repository_identity,
|
||||
)
|
||||
except MigrationPlanError as exc:
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted candidate migration binding is invalid"
|
||||
) from exc
|
||||
if attestation_verifier is not None:
|
||||
if self.attestation is None:
|
||||
raise CapabilityAuthorizationError(
|
||||
@@ -3090,6 +3132,7 @@ class ReleaseOrchestrator:
|
||||
runtime_rollback: Callable[..., Mapping[str, Any]],
|
||||
tag_publish: HardenedTagController,
|
||||
finalizer: GovernedFinalizationCoordinator,
|
||||
migration_pre_switch_validator: MigrationPreSwitchValidator | None = None,
|
||||
):
|
||||
self.candidates = candidates
|
||||
self.plans = plans
|
||||
@@ -3104,6 +3147,7 @@ class ReleaseOrchestrator:
|
||||
self.runtime_rollback = runtime_rollback
|
||||
self.tag_publish = tag_publish
|
||||
self.finalizer = finalizer
|
||||
self.migration_pre_switch_validator = migration_pre_switch_validator
|
||||
|
||||
def _transition(
|
||||
self,
|
||||
@@ -3304,6 +3348,14 @@ class ReleaseOrchestrator:
|
||||
plan.deployment.repository_identity
|
||||
),
|
||||
)
|
||||
if plan.migration is not None:
|
||||
validator = self.migration_pre_switch_validator
|
||||
if validator is None:
|
||||
raise StaleApprovalError(
|
||||
"MIGRATION_PLAN_REQUIRED: "
|
||||
"migration pre-switch validator is unavailable"
|
||||
)
|
||||
validator.validate(plan.migration)
|
||||
saga = self._transition(
|
||||
saga,
|
||||
SagaState.SWITCH_PENDING,
|
||||
@@ -3531,6 +3583,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 +3600,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 +3692,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,47 +3745,83 @@ 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"))
|
||||
prerequisite_states = {
|
||||
"SOURCE_ADMISSION_REQUIRED",
|
||||
"MIGRATION_PLAN_REQUIRED",
|
||||
}
|
||||
existing_status = str(existing.get("status") or "")
|
||||
existing_version = str(existing.get("version") or "")
|
||||
existing_admission = str(existing.get("source_admission_id") or "")
|
||||
incoming_admission = str(record.get("source_admission_id") or "")
|
||||
if (
|
||||
existing.get("event_id") != event_id
|
||||
or existing.get("source_commit") != source_commit
|
||||
or str(existing.get("version") or "") != version
|
||||
or (existing_version and existing_version != version)
|
||||
or (existing_admission and existing_admission != incoming_admission)
|
||||
):
|
||||
raise SagaConflict(
|
||||
"release intent id is bound to different evidence"
|
||||
)
|
||||
if existing.get("status") == "BUILDING":
|
||||
try:
|
||||
started_at = _parse_utc(
|
||||
str(existing.get("attempt_started_at") or "")
|
||||
)
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - started_at
|
||||
).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
existing.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
||||
"retryable": int(existing.get("attempt_count") or 0)
|
||||
< self.max_builder_attempts,
|
||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, existing)
|
||||
# A source-admission prerequisite may initially know only the
|
||||
# event/source identity. Once the verified admission becomes
|
||||
# available, bind the previously missing version/admission ID to
|
||||
# the same durable intent. Existing non-empty evidence can never
|
||||
# be replaced.
|
||||
if (
|
||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
||||
or int(existing.get("attempt_count") or 0)
|
||||
>= self.max_builder_attempts
|
||||
(not existing_version and version)
|
||||
or (not existing_admission and incoming_admission)
|
||||
):
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
record = dict(existing)
|
||||
if existing_status != "SOURCE_ADMISSION_REQUIRED":
|
||||
raise SagaConflict(
|
||||
"release intent prerequisite binding changed"
|
||||
)
|
||||
if version:
|
||||
existing["version"] = version
|
||||
if incoming_admission:
|
||||
existing["source_admission_id"] = incoming_admission
|
||||
existing["updated_at"] = utc_now()
|
||||
_durable_json(path, existing)
|
||||
if existing_status in prerequisite_states:
|
||||
# Prerequisite blockers are not builder attempts. Replaying
|
||||
# the same intent after Source Admission or Migration Plan
|
||||
# material appears is the expected autonomous recovery path.
|
||||
record = dict(existing)
|
||||
else:
|
||||
if existing.get("status") == "BUILDING":
|
||||
try:
|
||||
started_at = _parse_utc(
|
||||
str(existing.get("attempt_started_at") or "")
|
||||
)
|
||||
elapsed = (
|
||||
datetime.now(timezone.utc) - started_at
|
||||
).total_seconds()
|
||||
except (TypeError, ValueError):
|
||||
elapsed = self.builder_attempt_timeout_seconds + 1
|
||||
if elapsed <= self.builder_attempt_timeout_seconds:
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
existing.update(
|
||||
{
|
||||
"failure_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"last_recovery_code": "TRUSTED_BUILDER_CRASH_RECOVERED",
|
||||
"recovery_count": int(existing.get("recovery_count") or 0) + 1,
|
||||
"retryable": int(existing.get("attempt_count") or 0)
|
||||
< self.max_builder_attempts,
|
||||
"status": "BUILDER_RETRYABLE_FAILURE",
|
||||
"updated_at": utc_now(),
|
||||
}
|
||||
)
|
||||
_durable_json(path, existing)
|
||||
if (
|
||||
existing.get("status") != "BUILDER_RETRYABLE_FAILURE"
|
||||
or int(existing.get("attempt_count") or 0)
|
||||
>= self.max_builder_attempts
|
||||
):
|
||||
return self._intent_response(existing, idempotent=True)
|
||||
record = dict(existing)
|
||||
if self.candidate_builder is None:
|
||||
record.update(
|
||||
{
|
||||
@@ -3742,14 +3866,36 @@ 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:
|
||||
prerequisite_code = {
|
||||
"release source admission required": "SOURCE_ADMISSION_REQUIRED",
|
||||
"migration plan required": "MIGRATION_PLAN_REQUIRED",
|
||||
}.get(str(exc))
|
||||
if prerequisite_code is not None:
|
||||
record.update(
|
||||
{
|
||||
# Prerequisite discovery is a control-plane retry, not
|
||||
# a business/builder execution attempt.
|
||||
"attempt_count": max(
|
||||
0, int(record.get("attempt_count") or 0) - 1
|
||||
),
|
||||
"failure_code": prerequisite_code,
|
||||
"retryable": True,
|
||||
"status": prerequisite_code,
|
||||
}
|
||||
)
|
||||
else:
|
||||
record.update(
|
||||
{
|
||||
"failure_code": (
|
||||
"MIGRATION_PLAN_INVALID"
|
||||
if str(exc) == "migration plan invalid"
|
||||
else "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,617 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trusted evidence-provider executor (V2R2C2 P0 Round-3).
|
||||
|
||||
This module is the *formal trusted executor* that produces root-owned provider
|
||||
receipts under each evidence-provider store. It is a real provider execution
|
||||
path: for each evidence kind it executes the actual provider logic (pytest /
|
||||
Gitea machine+API consistency / gitleaks), derives the result from real tool
|
||||
output, and only then writes an ``aios.trusted-evidence-provider-receipt.v2``
|
||||
receipt into the store. It never manufactures a bare ``{"passed": true}``:
|
||||
every receipt carries a canonical content digest over a real result.
|
||||
|
||||
Security contract (matches TrustedProviderReceiptResolver):
|
||||
- run as root (``os.geteuid() == 0``); store dirs and receipts are root-owned.
|
||||
- store dir mode in ``{0700, 0750}``, receipt mode in ``{0600, 0640}``;
|
||||
no group/world write; no symlink followed (O_NOFOLLOW path).
|
||||
- receipt ref is relative and free of ``..``/``.``/absolute components.
|
||||
- content_sha256 = canonical_sha256(result) (sort_keys, compact, utf-8).
|
||||
- source_oid / tree_oid bound from the exact release source checkout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
PROVIDER_RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
||||
PROVIDER_RECEIPT_VERSION = 2
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# Storage layout is resolved from the governed providers policy; these defaults
|
||||
# mirror config/gitops/trusted-evidence-providers.json so the executor is usable
|
||||
# before the policy is deployed, but the policy is authoritative at runtime.
|
||||
POLICY_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "config"
|
||||
/ "gitops"
|
||||
/ "trusted-evidence-providers.json"
|
||||
)
|
||||
|
||||
|
||||
class TrustedProviderExecutionError(RuntimeError):
|
||||
"""Fail-closed provider execution / receipt write error."""
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _require_root() -> None:
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
raise TrustedProviderExecutionError(
|
||||
"PROVIDER_EXECUTOR_REQUIRES_ROOT (run via the governed root executor)"
|
||||
)
|
||||
|
||||
|
||||
def _validate_oid(value: str, field: str) -> str:
|
||||
v = str(value or "").strip().lower()
|
||||
if not _HEX40.fullmatch(v):
|
||||
raise TrustedProviderExecutionError(f"{field.upper()}_INVALID")
|
||||
return v
|
||||
|
||||
|
||||
def _safe_relative_ref(receipt_ref: str) -> tuple[str, ...]:
|
||||
ref = receipt_ref.strip()
|
||||
if not ref or ref.startswith("/"):
|
||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_REF_ABSOLUTE_DENIED")
|
||||
parts = ref.split("/")
|
||||
if not parts or any(part in {"", ".", ".."} for part in parts):
|
||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_REF_TRAVERSAL_DENIED")
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def load_provider_policy(path: Path = POLICY_PATH) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
||||
if (
|
||||
not isinstance(decoded, Mapping)
|
||||
or decoded.get("schema") != "aios.trusted-evidence-providers.v1"
|
||||
):
|
||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_INVALID")
|
||||
return decoded
|
||||
|
||||
|
||||
def provider_store_root(kind: str, policy_path: Path = POLICY_PATH) -> Path:
|
||||
policy = load_provider_policy(policy_path)
|
||||
providers = policy.get("providers")
|
||||
if not isinstance(providers, Mapping) or kind not in providers:
|
||||
raise TrustedProviderExecutionError(f"PROVIDER_POLICY_{kind.upper()}_NOT_FOUND")
|
||||
raw = providers[kind]
|
||||
if not isinstance(raw, Mapping):
|
||||
raise TrustedProviderExecutionError("PROVIDER_POLICY_INVALID")
|
||||
store_root = str(raw.get("store_root") or "").strip()
|
||||
if not store_root:
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_ROOT_UNSET")
|
||||
ec_path = os.environ.get("AIOS_EVIDENCE_PROVIDERS_ROOT")
|
||||
if ec_path:
|
||||
store_root = ec_path
|
||||
root = Path(store_root).expanduser()
|
||||
if not root.is_absolute():
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_ROOT_MUST_BE_ABSOLUTE")
|
||||
return root
|
||||
|
||||
|
||||
def _run(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
timeout: int = 600,
|
||||
extra_env: Mapping[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
env = dict(os.environ)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
try:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise TrustedProviderExecutionError("PROVIDER_TOOL_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def _tests_env(repo_root: Path) -> dict[str, str]:
|
||||
"""Forward source PYTHONPATH to pytest subprocess spawns.
|
||||
|
||||
Mirrors tools/baseline_v1_1/run_acceptance.py: subprocess-based tests (e.g.
|
||||
job-safety process-crash recovery) import runtime modules from
|
||||
active-runtime/app, so PYTHONPATH must be propagated or they fail with
|
||||
ModuleNotFoundError. See docs/implementation-baseline-1.1/ACCEPTANCE_RESULTS.md.
|
||||
"""
|
||||
app = repo_root / "active-runtime" / "app"
|
||||
return {"PYTHONPATH": os.pathsep.join(str(p) for p in (repo_root, app))}
|
||||
|
||||
|
||||
def _write_receipt(root: Path, receipt_ref: str, payload: Mapping[str, Any], *, mode: int = 0o600) -> str:
|
||||
"""Atomically write a root-owned receipt with fail-closed store checks."""
|
||||
_require_root()
|
||||
root_lstat = os.lstat(root)
|
||||
if stat.S_ISLNK(root_lstat.st_mode):
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_SYMLINK_DENIED")
|
||||
if not stat.S_ISDIR(root_lstat.st_mode):
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_DIRECTORY")
|
||||
if root_lstat.st_uid != 0:
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_ROOT_OWNED")
|
||||
if stat.S_IMODE(root_lstat.st_mode) & 0o022:
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_WRITABLE_DENIED")
|
||||
|
||||
parts = _safe_relative_ref(receipt_ref)
|
||||
current = root
|
||||
for part in parts[:-1]:
|
||||
current = current / part
|
||||
try:
|
||||
st = os.lstat(current)
|
||||
except FileNotFoundError:
|
||||
# Provider executor owns the store: create missing root-owned dirs
|
||||
# under the (already validated) store root, fail-closed if we cannot.
|
||||
os.mkdir(current, 0o700)
|
||||
os.chown(current, 0, 0)
|
||||
os.chmod(current, 0o700)
|
||||
st = os.lstat(current)
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_SYMLINK_DENIED")
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_DIRECTORY")
|
||||
if st.st_uid != 0:
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_NOT_ROOT_OWNED")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise TrustedProviderExecutionError("PROVIDER_STORE_WRITABLE_DENIED")
|
||||
|
||||
target = current / parts[-1]
|
||||
raw = canonical_json_bytes(payload)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(target, flags, mode)
|
||||
except OSError as exc:
|
||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_WRITE_DENIED") from exc
|
||||
try:
|
||||
os.write(fd, raw)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chown(target, 0, 0)
|
||||
os.chmod(target, mode)
|
||||
st = os.lstat(target)
|
||||
if stat.S_IMODE(st.st_mode) & 0o022 or st.st_uid != 0:
|
||||
raise TrustedProviderExecutionError("PROVIDER_RECEIPT_PERMISSION_DENIED")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _build_receipt(
|
||||
*,
|
||||
kind: str,
|
||||
provider_identity: str,
|
||||
provider_operation: str,
|
||||
operation_run_id: str,
|
||||
result_reference: str,
|
||||
result: Mapping[str, Any],
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
store_id: str,
|
||||
) -> Mapping[str, Any]:
|
||||
return {
|
||||
"schema": PROVIDER_RECEIPT_SCHEMA,
|
||||
"version": PROVIDER_RECEIPT_VERSION,
|
||||
"provider_identity": provider_identity,
|
||||
"provider_operation": provider_operation,
|
||||
"operation_run_id": operation_run_id,
|
||||
"result_reference": result_reference,
|
||||
"content_sha256": canonical_sha256(result),
|
||||
"source_oid": source_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"environment": "linux-isolated",
|
||||
"issued_at": now_iso(),
|
||||
"result": dict(result),
|
||||
"authentication": {
|
||||
"kind": "root-owned-store-v1",
|
||||
"store_id": store_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider executors (real tool execution; results derived from real output)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _junit_totals(junit_path: Path) -> dict[str, int]:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
if not junit_path.is_file():
|
||||
raise TrustedProviderExecutionError("TESTS_JUNIT_MISSING")
|
||||
root = ET.parse(junit_path).getroot()
|
||||
# pytest emits <testsuites><testsuite tests=.. failures=.. />...</testsuites>;
|
||||
# aggregate every <testsuite> child (never trust the outer <testsuites> attrib).
|
||||
totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
|
||||
suites = list(root.iter("testsuite"))
|
||||
for suite in suites:
|
||||
n = int(suite.attrib.get("tests", 0))
|
||||
totals["tests"] += n
|
||||
totals["failures"] += int(suite.attrib.get("failures", 0))
|
||||
totals["errors"] += int(suite.attrib.get("errors", 0))
|
||||
totals["skipped"] += int(suite.attrib.get("skipped", 0))
|
||||
return totals
|
||||
|
||||
|
||||
def run_tests_provider(
|
||||
*,
|
||||
repo_root: Path,
|
||||
collection_inventory: Path,
|
||||
junit_xml: Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
store_id: str = "tests",
|
||||
provider_identity: str = "aios.tests-runner",
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute the real test suite, collect-JUnit, and produce a root receipt."""
|
||||
# First prove POSIX modules are collected (never trust a bare exit code).
|
||||
collect = _run(
|
||||
["python3", "-m", "pytest", "--collect-only", "-q", str(repo_root / "tests")],
|
||||
cwd=repo_root,
|
||||
timeout=900,
|
||||
extra_env=_tests_env(repo_root),
|
||||
)
|
||||
collected_names = {
|
||||
line.strip()
|
||||
for line in collect.stdout.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
posix_probed = {
|
||||
"fcntl": any("fcntl" in n for n in collected_names),
|
||||
"grp": any("grp" in n for n in collected_names),
|
||||
"so_peercrd": any("peercred" in n.lower() for n in collected_names),
|
||||
"posix_mode": any("mode" in n.lower() for n in collected_names),
|
||||
"symlink": any("symlink" in n.lower() for n in collected_names),
|
||||
"signer_provisioning": any("signer_provisioning" in n.lower() for n in collected_names),
|
||||
}
|
||||
if junit_xml.parent and not junit_xml.parent.exists():
|
||||
junit_xml.parent.mkdir(parents=True, exist_ok=True)
|
||||
executed = _run(
|
||||
[
|
||||
"python3", "-m", "pytest",
|
||||
"--junitxml=" + str(junit_xml),
|
||||
"-q", str(repo_root / "tests"),
|
||||
],
|
||||
cwd=repo_root,
|
||||
timeout=3600,
|
||||
extra_env=_tests_env(repo_root),
|
||||
)
|
||||
totals = _junit_totals(junit_xml)
|
||||
collection_inventory.write_text(
|
||||
json.dumps({"collected": sorted(collected_names), "posix_probed": posix_probed}, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Passed is only true when a real suite actually ran and was green: a
|
||||
# vacuous 0-tests JUnit (unwritable junit, collection failure) must never
|
||||
# report passed. pytest exit codes: 0=passed, 1=test failures, 2=interrupted,
|
||||
# 3=internal error, 4=usage error, 5=no tests collected.
|
||||
ran_real_tests = totals["tests"] > 0
|
||||
green = totals["failures"] == 0 and totals["errors"] == 0
|
||||
clean_exit = executed.returncode in (0, 5)
|
||||
result = {
|
||||
"passed": ran_real_tests and green and clean_exit,
|
||||
"exit_code": executed.returncode,
|
||||
"totals": totals,
|
||||
"junit": junit_xml.name,
|
||||
"collection_inventory": collection_inventory.name,
|
||||
"posix_probed": posix_probed,
|
||||
"run_kind": "full-test-suite",
|
||||
"green": green,
|
||||
"ran_real_tests": ran_real_tests,
|
||||
}
|
||||
return result, _receipt_ref_for(result, "tests")
|
||||
|
||||
|
||||
def run_gitea_provider(
|
||||
*,
|
||||
gitea_api_url: str,
|
||||
repository_identity: str,
|
||||
token_file: Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
local_repo: Path,
|
||||
store_id: str = "gitea",
|
||||
provider_identity: str = "aios.gitea-machine-transport",
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute real Gitea machine+API consistency and produce a root receipt."""
|
||||
import urllib.request
|
||||
|
||||
token = token_file.read_text(encoding="utf-8").strip()
|
||||
def _api(path: str) -> dict[str, Any]:
|
||||
req = urllib.request.Request(
|
||||
gitea_api_url.rstrip("/") + path,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"token {token}",
|
||||
"User-Agent": "aios-trusted-gitea-provider/1",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
repository = _api(f"/repos/{repository_identity}")
|
||||
full_name = str(repository.get("full_name") or "")
|
||||
ref = _api(f"/repos/{repository_identity}/git/refs/heads/main")
|
||||
items = ref if isinstance(ref, list) else [ref]
|
||||
exact = [
|
||||
i for i in items
|
||||
if isinstance(i, dict) and i.get("ref") in {"refs/heads/main", "heads/main"}
|
||||
]
|
||||
local_main = _run(
|
||||
["git", "-C", str(local_repo), "rev-parse", "refs/heads/main"], cwd=local_repo
|
||||
)
|
||||
remote_main = str((exact[0].get("object") or {}).get("sha") or "").lower() if len(exact) == 1 else ""
|
||||
result = {
|
||||
"consistent": (
|
||||
full_name.lower() == repository_identity.lower()
|
||||
and len(exact) == 1
|
||||
and remote_main == source_oid.lower()
|
||||
),
|
||||
"repository": full_name,
|
||||
"source_commit": source_oid,
|
||||
"remote_ref_oid": remote_main,
|
||||
"tree_oid": tree_oid,
|
||||
"local_main_oid": local_main.stdout.strip() if local_main.returncode == 0 else "",
|
||||
}
|
||||
return result, _receipt_ref_for(result, "gitea")
|
||||
|
||||
|
||||
def run_secret_scan_provider(
|
||||
*,
|
||||
gitleaks_bin: str,
|
||||
target: Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
store_id: str = "secret_scan",
|
||||
provider_identity: str = "aios.secret-scanner",
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Execute the real secret scanner (gitleaks) and produce a root receipt."""
|
||||
if not Path(gitleaks_bin).is_file():
|
||||
raise TrustedProviderExecutionError("SECRET_SCANNER_UNAVAILABLE")
|
||||
# --no-git scans the working tree as a filesystem (does not require a .git
|
||||
# dir), so an exported release checkout can be scanned faithfully.
|
||||
scanned = _run(
|
||||
[
|
||||
gitleaks_bin, "detect",
|
||||
"--source", str(target),
|
||||
"--no-git",
|
||||
"--no-banner",
|
||||
"--exit-code", "0",
|
||||
],
|
||||
cwd=target,
|
||||
timeout=1800,
|
||||
)
|
||||
warnings = 0
|
||||
# gitleaks exits 1 when findings are present; with --exit-code 0 it returns 0
|
||||
# but prints findings. Count lines that indicate a finding.
|
||||
finding_lines = [
|
||||
ln for ln in scanned.stdout.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
] + [
|
||||
ln for ln in scanned.stderr.splitlines()
|
||||
if ("Finding:" in ln and ":" in ln and "RuleID" not in ln)
|
||||
]
|
||||
artifact_sha = None
|
||||
if target.is_file():
|
||||
artifact_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
# Passed only when the scanner actually ran cleanly (exit 0) AND found zero
|
||||
# leaks. A scanner crash (non-zero exit) is never a pass.
|
||||
clean_run = scanned.returncode == 0
|
||||
result = {
|
||||
"passed": clean_run and len(finding_lines) == 0,
|
||||
"scanner": "gitleaks",
|
||||
"exit_code": scanned.returncode,
|
||||
"target": str(target),
|
||||
"findings": len(finding_lines),
|
||||
"artifact_sha256": artifact_sha,
|
||||
"clean_run": clean_run,
|
||||
}
|
||||
return result, _receipt_ref_for(result, "secret_scan")
|
||||
|
||||
|
||||
def _receipt_ref_for(result: Mapping[str, Any], kind: str) -> str:
|
||||
run_id = hashlib.sha256(canonical_json_bytes(result)).hexdigest()[:16]
|
||||
return f"run-{run_id}/receipt.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public orchestration: execute a provider kind and write the root receipt.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Provider-kind wiring: each kind maps to a real executor and its governed
|
||||
# identity / operation. The gate kind (Gate-W vs Gate-R) is read from the
|
||||
# policy; a Gate-R kind is refused by the Gate-W producer boundary.
|
||||
_PROVIDER_EXECUTORS: Mapping[str, Mapping[str, Any]] = {
|
||||
"tests": {
|
||||
"identity": "aios.tests-runner",
|
||||
"operation": "tests.run",
|
||||
"gate": "W",
|
||||
},
|
||||
"gitea": {
|
||||
"identity": "aios.gitea-machine-transport",
|
||||
"operation": "gitea.consistency",
|
||||
"gate": "W",
|
||||
},
|
||||
"secret_scan": {
|
||||
"identity": "aios.secret-scanner",
|
||||
"operation": "secret.scan",
|
||||
"gate": "W",
|
||||
},
|
||||
"canary": {
|
||||
"identity": "aios.canary-gate",
|
||||
"operation": "canary.run",
|
||||
"gate": "R",
|
||||
},
|
||||
"rollback": {
|
||||
"identity": "aios.rollback-controller",
|
||||
"operation": "rollback.validate",
|
||||
"gate": "R",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _operation_for(kind: str) -> str:
|
||||
entry = _PROVIDER_EXECUTORS.get(kind)
|
||||
if entry is None:
|
||||
raise TrustedProviderExecutionError("PROVIDER_KIND_UNKNOWN")
|
||||
return entry["operation"]
|
||||
|
||||
|
||||
def _run_id_for(kind: str) -> str:
|
||||
return f"{kind}-{int(time.time())}"
|
||||
|
||||
|
||||
def _run_provider_executor(
|
||||
kind: str,
|
||||
*,
|
||||
repo_root: Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
) -> tuple[Mapping[str, Any], str]:
|
||||
"""Run the real provider executor and return (result, receipt_ref).
|
||||
|
||||
Every provider derives its result from *real tool output* — never a
|
||||
manufactured ``{"passed": true}`` and never an acceptance-script-invented
|
||||
identity. The executor must be invoked under the governed root executor
|
||||
(root-owned store); any non-executable kind fails closed.
|
||||
"""
|
||||
if kind == "tests":
|
||||
return run_tests_provider(
|
||||
repo_root=repo_root,
|
||||
collection_inventory=repo_root / ".provider" / "tests-collection.json",
|
||||
junit_xml=repo_root / ".provider" / "junit.xml",
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
if kind == "secret_scan":
|
||||
return run_secret_scan_provider(
|
||||
gitleaks_bin=os.environ.get("AIOS_GITLEAKS_BIN", "/usr/bin/gitleaks"),
|
||||
target=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
if kind == "gitea":
|
||||
gitea_api_url = os.environ.get("AIOS_GITEA_API_URL", "").strip()
|
||||
token_file = os.environ.get("AIOS_GITEA_TOKEN_FILE", "").strip()
|
||||
repository_identity = os.environ.get("AIOS_GITEA_REPOSITORY", "").strip()
|
||||
if not (gitea_api_url and token_file and repository_identity):
|
||||
raise TrustedProviderExecutionError(
|
||||
"PROVIDER_MECHANISM_REQUIRED (gitea: API URL / token / repository "
|
||||
"identity not provisioned; no real provider path available)"
|
||||
)
|
||||
return run_gitea_provider(
|
||||
gitea_api_url=gitea_api_url,
|
||||
repository_identity=repository_identity,
|
||||
token_file=Path(token_file),
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
local_repo=repo_root,
|
||||
)
|
||||
raise TrustedProviderExecutionError(f"PROVIDER_MECHANISM_REQUIRED (kind={kind})")
|
||||
|
||||
|
||||
def produce(kind: str, *, source_oid: str, tree_oid: str, receipt_ref: str, store_root: Path) -> str:
|
||||
"""Execute real provider kind, write its root-owned receipt, return sha256.
|
||||
|
||||
Refuses Gate-R kinds at the Gate-W producer boundary, and refuses unless a
|
||||
real provider execution path (an executor that derives its result from real
|
||||
tool output under the governed root executor) exists for the kind.
|
||||
"""
|
||||
entry = _PROVIDER_EXECUTORS.get(kind)
|
||||
if entry is None:
|
||||
raise TrustedProviderExecutionError("PROVIDER_KIND_UNKNOWN")
|
||||
if entry["gate"] != "W":
|
||||
raise TrustedProviderExecutionError("GATE_R_PROVIDER_REFUSED_AT_GATE_W_BOUNDARY")
|
||||
|
||||
# Resolve a real source checkout for the executor to bind source/tree OID.
|
||||
repo_root = Path(os.environ.get("AIOS_REPO_ROOT", store_root.parent / "checkout")).resolve()
|
||||
result, auto_ref = _run_provider_executor(
|
||||
kind,
|
||||
repo_root=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
|
||||
store_root.mkdir(mode=0o700, exist_ok=True)
|
||||
os.chown(store_root, 0, 0)
|
||||
os.chmod(store_root, 0o700)
|
||||
return _write_receipt(store_root, receipt_ref, _build_receipt(
|
||||
kind=kind,
|
||||
provider_identity=entry["identity"],
|
||||
provider_operation=_operation_for(kind),
|
||||
operation_run_id=_run_id_for(kind),
|
||||
result_reference=auto_ref,
|
||||
result=result,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
store_id=kind,
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI: trusted_evidence_providers.py <kind> --source-oid=X --tree-oid=Y --store-root=Z --receipt-ref=R
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("kind", choices=("tests", "gitea", "secret_scan"))
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
parser.add_argument("--tree-oid", required=True)
|
||||
parser.add_argument("--store-root", required=True)
|
||||
parser.add_argument("--receipt-ref", required=True)
|
||||
parser.add_argument("--repo-root", default=None, help="Real source checkout to execute against")
|
||||
args = parser.parse_args()
|
||||
if args.repo_root:
|
||||
os.environ["AIOS_REPO_ROOT"] = args.repo_root
|
||||
try:
|
||||
digest = produce(
|
||||
args.kind,
|
||||
source_oid=_validate_oid(args.source_oid, "source_oid"),
|
||||
tree_oid=_validate_oid(args.tree_oid, "tree_oid"),
|
||||
receipt_ref=args.receipt_ref,
|
||||
store_root=Path(args.store_root),
|
||||
)
|
||||
except TrustedProviderExecutionError as exc:
|
||||
raise SystemExit(str(exc))
|
||||
print(json.dumps({"receipt_sha256": digest}, sort_keys=True, separators=(",", ":")))
|
||||
@@ -0,0 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
PROVIDER_RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
||||
PROVIDER_RECEIPT_VERSION = 2
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ProviderAuthenticationError(RuntimeError):
|
||||
"""Fail-closed provider receipt authentication error."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustedProviderPolicy:
|
||||
evidence_kind: str
|
||||
provider_identity: str
|
||||
store_id: str
|
||||
store_root: Path
|
||||
allowed_operations: frozenset[str]
|
||||
expected_owner_uid: int = 0
|
||||
allowed_directory_modes: frozenset[int] = frozenset({0o700, 0o750})
|
||||
allowed_receipt_modes: frozenset[int] = frozenset({0o600, 0o640})
|
||||
authentication_kind: str = "root-owned-store-v1"
|
||||
hmac_key_id: str | None = None
|
||||
hmac_namespace: str = "aios.trusted-evidence-provider-receipt.v2"
|
||||
max_age_seconds: int | None = None
|
||||
max_future_skew_seconds: int = 30
|
||||
max_receipt_bytes: int = 1 << 20
|
||||
require_single_link: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedProviderReceipt:
|
||||
evidence_kind: str
|
||||
provider_identity: str
|
||||
provider_operation: str
|
||||
operation_run_id: str
|
||||
result_reference: str
|
||||
result: Mapping[str, Any]
|
||||
content_sha256: str
|
||||
source_oid: str
|
||||
tree_oid: str
|
||||
environment: str
|
||||
issued_at: str
|
||||
authentication: Mapping[str, Any]
|
||||
receipt_ref: str
|
||||
receipt_file_sha256: str
|
||||
|
||||
def provenance(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PROVIDER_RECEIPT_SCHEMA,
|
||||
"version": PROVIDER_RECEIPT_VERSION,
|
||||
"provider_identity": self.provider_identity,
|
||||
"provider_operation": self.provider_operation,
|
||||
"operation_run_id": self.operation_run_id,
|
||||
"result_reference": self.result_reference,
|
||||
"content_sha256": self.content_sha256,
|
||||
"source_oid": self.source_oid,
|
||||
"tree_oid": self.tree_oid,
|
||||
"environment": self.environment,
|
||||
"issued_at": self.issued_at,
|
||||
"authentication": dict(self.authentication),
|
||||
"receipt_ref": self.receipt_ref,
|
||||
"receipt_file_sha256": self.receipt_file_sha256,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedEvidence:
|
||||
result: Mapping[str, Any]
|
||||
provider_receipt: Mapping[str, Any]
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _parse_issued_at(value: str) -> datetime:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID")
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_ISSUED_AT_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _validate_ref(receipt_ref: str) -> tuple[str, ...]:
|
||||
if not isinstance(receipt_ref, str) or not receipt_ref.strip():
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_REQUIRED")
|
||||
ref = PurePosixPath(receipt_ref.strip())
|
||||
if ref.is_absolute():
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_ABSOLUTE_DENIED")
|
||||
parts = ref.parts
|
||||
if not parts or any(part in {"", ".", ".."} for part in parts):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_TRAVERSAL_DENIED")
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _validate_directory_stat(st: os.stat_result, policy: TrustedProviderPolicy) -> None:
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_NOT_DIRECTORY")
|
||||
if st.st_uid != policy.expected_owner_uid:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_OWNER_MISMATCH")
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
if mode not in policy.allowed_directory_modes:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_MODE_INVALID")
|
||||
if mode & 0o022:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_WRITABLE_DENIED")
|
||||
|
||||
|
||||
def _validate_receipt_stat(st: os.stat_result, policy: TrustedProviderPolicy) -> None:
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_NOT_REGULAR")
|
||||
if st.st_uid != policy.expected_owner_uid:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_OWNER_MISMATCH")
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
if mode not in policy.allowed_receipt_modes:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_MODE_INVALID")
|
||||
if mode & 0o022:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_WRITABLE_DENIED")
|
||||
if policy.require_single_link and st.st_nlink != 1:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_LINK_COUNT_INVALID")
|
||||
|
||||
|
||||
def _read_nofollow(root: Path, parts: tuple[str, ...], policy: TrustedProviderPolicy) -> bytes:
|
||||
root = Path(root)
|
||||
try:
|
||||
root_lstat = os.lstat(root)
|
||||
except OSError as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_UNAVAILABLE") from exc
|
||||
if stat.S_ISLNK(root_lstat.st_mode):
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_SYMLINK_DENIED")
|
||||
_validate_directory_stat(root_lstat, policy)
|
||||
|
||||
# Validate every parent component with lstat; this is portable and catches
|
||||
# symlink/path-mode violations before the final no-follow open.
|
||||
current = root
|
||||
for part in parts[:-1]:
|
||||
current = current / part
|
||||
try:
|
||||
st = os.lstat(current)
|
||||
except OSError as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_COMPONENT_UNAVAILABLE") from exc
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_SYMLINK_DENIED")
|
||||
_validate_directory_stat(st, policy)
|
||||
|
||||
target = current / parts[-1]
|
||||
try:
|
||||
st = os.lstat(target)
|
||||
except OSError as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_UNAVAILABLE") from exc
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_SYMLINK_DENIED")
|
||||
_validate_receipt_stat(st, policy)
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0)
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(target, flags)
|
||||
except OSError as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_OPEN_DENIED") from exc
|
||||
try:
|
||||
opened = os.fstat(fd)
|
||||
_validate_receipt_stat(opened, policy)
|
||||
if (opened.st_dev, opened.st_ino) != (st.st_dev, st.st_ino):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_RACE_DETECTED")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = os.read(fd, min(65536, policy.max_receipt_bytes + 1 - total))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > policy.max_receipt_bytes:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_TOO_LARGE")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _authentication_payload(receipt: Mapping[str, Any]) -> bytes:
|
||||
clone = dict(receipt)
|
||||
authentication = dict(clone.get("authentication") or {})
|
||||
authentication.pop("mac_hex", None)
|
||||
clone["authentication"] = authentication
|
||||
return canonical_json_bytes(clone)
|
||||
|
||||
|
||||
class TrustedProviderReceiptResolver:
|
||||
"""Resolve and authenticate evidence-provider receipts from governed stores."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policies: Mapping[str, TrustedProviderPolicy],
|
||||
*,
|
||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
||||
now_fn: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._policies = dict(policies)
|
||||
self._hmac_key_resolver = hmac_key_resolver
|
||||
self._now_fn = now_fn or (lambda: datetime.now(timezone.utc))
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
evidence_kind: str,
|
||||
receipt_ref: str,
|
||||
*,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
) -> AuthenticatedProviderReceipt:
|
||||
policy = self._policies.get(evidence_kind)
|
||||
if policy is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_POLICY_NOT_FOUND")
|
||||
if not _HEX40.fullmatch(source_oid):
|
||||
raise ProviderAuthenticationError("PROVIDER_SOURCE_OID_INVALID")
|
||||
if not _HEX40.fullmatch(tree_oid):
|
||||
raise ProviderAuthenticationError("PROVIDER_TREE_OID_INVALID")
|
||||
|
||||
parts = _validate_ref(receipt_ref)
|
||||
raw = _read_nofollow(policy.store_root, parts, policy)
|
||||
file_sha = hashlib.sha256(raw).hexdigest()
|
||||
try:
|
||||
receipt = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_JSON_INVALID") from exc
|
||||
if not isinstance(receipt, dict):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_JSON_INVALID")
|
||||
|
||||
if receipt.get("schema") != PROVIDER_RECEIPT_SCHEMA:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_SCHEMA_INVALID")
|
||||
if receipt.get("version") != PROVIDER_RECEIPT_VERSION:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_VERSION_INVALID")
|
||||
if receipt.get("provider_identity") != policy.provider_identity:
|
||||
raise ProviderAuthenticationError("PROVIDER_IDENTITY_MISMATCH")
|
||||
operation = receipt.get("provider_operation")
|
||||
if operation not in policy.allowed_operations:
|
||||
raise ProviderAuthenticationError("PROVIDER_OPERATION_DENIED")
|
||||
|
||||
run_id = receipt.get("operation_run_id")
|
||||
result_reference = receipt.get("result_reference")
|
||||
environment = receipt.get("environment")
|
||||
issued_at = receipt.get("issued_at")
|
||||
if not all(isinstance(v, str) and v.strip() for v in (run_id, result_reference, environment, issued_at)):
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REQUIRED_FIELD_INVALID")
|
||||
|
||||
if receipt.get("source_oid") != source_oid:
|
||||
raise ProviderAuthenticationError("PROVIDER_SOURCE_OID_MISMATCH")
|
||||
if receipt.get("tree_oid") != tree_oid:
|
||||
raise ProviderAuthenticationError("PROVIDER_TREE_OID_MISMATCH")
|
||||
|
||||
result = receipt.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise ProviderAuthenticationError("PROVIDER_RESULT_INVALID")
|
||||
content_sha = receipt.get("content_sha256")
|
||||
if not isinstance(content_sha, str) or not _HEX64.fullmatch(content_sha):
|
||||
raise ProviderAuthenticationError("PROVIDER_CONTENT_SHA256_INVALID")
|
||||
if canonical_sha256(result) != content_sha:
|
||||
raise ProviderAuthenticationError("PROVIDER_CONTENT_SHA256_MISMATCH")
|
||||
|
||||
issued = _parse_issued_at(issued_at)
|
||||
now = self._now_fn().astimezone(timezone.utc)
|
||||
if issued > now and (issued - now).total_seconds() > policy.max_future_skew_seconds:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_FROM_FUTURE")
|
||||
if policy.max_age_seconds is not None and (now - issued).total_seconds() > policy.max_age_seconds:
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_EXPIRED")
|
||||
|
||||
authentication = receipt.get("authentication")
|
||||
if not isinstance(authentication, dict):
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_MISSING")
|
||||
kind = authentication.get("kind")
|
||||
if kind != policy.authentication_kind:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_KIND_MISMATCH")
|
||||
|
||||
if kind == "root-owned-store-v1":
|
||||
if authentication.get("store_id") != policy.store_id:
|
||||
raise ProviderAuthenticationError("PROVIDER_STORE_ID_MISMATCH")
|
||||
elif kind == "hmac-sha256-v1":
|
||||
key_id = authentication.get("key_id")
|
||||
mac_hex = authentication.get("mac_hex")
|
||||
if policy.hmac_key_id is None or key_id != policy.hmac_key_id:
|
||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_ID_MISMATCH")
|
||||
if not isinstance(mac_hex, str) or not _HEX64.fullmatch(mac_hex):
|
||||
raise ProviderAuthenticationError("PROVIDER_HMAC_INVALID")
|
||||
if self._hmac_key_resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_RESOLVER_REQUIRED")
|
||||
key = self._hmac_key_resolver(key_id)
|
||||
if not isinstance(key, (bytes, bytearray)) or not key:
|
||||
raise ProviderAuthenticationError("PROVIDER_HMAC_KEY_UNAVAILABLE")
|
||||
namespace = policy.hmac_namespace.encode("utf-8")
|
||||
expected = hmac.new(bytes(key), namespace + b"\0" + _authentication_payload(receipt), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(mac_hex, expected):
|
||||
raise ProviderAuthenticationError("PROVIDER_HMAC_MISMATCH")
|
||||
else:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_KIND_UNSUPPORTED")
|
||||
|
||||
return AuthenticatedProviderReceipt(
|
||||
evidence_kind=evidence_kind,
|
||||
provider_identity=policy.provider_identity,
|
||||
provider_operation=operation,
|
||||
operation_run_id=run_id,
|
||||
result_reference=result_reference,
|
||||
result=result,
|
||||
content_sha256=content_sha,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
environment=environment,
|
||||
issued_at=issued_at,
|
||||
authentication=dict(authentication),
|
||||
receipt_ref="/".join(parts),
|
||||
receipt_file_sha256=file_sha,
|
||||
)
|
||||
|
||||
def resolve_evidence(
|
||||
self,
|
||||
evidence_kind: str,
|
||||
evidence_request: Mapping[str, Any],
|
||||
*,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
) -> ResolvedEvidence:
|
||||
if not isinstance(evidence_request, Mapping):
|
||||
raise ProviderAuthenticationError("EVIDENCE_REQUEST_INVALID")
|
||||
if "receipt" in evidence_request or "provider_receipt" in evidence_request:
|
||||
raise ProviderAuthenticationError("CALLER_SUPPLIED_PROVIDER_RECEIPT_DENIED")
|
||||
receipt_ref = evidence_request.get("receipt_ref")
|
||||
if not isinstance(receipt_ref, str) or not receipt_ref.strip():
|
||||
raise ProviderAuthenticationError("PROVIDER_RECEIPT_REF_REQUIRED")
|
||||
authenticated = self.resolve(
|
||||
evidence_kind,
|
||||
receipt_ref,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
# The authenticated receipt is authoritative for the evidence result.
|
||||
# Request-side result fields may only match; they can never override.
|
||||
request_body = {k: v for k, v in evidence_request.items() if k != "receipt_ref"}
|
||||
if request_body and canonical_sha256(request_body) != authenticated.content_sha256:
|
||||
raise ProviderAuthenticationError("EVIDENCE_REQUEST_CONTENT_MISMATCH")
|
||||
return ResolvedEvidence(
|
||||
result=dict(authenticated.result),
|
||||
provider_receipt=authenticated.provenance(),
|
||||
)
|
||||
|
||||
|
||||
def build_hmac_authentication(
|
||||
receipt_without_mac: Mapping[str, Any],
|
||||
*,
|
||||
key_id: str,
|
||||
key: bytes,
|
||||
namespace: str = "aios.trusted-evidence-provider-receipt.v2",
|
||||
) -> dict[str, Any]:
|
||||
if not key:
|
||||
raise ValueError("key is required")
|
||||
draft = dict(receipt_without_mac)
|
||||
auth = dict(draft.get("authentication") or {})
|
||||
auth["kind"] = "hmac-sha256-v1"
|
||||
auth["key_id"] = key_id
|
||||
auth.pop("mac_hex", None)
|
||||
draft["authentication"] = auth
|
||||
mac = hmac.new(key, namespace.encode("utf-8") + b"\0" + canonical_json_bytes(draft), hashlib.sha256).hexdigest()
|
||||
return {**auth, "mac_hex": mac}
|
||||
@@ -0,0 +1 @@
|
||||
a36e9ef667f0e6be3a9e77b2082c7f2b1b2b0e020af9dcd38af8c8083c28acac aios-round3-provider-auth-c6fe1a486fa0b2fe560cbdfea646116bcac637a0.tar.gz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"package": "aios-round3-provider-auth",
|
||||
"schema": "aios.immutable-package-ledger.v1",
|
||||
"generated_at": "2026-09-09T07:15:00Z",
|
||||
"platform": "linux-amd64",
|
||||
"freeze": {
|
||||
"PREVIOUS_FREEZE_HEAD": "a6dc0c3a63836a3668d972f632131f772626c363",
|
||||
"PROVIDER_AUTH_HEAD": "f7f53d0568767d6deec2c47008a89f4d8413f93d",
|
||||
"PROVIDER_EXECUTOR_HEAD": "2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d",
|
||||
"P0_FINAL_HEAD": "c6fe1a486fa0b2fe560cbdfea646116bcac637a0",
|
||||
"P0_FINAL_TREE": "2e36ccf170b59ac38f62c6c20a5259b3fe9a181b"
|
||||
},
|
||||
"receipt_binding": {
|
||||
"source_oid": "2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d",
|
||||
"tree_oid": "505a66a79062e1161b69d93063ffeb94ad1b95b9"
|
||||
},
|
||||
"gate_w": {
|
||||
"produced": ["tests", "secret_scan"],
|
||||
"mechanism_required": ["gitea"],
|
||||
"resolver_closure": "PASS",
|
||||
"tests_passed": false,
|
||||
"tests_failures": 3,
|
||||
"tests_note": "3 pre-existing non-round3 env failures (V2R1C6 digest, rollback script dep, missing deployed signer binary)",
|
||||
"secret_scan_passed": true
|
||||
},
|
||||
"providers": {
|
||||
"tests": {
|
||||
"provider_identity": "aios.tests-runner",
|
||||
"gate": "W",
|
||||
"receipt_ref": "round3-gate-w/tests.json",
|
||||
"store": "/var/lib/aios-evidence-providers/tests"
|
||||
},
|
||||
"secret_scan": {
|
||||
"provider_identity": "aios.secret-scanner",
|
||||
"gate": "W",
|
||||
"receipt_ref": "round3-gate-w/secret_scan.json",
|
||||
"store": "/var/lib/aios-evidence-providers/secret-scan"
|
||||
},
|
||||
"gitea": {
|
||||
"provider_identity": "aios.gitea-machine-transport",
|
||||
"gate": "W",
|
||||
"status": "PROVIDER_MECHANISM_REQUIRED",
|
||||
"note": "API URL / token / repository identity not provisioned; fail-closed, no fake receipt"
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"bundle24_and_affected": "24 passed",
|
||||
"verify_round3_integration": "ROUND3_PROVIDER_AUTH_INTEGRATION=PASS",
|
||||
"no_manufactured_passed": true,
|
||||
"no_acceptance_invented_identity": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"schema": "aios.immutable-package-tariff.v1",
|
||||
"package": "aios-round3-provider-auth",
|
||||
"from": "a6dc0c3a63836a3668d972f632131f772626c363",
|
||||
"to": "c6fe1a486fa0b2fe560cbdfea646116bcac637a0",
|
||||
"summary": {
|
||||
"files": 13,
|
||||
"insertions": 2233,
|
||||
"deletions": 27
|
||||
},
|
||||
"changes": [
|
||||
{
|
||||
"path": "active-runtime/app/governed_authenticated_evidence_operation.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/governed_provider_auth_adapter.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/trusted_evidence_providers.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/trusted_provider_receipts.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "config/gitops/trusted-evidence-providers.json",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "scripts/round3_provider_gatew_prove.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_governed_authenticated_evidence_operation.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_governed_provider_auth_adapter.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_trusted_provider_receipts.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "verify_round3_integration.py",
|
||||
"status": "A"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/governed_evidence_runtime.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/governed_release_evidence_producer.py",
|
||||
"status": "M"
|
||||
},
|
||||
{
|
||||
"path": "docs/V2R2C2-P0-convergence-20260909.md",
|
||||
"status": "M"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"activation_file":"/var/lib/aios-gitops/bridge-v2-staging/task-admission-activation.json","current_components":[{"artifact_sha_env":"AIOS_GITOPS_ARTIFACT_SHA256","code_line_id":"legacy-governed-main-v15","component_id":"gitops-model-api-staging","expected_source_oid":"91f104ef62a3b688a9259b96e2ca247e0363b564","interface_files":["app/aios_model_api_staging.py","app/silent_release_hardening.py"],"marker_files":["app/aios_model_api_staging.py"],"service":"aios-gitops-model-api-v2r2c1-staging.service","source_oid_env":"AIOS_GITOPS_RUNTIME_OID"},{"artifact_sha_env":"AIOS_CONTROL_PLANE_ARTIFACT_SHA256","code_line_id":"legacy-p0-task-principal","component_id":"chatgpt-bridge-v2-staging","expected_source_oid":"91f104ef62a3b688a9259b96e2ca247e0363b564","interface_files":["app/aios_chatgpt_bridge_v2_server.py","app/aios_task_principal_admission.py"],"marker_files":["app/aios_task_principal_admission.py"],"service":"aios-chatgpt-bridge-v2-staging.service","source_oid_env":"AIOS_CONTROL_PLANE_TARGET_OID"}],"profile_id":"baseline11-principal-recovery-cross-code-line-v1","repository":"aios/aios","rollback_window_seconds":3600,"schema":"aios.release-migration-profile.v1","state_db":"/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3","state_mode":"REUSE_IN_PLACE_WITH_SNAPSHOT","state_ref":"state:task-admission","target_components":[{"code_line_id":"baseline11-release-source-admission","component_id":"gitops-model-api-staging","interface_files":["active-runtime/app/aios_model_api_staging.py","active-runtime/app/silent_release_hardening.py","active-runtime/app/aios_trusted_candidate_builder.py","active-runtime/app/release_migration_plan.py"],"service":"aios-gitops-model-api-v2r2c1-staging.service"},{"code_line_id":"baseline11-task-admission","component_id":"chatgpt-bridge-v2-staging","interface_files":["active-runtime/app/aios_chatgpt_bridge_v2_server.py","active-runtime/app/aios_task_admission_bootstrap.py","active-runtime/app/aios_task_admission_live_adapters.py"],"service":"aios-chatgpt-bridge-v2-staging.service"}],"version":"V2R2C2-baseline11-principal-recovery"}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"schema": "aios.system-truth-contract.v1",
|
||||
"state_ownership": {
|
||||
"TaskAdmission": {
|
||||
"owns": [
|
||||
"envelope",
|
||||
"authorization",
|
||||
"grant",
|
||||
"admission_receipt"
|
||||
],
|
||||
"must_not_own": [
|
||||
"claim",
|
||||
"heartbeat",
|
||||
"execution_status",
|
||||
"commit_result"
|
||||
]
|
||||
},
|
||||
"TaskPlane": {
|
||||
"owns": [
|
||||
"task_state",
|
||||
"claim",
|
||||
"lease",
|
||||
"heartbeat",
|
||||
"attempt",
|
||||
"deadline"
|
||||
],
|
||||
"must_not_own": [
|
||||
"owner_signature",
|
||||
"git_commit_truth"
|
||||
]
|
||||
},
|
||||
"GitAuthority": {
|
||||
"owns": [
|
||||
"branch_oid",
|
||||
"commit_oid",
|
||||
"file_bytes"
|
||||
],
|
||||
"must_not_own": [
|
||||
"task_state"
|
||||
]
|
||||
},
|
||||
"RuntimeProjection": {
|
||||
"owns": [
|
||||
"deployed_source_oid",
|
||||
"served_routes",
|
||||
"external_action_readback"
|
||||
],
|
||||
"must_not_own": [
|
||||
"source_commit_status"
|
||||
]
|
||||
}
|
||||
},
|
||||
"completion_requires": [
|
||||
"valid_claim_holder",
|
||||
"fresh_worker_heartbeat",
|
||||
"valid_writer_lease",
|
||||
"commit_receipt",
|
||||
"post_oid_differs_from_pre_oid",
|
||||
"output_sha256_matches_plan",
|
||||
"runtime_readback",
|
||||
"external_projection_readback"
|
||||
],
|
||||
"queue_requires": [
|
||||
"fresh_schedulability_proof",
|
||||
"at_least_one_healthy_scope_matching_worker",
|
||||
"grant_or_jit_principal_can_cover_claim_window"
|
||||
],
|
||||
"forbidden_equivalences": [
|
||||
"ADMITTED == EXECUTED",
|
||||
"QUEUED == CLAIMED",
|
||||
"service_active == scenario_pass",
|
||||
"drift_status_PASS == evidence_complete",
|
||||
"hash_declared == artifact_exists",
|
||||
"OpenAPI_operation == backend_route_exists"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema": "aios.trusted-evidence-providers.v1",
|
||||
"defaults": {
|
||||
"authentication_kind": "root-owned-store-v1",
|
||||
"expected_owner_uid": 0,
|
||||
"allowed_directory_modes": ["0700", "0750"],
|
||||
"allowed_receipt_modes": ["0600", "0640"],
|
||||
"max_age_seconds": 3600
|
||||
},
|
||||
"providers": {
|
||||
"tests": {
|
||||
"provider_identity": "aios.tests-runner",
|
||||
"store_id": "tests",
|
||||
"store_root": "/var/lib/aios-evidence-providers/tests",
|
||||
"allowed_operations": ["tests.run"],
|
||||
"gate": "W"
|
||||
},
|
||||
"gitea": {
|
||||
"provider_identity": "aios.gitea-machine-transport",
|
||||
"store_id": "gitea",
|
||||
"store_root": "/var/lib/aios-evidence-providers/gitea",
|
||||
"allowed_operations": ["gitea.consistency"],
|
||||
"gate": "W"
|
||||
},
|
||||
"secret_scan": {
|
||||
"provider_identity": "aios.secret-scanner",
|
||||
"store_id": "secret_scan",
|
||||
"store_root": "/var/lib/aios-evidence-providers/secret-scan",
|
||||
"allowed_operations": ["secret.scan"],
|
||||
"gate": "W"
|
||||
},
|
||||
"canary": {
|
||||
"provider_identity": "aios.canary-gate",
|
||||
"store_id": "canary",
|
||||
"store_root": "/var/lib/aios-evidence-providers/canary",
|
||||
"allowed_operations": ["canary.run"],
|
||||
"gate": "R"
|
||||
},
|
||||
"rollback": {
|
||||
"provider_identity": "aios.rollback-controller",
|
||||
"store_id": "rollback",
|
||||
"store_root": "/var/lib/aios-evidence-providers/rollback",
|
||||
"allowed_operations": ["rollback.validate"],
|
||||
"gate": "R"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 trusted-builder evidence materializer
|
||||
After=aios-release-source-admission.service aios-release-migration-planner.service
|
||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
||||
ConditionPathExists=/var/lib/aios-release-migration
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
UMask=0027
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_RELEASE_EVIDENCE_SOURCE_ROOT=/var/lib/aios-release-evidence-staging
|
||||
Environment=AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT=/var/lib/aios-trusted-candidate-builder/evidence
|
||||
EnvironmentFile=-/run/aios-release-evidence-materializer/request.env
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-evidence-materializer/current/app/release_evidence_materializer.py materialize --version ${AIOS_RELEASE_VERSION} --candidate-id ${AIOS_RELEASE_CANDIDATE_ID} --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --builder-policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --authority-git-dir /data/aios-gitops/repo.git --source-root ${AIOS_RELEASE_EVIDENCE_SOURCE_ROOT} --output-root ${AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT} --source-admission-root /var/lib/aios-release-source-admission --migration-plan-root /var/lib/aios-release-migration --migration-profile-root /etc/aios-gitops/release-migration-profiles
|
||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-evidence-materializer/request.env
|
||||
RuntimeDirectory=aios-release-evidence-materializer
|
||||
RuntimeDirectoryMode=0750
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/release-evidence-materializer /opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-release-evidence-staging /var/lib/aios-release-source-admission /var/lib/aios-release-migration /etc/aios-gitops/release-migration-profiles /etc/credstore.encrypted
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder/evidence /run/aios-release-evidence-materializer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
Description=AIOS governed cross-code-line release migration planner
|
||||
After=aios-release-source-admission.service
|
||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
UMask=0027
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_RELEASE_MIGRATION_PROFILE=/etc/aios-gitops/release-migration-profiles/V2R2C2-baseline11-principal-recovery.json
|
||||
EnvironmentFile=-/run/aios-release-migration-planner/request.env
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-migration/current/app/release_migration_plan.py --authority-git-dir /data/aios-gitops/repo.git --profile ${AIOS_RELEASE_MIGRATION_PROFILE} --output-root /var/lib/aios-release-migration --source-admission-root /var/lib/aios-release-source-admission --builder-policy /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --candidate-id ${AIOS_RELEASE_CANDIDATE_ID}
|
||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-migration-planner/request.env
|
||||
StateDirectory=aios-release-migration
|
||||
StateDirectoryMode=0750
|
||||
RuntimeDirectory=aios-release-migration-planner
|
||||
RuntimeDirectoryMode=0750
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/release-migration /data/aios-gitops/repo.git /var/lib/aios-release-source-admission /opt/aios/trusted-candidate-builder/current/config/gitops/trusted-candidate-builder-policy.json /etc/aios-gitops/release-migration-profiles /var/lib/aios-gitops/bridge-v2-staging
|
||||
ReadWritePaths=/var/lib/aios-release-migration /run/aios-release-migration-planner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,40 @@
|
||||
[Unit]
|
||||
Description=AIOS governed Source Admission isolated-peer policy rebind
|
||||
After=aios-release-source-admission.service
|
||||
ConditionPathExists=/var/lib/aios-release-source-admission
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
UMask=0027
|
||||
LoadCredentialEncrypted=source_admission_hmac:/etc/credstore.encrypted/aios-release-source-admission/source_admission_hmac
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_RELEASE_ISOLATED_PEER_CGROUP=/system.slice/aios-gitops-model-api-v2r2c2-baseline11-candidate.service
|
||||
EnvironmentFile=-/run/aios-release-source-admission-rebind/request.env
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/release-source-admission/current/app/release_source_admission.py rebind-policy --source-commit ${AIOS_RELEASE_SOURCE_COMMIT} --previous-policy ${AIOS_RELEASE_PREVIOUS_POLICY} --policy ${AIOS_RELEASE_POLICY} --isolated-peer-cgroup ${AIOS_RELEASE_ISOLATED_PEER_CGROUP} --expected-admission-id ${AIOS_RELEASE_EXPECTED_ADMISSION_ID} --reason ${AIOS_RELEASE_REBIND_REASON} --store-root /var/lib/aios-release-source-admission
|
||||
ExecStartPost=/usr/bin/rm -f /run/aios-release-source-admission-rebind/request.env
|
||||
RuntimeDirectory=aios-release-source-admission-rebind
|
||||
RuntimeDirectoryMode=0750
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
ReadOnlyPaths=/opt/aios/release-source-admission /etc/credstore.encrypted
|
||||
ReadWritePaths=/var/lib/aios-release-source-admission /run/aios-release-source-admission-rebind
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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,14 @@ 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_MIGRATION_PLAN_ROOT=/var/lib/aios-release-migration
|
||||
Environment=AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT=/etc/aios-gitops/release-migration-profiles
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_ID=builder-attestation-ed25519-g1
|
||||
Environment=AIOS_TRUSTED_BUILDER_KEY_GENERATION=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
@@ -36,8 +40,9 @@ LockPersonality=true
|
||||
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
|
||||
AmbientCapabilities=CAP_SYS_PTRACE
|
||||
CapabilityBoundingSet=CAP_SYS_PTRACE
|
||||
ReadOnlyPaths=/opt/aios/trusted-candidate-builder /data/aios-gitops/repo.git /var/lib/aios-trusted-candidate-builder/evidence -/var/lib/aios-release-source-admission -/var/lib/aios-release-migration -/etc/aios-gitops/release-migration-profiles
|
||||
ReadWritePaths=/var/lib/aios-trusted-candidate-builder
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
"source_mode": "0644",
|
||||
"source_path": "deploy/systemd/aios-trusted-candidate-builder.socket"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
"source_path": "docs/security/trusted-builder-captrace.md"
|
||||
},
|
||||
{
|
||||
"install_mode": "0644",
|
||||
"source_mode": "0644",
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# V2R2C2 P0 — 收敛状态与缺口记录
|
||||
|
||||
日期:2026-09-09 · 分支:`agent/v2r2c2-p0-ttl-captrace-contract-20260909`
|
||||
基线:authority main `0136a53`,integration serial head `49d30a2`(V2R2C2 integration base)
|
||||
|
||||
> 本记录为**工程收敛状态**的可审查交付,非发布宣称。凡标注"待 Linux 实跑 / 待 Owner gate"的项,未在本机声称完成。
|
||||
|
||||
---
|
||||
|
||||
## 1. Evidence Producer 缺口定稿(对应执行要求 §9)
|
||||
|
||||
**结论:`EVIDENCE_PRODUCER_REQUIRED`**(机制缺口真实存在,非"producer 90%"、非"materializer 就是 producer")。
|
||||
|
||||
依据(代码核验):
|
||||
- `active-runtime/app/release_evidence_materializer.py`
|
||||
- `TrustedBuilderEvidenceMaterializer` = **验证并投影** closure(collect 重算 + atomically project),docstring 明确:*"producer must first write a complete, root-owned evidence closure below source_root/<version>"*——即 Materializer 依赖**上游 Producer** 先写 closure。
|
||||
- `config/gitops/trusted-candidate-builder-policy.json` 的 `evidence_layout` 声明 closure 组成:
|
||||
`artifact / manifest / package_closure / tests / canary / gitea / rollback / policy / secret_scan`
|
||||
(= 执行要求 §9 点名的 canary/rollback/secret-scan/Gitea consistency 证据来源)
|
||||
- **未发现**独立注册的 `Governed Release Evidence Producer` operation:
|
||||
- `aios_baseline/operations.py` 是声明式(非执行)operation registry 框架,**不注册**该 producer。
|
||||
- `trusted_builder_signer_provisioning.py` 是 builder 私钥/打包 **signer provisioning**,非 release closure producer。
|
||||
|
||||
**缺口 → 阻塞关系**:closure 的产生方缺失,阻塞 Gate R 的 "evidence closure" 冻结项。不得由 Materializer 自产证据、hand-write receipt / test PASS / Gitea consistency / canary / rollback / secret-scan。
|
||||
|
||||
**实现完成(本轮 wt-p0)**:
|
||||
- `active-runtime/app/governed_release_evidence_producer.py` — operation `governed.release.evidence.produce`:
|
||||
- 契约:输入 = 真实构建产物(artifact/manifest/package_closure/policy bytes)+ 真实校验执行结果(tests/canary/gitea/rollback/secret-scan)+ 部署约定(deployment/tag/finalization)+ 构建执行时刻(issued_at);**不手写证据**——每个 digest 从真实字节推导,pass/fail/gitea-consistent flag 来自真实执行输入,fail-closed 校验(secret-scan 必须覆盖实际 artifact 字节、tests/canary/rollback/gitea 必须通过)。
|
||||
- 产出:写 `source_root/<version>/` 完整 closure(receipt.json + 全 evidence_layout 文件),幂等(相同闭包接受、冲突拒绝),O_BINARY 写入避免 CRLF。
|
||||
- 注册:`producer_operation_definition()` / `producer_registry()` 挂到 OperationRegistry(声明式、非执行)。
|
||||
- `tests/test_governed_release_evidence_producer.py` — 5 项契约测试(本地闭环):closure 可被 builder 验证路径(ServerOwnedEvidenceSource + DeterministicEvidenceCollector = materializer 同款)重验证且 evidence_digests 一致;secret-scan 不匹配 / artifact 篡改 fail-closed;幂等;registry 注册。**5 passed**。
|
||||
- **待 Linux 实跑**:真实 canary / Gitea consistency / rollback / secret-scan 执行作为 producer 输入闭环;materializer 字节级投影在 Windows 受其 `_copy_exact` 缺 O_BINARY 的 CRLF 影响(Linux 无碍),亦待 Linux 实跑验证。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现场未 Git 化配置收敛(对应执行要求 §7)
|
||||
|
||||
主 clone 根目录 11 个现场 untracked 文件,分类如下(**不擅自删除**,交由后续按此清单 DROP 或归档):
|
||||
|
||||
| 文件 | 类别 | 处置 |
|
||||
|---|---|---|
|
||||
| `aios-gitops-model-api-v2r2c2-baseline11-candidate.service` | 现场具体 OID(2852c2a) 部署实例 | `DROP_WITH_REASON: 现场部署实例;正式 unit 形态已由 deploy/systemd/ 收敛(含 aios-trusted-candidate-builder-ptrace.service)` |
|
||||
| `governed-runtime-package-2852c2a.json` | 生成部署包 manifest | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
||||
| `aios_2852c2a_app.tar` | generated tarball | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
||||
| `aios_2852c2a_lf.tar` | generated tarball | `DROP_WITH_REASON: 生成产物 → artifact store` |
|
||||
| `inspect_db.py` / `inspect_db2.py` | local DB inspection artifact | `DROP_WITH_REASON: 一次性 debug probe` |
|
||||
| `prove_prereq.py` | 一次性验证脚本(硬编码 /opt 路径) | `DROP_WITH_REASON: 一次性 debug probe` |
|
||||
| `verify_after.py` / `verify_phaseD.py` | 针对特定 OID 的哈希验证脚本 | `DROP_WITH_REASON: 一次性验证脚本` |
|
||||
| `verify_tar.py` | 硬编码 Windows 路径的 tar 校验 | `DROP_WITH_REASON: 一次性 debug probe` |
|
||||
| `readiness_fix.patch` | temporary patch | `DROP_WITH_REASON: 一次性临时补丁,正式代码已含其意图` |
|
||||
|
||||
**应进入 Git / 已 Git 化**(本轮 wt-p0 已提交,Task 10 定稿):
|
||||
- `deploy/systemd/aios-trusted-candidate-builder.service`(**tracked unit 已与 live 字节一致**:root User + `AmbientCapabilities=CAP_SYS_PTRACE` + `CapabilityBoundingSet=CAP_SYS_PTRACE`;LF 归一化 sha256=`9d194c23…`)
|
||||
- `docs/security/trusted-builder-captrace.md`(安全契约,CAP_SYS_PTRACE 标注 **HIGH-SENSITIVITY**——"broadens process's observation and debugging authority",禁止回卷出非 root 分歧 unit)
|
||||
- `deploy/trusted-builder/aios-trusted-builder-package-manifest.json`(部署 manifest 更新)
|
||||
- `tests/test_trusted_candidate_builder_captrace.py`(可复用测试,EACCES monkeypatch 覆盖 cgroup/exe/cmdline 三路径)
|
||||
- **`aios-trusted-candidate-builder-ptrace.service` 已于 Task 10 删除**——其自造 `User=aios-builder`/`ProtectProc=invisible`/`ProcSubset=pid` 违反 §2(禁止改 uid/group/ProtectProc/ProcSubset/credential/socket 权属);以 live unit 为唯一基线。
|
||||
|
||||
---
|
||||
|
||||
## 3. P0 已完成(本地闭环、可验证)
|
||||
|
||||
完整 P0 收敛提交链(相对 serial head `49d30a2`,fast-forward,merge-base=`49d30a2`):
|
||||
- `ae5aeea` — TTL 修复 + TTL 专项测试 + harness 修复 + bootstrap 全链路契约测试(4 files, +520/-5)
|
||||
- `7f83845` — CAP_SYS_PTRACE Git 化(unit + 安全文档 + manifest + 测试,4 files, +240)
|
||||
- `69fc61c` — 收敛状态与已确认缺口记录
|
||||
- `18674d7` — governed release evidence producer 实现 + registry 注册 + 契约测试
|
||||
- `6271bc8` — materializer `_copy_exact`/`_write_json_exclusive` 增加 `os.O_BINARY`(Windows LF→CRLF 字节一致);恢复 producer 真实完成判据(逐字节投影 + sha256 相等 + collector PASS)
|
||||
- `d4f181f` — CAP_SYS_PTRACE 定稿:tracked unit 对齐 live 基线(root+CAP_SYS_PTRACE,LF sha256=`9d194c23…`),删除自造 `-ptrace.service`,安全文档标 HIGH-SENSITIVITY,测试改 EACCES monkeypatch 覆盖三路径
|
||||
- `b519aa0` — Producer 变 trusted-provider-bound + runtime-registered operation;新增 `governed_evidence_runtime.py`(Inventory→Resolver→operation→Producer→closure → `PRODUCER_RUNTIME_REGISTERED_PASS`);10 producer + 5 runtime 测试
|
||||
|
||||
**Windows 侧实测(当前 HEAD `b519aa0`)**:
|
||||
- 核心 P0 收敛套件(producer/runtime/local_writer/receipt_contract/captrace/trusted_builder/candidate_builder + artifact):**54 passed / 2 skipped**
|
||||
- 2 skip = `SO_PEERCRED`(G9)、POSIX evidence-root mode(G5)——均为文档化 Linux 平台边界
|
||||
- 完整可收集套件(排除 20 POSIX-only collection-error)见 §3b:P0 未引入任何新失败。
|
||||
|
||||
**状态标签(遵守命令纪律,不称 P0 PASS)**:`P0_LOCAL_IMPLEMENTATION_IN_PROGRESS`(本地核心闭环完成,Linux 实跑 + finalize 待办)。
|
||||
|
||||
## 3b. 完整风险驱动测试结论(Windows 差分)
|
||||
|
||||
在 P0 分支跑**完整可收集套件**(排除 20 个 POSIX-only collection-errors:fcntl 16 / grp 3 为 Windows 无标准等价物):**850 passed / 99 failed / 14 skipped**。
|
||||
- **回归判定(对照 base=49d30a2,同 P0 相关既有文件)**:base 33 failed / 97 passed vs P0 32 failed / 103 passed → **P0 改动未引入任何新失败**(失败数不增反减)。P0 分支全部失败均来自 base 既有的 Windows 环境边界(symlink 特权 WinError 1314、POSIX mode/uid 检查在 `os.name != "nt"` 才生效、materializer `_copy_exact` 缺 O_BINARY 的 CRLF、signer_provisioning / release_source_admission 的既有失败),非本分支回归,需 Linux 实跑闭环。
|
||||
- 我的 P0 覆盖区文件(chain_contract / captrace / producer)隔离跑全部通过。
|
||||
- **状态标签**:`WINDOWS_DIFFERENTIAL_NO_NEW_P0_FAILURES`(不是 P0 PASS)。
|
||||
|
||||
## 3c. Linux immutable test package(Task #15,prep 完成,实跑待 Linux)
|
||||
|
||||
- 产物位于 `deliverables/v2r2c2-linux-track/`(**不在 authority repo/源码树**,隔离 staging):
|
||||
- `P0_FINAL-LinuxTrack.manifest.json` + `P0_FINAL-LinuxTrack.sha256` — 钉住 15 个 P0 文件在 P0_HEAD `b519aa0` 的 LF 源字节(CRLF 归一化验证,Linux checkout 天然 LF 匹配)
|
||||
- `run_linux_native_acceptance.sh` — Linux 运行器:不可变校验 → 全套件收集(fcntl/grp/SO_PEERCRED)→ POSIX spot checks(mode-bit/symlink/trusted-builder CAP 契约/禁 ProtectProc/ProcSubset 回卷)→ ledger ADJUDICATE=0 复核
|
||||
- `LINUX_TRACK_ACCEPTANCE.md` — G1–G10 验收清单
|
||||
- 本机 dry-run:15/15 文件 sha256 校验 OK;bash 语法 OK。
|
||||
- **Linux 实跑**(G1–G10)待 Linux 主机/远端 `hw` 受控执行。
|
||||
|
||||
## 4. 待继续及 gate 依赖
|
||||
|
||||
| 项 | 状态 | gate 依赖 |
|
||||
|---|---|---|
|
||||
| Evidence Producer 真实执行闭环 | ✅ 代码+契约测试完成(trusted-provider receipt + runtime registration,10+5 passed);真实输入闭环 | **Linux/生产实跑** → Gate R evidence closure |
|
||||
| materializer 字节投影 | ✅ `_copy_exact` 已加 `O_BINARY`(Task 12) | Linux 实跑复跑确认 |
|
||||
| Merge ledger ADJUDICATE=0 | ✅ 完成(Task 13)——192 SUPERSEDED_BY(均 40hex)+ 20 DROP + 4 ARCHIVED,MERGE=0 | freeze/closure |
|
||||
| CAP_SYS_PTRACE 材料 Git 化 | ✅ 完成(Task 10,`d4f181f`) | **Linux 主机实跑**(systemd/runtime 三态) |
|
||||
| Linux immutable 测试包 prep | ✅ 完成(Task 15)——manifest+sha256+runner+清单 | **Linux 实跑** G1–G10 |
|
||||
| POSIX mode-bit / fcntl / grp / symlink / SO_PEERCRED 测试 | Windows skipped / collection-error | **Linux 实跑 / CI** |
|
||||
| **P0_FINAL_HEAD 生成 + `git diff 49d30a2..P0_FINAL_HEAD`** | 本文档更新后 freeze commit | §13 |
|
||||
| P0 final gate(§14)全 TRUE → `INTEGRATION_DIFF_FROZEN` | Linux 实跑 + freeze 后 | §14 |
|
||||
| Gate W(§15)freeze + 正式 integration task | 待 freeze | §15 |
|
||||
| Gate W / Gate R finalize | — | **Owner 密码学签名** |
|
||||
| main merge / tag / baseline / staging / production | — | Owner / protected PR / 远端 Linux |
|
||||
|
||||
---
|
||||
|
||||
## 5. Round-3 Provider Authentication(ProviderAuth,P0 续)
|
||||
|
||||
**范围**:落地 `AIOS_V2R2C2_Round3_ProviderAuth_CodeBundle.zip`(SHA256=082f220da7e5fd19204d3eac2377d4ea00751e0db9b23f6fa58e5cad381b4ae0,manifest 逐文件校验一致),不重新设计认证方案。
|
||||
|
||||
**冻结基线**:
|
||||
```
|
||||
PREVIOUS_FREEZE_HEAD = a6dc0c3a63836a3668d972f632131f772626c363
|
||||
PROVIDER_AUTH_HEAD = f7f53d0568767d6deec2c47008a89f4d8413f93d
|
||||
PROVIDER_EXECUTOR_HEAD = 2d64d5a8a1dd63f77b1e5264037aac2a183c5d3d (tree 505a66a7)
|
||||
```
|
||||
|
||||
**接线(新增独立 Round-3 路径,保留 Round-2 链)**:
|
||||
- `trusted_provider_receipts.py` — fail-closed `TrustedProviderReceiptResolver`(root-owned-store-v1 / hmac-sha256-v1)硬安全门:absolute/traversal/symlink/owner-UID/mode/hardlink/max-size/identity/operation allowlist/canonical SHA256/source-tree OID/issued_at future-skew/max-age/HMAC compare_digest/authenticated provenance。
|
||||
- `governed_provider_auth_adapter.py` — receipt_ref-only;拒绝 `CALLER_SUPPLIED_PROVIDER_RECEIPT_DENIED`;无 resolver `PROVIDER_AUTHENTICATION_REQUIRED`;Gate-W(tests/gitea/secret_scan) vs Gate-R(canary/rollback) 分离。
|
||||
- `governed_authenticated_evidence_operation.py` — 生产 `produce_authenticated_evidence_closure` + `validate_gate_w_provider_auth`。
|
||||
- producer 新增注册 `governed.authenticated.release.evidence.produce`,`handler_ref` → `governed_authenticated_evidence_operation.produce_authenticated_evidence_closure`(文本 + verify 校验均满足);round-2 `producer_operation_definition()` 保留。
|
||||
- runtime 从 governed policy(`config/gitops/trusted-evidence-providers.json`)建 `TrustedProviderReceiptResolver`,新增 Gate-W / Gate-R 认证入口;禁止 request 提供 store_root/provider_identity/key_id/HMAC。
|
||||
- 二层 receipt binding:legacy v1 `receipt` 保留 + resolver 认证 v2 `provider_receipt` provenance 作为第二层输入(schema `aios.trusted-evidence-provider-receipt.v2`);未放宽任何拒绝路径。
|
||||
- `trusted_evidence_providers.py` — 正式 root trusted executor:`produce()` 逐 kind 走**真实执行路径**(tests→pytest+JUnit 聚合;secret_scan→gitleaks `--no-git` 文件系统扫描;gitea→需 API/token/repo 前提,缺则 fail-closed),`identity/operation` 来自 governed policy(绝不 acceptance 脚本伪造),receipt 由 `_write_receipt` root-owned 写入(nofollow/owner/mode 门,缺失 root 子目录自动创建),`content_sha256` 派生自真实工具输出。
|
||||
|
||||
**接受度实测**:
|
||||
- Linux(cn-network-mkt `hw`,Python 3.12.3 + pytest 9.1.1)集成闭环:bundle 24 passed · producer 10 · runtime 5 · materializer 5 · captrace/trusted-builder 30 · task-admission 106 · `verify_round3_integration.py` = **PASS**(合计 180 passed / 0 failed)。
|
||||
- Windows:round-2 producer+runtime 15 passed;bundle 23/1 failed 为**平台边界**(`os.getuid` absent、symlink 特权 WinError 1314、Python 3.13 `stat_result._fields` 移除、NTFS 无 POSIX mode/hardlink),非接口差异,Linux 已闭环。
|
||||
- materializer symlink 平台测试在 Windows 1 failed(既有,非回归)、Linux 5 passed(同 convergence §3b 平台边界记录)。
|
||||
|
||||
**commit**:`PROVIDER_AUTH_HEAD=f7f53d0`(10 files,+1436/−27);tree=`a1430cd8…`;随后 `PROVIDER_EXECUTOR_HEAD=2d64d5a8`(executor 机制 + 真实执行正确性修复 + 可复现 Gate-W prove 工具 `scripts/round3_provider_gatew_prove.py`)。
|
||||
|
||||
**Real provider 执行(hw Linux,root,最终绑定 `2d64d5a8`/`505a66a7`,经 `scripts/round3_provider_gatew_prove.py`)**:
|
||||
- `tests` → `aios.tests-runner`/`tests.run`:真实 pytest(1480 collected),JUnit 聚合 → `passed=false`(`failures=3`;`exit_code=1`;`ran_real_tests=true`)。3 个失败均为**既存环境性、非 Round-3 回归**:`test_artifact_digest_is_bound`(V2R1C6 artifact/version digest 绑定不一致)、`test_rollback_is_fail_closed_without_approval`(rollback 脚本依赖)、`test_real_signer_help_and_self_test_are_available`(缺部署 signer 二进制)。
|
||||
- `secret_scan` → `aios.secret-scanner`/`secret.scan`:真实 gitleaks `--no-git` 扫描 checkout → `passed=true`(`clean_run=true`,0 findings)。
|
||||
- `gitea` → `aios.gitea-machine-transport`:**`PROVIDER_MECHANISM_REQUIRED`**(API URL/token/repo identity 未 provisioned,fail-closed,未写任何失真 receipt)。
|
||||
- 闭环:`TrustedProviderReceiptResolver`(由 policy 构建)resolve 上述真实 receipt → **`GATE_W_PROVE={"closed":["tests","secret_scan"],"mechanism_required":["gitea"],"resolver_closure":"PASS"}`**(tests + secret_scan 均通过全部安全门:owner/mode/nofollow/OID/time/content_sha/authentication kind+store_id/operation allowlist)。
|
||||
- 过程修正:发现并修复 executor 的 vacuous-pass 缺陷(0-test JUnit 不再判 passed;gitleaks `--no-git` 文件系统扫描支持无 .git 导出树;secret_scan store_root 以 governed policy 为准,非 UI 拼接);全部经真实重跑验证。
|
||||
- 全程未伪造 `{"passed":true}`、未由 acceptance 脚本自造 identity;tests receipt 如实记录 `passed=false`(真实状态)。
|
||||
|
||||
**Gate W**:真实 provider executor 与两枚真实 receipt 已就绪(tests/secret-scan);gitea 因无真实 API/token 前提保持 `PROVIDER_MECHANISM_REQUIRED`(未伪造)。Gate-R canary/rollback 保持为空(未提前制造 fake)。Linux immutable package 重建 + Gate-W producer smoke(仅 receipt_ref)+ 正式 integration task 待本轮 freeze 后执行。
|
||||
|
||||
**状态标签**:`ROUND3_PROVIDER_AUTH_SOURCE_COMMITTED` + `ROUND3_PROVIDER_EXECUTOR_REAL`(tests/secret-scan 真实 receipt + resolver 闭环验证通过;gitea `PROVIDER_MECHANISM_REQUIRED`;Package 重建 + Gate-W smoke 待续)。
|
||||
|
||||
---
|
||||
|
||||
## 5b. Round-3 Gate-W Finalizer v2(Gate-W 三审计问题修复,P0 续)
|
||||
|
||||
**范围**:落地 `AIOS_V2R2C2_Round3_GateW_Finalizer_v2.zip`(SHA256=`c5f031967a7359b89398eccacb68c07162d92e1c5b2c5f4badf1a669c9f31ef6`,manifest 4 文件 sha256 逐文件一致)。三修复:
|
||||
|
||||
1. **Gitea 一致性用权威 Git transport**——不再依赖不存在的 REST API;用真实 `git remote get-url origin` / `git symbolic-ref --quiet HEAD` / `git ls-remote --exit-code origin <exact branch>` 证明。receipt 显式 `transport=git-ls-remote`、`api_verified=false`。
|
||||
2. **Receipt append-only 不可变**——每次 run 独立 namespace `round3-gate-w/<source[:12]>/<run-id>/<kind>.json`,旧 receipt 永不删除/覆盖;相同 ref 已存在则 `GITEA_PROVIDER_RECEIPT_EXISTS` fail-closed(O_EXCL+O_NOFOLLOW root-owned 写入)。
|
||||
3. **Gate 语义分离**——`provider_auth_closed` / `tests_result_passed` / `secret_scan_result_passed` / `gitea_consistent` / `gate_w_ready` 五元组独立;resolver/authentication PASS ≠ result PASS;`tests.result.passed=false` 保持 `gate_w_ready=false`。
|
||||
|
||||
**接线(新增/替换,均真实 git checkout,非 tar-export)**:
|
||||
- `active-runtime/app/gitea_transport_consistency_provider.py`(新增)——`collect_gitea_transport_consistency()` 校验 local HEAD==source、HEAD^{tree}==tree、origin identity==`aios/aios`、symbolic branch 为 `refs/heads/...`、`git ls-remote --exit-code` 返回 source;`produce_gitea_consistency_receipt()` 从 governed policy(`config/gitops/trusted-evidence-providers.json` 的 `gitea`)读 identity/store_id/store_root/operation/modes,exclusive-create 写 root-owned append-only receipt。
|
||||
- `scripts/round3_provider_gatew_prove_v2.py`(替换 `round3_provider_gatew_prove.py`,旧脚本已删)——前置强制真实 checkout(`HEAD^{commit}`==source、`{source}^{tree}`==tree、`status --porcelain` 空);append-only ref;gitea 走 `produce_gitea_consistency_receipt`,tests/secret_scan 走 `produce`;summary 五元组;RC 0/1/2/3(0 全绿 / 1 认证或一致性失败 / 2 机制缺失 / 3 认证 receipt 在而某 result gate 红)。
|
||||
- `tests/test_gitea_transport_consistency_provider.py`(新增)——mock `GitRunner`:identity 解析、exact-ref pass、remote/local/repo mismatch denied、remote failure denied(6 用例,命名分支与 governed 分支一致)。
|
||||
- `trusted_evidence_providers.py` / `trusted_provider_receipts.py`(既有,兼容保留)——gitea 分支抛 `PROVIDER_MECHANISM_REQUIRED` 路径仍在(prove v2 的 gitea 走独立路径不触发);resolver 安全门不变。
|
||||
|
||||
**§11/§12 三 tests 真实失败收敛(不改任何 expected assertion,未建 skip/allowlist,未用 known-baseline contract)**:
|
||||
|
||||
| 失败项 | 判定 | 证据 |
|
||||
|---|---|---|
|
||||
| `test_artifact_digest_is_bound`(V2R1C6 artifact/version digest) | **假失败**(CRLF 工作树污染) | worktree artifact CRLF → sha256=`a744fc5a`;LF 归一化后=`437b1f89`=metadata 精确一致;HEAD LF blob 自洽。干净 LF checkout 通过。 |
|
||||
| `test_rollback_is_fail_closed_without_approval` | **假失败**(旧 tar-export 污染 checkout) | `sh scripts/rollback_v2r1c6_release.sh` 无 approval 本地实跑返回 `42` + stderr `BLOCKED: rollback approval required`=assertions 精确;hw 旧树 `set: Illegal option -` 系污染 checkout 残留 CRLF。 |
|
||||
| `test_real_signer_help_and_self_test_are_available` | **真实部署配置 bug(已修复)** | `/usr/local/libexec/aios-grant-signer-adapter self-test` 返回 42:`allowed_signers_file`(`/etc/aios/trust/aios-task-grant-allowed-signers`) 内 `aios-task-grant-signer` 公钥 fp=`rPqpxf…` ≠ 真实凭据/`root_public_key_file` fp=`/nsGZD…`。已将 allowed_signers 条目就地同步为真实凭据公钥(备份 `.bak-20260909`),自测 `SIGNER_SELF_TEST=PASS`、rc=0。 |
|
||||
|
||||
**FINAL_SOURCE_HEAD / TREE(Gate-W prove 唯一绑定坐标)**:
|
||||
```
|
||||
FINAL_SOURCE_HEAD = 本最终源码 freeze commit 的 commit OID(push exact governed branch 后由 git rev-parse HEAD 定)
|
||||
FINAL_SOURCE_TREE = FINAL_SOURCE_HEAD^{tree}
|
||||
```
|
||||
时序(对应执行要求 §5/§8):源码+docs 全部完成 → 最后源码 freeze commit 得 FINAL_SOURCE_HEAD/TREE → push exact governed branch → 之后 source branch **不再 commit** package/docs(package 进 artifact store / detached)。所有三类 provider receipt(tests/secret_scan/gitea)的 `source_oid`/`tree_oid` 都必须 == FINAL_SOURCE_HEAD/TREE。prove v2 以显式 `--source-oid FINAL_SOURCE_HEAD --tree-oid FINAL_SOURCE_TREE` 在与之逐字节一致的干净 git checkout 上生成/校验 receipt,杜绝 tar-export 假树。
|
||||
|
||||
**Gate-W 实跑(首轮,clean Linux checkout)与 harness 修复(re-freeze)**:
|
||||
- 首轮 prove v2(绑定 c8ea953)结果 `GATE_W_PROVE={"authenticated_providers":["gitea","secret_scan","tests"],"provider_auth_closed":true,"gitea_consistent":true,"secret_scan_result_passed":true,"tests_result_passed":false,"mechanism_required":[],"gate_w_ready":false}`,RC=3。**重大进展**:Gitea 由原先 `PROVIDER_MECHANISM_REQUIRED` 变为真实 git-transport 认证(`transport=git-ls-remote`、`api_verified=false`、remote_ref_oid==FINAL_SOURCE_HEAD)。
|
||||
- tests 唯一剩余失败 = `tests.baseline_v1_1.test_job_safety::test_subprocess_crash_after_durable_sink_success_recovers_without_repeat`(1486 tests,failures=1)。根因:`run_tests_provider` 调用 pytest 未置 `PYTHONPATH=active-runtime/app`,子进程式测试找不到仓库 runtime 模块(`ModuleNotFoundError: aios_gitops_job_executor`)→ 假失败。同测试置 PYTHONPATH 后 **1 passed**。仓库权威 runner `tools/baseline_v1_1/run_acceptance.py` 与 `docs/implementation-baseline-1.1/ACCEPTANCE_RESULTS.md` 均要求向子进程传递源码 PYTHONPATH。
|
||||
- **修复(非 skip/allowlist,不改任何 assertion)**:`trusted_evidence_providers.py` 的 `_run` 增加 `extra_env`,新增 `_tests_env()` 转发 `PYTHONPATH=<repo_root>;<repo_root>/active-runtime/app` 至 pytest collect 与执行两个子进程;新增 `tests/test_trusted_evidence_providers_harness.py`(2 passed,hw 验证)锁定该 harness 行为。
|
||||
- 因该源码修复,FINAL_SOURCE_HEAD **re-freeze** 至本 freeze commit(push 前次 c8ea953 现为中间态,最终以 re-freeze OID 为准),并重新完整 prove。
|
||||
@@ -0,0 +1,64 @@
|
||||
# ADR: AIOS Control-Plane System Truth and Execution Integrity
|
||||
|
||||
## Status
|
||||
Proposed implementation baseline for ANNEX-2.3 hardening.
|
||||
|
||||
## Problem
|
||||
Historical failures show that multiple planes can each report locally valid state while the end-to-end goal is false: Admission can be ADMITTED while TaskPlane has no claim; a task can be QUEUED until its grant expires; a worker can have matching capability names but an exact scope for another job; OpenAPI can advertise a route absent from the deployed backend; and PASS labels can be emitted without production evidence.
|
||||
|
||||
## Decision
|
||||
Do not create a third persistence plane. Keep one authority per state dimension and derive `SystemTruth` read-only.
|
||||
|
||||
| State dimension | Authority |
|
||||
|---|---|
|
||||
| envelope / grant / admission receipt | Task Admission |
|
||||
| task lifecycle / claim / attempt / heartbeat / deadline | TaskPlane |
|
||||
| writer lease | governed writer / lease authority |
|
||||
| source bytes / branch and commit OIDs | Git authority |
|
||||
| deployed source / served routes | runtime |
|
||||
| external ChatGPT Action operations | external projection readback |
|
||||
|
||||
### 1. Admission is not execution
|
||||
`ADMITTED` means authorization has been accepted. It must never be presented as running, claimed, committed, or complete.
|
||||
|
||||
### 2. Schedulability before QUEUED
|
||||
Before TaskPlane projection to `QUEUED`, produce a fresh `SchedulabilityProof` containing:
|
||||
- required capabilities
|
||||
- exact task/job/branch/execution-digest scope
|
||||
- at least one healthy eligible worker
|
||||
- current grant/principal viability
|
||||
- dependencies/freeze state
|
||||
|
||||
If no eligible worker exists, keep execution projection in `WAITING_CAPACITY` (Admission may remain ADMITTED). Do not burn a short one-time grant while the task is waiting for capacity.
|
||||
|
||||
### 3. Mint runtime authority just-in-time
|
||||
Separate long-lived Owner authorization from short-lived runtime TaskPrincipal. Mint the execution principal at claim time or immediately before claim, not at admission time. This prevents 15-minute grants expiring in a queue with no eligible worker.
|
||||
|
||||
### 4. Exact-scope is mandatory
|
||||
Capabilities alone never establish eligibility. A worker is eligible only when `task_id`, `external_job_id`, `target_branch`, `generation`, and `execution_digest` are within its signed scope.
|
||||
|
||||
### 5. No fake execution
|
||||
Transitions require evidence:
|
||||
- CLAIMED: claim holder + fencing generation + lease readback
|
||||
- RUNNING: CLAIMED + fresh heartbeat + valid lease
|
||||
- SUCCEEDED: commit receipt + pre/post OID change + planned file hashes + runtime readback
|
||||
- SCENARIO_ACCEPTED: SUCCEEDED + external projection readback
|
||||
|
||||
`service active`, `drift_status=PASS`, focused tests, or an Admission receipt are not substitutes.
|
||||
|
||||
### 6. Real bytes, not placeholders
|
||||
Execution plans must reference content-addressed bytes that exist before admission. Hash-only plans with missing bytes, zero-length files, TODO/pass placeholders, or mismatched hashes are rejected.
|
||||
|
||||
### 7. One source for route contracts
|
||||
Backend route registry and external OpenAPI must derive from one canonical route manifest, or be parity-checked in CI. Schema-only deployment is blocked if the matching backend route is absent; backend-only deployment is blocked if the external projection is absent when required.
|
||||
|
||||
### 8. Dual-plane reconciliation
|
||||
Projection is idempotent and read-back verified. Admission writes an append-only projection intent/outbox in the same transaction as the grant; a projector writes TaskPlane, reads back exact task identity/revision, and records a receipt. Divergence is a first-class failure, never silently masked.
|
||||
|
||||
## Required release gates
|
||||
- no BLOCKER from control-plane consistency audit
|
||||
- C4 regression: legacy task creation path remains TaskPrincipal-gated
|
||||
- admission -> CLAIMED -> RUNNING -> WriterLease -> governed commit -> readback integration test
|
||||
- real activation + real SSH signature host test
|
||||
- external projection readback test
|
||||
- no placeholder artifact findings
|
||||
@@ -0,0 +1,33 @@
|
||||
# Remediation Work Packages
|
||||
|
||||
## P0-A — Single System Truth (no new DB)
|
||||
Implement the read-only SystemTruth derivation in the runtime explain/status surface. Remove or rename PASS labels that are configuration-only.
|
||||
|
||||
## P0-B — Schedulability Gate
|
||||
Admission may authorize a task, but TaskPlane projection to QUEUED requires a fresh SchedulabilityProof. If no eligible worker exists, expose WAITING_CAPACITY and do not start the short runtime grant TTL.
|
||||
|
||||
## P0-C — Just-in-time TaskPrincipal
|
||||
Keep Owner authorization durable; mint short-lived TaskPrincipal only when an eligible worker is ready to claim. Bind task/job/branch/generation/execution_digest exactly.
|
||||
|
||||
## P0-D — Evidence-required state transitions
|
||||
Guard CLAIMED/RUNNING/SUCCEEDED with claim holder, heartbeat, lease, commit receipt, OID change, file hashes, runtime readback, external projection readback.
|
||||
|
||||
## P0-E — Artifact reality gate
|
||||
Before admission, verify every planned file byte exists, is non-placeholder, and matches SHA256. Reject hash-only or placeholder plans.
|
||||
|
||||
## P0-F — Route/projection single source
|
||||
Generate or verify ChatGPT Actions OpenAPI from the backend route manifest. Block dead endpoints and unprojected required operations.
|
||||
|
||||
## P0-G — Dual-plane durable projection
|
||||
Use append-only projection intent + idempotent TaskPlane write + exact readback receipt. Admission status and execution status remain separate.
|
||||
|
||||
## P0-H — Worker scope/schedulability diagnostics
|
||||
Eligibility = capability AND heartbeat AND exact signed scope. Never select a worker only because capability names match.
|
||||
|
||||
## P0-I — QA gaps
|
||||
Add:
|
||||
1. C4 legacy createGovernedTask/projectOnlineAgentExecution remains TaskPrincipal-gated.
|
||||
2. `_verified_task_principal` body injection negative test.
|
||||
3. bearer-only no-delegation negative test.
|
||||
4. real CLAIMED/RUNNING/WriterLease/commit readback path.
|
||||
5. real activation/SSH signature host test.
|
||||
@@ -410,7 +410,7 @@ paths:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Submit one atomic governed commit containing multiple files
|
||||
security: [{taskPrincipalAuth: []}]
|
||||
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.
|
||||
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:
|
||||
@@ -782,3 +782,47 @@ paths:
|
||||
'200': {description: Safe GitOps plan}
|
||||
'401': {description: Unauthorized}
|
||||
x-openai-isConsequential: true
|
||||
/reopenOwnerApproval:
|
||||
post:
|
||||
operationId: reopenOwnerApproval
|
||||
summary: Reopen one expired Owner approval cycle without rebuilding release bytes
|
||||
security:
|
||||
- bearerAuth: []
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- schema
|
||||
- candidate_id
|
||||
- expected_approval_fingerprint
|
||||
- expected_source_commit
|
||||
- expected_tree_oid
|
||||
- expected_artifact_sha256
|
||||
- expected_manifest_sha256
|
||||
- expected_release_plan_sha256
|
||||
- expected_policy_sha256
|
||||
- reason
|
||||
- idempotency_key
|
||||
properties:
|
||||
schema:
|
||||
type: string
|
||||
enum: [aios.owner-approval-reopen-request.v1]
|
||||
candidate_id: {type: string}
|
||||
expected_approval_fingerprint: {type: string, pattern: "^[0-9a-f]{64}$"}
|
||||
expected_source_commit: {type: string, pattern: "^[0-9a-f]{40}$"}
|
||||
expected_tree_oid: {type: string, pattern: "^[0-9a-f]{40}$"}
|
||||
expected_artifact_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
||||
expected_manifest_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
||||
expected_release_plan_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
||||
expected_policy_sha256: {type: string, pattern: "^[0-9a-f]{64}$"}
|
||||
reason: {type: string, minLength: 1, maxLength: 512}
|
||||
idempotency_key: {type: string, minLength: 8, maxLength: 128}
|
||||
responses:
|
||||
"200": {description: Reopened or idempotent replay}
|
||||
"403": {description: Missing release:approve capability}
|
||||
"409": {description: Governed reopen gate rejected}
|
||||
|
||||
@@ -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,112 @@
|
||||
# Trusted Candidate Builder — CAP_SYS_PTRACE security contract
|
||||
|
||||
Status: formal (V2R2C2) · Applies to:
|
||||
`deploy/systemd/aios-trusted-candidate-builder.service` (the live baseline unit)
|
||||
|
||||
## 0. Live baseline (rea truth, not nominal)
|
||||
|
||||
The authoritative deployed unit is the live systemd unit on the `hw` control
|
||||
plane host:
|
||||
|
||||
- `User=root` / `Group=root` (the builder runs as root)
|
||||
- socket: `aios-trusted-candidate-builder.socket`
|
||||
`ListenStream=/run/aios-trusted-candidate-builder.sock`,
|
||||
`SocketUser=root`, `SocketGroup=gitops`, `SocketMode=0660`
|
||||
- credentials: `LoadCredentialEncrypted` for `signing_private_key` /
|
||||
`builder_hmac` (under `/etc/credstore.encrypted/aios-trusted-candidate-builder/`)
|
||||
and `source_admission_hmac` (under
|
||||
`/etc/credstore.encrypted/aios-release-source-admission/`)
|
||||
- hardening: `NoNewPrivileges`, `PrivateTmp`, `PrivateNetwork`,
|
||||
`ProtectSystem=strict`, `ProtectHome`, `ProtectControlGroups`,
|
||||
`ProtectKernelTunables/Modules/Logs`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`LockPersonality`, `MemoryDenyWriteExecute`, `RestrictSUIDSGID`,
|
||||
`SystemCallArchitectures=native`
|
||||
- capability: `AmbientCapabilities=CAP_SYS_PTRACE` +
|
||||
`CapabilityBoundingSet=CAP_SYS_PTRACE` (live current state)
|
||||
|
||||
The tracked `deploy/systemd/aios-trusted-candidate-builder.service` is kept
|
||||
byte-identical (LF-normalized) to the live unit so sha256 drift is detectable:
|
||||
live sha256 = `9d194c23ec981528d29d55f601d1be858635ac6229c7e25c10736df4e44042d3`.
|
||||
|
||||
> This contract deliberately does NOT introduce a separate non-root
|
||||
> `-ptrace` unit. §2 of the V2R2C2 instruction forbids inventing a distinct
|
||||
> service uid/group, `ProtectProc`/`ProcSubset`, credential paths or socket
|
||||
> ownership that diverge from the live baseline. The live baseline is root +
|
||||
> the single minimal capability, and that is the only variant tracked.
|
||||
|
||||
## 1. Why a capability is declared at all
|
||||
|
||||
`TrustedCandidateBuilder._verify_peer` validates the connecting peer before
|
||||
signing any candidate. It reads three `/proc/<pid>` views of the peer:
|
||||
|
||||
| view | function | failure → error |
|
||||
|---|---|---|
|
||||
| `/proc/<pid>/cgroup` | `_resolve_cgroups` | `PEER_CGROUP_UNAVAILABLE` |
|
||||
| `/proc/<pid>/exe` | `_resolve_process_identity` | `PEER_PROCESS_UNAVAILABLE` |
|
||||
| `/proc/<pid>/cmdline` | `_resolve_process_identity` | `PEER_PROCESS_UNAVAILABLE` |
|
||||
|
||||
A cross-uid `readlink("/proc/<pid>/exe")` for a peer of a **different uid** is
|
||||
subject to the Yama `ptrace_scope` sysctl and returns `EACCES` for a process
|
||||
without the minimal `CAP_SYS_PTRACE`. Result: the builder fails closed
|
||||
(`PEER_PROCESS_UNAVAILABLE`), which is safe but loses availability for
|
||||
legitimate cross-uid peers.
|
||||
|
||||
The minimal `CAP_SYS_PTRACE` restores the ability to *inspect* the peer's
|
||||
`/proc` view across uids.
|
||||
|
||||
## 2. Capability sensitivity — MANDATORY reading
|
||||
|
||||
`CAP_SYS_PTRACE` is a **high-sensitivity capability**. It is not a read-only
|
||||
helper: it broadens the process's observation and debugging authority across
|
||||
uids, including `ptrace`, reading `/proc/<pid>/mem`, and inspecting another
|
||||
user's process state. It is therefore:
|
||||
|
||||
- limited to the **single capability** `CAP_SYS_PTRACE` (both
|
||||
`AmbientCapabilities` and `CapabilityBoundingSet`), and
|
||||
- scoped to the dedicated builder service unit only.
|
||||
|
||||
It **must never** be used to imply "read-only" or "no control authority." It
|
||||
carries real inspection/debug power and is declared for one concrete purpose:
|
||||
restoring cross-uid `/proc/<pid>/exe` identity resolution for the builder's
|
||||
peer validation. Nothing more.
|
||||
|
||||
### Three-state verification contract
|
||||
|
||||
- **A. No CAP_SYS_PTRACE (fail-closed):** cross-uid `/proc/<pid>/exe` raises
|
||||
`PermissionError(EACCES)` → resolvers fail closed →
|
||||
`PEER_PROCESS_UNAVAILABLE` (and `PEER_CGROUP_UNAVAILABLE` for cgroup).
|
||||
Never degrade to allow.
|
||||
- **B. Minimal CAP_SYS_PTRACE + legitimate peer:** peer uid in
|
||||
`allowed_peer_uid`, cgroup in `allowed_peer_cgroups`, executable matches
|
||||
`allowed_peer_executable_prefix`, argv matches the runtime argument
|
||||
contract → readiness `READY`.
|
||||
- **C. CAP present but peer illegitimate:** any identity check fails →
|
||||
`PEER_IDENTITY_DENIED` / `PEER_CGROUP_DENIED` / `PEER_PROCESS_DENIED`.
|
||||
The capability **must never bypass the identity policy** — it restores the
|
||||
ability to perform identity validation, not the authorization itself.
|
||||
|
||||
## 3. Operational rules
|
||||
|
||||
- Keep `CapabilityBoundingSet` and `AmbientCapabilities` pinned to exactly
|
||||
`CAP_SYS_PTRACE`. Never use capability wildcards (e.g. `CAP_*` applying to
|
||||
every capability).
|
||||
- Keep `NoNewPrivileges=true`; ambient capabilities are the only inheritance
|
||||
path into the exec'd Python process.
|
||||
- Do not re-introduce a separate non-root unit with a different
|
||||
uid/group/`ProtectProc`/`ProcSubset`/credential paths/socket ownership
|
||||
(see §0). The root + single-capability unit is the live baseline.
|
||||
- Runtime verification (three-state) MUST be executed on the real Linux host
|
||||
that runs systemd; the adversarial states require real uids/cgroups/argv.
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- Local (no `/proc` peer needed): the resolvers are exercised with
|
||||
monkeypatched `PermissionError(EACCES)` — mirroring the exact bytes a
|
||||
missing `CAP_SYS_PTRACE` / cross-uid read produces — covering
|
||||
`cgroup → PEER_CGROUP_UNAVAILABLE`, `exe → PEER_PROCESS_UNAVAILABLE`,
|
||||
`cmdline → PEER_PROCESS_UNAVAILABLE` (see
|
||||
`tests/test_trusted_candidate_builder_captrace.py`).
|
||||
- Host (Linux, required for final acceptance): run `systemd-analyze verify`
|
||||
on the tracked unit; confirm the unit matches the live sha256; launch
|
||||
with/without the capability; confirm A → `PEER_PROCESS_UNAVAILABLE`,
|
||||
B → `READY`, C → `*_DENIED`.
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import argparse, json, sys, time, re
|
||||
from aios_control_plane_consistency import *
|
||||
|
||||
def jload(value):
|
||||
try:
|
||||
return json.loads(value or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def rows(path, query, params=()):
|
||||
try:
|
||||
return sqlite_rows(path, query, params)
|
||||
except Exception as exc:
|
||||
return [{"_error": str(exc)}]
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="AIOS read-only control-plane consistency auditor")
|
||||
ap.add_argument("--task-id", required=True)
|
||||
ap.add_argument("--admission-db", type=Path, default=Path("/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3"))
|
||||
ap.add_argument("--task-db", type=Path, default=Path("/var/lib/aios-agent-task/state.sqlite3"))
|
||||
ap.add_argument("--git-dir", type=Path, default=Path("/data/aios-gitops/repo.git"))
|
||||
ap.add_argument("--branch")
|
||||
ap.add_argument("--openapi", type=Path)
|
||||
ap.add_argument("--server-source", type=Path)
|
||||
ap.add_argument("--artifact-root", type=Path)
|
||||
ap.add_argument("--now", type=int)
|
||||
ap.add_argument("--out", type=Path, default=Path("aios-control-plane-audit.json"))
|
||||
ns = ap.parse_args()
|
||||
now = ns.now or int(time.time())
|
||||
findings = []
|
||||
admission = None
|
||||
task = None
|
||||
workers = []
|
||||
lease = None
|
||||
git = None
|
||||
evidence = EvidenceSnapshot()
|
||||
|
||||
# Admission plane.
|
||||
try:
|
||||
receipt = sqlite_rows(ns.admission_db,
|
||||
"SELECT * FROM task_admission_receipts WHERE task_id=? ORDER BY rowid DESC LIMIT 1", (ns.task_id,))
|
||||
if receipt:
|
||||
r = dict(receipt[0])
|
||||
envelope_id = r.get("envelope_id")
|
||||
env = sqlite_rows(ns.admission_db,
|
||||
"SELECT * FROM task_admission_envelopes WHERE envelope_id=? LIMIT 1", (envelope_id,))
|
||||
grant = sqlite_rows(ns.admission_db,
|
||||
"SELECT * FROM task_admission_grants WHERE envelope_id=? ORDER BY rowid DESC LIMIT 1", (envelope_id,))
|
||||
ed = dict(env[0]) if env else {}
|
||||
gd = dict(grant[0]) if grant else {}
|
||||
gj = jload(gd.get("grant_json"))
|
||||
ej = jload(ed.get("envelope_json"))
|
||||
admission = AdmissionSnapshot(
|
||||
task_id=ns.task_id,
|
||||
envelope_id=envelope_id,
|
||||
admission_status=ed.get("status"),
|
||||
grant_id=gd.get("grant_id"),
|
||||
grant_expires_at=gj.get("expires_at"),
|
||||
one_time_use=gj.get("one_time_use"),
|
||||
authority_base_oid=r.get("authority_base_oid") or ej.get("authority_base_oid"),
|
||||
execution_digest=r.get("execution_digest") or ej.get("execution_digest"),
|
||||
requested_capabilities=tuple(ej.get("requested_capabilities") or ()),
|
||||
file_allowlist=tuple(ej.get("file_allowlist") or ()),
|
||||
admitted_at=r.get("created_at") or r.get("admitted_at"),
|
||||
)
|
||||
except Exception as exc:
|
||||
findings.append(Finding("ADMISSION_PLANE_UNREADABLE", Severity.BLOCKER, str(exc)))
|
||||
|
||||
# TaskPlane.
|
||||
try:
|
||||
trows = sqlite_rows(ns.task_db, "SELECT * FROM tasks WHERE task_id=? LIMIT 1", (ns.task_id,))
|
||||
if trows:
|
||||
td = dict(trows[0])
|
||||
meta = jload(td.get("metadata_json"))
|
||||
required = jload(td.get("required_capabilities_json"))
|
||||
if isinstance(required, dict):
|
||||
required = required.get("capabilities") or []
|
||||
task = TaskSnapshot(
|
||||
task_id=ns.task_id,
|
||||
status=td.get("status"),
|
||||
claimed_by=td.get("claimed_by"),
|
||||
lease_expires_at=td.get("lease_expires_at"),
|
||||
attempt=td.get("attempt"),
|
||||
revision=td.get("revision"),
|
||||
created_at=td.get("created_at"),
|
||||
updated_at=td.get("updated_at"),
|
||||
deadline=td.get("deadline"),
|
||||
required_capabilities=tuple(required or ()),
|
||||
metadata=meta if isinstance(meta, dict) else {},
|
||||
)
|
||||
arows = sqlite_rows(ns.task_db, "SELECT * FROM agents")
|
||||
for row in arows:
|
||||
d = dict(row)
|
||||
caps = jload(d.get("capabilities_json"))
|
||||
if isinstance(caps, dict):
|
||||
caps = caps.get("capabilities") or []
|
||||
last = parse_iso_epoch(d.get("last_seen_at"))
|
||||
workers.append(WorkerSnapshot(
|
||||
worker_id=d.get("agent_id") or d.get("worker_id") or "unknown",
|
||||
status=d.get("status"),
|
||||
last_seen_epoch=last,
|
||||
capabilities=tuple(caps or ()),
|
||||
))
|
||||
except Exception as exc:
|
||||
findings.append(Finding("TASK_PLANE_UNREADABLE", Severity.BLOCKER, str(exc)))
|
||||
|
||||
branch = ns.branch
|
||||
if not branch and task and isinstance(task.metadata, dict):
|
||||
plan = task.metadata.get("execution_plan") or task.metadata.get("plan") or task.metadata
|
||||
if isinstance(plan, dict):
|
||||
branch = plan.get("target_branch")
|
||||
if branch:
|
||||
current = git_ref(ns.git_dir, f"refs/heads/{branch}")
|
||||
git = GitSnapshot(branch=branch, current_oid=current, expected_oid=admission.authority_base_oid if admission else None)
|
||||
if admission and current and admission.authority_base_oid and current != admission.authority_base_oid:
|
||||
findings.append(Finding(
|
||||
"BRANCH_BINDING_DRIFT",
|
||||
Severity.BLOCKER,
|
||||
"Current branch OID differs from Admission authority_base_oid.",
|
||||
{"current": current, "bound": admission.authority_base_oid},
|
||||
"Replan/rebind formally; do not reuse old execution_digest.",
|
||||
))
|
||||
|
||||
truth, derived = derive_truth(admission, task, workers, lease, git, evidence, now)
|
||||
findings.extend(derived)
|
||||
if task:
|
||||
findings.extend(check_capability_aliases(task.required_capabilities, workers))
|
||||
|
||||
# Plan/artifact integrity if the immutable plan points to files.
|
||||
if task and ns.artifact_root:
|
||||
plan = task.metadata.get("execution_plan") or task.metadata.get("plan") or task.metadata
|
||||
if isinstance(plan, dict):
|
||||
hashes = plan.get("file_sha256") or {}
|
||||
for rel in plan.get("file_allowlist") or []:
|
||||
findings.extend(inspect_artifact(ns.artifact_root / rel, hashes.get(rel)))
|
||||
|
||||
if ns.openapi and ns.server_source:
|
||||
findings.extend(check_openapi_backend_parity(
|
||||
ns.openapi, ns.server_source,
|
||||
required_ops=("getTaskAdmissionEnvelope","submitTaskAdmissionAuthorization","getTaskAdmissionStatus"),
|
||||
))
|
||||
|
||||
report = {
|
||||
"schema": "aios.control-plane-consistency-audit.v1",
|
||||
"observed_at": now,
|
||||
"task_id": ns.task_id,
|
||||
"truth_state": truth.value,
|
||||
"admission": asdict(admission) if admission else None,
|
||||
"task": asdict(task) if task else None,
|
||||
"eligible_workers": [asdict(w) for w in eligible_workers(task, workers, now)] if task else [],
|
||||
"git": asdict(git) if git else None,
|
||||
"findings": [asdict(f) for f in findings],
|
||||
"summary": {
|
||||
"blockers": sum(f.severity == Severity.BLOCKER for f in findings),
|
||||
"majors": sum(f.severity == Severity.MAJOR for f in findings),
|
||||
"minors": sum(f.severity == Severity.MINOR for f in findings),
|
||||
},
|
||||
}
|
||||
dump_report(ns.out, report)
|
||||
print(json.dumps(report["summary"], sort_keys=True))
|
||||
return 2 if report["summary"]["blockers"] else 1 if report["summary"]["majors"] else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
from aios_control_plane_consistency import *
|
||||
NOW=2_000_000_000
|
||||
|
||||
scenarios = {}
|
||||
|
||||
a=AdmissionSnapshot(task_id="T",admission_status="ADMITTED",grant_expires_at=NOW+60)
|
||||
scenarios["dual_plane_missing_task"]=derive_truth(a,None,[],None,None,None,NOW)
|
||||
|
||||
t=TaskSnapshot(task_id="T",status="QUEUED",required_capabilities=("writer.lease",),created_at="2033-05-18T03:23:20+00:00")
|
||||
scenarios["queued_no_worker"]=derive_truth(a,t,[],None,None,None,NOW)
|
||||
|
||||
t2=TaskSnapshot(task_id="T",status="RUNNING",claimed_by="ghost")
|
||||
scenarios["fake_running"]=derive_truth(a,t2,[],None,None,None,NOW)
|
||||
|
||||
t3=TaskSnapshot(task_id="T",status="SUCCEEDED")
|
||||
scenarios["fake_success"]=derive_truth(a,t3,[],None,GitSnapshot("agent/x",expected_oid="a"*40),EvidenceSnapshot(),NOW)
|
||||
|
||||
for name,(state,findings) in scenarios.items():
|
||||
print(name,state.value,[f.code for f in findings])
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Round-3 Gate-W proof using immutable, append-only provider receipts.
|
||||
|
||||
Requires a REAL clean Git checkout at FINAL_SOURCE_HEAD. It never deletes or
|
||||
overwrites old receipts. Each run writes a new namespace.
|
||||
|
||||
It distinguishes three dimensions:
|
||||
* provider authentication closure,
|
||||
* provider result gates (e.g. tests passed),
|
||||
* overall Gate-W readiness.
|
||||
|
||||
A resolver PASS never converts ``tests.result.passed=false`` into Gate-W PASS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(_APP))
|
||||
|
||||
from gitea_transport_consistency_provider import produce_gitea_consistency_receipt
|
||||
from trusted_evidence_providers import TrustedProviderExecutionError, _validate_oid, produce
|
||||
from trusted_provider_receipts import TrustedProviderPolicy, TrustedProviderReceiptResolver
|
||||
|
||||
GATE_W = ("tests", "secret_scan", "gitea")
|
||||
|
||||
|
||||
def mode(v):
|
||||
return v if isinstance(v, int) else int(str(v), 8)
|
||||
|
||||
|
||||
def build_resolver(policy_path):
|
||||
raw = json.loads(Path(policy_path).read_text(encoding="utf-8"))
|
||||
defaults = dict(raw.get("defaults") or {})
|
||||
policies = {}
|
||||
for name,cfg in dict(raw.get("providers") or {}).items():
|
||||
policies[name] = TrustedProviderPolicy(
|
||||
evidence_kind=name,
|
||||
provider_identity=str(cfg["provider_identity"]),
|
||||
store_id=str(cfg["store_id"]),
|
||||
store_root=Path(str(cfg["store_root"])),
|
||||
allowed_operations=frozenset(map(str,cfg["allowed_operations"])),
|
||||
expected_owner_uid=int(cfg.get("expected_owner_uid", defaults.get("expected_owner_uid",0))),
|
||||
allowed_directory_modes=frozenset(mode(x) for x in cfg.get("allowed_directory_modes",defaults.get("allowed_directory_modes",["0700","0750"]))),
|
||||
allowed_receipt_modes=frozenset(mode(x) for x in cfg.get("allowed_receipt_modes",defaults.get("allowed_receipt_modes",["0600","0640"]))),
|
||||
authentication_kind=str(cfg.get("authentication_kind",defaults.get("authentication_kind","root-owned-store-v1"))),
|
||||
max_age_seconds=cfg.get("max_age_seconds",defaults.get("max_age_seconds")),
|
||||
)
|
||||
return TrustedProviderReceiptResolver(policies), policies
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
p = subprocess.run(["git",*args],cwd=str(repo),text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
|
||||
if p.returncode:
|
||||
raise SystemExit(f"GIT_PRECHECK_FAILED {' '.join(args)}: {p.stderr.strip()}")
|
||||
return p.stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
ap=argparse.ArgumentParser()
|
||||
ap.add_argument("--source-oid",required=True)
|
||||
ap.add_argument("--tree-oid",required=True)
|
||||
ap.add_argument("--checkout",required=True)
|
||||
ap.add_argument("--policy",required=True)
|
||||
ap.add_argument("--run-id",default=None)
|
||||
args=ap.parse_args()
|
||||
|
||||
source=_validate_oid(args.source_oid,"source_oid")
|
||||
tree=_validate_oid(args.tree_oid,"tree_oid")
|
||||
repo=Path(args.checkout).resolve()
|
||||
if git(repo,"rev-parse","HEAD^{commit}").lower()!=source:
|
||||
raise SystemExit("FINAL_SOURCE_HEAD_MISMATCH")
|
||||
if git(repo,"rev-parse",f"{source}^{{tree}}").lower()!=tree:
|
||||
raise SystemExit("FINAL_SOURCE_TREE_MISMATCH")
|
||||
if git(repo,"status","--porcelain"):
|
||||
raise SystemExit("FINAL_SOURCE_WORKTREE_DIRTY")
|
||||
|
||||
run_id=args.run_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
resolver,policies=build_resolver(args.policy)
|
||||
os.environ["AIOS_REPO_ROOT"]=str(repo)
|
||||
os.environ.setdefault("AIOS_GITLEAKS_BIN","/usr/bin/gitleaks")
|
||||
|
||||
produced=[]
|
||||
mechanism_required=[]
|
||||
for kind in GATE_W:
|
||||
ref=f"round3-gate-w/{source[:12]}/{run_id}/{kind}.json"
|
||||
try:
|
||||
if kind=="gitea":
|
||||
sha,_payload=produce_gitea_consistency_receipt(
|
||||
repo_root=repo,
|
||||
source_oid=source,
|
||||
tree_oid=tree,
|
||||
policy_path=args.policy,
|
||||
receipt_ref=ref,
|
||||
)
|
||||
else:
|
||||
sha=produce(
|
||||
kind,
|
||||
source_oid=source,
|
||||
tree_oid=tree,
|
||||
receipt_ref=ref,
|
||||
store_root=policies[kind].store_root,
|
||||
)
|
||||
produced.append((kind,ref,sha))
|
||||
except TrustedProviderExecutionError as exc:
|
||||
if "PROVIDER_MECHANISM_REQUIRED" in str(exc):
|
||||
mechanism_required.append((kind,str(exc)))
|
||||
else:
|
||||
raise
|
||||
|
||||
auth_ok=True
|
||||
results={}
|
||||
for kind,ref,sha in produced:
|
||||
r=resolver.resolve(kind,ref,source_oid=source,tree_oid=tree)
|
||||
results[kind]=dict(r.result)
|
||||
print(f"[AUTH PASS] {kind} ref={ref} sha={sha} result={json.dumps(r.result,sort_keys=True)}")
|
||||
|
||||
expected=set(GATE_W)
|
||||
closed={k for k,_,_ in produced}
|
||||
auth_closed=auth_ok and closed==expected and not mechanism_required
|
||||
test_gate=bool(results.get("tests",{}).get("passed") is True)
|
||||
secret_gate=bool(results.get("secret_scan",{}).get("passed") is True)
|
||||
gitea_gate=bool(results.get("gitea",{}).get("consistent") is True)
|
||||
gate_ready=auth_closed and test_gate and secret_gate and gitea_gate
|
||||
|
||||
summary={
|
||||
"source_oid":source,
|
||||
"tree_oid":tree,
|
||||
"run_id":run_id,
|
||||
"authenticated_providers":sorted(closed),
|
||||
"mechanism_required":[k for k,_ in mechanism_required],
|
||||
"provider_auth_closed":auth_closed,
|
||||
"tests_result_passed":test_gate,
|
||||
"secret_scan_result_passed":secret_gate,
|
||||
"gitea_consistent":gitea_gate,
|
||||
"gate_w_ready":gate_ready,
|
||||
}
|
||||
print("GATE_W_PROVE="+json.dumps(summary,sort_keys=True))
|
||||
if mechanism_required:
|
||||
return 2
|
||||
if not auth_closed:
|
||||
return 1
|
||||
if not gate_ready:
|
||||
return 3
|
||||
return 0
|
||||
|
||||
if __name__=="__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,76 @@
|
||||
from pathlib import Path
|
||||
import time
|
||||
from aios_control_plane_consistency import *
|
||||
|
||||
NOW=2_000_000_000
|
||||
|
||||
def task(status="QUEUED", caps=("governed.write.files","task.principal.issue","writer.lease"), **kw):
|
||||
return TaskSnapshot(
|
||||
task_id="T", status=status, required_capabilities=tuple(caps),
|
||||
created_at="2033-05-18T03:23:20+00:00", metadata={
|
||||
"external_job_id":"job-a",
|
||||
"target_branch":"agent/x",
|
||||
"execution_digest":"d"*64,
|
||||
}, **kw
|
||||
)
|
||||
|
||||
def admission(**kw):
|
||||
base=dict(task_id="T", admission_status="ADMITTED", envelope_id="E", grant_expires_at=NOW+600)
|
||||
base.update(kw)
|
||||
return AdmissionSnapshot(**base)
|
||||
|
||||
def worker(worker_id="w", caps=("governed.write.files","task.principal.issue","writer.lease"), last_seen=NOW-10, **kw):
|
||||
return WorkerSnapshot(worker_id=worker_id,status="ACTIVE",last_seen_epoch=last_seen,capabilities=tuple(caps),**kw)
|
||||
|
||||
def test_admitted_without_taskplane_is_not_execution():
|
||||
state, fs=derive_truth(admission(),None,[],None,None,None,NOW)
|
||||
assert state==TruthState.ADMITTED_NOT_PROJECTED
|
||||
assert any(f.code=="DUAL_PLANE_ADMITTED_TASK_MISSING" for f in fs)
|
||||
|
||||
def test_queued_without_eligible_worker_is_waiting_capacity_not_real_execution():
|
||||
state, fs=derive_truth(admission(),task(),[],None,None,None,NOW)
|
||||
assert state==TruthState.WAITING_CAPACITY
|
||||
assert any(f.code=="QUEUED_WITHOUT_ELIGIBLE_WORKER" for f in fs)
|
||||
|
||||
def test_scope_mismatched_worker_is_not_eligible():
|
||||
w=worker(scope_task_id="OTHER")
|
||||
assert eligible_workers(task(),[w],NOW)==[]
|
||||
|
||||
def test_stale_worker_is_not_eligible():
|
||||
w=worker(last_seen=NOW-1000)
|
||||
assert eligible_workers(task(),[w],NOW)==[]
|
||||
|
||||
def test_capability_singular_plural_is_not_silent_alias():
|
||||
fs=check_capability_aliases(("governed.write.files",),[
|
||||
worker(caps=("governed.write.file","task.principal.issue","writer.lease"))
|
||||
])
|
||||
assert any(f.code=="CAPABILITY_SINGULAR_PLURAL_ASYMMETRY" for f in fs)
|
||||
|
||||
def test_claimed_without_claim_holder_is_fake():
|
||||
state, fs=derive_truth(admission(),task(status="CLAIMED"),[],None,None,None,NOW)
|
||||
assert state==TruthState.CLAIMED
|
||||
assert any(f.code=="FAKE_CLAIM_STATE" for f in fs)
|
||||
|
||||
def test_running_without_heartbeat_is_fake_execution():
|
||||
t=task(status="RUNNING",claimed_by="w")
|
||||
state, fs=derive_truth(admission(),t,[worker(last_seen=NOW-999)],LeaseSnapshot("T","w",NOW+60,"ACTIVE"),None,None,NOW)
|
||||
assert any(f.code=="FAKE_RUNNING_NO_HEARTBEAT" for f in fs)
|
||||
|
||||
def test_succeeded_without_commit_receipt_is_unproven():
|
||||
state, fs=derive_truth(admission(),task(status="SUCCEEDED"),[],None,GitSnapshot("agent/x",expected_oid="a"*40),EvidenceSnapshot(),NOW)
|
||||
assert state==TruthState.TERMINAL_UNPROVEN
|
||||
assert any(f.code=="TERMINAL_SUCCESS_UNPROVEN" for f in fs)
|
||||
|
||||
def test_expired_without_claim_is_schedulability_failure():
|
||||
state, fs=derive_truth(admission(grant_expires_at=NOW-1),task(status="EXPIRED",attempt=0),[],None,None,None,NOW)
|
||||
assert state==TruthState.EXPIRED
|
||||
assert any(f.code=="EXPIRED_WITHOUT_ANY_CLAIM" for f in fs)
|
||||
|
||||
def test_placeholder_artifact_is_rejected(tmp_path):
|
||||
p=tmp_path/"x.py"; p.write_text("TODO: real content\n",encoding="utf-8")
|
||||
fs=inspect_artifact(p)
|
||||
assert any(f.code=="PLACEHOLDER_ARTIFACT" for f in fs)
|
||||
|
||||
def test_hash_only_does_not_make_missing_artifact_real(tmp_path):
|
||||
fs=inspect_artifact(tmp_path/"missing.py","0"*64)
|
||||
assert any(f.code=="ARTIFACT_MISSING" for f in fs)
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aios_control_plane_consistency import (
|
||||
AdmissionSnapshot,
|
||||
EvidenceSnapshot,
|
||||
GitSnapshot,
|
||||
LeaseSnapshot,
|
||||
TaskSnapshot,
|
||||
WorkerSnapshot,
|
||||
)
|
||||
from aios_control_plane_snapshot import (
|
||||
attach_control_plane_truth,
|
||||
build_control_plane_snapshot,
|
||||
)
|
||||
|
||||
NOW = 2_000_000_000
|
||||
|
||||
|
||||
def _base():
|
||||
return build_control_plane_snapshot(
|
||||
target_version="V2R2C2",
|
||||
runtime={"drift_status": "PASS"},
|
||||
task={"task_id": "T", "reconciliation_state": "ok"},
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_base_has_no_derived_block_by_default():
|
||||
assert "derived" not in _base()
|
||||
|
||||
|
||||
def test_truth_attach_preserves_existing_keys():
|
||||
out = attach_control_plane_truth(_base(), now=NOW)
|
||||
assert set(_base().keys()) <= set(out.keys())
|
||||
assert "derived" in out
|
||||
|
||||
|
||||
def test_admitted_without_taskplane_is_admitted_not_projected():
|
||||
out = attach_control_plane_truth(
|
||||
_base(),
|
||||
admission=AdmissionSnapshot(task_id="T", admission_status="ADMITTED", envelope_id="E"),
|
||||
now=NOW,
|
||||
)
|
||||
truth = out["derived"]["system_truth"]
|
||||
assert truth["truth_state"] == "ADMITTED_NOT_PROJECTED"
|
||||
assert truth["blocker_count"] == 1
|
||||
assert any(f["code"] == "DUAL_PLANE_ADMITTED_TASK_MISSING" for f in truth["findings"])
|
||||
# A drift_status=PASS surface must NOT be reported as evidence-complete.
|
||||
assert truth["evidence_flow_confirmed"] is False
|
||||
|
||||
|
||||
def test_fake_success_is_terminal_unproven_even_under_drift_pass():
|
||||
out = attach_control_plane_truth(
|
||||
_base(),
|
||||
admission=AdmissionSnapshot(task_id="T"),
|
||||
task=TaskSnapshot(task_id="T", status="SUCCEEDED"),
|
||||
git=GitSnapshot("agent/x", expected_oid="a" * 40),
|
||||
evidence=EvidenceSnapshot(),
|
||||
now=NOW,
|
||||
)
|
||||
truth = out["derived"]["system_truth"]
|
||||
assert truth["truth_state"] == "TERMINAL_UNPROVEN"
|
||||
assert truth["blocker_count"] == 1
|
||||
assert truth["evidence_flow_confirmed"] is False
|
||||
|
||||
|
||||
def test_complete_requires_full_evidence_chain():
|
||||
worker = WorkerSnapshot(
|
||||
worker_id="w", status="ACTIVE", last_seen_epoch=NOW - 10,
|
||||
capabilities=("governed.write.files",),
|
||||
)
|
||||
task = TaskSnapshot(
|
||||
task_id="T", status="SUCCEEDED", claimed_by="w",
|
||||
required_capabilities=("governed.write.files",),
|
||||
metadata={"external_job_id": "job-a", "target_branch": "agent/x",
|
||||
"execution_digest": "d" * 64},
|
||||
)
|
||||
git = GitSnapshot("agent/x", expected_oid="a" * 40, commit_oid="b" * 40, post_oid="c" * 40)
|
||||
evidence = EvidenceSnapshot(runtime_readback={"ok": True})
|
||||
out = attach_control_plane_truth(
|
||||
_base(),
|
||||
admission=AdmissionSnapshot(task_id="T"),
|
||||
task=task, workers=[worker],
|
||||
lease=LeaseSnapshot("T", "w", NOW + 60, "ACTIVE"),
|
||||
git=git, evidence=evidence, now=NOW,
|
||||
)
|
||||
truth = out["derived"]["system_truth"]
|
||||
assert truth["truth_state"] == "COMPLETE"
|
||||
assert truth["blocker_count"] == 0
|
||||
assert truth["evidence_flow_confirmed"] is True
|
||||
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
from aios_control_plane_consistency import check_openapi_backend_parity
|
||||
|
||||
def test_openapi_dead_endpoint_is_blocker(tmp_path):
|
||||
oa=tmp_path/"o.yaml"
|
||||
oa.write_text("""openapi: 3.1.0
|
||||
info: {title: t, version: '1'}
|
||||
paths:
|
||||
/v2/task-admission/envelope:
|
||||
post:
|
||||
operationId: getTaskAdmissionEnvelope
|
||||
""",encoding="utf-8")
|
||||
srv=tmp_path/"s.py"
|
||||
srv.write_text('ROUTES = {"/v2/other": "other"}\n',encoding="utf-8")
|
||||
fs=check_openapi_backend_parity(oa,srv,("getTaskAdmissionEnvelope",))
|
||||
assert any(f.code=="OPENAPI_DEAD_ENDPOINT" for f in fs)
|
||||
|
||||
def test_backend_unprojected_operation_is_reported(tmp_path):
|
||||
oa=tmp_path/"o.yaml"
|
||||
oa.write_text("""openapi: 3.1.0
|
||||
info: {title: t, version: '1'}
|
||||
paths: {}
|
||||
""",encoding="utf-8")
|
||||
srv=tmp_path/"s.py"
|
||||
srv.write_text('ROUTES = {"/v2/task-admission/status": "getTaskAdmissionStatus"}\n',encoding="utf-8")
|
||||
fs=check_openapi_backend_parity(oa,srv,("getTaskAdmissionStatus",))
|
||||
assert any(f.code=="BACKEND_UNPROJECTED_OPERATION" for f in fs)
|
||||
@@ -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"):
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from gitea_transport_consistency_provider import (
|
||||
GitCommandResult,
|
||||
GiteaProviderError,
|
||||
collect_gitea_transport_consistency,
|
||||
repository_identity_from_remote,
|
||||
)
|
||||
|
||||
SRC = "a"*40
|
||||
TREE = "b"*40
|
||||
BRANCH = "refs/heads/agent/v2r2c2-p0-ttl-captrace-contract-20260909"
|
||||
|
||||
def mapping(remote_oid=SRC, url="https://g.cnnetmkt.top/git/aios/aios.git"):
|
||||
return {
|
||||
("rev-parse", "--is-inside-work-tree"): "true\n",
|
||||
("rev-parse", "HEAD^{commit}"): SRC+"\n",
|
||||
("rev-parse", f"{SRC}^{{tree}}"): TREE+"\n",
|
||||
("symbolic-ref", "--quiet", "HEAD"): BRANCH+"\n",
|
||||
("remote", "get-url", "origin"): url+"\n",
|
||||
("ls-remote", "--exit-code", "origin", BRANCH): f"{remote_oid}\t{BRANCH}\n",
|
||||
}
|
||||
|
||||
def runner(mp):
|
||||
def run(args, cwd):
|
||||
val = mp[tuple(args[1:])]
|
||||
if isinstance(val, GitCommandResult):
|
||||
return val
|
||||
return GitCommandResult(0, val, "")
|
||||
return run
|
||||
|
||||
class Tests(unittest.TestCase):
|
||||
def call(self, mp):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
return collect_gitea_transport_consistency(
|
||||
repo_root=Path(d),
|
||||
source_oid=SRC,
|
||||
tree_oid=TREE,
|
||||
git_runner=runner(mp),
|
||||
)
|
||||
|
||||
def test_identity_parsing(self):
|
||||
self.assertEqual(repository_identity_from_remote(
|
||||
"https://user:secret@g.cnnetmkt.top/git/aios/aios.git?x=y"
|
||||
), "aios/aios")
|
||||
self.assertEqual(repository_identity_from_remote(
|
||||
"git@g.cnnetmkt.top:aios/aios.git"
|
||||
), "aios/aios")
|
||||
|
||||
def test_exact_remote_ref_passes(self):
|
||||
r = self.call(mapping())
|
||||
self.assertTrue(r["consistent"])
|
||||
self.assertEqual(r["remote_ref_oid"], SRC)
|
||||
self.assertFalse(r["api_verified"])
|
||||
self.assertEqual(r["transport"], "git-ls-remote")
|
||||
|
||||
def test_remote_oid_mismatch_denied(self):
|
||||
with self.assertRaisesRegex(GiteaProviderError, "GITEA_REMOTE_REF_OID_MISMATCH"):
|
||||
self.call(mapping(remote_oid="c"*40))
|
||||
|
||||
def test_repository_mismatch_denied(self):
|
||||
with self.assertRaisesRegex(GiteaProviderError, "GITEA_REPOSITORY_IDENTITY_MISMATCH"):
|
||||
self.call(mapping(url="https://g.cnnetmkt.top/git/other/repo.git"))
|
||||
|
||||
def test_local_source_mismatch_denied(self):
|
||||
mp = mapping()
|
||||
mp[("rev-parse", "HEAD^{commit}")] = "d"*40+"\n"
|
||||
with self.assertRaisesRegex(GiteaProviderError, "GITEA_LOCAL_SOURCE_OID_MISMATCH"):
|
||||
self.call(mp)
|
||||
|
||||
def test_remote_failure_denied(self):
|
||||
mp = mapping()
|
||||
mp[("ls-remote", "--exit-code", "origin", BRANCH)] = GitCommandResult(2,"","down")
|
||||
with self.assertRaisesRegex(GiteaProviderError, "GITEA_REMOTE_REF_UNAVAILABLE"):
|
||||
self.call(mp)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user