Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffd7fe19c |
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
@@ -13,7 +13,7 @@ Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_GITOPS_DURABLE_JOB_EXECUTION_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 +21,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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# AIOS Baseline 1.1 验收矩阵
|
||||
|
||||
本矩阵定义 AT-01~AT-23 的验收标准和证据边界。它不以“测试文件存在”冒充通过:本次本地自动化的实际命令、退出码和数量以包内测试结果文件为准。下表标注的是覆盖入口及尚需现场核验的范围,单项 AT 只有在全部必要证据齐备后才能标记验收通过。
|
||||
|
||||
状态约定:`LOCAL_AUTOMATED` 表示已有可执行本地测试入口;`LOCAL_PARTIAL` 表示只能覆盖该场景的一部分;`SITE_REQUIRED` 表示必须在准确绑定的现场环境补充验收。上述标签不是 PASS。未执行、证据过期、身份不符或无法访问目标均为 `NOT_VERIFIED/UNKNOWN`,不能按健康放行。
|
||||
|
||||
| ID | 场景与必须满足的结果 | 本地代码/测试入口 | 当前证据边界与现场补充 |
|
||||
| --- | --- | --- | --- |
|
||||
| AT-01 | 缺失/过期 observation、错误任务身份、main 与 runtime 不同;UNKNOWN fail-closed;drift 比较 DeploymentTarget 与 observation,不拿 main HEAD 当 desired | `test_contracts.py`、`test_loop.py`;Runtime Contract / deployment evaluator | LOCAL_PARTIAL;需现场核对 Resolver、正式入口、canonical ref 和 artifact identity |
|
||||
| AT-02 | 支持包缺文件、改一字节、额外未声明内容必须校验失败;生成身份与内容可追溯 | `test_package_tools.py`;`bundle.py verify/stage` | LOCAL_AUTOMATED(源码包边界);线上运行镜像与 overlay 构建仍须独立验证 |
|
||||
| AT-03 | 每个允许执行的 operation 在 dry-run/shadow 下都不调用副作用 writer,不伪造已执行或发布成功 | `test_job_safety.py`;既有 Job executor dry-run 分支 | LOCAL_PARTIAL;新 adapter 加入注册表时必须补相同负面测试,不能沿用其他 adapter 结论 |
|
||||
| AT-04 | 同一有效 Grant 下短期证明过期/服务重启仍能完成后续授权范围内任务;不重复业务审批、不扩大权限 | `test_authorization.py`、`test_loop.py` | LOCAL_AUTOMATED(授权/本地任务);完整生产发布链仍需 AT-16 |
|
||||
| AT-05 | 父 Grant 撤销、过期、签名错误、跨 tenant/subject/scope 重放、伪造 Mapping 在派生与消费两端均拒绝 | `test_authorization.py`、`test_transport.py` | LOCAL_AUTOMATED;真实密钥轮换和撤销传播需部署证据 |
|
||||
| AT-06 | 过期 lease、终态 Job 再领 lease、旧 generation 延迟写均拒绝;新 claim 保持独占 | `test_job_safety.py`、`test_worker_host.py`;TaskPlaneStore 原有回归 | LOCAL_PARTIAL;每个实际外部写接收端须证明 fencing 生效,不能只在发送端检查 |
|
||||
| AT-07 | 外部成功而 receipt 丢失、对账中止、双 observer 乱序;复用 effect、精确 readback、旧结果不能覆盖新事实 | `test_job_safety.py`、`test_release_lineage.py` | LOCAL_AUTOMATED(可控 effect sink);实际远端 readback 身份契约需验收 |
|
||||
| AT-08 | ready+600 秒后扩大 eligible Worker;普通与 fallback Worker 竞争仍只有一个 CAS claim,不抢有效 lease | `test_worker_host.py`、`test_loop.py`;WorkerProfile/TaskPlaneStore | LOCAL_PARTIAL;需常驻实例 heartbeat/transport 与竞争运行证据;聊天 GPT 不算 Worker |
|
||||
| AT-09 | blocked 任务未进入 ready 时不静默无限等待;显示 blocker、持续时间、修复和升级边界 | 服务端状态与错误返回;本包实现范围以源码报告为准 | SITE_REQUIRED;完整 Doctor 自动修复/升级场景不能用普通任务成功代替 |
|
||||
| AT-10 | Worker/子进程退出、取消、超时、重启;有限终止、服务端恢复,同 effect 不因缓存丢失重复 | `test_worker_host.py`、`test_loop.py`、`test_transport.py`、`test_job_safety.py` | LOCAL_PARTIAL;实际 systemd kill/restart、取消竞争和主机启动顺序需演练 |
|
||||
| AT-11 | 重复提交/事件/回执、提交后投递器退出;outbox 持久、去重、终态不倒退 | `test_loop.py`;TaskPlaneStore outbox 与幂等接口既有测试 | LOCAL_PARTIAL;通知投递器/inbox 的端到端故障仍需专门证据 |
|
||||
| AT-12 | 跨 tenant/subject 查询、取消、执行和关联被拒;Worker 不能读服务端私钥和其他工作目录 | `test_authorization.py`、`test_loop.py`、`test_transport.py`、`test_release_lineage.py`;独立 UID unit 模板 | LOCAL_PARTIAL;artifact 下载、cache key、文件权限及真实子进程边界需现场覆盖 |
|
||||
| AT-13 | Bridge/provider 独立重启或 token 轮换后使用自身 credential projection,不依赖另一服务临时目录 | 修订现有 staging unit;`deploy/baseline-v1.1/` 凭证模板 | SITE_REQUIRED;静态 unit 不等同于解密、轮换和重启已验证 |
|
||||
| AT-14 | 子任务、重复诊断和连续失败不能用新 task ID 重置 Grant 预算;超限有界停止 | `test_authorization.py`;持久 effect 预算;`test_optimization.py` | LOCAL_PARTIAL;目标级总成本、墙钟、模型预算的完整现场计费来源仍须接线 |
|
||||
| AT-15 | 支持 profile 可在干净目录执行;错 source/config 的证据不能放行;未绑定线上权威明确拒绝 | `test_package_tools.py`、`test_cli.py`、`test_contracts.py` | LOCAL_AUTOMATED(隔离 profile);目标 OS、Python、systemd 版本与现场配置仍需记录 |
|
||||
| AT-16 | 同一 Grant:patch→tests→受保护 merge→build→staging→业务验收→production→生产业务验收;全程有唯一关联 | `test_release_lineage.py`;ReleaseLedger/recipe/receipt verifier 接口 | SITE_REQUIRED;本包的状态机和模拟 adapter 测试不等于真实发布链已完成 |
|
||||
| AT-17 | 编码 Worker 自报成功、伪造 receipt、降低验收规则或读签名私钥不能放行 | `test_authorization.py`、`test_release_lineage.py`、`test_optimization.py`;API/Worker 独立 UID | LOCAL_PARTIAL;生产 verifier 独立职责、规则冻结和私钥访问权限需现场验证 |
|
||||
| AT-18 | 优化无改善或越过质量底线不晋升;优化提案只生成受治理任务,不直接写 policy DB | `test_optimization.py`;签名 OptimizationEnvelope | LOCAL_AUTOMATED(提案与质量约束);真实实验数据来源、回滚及模型成本路由需后续验收 |
|
||||
| AT-19 | 旧 schema/任务与新版本并存时保留原签名字节;main 合法前进不等于 runtime drift;旧 handler 明确处理 | `test_authorization.py`、`test_contracts.py`、`test_release_lineage.py` | LOCAL_PARTIAL;线上旧任务排空、writer 停用、迁移/路由策略需现场清单 |
|
||||
| AT-20 | 坏候选、控制面不能启动、数据迁移不可逆时只允许兼容回退或前向修复;无万能 root | `test_release_lineage.py`、`test_package_tools.py`;源码回滚、Release 回退约束 | LOCAL_PARTIAL;数据库迁移/外部副作用不由源码 rollback 撤销;独立最小恢复流程需现场演练 |
|
||||
| AT-21 | SQLite/Git/receipt 一致备份在隔离环境恢复,保留身份和未决 effect;实测 RPO/RTO | 各 ledger 重启测试是局部依据;安装文档给出一致备份边界 | SITE_REQUIRED;本包没有以“可重开 SQLite”宣称整机备份恢复验收通过 |
|
||||
| AT-22 | persist intent→外部 effect 成功→主机在 receipt 落盘前失去执行能力;恢复同 effect→RECONCILIATION_REQUIRED→readback→补 receipt;外部效果次数仍为 1 | `test_job_safety.py`;子进程退出/独立持久 effect sink 故障注入 | LOCAL_PARTIAL;SIGKILL/进程崩溃不能证明磁盘缓存断电持久性,物理/虚拟机硬掉电需现场验证 |
|
||||
| AT-23 | intent/lease 无法持久化时不得开始 effect;effect 后 receipt 无法提交则停止新 effect 并恢复对账;磁盘满/WAL/receipt unavailable 不冒充完成 | `test_job_safety.py`;SQLite/receipt 写失败注入及持久停止边界 | LOCAL_PARTIAL;实际 ENOSPC、WAL checkpoint、目录权限/介质损坏需隔离卷或现场故障试验 |
|
||||
|
||||
## 每次验收必须保存的记录
|
||||
|
||||
每条记录至少包含:AT 编号、完整命令、开始/结束时间、执行环境、源码/构建/配置身份、Grant/Task/effect/job/Release/deployment/acceptance 关联(适用时)、注入点、实际 observation、receipt 引用、退出码、明确结论和剩余缺口。密钥/token 只保存 CredentialReference,不能复制到报告。
|
||||
|
||||
`UNKNOWN`、`RECONCILIATION_REQUIRED`、预算耗尽导致的有界停止都可能是正确故障处理结果;它们不是业务成功。只有可信且当前的业务 acceptance,准确绑定 deployment+release+artifact,才能关闭相应发布验收。
|
||||
|
||||
## M0–M5 的放行顺序
|
||||
|
||||
| 实施包 | 放行条件 | 不可用来代替放行的证据 |
|
||||
| --- | --- | --- |
|
||||
| M0 Truth & Identity | 静态 Contract、动态 target、observation、provenance 分离;Resolver 与 CredentialReference 对应真实目标;UNKNOWN fail-closed | 目录存在、进程健康、main 与 runtime 碰巧相同 |
|
||||
| M1 Authorization & Effect Safety | Grant→短期证明→Principal→实际 lease/fencing;意图先持久化;未知结果精确对账;AT-22/23 本地故障通过 | 仅延长 TTL、发送端自称 fenced、effect 成功但未写 receipt |
|
||||
| M2 Real Autonomous Loop | 至少一条真实非发布 Action→Task→常驻 Worker→已注册 operation→receipt→result;tenant/subject 在服务端派生 | 仅 scheduler 单元测试、伪造 worker 或直接函数调用绕过 admission |
|
||||
| M3 Release Closure | 同一 Grant 完成真实 patch 至 production business acceptance,至少一次 ambiguous publication/worker crash 自动恢复 | 本地 Release 状态机、模拟 adapter、部署命令返回 0 |
|
||||
| M4 Surface Reduction | 每类事实 active write authority=1;旧入口停止接收新任务;历史任务可追溯;恢复演练通过 | 新模块存在但旧 writers 仍在写,或把全系统静态函数数当活跃写者数 |
|
||||
| M5 Optimization | 签名 Envelope 限定范围;真实效果和质量底线可核验;只经受治理任务晋升并能回退 | optimizer 自报改善、直接改 policy DB、放宽验收阈值 |
|
||||
@@ -0,0 +1,20 @@
|
||||
# 交付统一验收结果
|
||||
|
||||
本次验收在新建虚拟环境中,仅使用包内锁定离线 wheel 安装依赖。没有连接生产系统。
|
||||
|
||||
| 范围 | 实际结果 |
|
||||
| --- | --- |
|
||||
| 新增 Baseline 1.1 | 165 passed;0 skipped |
|
||||
| 全量源码回归 | 1212 passed;154 subtests passed;3 skipped |
|
||||
| 全量退出码 | 0 |
|
||||
| Python | 3.12.13 |
|
||||
| 平台 | Linux-6.18.35-x86_64-with-glibc2.39 |
|
||||
| 验收完成时间(UTC) | 2026-09-07T19:07:27.816913+00:00 |
|
||||
|
||||
JUnit tests=1369 包含子测试,不能再把这个数字与 1212 相加。3 项跳过分别是 live Gitea tag publication、opt-in live signer integration,以及离线验收主动隔离的 deployed signer 检查。它们保持现场未验收状态,没有改成通过。
|
||||
|
||||
实际命令为完整包 `run_acceptance.py --profile all --output ...`,该工具在一次性源码副本中执行,显式向子进程传递源码 PYTHONPATH,使进程崩溃/重启测试不依赖编辑器环境,也避免历史测试的 runtime 输出污染发行目录。副本保留原始 runtime/agent-os 源码;只隔离生成输出,不删除历史实现。首次干净环境运行发现并修正了这个验收启动器的子进程导入问题;修正后完整重跑通过。
|
||||
|
||||
完整日志、JUnit、依赖版本、开始/结束时间、命令、退出码和逐源码文件 SHA 在完整包 `acceptance/full/`;新增基线证据在 `acceptance/baseline/`。源码改动后应重新生成相应证据,不能沿用本结果放行。
|
||||
|
||||
AT-22 的进程故障注入通过;真实主机硬掉电仍需现场演练。AT-23 的 intent/receipt 持久化失败与停止/恢复通过;真实介质 ENOSPC、WAL checkpoint、备份 RPO/RTO 仍需现场验收。生产发布、正式 Task API 接入和旧写权威清退不在本次执行范围。
|
||||
@@ -0,0 +1,107 @@
|
||||
# AIOS 最终架构修改建议:Implementation Baseline 1.1
|
||||
|
||||
本基线吸收七项最终修订,作为后续编码、集成与验收的共同约束。目标是一台机器、一人维护、多个常驻自治 Worker:Owner 给出目标和真实业务边界,系统在既有授权内自动发现对象、领任务、执行、验证、恢复并产生后续优化任务。
|
||||
|
||||
本文件依据本次明确要求、上传源码和可见交互/评审材料;相关材料反映的持续诉求是少反复授权、少人工补路径、可恢复、低维护成本和严格身份隔离。它不声称已逐条读取全部三个月历史聊天,也不将材料里的现场状态当作本次在线测量。
|
||||
|
||||
## 1. 最高原则与冻结决策
|
||||
|
||||
**AIOS 的自动化程度不得高于其可观测、可恢复和可归因程度。**
|
||||
|
||||
不能精确识别对象、持久化 intent、证明当前授权或围栏旧执行者,就不开始副作用。结果未知时先对账;不能验证业务结果时不宣称完成;无法安全恢复时有界停止并给出最小决策材料。正常续期、已授权恢复、同一范围内重试不重新索要 Owner 业务审批。
|
||||
|
||||
继续使用 SQLite、Git、systemd;不引入 Kubernetes、消息中间件、Backstage 或新 Secrets 平台。不打开 legacy global write,不增加万能 SSH/root,不把 GPT 对话包装成执行身份。Task、Job、Release 保持各自事实域,Action 只负责入口。自动优化使用与普通开发相同的授权、执行、验证和发布边界。
|
||||
|
||||
“单一写权威”按事实及资源范围判定,不等于全系统共用一张状态表或一个 daemon。允许同一进程承载多个模块,但模块不能直接修改另一模块的权威表。
|
||||
|
||||
## 2. 事实域及写权威
|
||||
|
||||
| 事实域 | 唯一写入口 | 权威内容 | 其他模块的使用方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| Authorization | `AuthorizationService` | 已验签 Grant、当前 generation、撤销、effect 预算预留 | 请求派生证明,在执行边界重新验证 |
|
||||
| Task | 既有正式 Task authority;隔离 profile 使用既有 `TaskPlaneStore` | 准入、任务状态、claim、Worker 登记、任务 outbox | 通过正式 API/适配器调用,不能另写 Task 状态 |
|
||||
| Job / Effect | 既有 `JobController` 与受治理 executor | intent、attempt、资源 lease/fence、receipt、reconciliation | 持久化请求后执行;由权威 receipt 解释结果 |
|
||||
| Release / DeploymentTarget | 正式 Release authority;候选实现为 `ReleaseLedger` | 发布 recipe/saga、动态目标、部署事实、验收收口 | CAS 更新目标;消费经可信 verifier 验证的 receipt |
|
||||
| Runtime Contract | 受版本治理的配置入口 | 组件结构、入口、能力、资源及凭证引用 | Resolver 解析;不保存动态 desired |
|
||||
| Runtime Observation | 对应组件的可信观测适配器 | 实际部署身份、时间、来源、健康事实 | 只作为带新鲜度和来源的证据,不反写目标 |
|
||||
| 关联图 | `LineageLedger` | 不可改绑的身份关系 | `goal-explain` 查询;不拥有 Task/Job 状态机 |
|
||||
| Optimization | `OptimizationController` | 提案、实验证据、停止或晋升申请 | 只能请求受治理 Task,不能写政策或部署库 |
|
||||
|
||||
新增库是明确事实域的持久层,不是新的线上写权威自动生效。当前 CLI 只支持 `isolated-validation`,复用上传源码中的 TaskPlaneStore;连接真实 Task API 前必须证明其正式入口、身份与兼容性,不能因为本地新库可启动就切换线上 admission。
|
||||
|
||||
## 3. 运行结构、期望目标和实际观测分离
|
||||
|
||||
| 对象 | 回答的问题 | 关键身份 |
|
||||
| --- | --- | --- |
|
||||
| `RuntimeContract` | 组件是什么、正式入口和允许能力是什么 | component、environment、tenant scope、contract revision、引用 |
|
||||
| `EnvironmentDeploymentTarget` | 此部署资源现在应运行哪个 Release | 部署资源范围、release、artifact、target revision |
|
||||
| `RuntimeObservation` | 实际运行什么,证据多新 | component、deployment、actual artifact、observed time、source |
|
||||
| `ReleaseProvenance` | 制品从哪里产生 | artifact/source/builder 身份与构建证据 |
|
||||
|
||||
drift 比较同一部署资源的 desired 与 observed。main HEAD、staging source、production Release 不同本身不是故障;缺目标、缺来源、观测过期或身份绑定不完整应为 UNKNOWN。用户报告的 `0136a… / 91f… / 998d…` 以及缺 `gitea_token` 的 UNKNOWN,只构成待核验输入,不作为本包生产运行结论。
|
||||
|
||||
M0 只做薄合同和可信解析:回答操作哪个组件、入口、desired/observed、制品来源、凭证引用和证据新鲜度,不做大目录迁移。当前实现提供合同校验、动态目标评价和受信固定绑定;完整在线 Resolver/Doctor 仍需适配实际 authority 与服务清单。
|
||||
|
||||
目标的唯一性固定为“tenant + component/deployment scope + environment”,subject 用于授权及审计。`ReleaseLedger` 必须按资源主键实施 CAS,不能让不同用户为同一物理组件建立互相冲突的 desired。主键迁移必须检测已有目标冲突,不能静默合并或用当前观测覆盖期望目标。
|
||||
|
||||
## 4. 持续业务授权与短期执行权
|
||||
|
||||
授权链固定为 `OwnerDelegationGrant → OperationalEpoch → TaskPrincipal`,执行时另行获得正式 `ClaimLease / Fencing`。Grant 保存目标/任务范围、tenant、subject、环境、操作能力、破坏性上限、预算、明确期限或 UNTIL_REVOKED,以及撤销代次。
|
||||
|
||||
新增长期 schema `aios.owner-delegation-grant.v1` 和短期 `aios.operational-epoch.v1`;历史 `owner-delegation-epoch.v1/v2` 不改名、不改签名字节,也不重解释为长期 Grant。派生证明保存根授权 digest/generation;代码字段为 `root_grant_digest`、`root_grant_generation`,承担提议中 `parent_grant_digest` 的根绑定语义。Principal 另绑定其父 Epoch digest。
|
||||
|
||||
签名采用显式受信公钥验证,调用者不能附一份 Mapping 或公钥就自称受信。每次实际副作用检查根授权当前状态、精确 scope、破坏性上限、预算及真实 lease。短期证明过期可在有效 Grant 内自动更新;撤销、到期、跨环境或越过破坏性上限不能靠续期绕过。租约来自 Task/Job 权威,Principal 不伪造 claim。
|
||||
|
||||
当前实现提供持久 `max_effects` 预留:同一稳定 effect 不重复扣额,换 task ID 不重置同一 Grant generation 的限额。总模型费用、计算资源和整个 mission 的综合预算仍需真实计量接入;不能把 effect 计数声称为完整成本控制。
|
||||
|
||||
## 5. 不可改绑的业务关联与执行恢复
|
||||
|
||||
各对象共享 `mission_id`、授权身份和 tenant/subject,但不共享状态机:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G["Mission / Grant"] --> T["Task 权威"]
|
||||
T --> J["Effect / Job 权威"]
|
||||
J --> R["Release 权威"]
|
||||
R --> D["Deployment"]
|
||||
D --> A["Business Acceptance"]
|
||||
T -. 身份关系 .-> L["Lineage / goal-explain"]
|
||||
J -. 身份关系 .-> L
|
||||
A -. 验证结果 .-> L
|
||||
```
|
||||
|
||||
每个新 operation Job 必须绑定一个真实 Task;effect/Job 不能换绑另一任务。计划中的 job_id 必须传到正式 Job adapter,并在权威确认后才能显示为已存在的 Job。产生 Release 的 effect 只能指向唯一 release 身份,Release 保存来源链;Acceptance 必须绑定 deployment、release、artifact。非发布诊断任务只生成真实存在的 Task/Job/receipt,不能为补齐图形虚构 Release。
|
||||
|
||||
effect 的稳定身份先于执行产生,attempt 可以变化。正常路径为持久 intent、有效 claim/lease/fence、执行、精确 readback、receipt、业务结果;结果不确定或跨域投递失败时进入 reconciliation。没有 readback/reconcile 能力时明确停止,不退回 execute。已有 Task outbox 及持久阶段记录支持恢复,但跨事实域不声称存在一个 SQLite 分布式事务;消费必须幂等,断点必须保留,关联缺口由受控重放补齐。
|
||||
|
||||
旧执行者的 fencing 必须由副作用接收端再次检查;只在 scheduler 端检查不能阻止延迟写。AT-22 覆盖外部成功而 receipt 未提交后的同 effect 对账;AT-23 要求 intent/lease 无法持久化时 effect 不开始,receipt 提交失败后阻止新 effect。进程崩溃测试和 SQLite 错误注入已作为本地入口;真实掉电、ENOSPC、WAL checkpoint 和介质故障仍须现场验证。
|
||||
|
||||
## 6. Operation Registry 与无人值守 Worker
|
||||
|
||||
`OperationDefinition` 声明版本、输入 schema、所需能力、可信 handler 引用、期限、effect class,以及独立的 retry/readback/reconcile/rollback/owner-boundary 策略。Registry 是受治理查表入口,不是动态 shell 或任意 Python 加载入口。
|
||||
|
||||
| 主 effect class | 未知结果的处理原则 |
|
||||
| --- | --- |
|
||||
| READ_ONLY | 可在预算内重新观察;诊断执行成功不代表对象健康 |
|
||||
| IDEMPOTENT | 仅在幂等键、权限、围栏和重试策略仍有效时重试 |
|
||||
| CONDITIONAL | 重新读 revision,保留 expected revision/CAS,不能删掉条件重试 |
|
||||
| RECONCILABLE | 先按稳定 effect 精确 readback,再决定补 receipt 或后续动作 |
|
||||
| IRREVERSIBLE | 不盲重试;遵守显式 Owner 边界和恢复能力 |
|
||||
|
||||
这些属性会重叠,因此 class 负责主分派,幂等性、可观察性和可逆性另作约束。service-restart 不能仅凭 `active` 宣称本次重启已发生,需调用实例/事件等精确关联;当前 validation 内置仅为固定参数 `service-status`。旧 COMMIT_FILE adapter 缺乏 intent 可定位的未引用 commit readback 时明确停止,不为打通演示而制造成功。
|
||||
|
||||
Worker 登记包含身份、capabilities、tenant/subject scope、priority、fallback_executor、heartbeat 和并发限制。正常选择 eligible Worker;从 `ready_at` 起 600 秒后扩大 fallback eligibility,仍使用同一 CAS/lease/fencing。blocked 时间不冒充 ready 时间,现存有效 claim 不被强抢。“主 Agent”只是部署策略;聊天 GPT 不拥有 Worker 的常驻进程身份。
|
||||
|
||||
## 7. Tenant-first 与优化边界
|
||||
|
||||
核心记录从第一天携带 tenant、subject、scope,身份从受信服务端认证上下文取得。查询、取消、执行、关联以及制品/cache/工作目录/credential 都必须在各自边界验证;不能只在 UI 隐藏对象。首版不做租户 UI、billing、独立租户管理服务或复杂公平调度。当前独立 UID 模板与 API 隔离不是所有未来 artifact/cache 实现已验收的证明。
|
||||
|
||||
签名 `OptimizationEnvelope` 限定参数、组件、非生产环境、变更量、计算预算、质量下限、回退要求和晋升条件。初期仅开放有限 retry/backoff、worker routing、evidence reuse、timeout、poll interval、通知去重和非强制测试选择。授权政策、信任根、租户隔离、验收阈值、破坏性能力、Grant 语义和生产迁移规则禁止修改。
|
||||
|
||||
优化流程固定为观察、提案、受治理 Task、实验、验证、晋升申请;优化器不能直接修改 policy DB 或运行配置。质量评价区分越高越好/越低越好,无改善或低于门槛就停止。当前模块已实现这些约束和持久提案,真实实验执行器、费用来源、运行参数晋升与回退仍需通过同一治理链接线。
|
||||
|
||||
## 8. 交付状态和实施顺序
|
||||
|
||||
本包包含实际代码、负面测试、可运行的 loopback 非发布闭环、安全源码 stage/rollback 和 AT-01~AT-23 标准。M0/M1 的本地模块与 M2 隔离纵向闭环已交付;M3 的关联/目标/saga/验签是候选基础,未宣称真实生产发布通过;M4 的旧写者排空和生产 imports 清理未执行;M5 是有界提案实现,未自动优化线上系统。
|
||||
|
||||
上线顺序为 M0 Truth & Identity、M1 Authorization & Effect Safety、M2 Real Autonomous Loop、M3 Release Closure、M4 Surface Reduction、M5 Optimization。每阶段以完整输入、实际 adapter 与退出标准放行,详见 `M0_M5_IMPLEMENTATION_RUNBOOK.md`。不使用“架构方案正确”替代接收端围栏、正式权威绑定、可信业务验收或备份恢复证据。
|
||||
@@ -0,0 +1,210 @@
|
||||
# Baseline 1.1 signed authorization chain
|
||||
|
||||
## Result
|
||||
|
||||
`aios_baseline.authorization` implements a new authorization chain without
|
||||
changing the frozen historical owner-delegation wire formats:
|
||||
|
||||
1. an Owner signs `aios.owner-delegation-grant.v1` with a supplied Ed25519 key;
|
||||
2. the local authorization service verifies and durably registers that grant;
|
||||
3. the service issues a short, signed `aios.operational-epoch.v1`;
|
||||
4. the service embeds that epoch in a signed `aios.task-principal.v1` and binds
|
||||
the principal to it by digest;
|
||||
5. every authorization and effect reservation rereads SQLite and checks the
|
||||
current root grant digest, generation, revocation state, expiry, signature,
|
||||
tenant, subject and narrowed scope.
|
||||
|
||||
The TaskPrincipal is a short authorization credential. It does not manufacture
|
||||
a Task claim or ClaimLease. The task authority and executor must independently
|
||||
enforce their authoritative claim lease, heartbeat and fencing token.
|
||||
|
||||
## Public API
|
||||
|
||||
```python
|
||||
AuthorizationService(
|
||||
db_path,
|
||||
owner_public_keys, # key_id -> Ed25519PublicKey
|
||||
machine_private_key,
|
||||
machine_key_id,
|
||||
*,
|
||||
owner_key_scopes, # key_id -> exact tenant_ids/subject_ids lists
|
||||
clock=time.time,
|
||||
control_context_verifier=None,
|
||||
trusted_machine_public_keys=None,
|
||||
)
|
||||
|
||||
sign_owner_delegation_grant(
|
||||
owner_private_key,
|
||||
owner_key_id,
|
||||
*,
|
||||
grant_id,
|
||||
generation,
|
||||
tenant_id,
|
||||
subject_id,
|
||||
mission_ids,
|
||||
task_ids,
|
||||
operations,
|
||||
component_ids,
|
||||
environments,
|
||||
issued_at,
|
||||
validity_mode, # required: UNTIL_REVOKED or EXPIRES_AT
|
||||
expires_at=None,
|
||||
max_effects=None,
|
||||
destructive_ceiling="NONE",
|
||||
)
|
||||
|
||||
service.register_grant(signed_envelope) -> dict
|
||||
service.issue_principal(grant_id, *, exact_scope, ttl_seconds) -> dict
|
||||
service.authorize(principal, *, exact_scope) -> dict
|
||||
service.assert_effect_bounds(principal, operation_definition, *, exact_scope) -> dict
|
||||
service.reserve_effect(grant_id, effect_id, principal, *, exact_scope) -> bool
|
||||
service.revoke_grant(grant_id, *, control_context) -> int
|
||||
```
|
||||
|
||||
`register_grant` and `authorize` return JSON-safe copies of the signed grant.
|
||||
`reserve_effect` returns `True` for the first reservation and `False` for an
|
||||
idempotent retry of the same stable effect ID. It reauthorizes and reserves in
|
||||
one `BEGIN IMMEDIATE` transaction. A configured `max_effects` applies per grant
|
||||
generation. An omitted `max_effects` leaves mission-level budget accounting to
|
||||
the calling service.
|
||||
|
||||
The grant signing helper requires an explicit validity mode. It never creates a
|
||||
key and does not accept an unsigned grant payload as authority. `EXPIRES_AT`
|
||||
requires an integer expiry after issuance. `UNTIL_REVOKED` forbids an expiry.
|
||||
The signed `destructive_ceiling` defaults to `NONE` and is strictly one of
|
||||
`NONE`, `REVERSIBLE`, or `IRREVERSIBLE`. `assert_effect_bounds` accepts only a
|
||||
trusted `OperationDefinition`: `NONE` permits only `READ_ONLY`; `REVERSIBLE`
|
||||
also requires rollback, required state/exact readback, and reconciliation; and
|
||||
`IRREVERSIBLE` is an explicit maximum. A ceiling never replaces Job
|
||||
authorization, durable intent, claim lease, receiver fencing, readback, or
|
||||
reconciliation.
|
||||
|
||||
Owner public-key trust is separately constrained by the required constructor
|
||||
argument `owner_key_scopes`. Its key set must exactly equal `owner_public_keys`;
|
||||
each key has exact nonempty `tenant_ids` and `subject_ids` lists. A wildcard is
|
||||
effective only when the configuration explicitly supplies the sole list item
|
||||
`"*"`. Registration and every later durable grant load check the signing key's
|
||||
scope, so placing keys for several tenants in one process does not let one
|
||||
tenant's Owner key sign another tenant's grant.
|
||||
|
||||
## Exact wire objects
|
||||
|
||||
Every signed object uses an exact `signing` object:
|
||||
|
||||
```json
|
||||
{"algorithm":"Ed25519","key_id":"...","signature":"base64-64-byte-signature"}
|
||||
```
|
||||
|
||||
The signature covers the schema, every payload field, the signing algorithm and
|
||||
key ID, and, for a TaskPrincipal, the complete nested signed epoch. The message
|
||||
starts with the `AIOS-AUTHORIZATION` domain, a NUL separator, the schema, and a
|
||||
second NUL separator before canonical JSON.
|
||||
|
||||
An OwnerDelegationGrant has exact outer keys `schema`, `payload`, `signing`.
|
||||
Its payload has:
|
||||
|
||||
```json
|
||||
{
|
||||
"grant_id": "...",
|
||||
"generation": 1,
|
||||
"tenant_id": "...",
|
||||
"subject_id": "...",
|
||||
"scope": {
|
||||
"mission_ids": ["..."],
|
||||
"task_ids": ["..."],
|
||||
"operations": ["..."],
|
||||
"component_ids": ["..."],
|
||||
"environments": ["..."]
|
||||
},
|
||||
"issued_at": 2000000000,
|
||||
"validity": {"mode": "UNTIL_REVOKED"},
|
||||
"limits": {},
|
||||
"destructive_ceiling": "NONE"
|
||||
}
|
||||
```
|
||||
|
||||
The alternative validity is
|
||||
`{"mode":"EXPIRES_AT","expires_at":<integer>}`. `limits` is either empty or
|
||||
contains one positive integer `max_effects`. Each scope list is nonempty and
|
||||
may contain explicit values or the sole wildcard `"*"`.
|
||||
|
||||
An OperationalEpoch has exact outer keys `schema`, `payload`, `signing`. Its
|
||||
payload has `epoch_id`, `root_grant_id`, `root_grant_digest`,
|
||||
`root_grant_generation`, all seven exact binding fields (`tenant_id`,
|
||||
`subject_id`, `mission_id`, `task_id`, `operation`, `component_id`,
|
||||
`environment`), `issued_at` and `expires_at`.
|
||||
|
||||
A TaskPrincipal has exact outer keys `schema`, `payload`, `operational_epoch`,
|
||||
`signing`. Its payload has `credential_id`, `nonce`, `parent_epoch_digest`, the
|
||||
same three root fields, the same seven binding fields, `issued_at` and
|
||||
`expires_at`. Epoch and principal validity is capped at 300 seconds and at an
|
||||
explicit root-grant expiry.
|
||||
|
||||
## Durable state and revocation
|
||||
|
||||
SQLite stores the canonical signed grant, its digest, its current durable
|
||||
generation and revocation flag. Revocation increments the durable generation in
|
||||
an immediate transaction, records a revocation row, and leaves old proofs
|
||||
invalid even when their short expiry has not elapsed. Reopening the service on
|
||||
the same database retains that decision. The same signed generation cannot
|
||||
clear revocation. Reactivation requires a newly Owner-signed generation greater
|
||||
than the durable revocation generation.
|
||||
|
||||
`revoke_grant` fails closed unless `control_context_verifier` returns the exact
|
||||
boolean `True` for the caller's authenticated local control-service context.
|
||||
The authorization module does not authenticate network requests; the hosting
|
||||
service must construct that trusted context from its local authenticated API
|
||||
boundary. Authorization never relies on the verifier or a cache for freshness;
|
||||
it reads current durable state on every use.
|
||||
|
||||
The active machine signing key is always trusted for newly issued objects.
|
||||
`trusted_machine_public_keys` can retain previous public keys for the remainder
|
||||
of already-issued proof lifetimes during rotation. Owner trust keys and machine
|
||||
trust keys remain separate.
|
||||
|
||||
## Verification
|
||||
|
||||
The initial failure-first run stopped during collection because
|
||||
`aios_baseline.authorization` did not yet exist. After implementation, a focused
|
||||
run found and fixed strict nonce validation and clarified that substituting the
|
||||
nested epoch invalidates the outer signature before the parent-digest check.
|
||||
The final focused commands were:
|
||||
|
||||
```console
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app \
|
||||
python3 -m pytest -q \
|
||||
tests/baseline_v1_1/test_authorization.py \
|
||||
tests/baseline_v1_1/test_claim_heartbeat.py
|
||||
```
|
||||
|
||||
Result before the ceiling extension: `25 passed in 0.80s`. The post-extension
|
||||
focused run reported `28 passed in 0.35s`. After the Owner trust-root and claim
|
||||
heartbeat hardening, the current focused result is `35 passed`.
|
||||
|
||||
The tests cover exact-scope success, all three signature layers, unknown Owner
|
||||
keys, unsigned payload refusal, fake Mapping parents, nested-parent
|
||||
substitution, every cross-scope dimension, grant narrowing, live and persisted
|
||||
revocation, proof renewal without Owner reapproval, explicit root expiry,
|
||||
Owner-signed generation reactivation, strict types and signature encoding,
|
||||
explicit helper validity, durable idempotent effect budgets, unbounded budget
|
||||
delegation, machine-key rotation, refusal of untrusted retired keys, NaN and
|
||||
empty-identity rejection, and the revocation-generation boundary around durable
|
||||
effect budgets. Revocation cannot reset a budget or revive the immutable old
|
||||
signed generation; only a newer Owner-signed generation can reactivate it.
|
||||
The extension tests also cover signed ceiling tampering, conservative default
|
||||
denial, rollback/readback requirements, and explicit irreversible authority.
|
||||
They now also cover exact Owner-key scope configuration, cross-tenant and
|
||||
cross-subject Owner-key refusal, explicitly configured global trust, cancellation
|
||||
before heartbeat, and the exact lease-expiry boundary.
|
||||
|
||||
## Security review disposition
|
||||
|
||||
| Severity | Status | Boundary | Disposition |
|
||||
| --- | --- | --- | --- |
|
||||
| High | Closed | Owner trust roots | Each trusted Owner key now requires explicit tenant and subject scope; no implicit cross-tenant authority remains. |
|
||||
| High | Closed | Revocation and effect budget | Every use rereads root generation and revocation state in SQLite. Revocation cannot revive or reset the old generation's reservations. |
|
||||
| High | Closed | Receipt and recovery integration | Generic Jobs require the exact transition graph and trusted, fully bound signed receipts; Task recovery preserves terminal Owner cancellation. |
|
||||
| Medium | Closed | Claim heartbeat | Heartbeat now checks terminal Task state and performs the live-lease update with `status='ACTIVE'` and `lease_until>now` under one immediate transaction. |
|
||||
| High | Pending M3 | Mutating receiver fence | The supplied handler remains read-only. Any future mutating receiver must atomically enforce the authoritative fence at the sink and recheck authorization, claim and deadline around receiver execution. |
|
||||
| Medium | Accepted for initial single-host profile | Admission scope | The durable unknown-effect admission latch intentionally stops new effects across the shared Job store. This conservative availability policy is documented in SERVICE_LOOP_REPORT.md; later tenant/resource partitioning must retain a global storage-integrity gate. |
|
||||
@@ -0,0 +1,220 @@
|
||||
# Truth and Operation Contracts Report
|
||||
|
||||
## Scope
|
||||
|
||||
Implemented Task 3 and the adjacent C01 trusted-snapshot correction without live calls or production access.
|
||||
|
||||
Files changed for this work:
|
||||
|
||||
- `active-runtime/app/aios_baseline/runtime_contract.py`
|
||||
- `active-runtime/app/aios_baseline/operations.py`
|
||||
- `active-runtime/app/reconciliation_controller.py`
|
||||
- `active-runtime/app/aios_chatgpt_bridge_v2_server.py`
|
||||
- `deploy/systemd/aios-chatgpt-bridge-v2-staging.service`
|
||||
- `tests/baseline_v1_1/test_contracts.py`
|
||||
- `tests/test_completed_replay_reconciliation_v7.py`
|
||||
- `tests/test_isolated_writer_exact_path_policy.py`
|
||||
- `tests/test_r7_atomic_recovery_and_bridge_sandbox.py`
|
||||
- `docs/implementation-baseline-1.1/CONTRACTS_REPORT.md`
|
||||
|
||||
No package `__init__.py` was changed by Task 3. No arbitrary command executor, production trust root, live call, deployment, or Git commit was created.
|
||||
|
||||
## Public functions and payload schemas
|
||||
|
||||
### `validate_contract(document: Mapping[str, Any]) -> dict[str, Any]`
|
||||
|
||||
Required stable contract fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"contract_schema": "aios.runtime-contract.v1",
|
||||
"contract_revision": 1,
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"tenant_scope": "tenant-a",
|
||||
"lifecycle": "active",
|
||||
"entrypoints": {
|
||||
"host": {
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://sample/status"
|
||||
}
|
||||
},
|
||||
"bindings": {},
|
||||
"resource_refs": {},
|
||||
"credential_refs": {},
|
||||
"operations": ["service-status"]
|
||||
}
|
||||
```
|
||||
|
||||
`lifecycle` is one of `active`, `candidate`, `legacy`, or `example`. Reference groups are mappings and may be empty. A reference value may be a symbolic string, which is normalized with the contract's component and environment, or an object containing matching `component_id` and `environment` plus exactly one of `resolver_ref`, `path_ref`, or `credential_ref`. If a reference object supplies `tenant_scope`, it must match the contract.
|
||||
|
||||
`path_ref` must be relative and cannot contain traversal, backslashes, or NUL. Credential references accept `credential://`, `secret://`, and `systemd-credential:` references; raw `/run/credentials/...` paths and inline secret fields are rejected. The keys `desired`, `desired_commit`, `desired_release`, `desired_target`, and `desired_targets` are rejected at every depth because release targets are separate objects.
|
||||
|
||||
### `evaluate_deployment(contract, target, observation, provenance, now) -> dict[str, Any]`
|
||||
|
||||
Target payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"release_id": "release-17",
|
||||
"deployment_id": "deployment-22",
|
||||
"artifact_digest": "sha256:<64 lowercase or uppercase hex characters>",
|
||||
"revision": 4
|
||||
}
|
||||
```
|
||||
|
||||
Observation payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"observed_at": "2026-09-07T11:59:30Z",
|
||||
"max_age_seconds": 60,
|
||||
"source": "systemd-readback.v1",
|
||||
"actual_artifact_digest": "sha256:<64 hex characters>",
|
||||
"healthy": true,
|
||||
"component_id": "sample",
|
||||
"environment": "staging",
|
||||
"deployment_id": "deployment-22",
|
||||
"revision": 4
|
||||
}
|
||||
```
|
||||
|
||||
`max_age` is accepted as an alias for `max_age_seconds`; `actual_artifact` and `artifact_digest` are accepted aliases for `actual_artifact_digest`. The final four binding fields are optional, but when present they must be well typed and match the target. `healthy` is nullable at the source boundary, but null is evaluated as `UNKNOWN`, never healthy.
|
||||
|
||||
Provenance payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"artifact_digest": "sha256:<64 hex characters>",
|
||||
"source_digest": "sha256:<64 hex characters>",
|
||||
"builder_digest": "sha256:<64 hex characters>"
|
||||
}
|
||||
```
|
||||
|
||||
`artifact_hash`, `source_hash`, and `builder_hash` are accepted aliases. `now` accepts a timezone-aware `datetime` or ISO-8601 string and is injected by tests. Missing, malformed, future, stale, or nullable evidence returns `status/state/verification = UNKNOWN`. A complete incompatible target returns `DRIFT`. Matching contract, target, observation, and provenance returns `status = VERIFIED`, `state = CONVERGED`, and `converged = true`. A repository's current HEAD is deliberately ignored.
|
||||
|
||||
### `OperationDefinition.from_dict(data)`
|
||||
|
||||
Operation definition payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "service-status",
|
||||
"version": 1,
|
||||
"effect_class": "READ_ONLY",
|
||||
"handler_ref": "host.service_status",
|
||||
"input_schema": "aios.operation.service-status.input.v1",
|
||||
"required_permissions": ["service-status"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "NONE", "required": false},
|
||||
"retry": {"mode": "SAFE", "max_attempts": 2},
|
||||
"reconcile": {"mode": "NONE"},
|
||||
"rollback": {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions` is accepted as an alias for `required_permissions`. Primary effect classes are `READ_ONLY`, `IDEMPOTENT`, `CONDITIONAL`, `RECONCILABLE`, and `IRREVERSIBLE`. Retry modes are `NEVER`, `SAFE`, `AFTER_READBACK`, and `RECONCILE_FIRST`. Readback, retry, reconcile, rollback, and owner-boundary data remain separate mappings rather than being inferred from the effect class. A service restart must declare required `EXACT` readback and reconciliation. Top-level `command`, `shell`, `argv`, `executable`, and `script` fields are rejected, and handler references cannot contain shell syntax.
|
||||
|
||||
### Registry and operation helpers
|
||||
|
||||
- `OperationRegistry(definitions).get(operation_id) -> OperationDefinition` performs lookup only and raises `KeyError` for an unregistered ID.
|
||||
- `OperationRegistry.definitions -> tuple[OperationDefinition, ...]` exposes immutable registration order.
|
||||
- `operation_timeout_action(definition, outcome="AMBIGUOUS", attempts=1) -> str` returns `RETRY`, `RECONCILE`, or `STOP`. It never retries an irreversible effect and requires reconciliation for an ambiguous mutating outcome when reconciliation/readback exists.
|
||||
- `verify_service_restart(before, after) -> dict` requires a changed instance identity and a later start timestamp. An active state without restart identity returns `UNKNOWN`.
|
||||
- `load_production_observation(path, now=None) -> dict` reads a bounded, regular, non-group/world-writable `production-observation.json` bundle with exact keys `contract`, `target`, `observation`, and `provenance`. It projects `ACTIVE` only after `evaluate_deployment` returns `VERIFIED`; otherwise it projects `UNKNOWN` without a baseline version or target OID.
|
||||
|
||||
## Reconciliation and snapshot corrections
|
||||
|
||||
`ReconciliationController.reconcile` now treats absent, null, wrong-type, or empty evidence as drift requiring reconciliation. This fixes the root cause where every missing pair compared as `None == None` and an empty mapping returned healthy.
|
||||
|
||||
`GovernedRuntime.snapshot` no longer embeds `V2R1C7`, `c7adece1cc8beffe7c718d53c775898b45d6c7f8`, or `ACTIVE`. It reads `<state_root>/production-observation.json`; absent or unverified evidence retains the existing three-field production response shape with `deployment_state: UNKNOWN`.
|
||||
|
||||
The staging unit now projects the existing encrypted credential ID directly for its own service:
|
||||
|
||||
```ini
|
||||
LoadCredentialEncrypted=gitea-governance-executor-token:/etc/credstore.encrypted/gitea-governance-executor-token
|
||||
Environment=AIOS_GITEA_TOKEN_FILE=%d/gitea-governance-executor-token
|
||||
```
|
||||
|
||||
The legacy bridge sandbox test now parses both directives and asserts that the projected credential name, encrypted-file basename, and consumer `%d` basename are identical. It also rejects any reference to the governance executor's runtime credential directory.
|
||||
|
||||
## Failure-first and verification evidence
|
||||
|
||||
Initial Task 3 collection before implementation:
|
||||
|
||||
```text
|
||||
ERROR tests/baseline_v1_1/test_contracts.py
|
||||
ModuleNotFoundError: No module named 'aios_baseline'
|
||||
1 error in 0.15s
|
||||
```
|
||||
|
||||
The empty optional groups and permissions-alias tests failed before their implementation:
|
||||
|
||||
```text
|
||||
FAILED test_structural_contract_allows_empty_optional_reference_groups
|
||||
FAILED test_read_only_definition_accepts_permissions_alias_and_registry_lists_definitions
|
||||
2 failed, 39 passed in 0.20s
|
||||
```
|
||||
|
||||
The trusted snapshot tests failed before C01 implementation:
|
||||
|
||||
```text
|
||||
ImportError: cannot import name 'load_production_observation'
|
||||
1 error in 0.28s
|
||||
```
|
||||
|
||||
Focused Task 3, C01, and legacy reconciliation command:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/baseline_v1_1/test_contracts.py tests/test_autonomy_v1_components.py
|
||||
.................................................................... [100%]
|
||||
68 passed in 0.30s
|
||||
```
|
||||
|
||||
Existing Bridge/control-plane compatibility command:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py tests/test_control_plane_convergence_contract.py tests/test_canonical_task_projection_recovery.py
|
||||
............................................ [100%]
|
||||
44 passed in 0.99s
|
||||
```
|
||||
|
||||
The standalone completed-replay HTTP test originally returned `423` with body error `TASK_PRINCIPAL_WRITES_DISABLED`. Its mutating-operation adapter exposed no `getRuntimeStatus`, and the fixture provided neither the required TaskPrincipal verifier nor an authorization header. The fixture now supplies the exact ready gate facts and authenticated verifier; no production gate was relaxed:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_completed_replay_reconciliation_v7.py
|
||||
........... [100%]
|
||||
11 passed in 0.60s
|
||||
```
|
||||
|
||||
Fresh final scoped verification across Task 3, C01, Bridge/control-plane compatibility, completed replay, and isolated-writer fixtures:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/baseline_v1_1/test_contracts.py tests/test_autonomy_v1_components.py tests/test_action_contract_regressions.py tests/test_bridge_v2_governed_http_bootstrap.py tests/test_control_plane_convergence_contract.py tests/test_canonical_task_projection_recovery.py tests/test_completed_replay_reconciliation_v7.py tests/test_isolated_writer_exact_path_policy.py tests/test_isolated_writer_policy.py
|
||||
........................................................................ [ 47%]
|
||||
................................................................................ [100%]
|
||||
152 passed, 64 subtests passed in 1.87s
|
||||
```
|
||||
|
||||
`git diff --check` completed with no output for all files in this report.
|
||||
|
||||
The isolated-writer legacy subset initially had two pristine-baseline negative-fixture failures. The audience and branch tests did not establish the required `gitops` group lookup, so the unchanged production guard correctly stopped at `isolated_writer_path_policy_group_rejected` before either intended assertion. The fixtures now supply only that valid prerequisite; production guard order and strictness are unchanged:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_isolated_writer_exact_path_policy.py tests/test_isolated_writer_policy.py
|
||||
........................ [ 82%]
|
||||
..... [100%]
|
||||
29 passed, 64 subtests passed in 0.40s
|
||||
```
|
||||
|
||||
Focused legacy systemd assertion:
|
||||
|
||||
```text
|
||||
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=/workspace/scratch/63950cb15f5c/test_deps:$PWD/active-runtime/app python3 -m pytest -q tests/test_r7_atomic_recovery_and_bridge_sandbox.py
|
||||
.. [100%]
|
||||
2 passed in 0.04s
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# AIOS Implementation Baseline 1.1 Implementation Plan
|
||||
|
||||
> For agentic workers: use the approved user architecture and the task interfaces below. Implement continuously, with failure-first tests and scoped review. No production access is part of this package-build task.
|
||||
|
||||
Goal: deliver source changes, runnable integration/acceptance, safe migration/package tools and precise deployment instructions for the seven final architecture revisions.
|
||||
Architecture: extend the existing Task/Job/Release authorities through explicit adapters. RuntimeContract describes stable structure; deployment targets are dynamic Release-owned records. Historical authorization wire formats remain frozen. New Grant and OperationalEpoch are separate objects. Generic operation execution persists intent before effects and uses receiver fencing and reconciliation.
|
||||
Tech stack: Python 3.11+, SQLite/Git/systemd, existing cryptography and pytest; no new online services or middleware.
|
||||
Spec: ARCHITECTURE_BASELINE_1_1.md (generated from the approved baseline and current user's seven revisions).
|
||||
|
||||
Global constraints:
|
||||
- The imported ZIP SHA256 is fd28f8f065873c5600f17de0a536cae6f4bc18567687fd3f178d67ad8c1f931a; local snapshot commit is an import identity, not remote main.
|
||||
- Legacy global writes remain false. No arbitrary remote shell, generated production trust roots, direct production policy DB writes or implicit authority cutover.
|
||||
- Tenant and subject come from authenticated service context. All durable identity links are tenant-scoped. Existing TaskPlaneStore remains the task authority for the explicit standalone profile; existing official Task API integrations use adapters and activation evidence, not silent replacement.
|
||||
- Every code task: write behavioral tests, observe expected failure, implement, run covering regression, record output. Package inspection and deployment preflight are read-only by default.
|
||||
|
||||
## Task 1: Existing Job reliability
|
||||
Files: aios_gitops_job_controller.py, aios_gitops_job_executor.py, tests/baseline_v1_1/test_job_safety.py and affected existing fixtures.
|
||||
Deliver: finite leases/state checks, recoverable reconcile claims/epoch CAS, universal dry-run, complete operation receipt/readback. Unknown effects must not blindly repeat; DB failure latches stop further effects until explicit successful reconciliation. Preserve old campaign authorization limits.
|
||||
Tests: dry-run mutator never called; expired and terminal lease refused; observer crash/reordering; duplicate execution; intent commit failure; external success followed by receipt storage failure.
|
||||
|
||||
## Task 2: Signed authorization chain
|
||||
Files: aios_baseline/authorization.py; tests/baseline_v1_1/test_authorization.py.
|
||||
Deliver: signed owner-delegation-grant.v1, new operational-epoch.v1 and task-principal.v1 with root grant digest/generation, persistent grant/revocation state, expiry/UNTIL_REVOKED, narrowing, Ed25519 verified trusted keys. Preserve old v1/v2 modules.
|
||||
Tests: signature tampering, fake parent Mapping, cross-tenant/subject/task/scope, revoked generation, expired proof auto-refresh under active grant, restart persistence.
|
||||
|
||||
## Task 3: Truth and operation contracts
|
||||
Files: aios_baseline/runtime_contract.py, aios_baseline/operations.py, reconciliation_controller.py; tests/baseline_v1_1/test_contracts.py.
|
||||
Public interfaces: validate_contract(document)->dict; evaluate_deployment(contract,target,observation,provenance,now)->dict; OperationDefinition.from_dict(data); OperationRegistry(definitions).get(operation_id).
|
||||
Deliver: structural contract rejects dynamic desired; dynamic target/observation/provenance validation and UNKNOWN, effect class plus orthogonal readback/retry/reversibility policy. No arbitrary command execution in registry.
|
||||
|
||||
## Task 4: Task loop and identity linkage
|
||||
Files: aios_baseline/lineage.py, service.py, worker.py, http_api.py, host.py; existing task_plane through adapters; tests/baseline_v1_1/test_loop.py.
|
||||
Deliver: use TaskPlaneStore for tasks/claims; authenticate tenant-scoped thin Action; persistent correlation graph; registry-based fallback eligibility at ready_at+600; bounded subprocess host-status operation; cancellation and evidence; no special main-agent branch. Worker heartbeat/transport are real.
|
||||
Tests: authenticated HTTP submit->claim->real local diagnostic->receipt->result; tenant denial; duplicate submission; restart and fallback; lineage referential constraints.
|
||||
|
||||
## Task 5: Release and optimization
|
||||
Files: aios_baseline/release.py, optimization.py; tests/baseline_v1_1/test_release_optimization.py.
|
||||
Deliver: Release-owned targets, deployment/acceptance IDs and source lineage, recipe/receipt adapters to governed operations; bounded OptimizationEnvelope, proposal/task-only execution, objective quality floors. No direct policy mutation.
|
||||
Tests: acceptance wrong deployment rejected; restartable saga; stale target CAS; forbidden optimization; quality regression stops promotion.
|
||||
|
||||
## Task 6: Packaging, activation and acceptance
|
||||
Files: scripts/baseline_v1_1/*, deploy/baseline_v1_1/*, docs/implementation-baseline-1.1/*; tests/baseline_v1_1/test_package_tools.py.
|
||||
Deliver: manifest with before/after hashes, patch/full source packages, conflict-safe stage/apply/rollback, authority and credential preflight, sanitized config examples, Linux systemd profile, full AT-01..23 matrix with actual statuses. AT-22 uses subprocess crash plus durable separate effect sink; physical power failure is marked online-required. AT-23 exercises SQLite and receipt failures with fail-closed admission.
|
||||
Tests: clean staging, wrong baseline/tamper refusal, repeated apply, rollback conflict refusal, credential path validation; run integrated regression and public-interface suites.
|
||||
|
||||
Rulings:
|
||||
- Effect class is primary dispatch metadata, with independent retry/readback/rollback properties, since categories overlap.
|
||||
- Historical owner-delegation-epoch.v2 is not relabeled or reinterpreted. New proof schema refers to the new Grant; migration verifies original bytes.
|
||||
- Live activation requires observed official authority bindings and valid installed signing identities. Packaging does not claim production activation.
|
||||
@@ -0,0 +1,146 @@
|
||||
# AIOS Implementation Baseline 1.1:安装、验证与回滚
|
||||
|
||||
本包交付架构修订、可执行代码、隔离验证闭环和源码变更工具。默认运行 `isolated-validation` profile;线上 Task/Job/Release 的实际写权威绑定、真实发布和生产业务验收仍须通过现场门禁。源码包安装完成不能作为生产发布完成的证据。
|
||||
|
||||
## 1. 先确认包身份和运行前提
|
||||
|
||||
提供的源码来自用户上传 ZIP,原始 ZIP SHA-256 为 `fd28f8f065873c5600f17de0a536cae6f4bc18567687fd3f178d67ad8c1f931a`。本地导入基线为 `f5d015249604ce5bdacfd707608f83e8a3875f60`;这是离线快照身份,不是远端 main 或当前 runtime 的证明。
|
||||
|
||||
最终包的顶层路径约定:
|
||||
|
||||
| 路径 | 用途 |
|
||||
| --- | --- |
|
||||
| `source/aios/` | 完整修改后源码;首先阅读根 README 和本文件 |
|
||||
| `source-baseline.git.bundle` | 可离线恢复原始导入 commit 的 Git 对象 |
|
||||
| `source-changes.zip` | 每文件 before/after hash、旧/新内容及 manifest |
|
||||
| 根目录校验清单与验收结果 | 本次实际生成的包身份及本地测试证据;以根 README 中的实际文件名为准 |
|
||||
|
||||
需要 Python 3.11+、Git,以及源码依赖清单中固定版本的 `cryptography`。运行测试需要 `pytest`。真实服务诊断需要 Linux/systemd 以及可读取状态的现存 `.service`。源码包工具只依赖 Python 标准库和 Git。使用包内依赖说明准备独立虚拟环境,不自动升级或覆盖正在运行的 AIOS Python 环境。
|
||||
|
||||
SHA-256 校验只证明字节完整性;它不证明 Owner 业务授权。使用可信的交付来源,并保留实际下载包的校验值。不要将测试生成的密钥、token 或 Grant 移入生产信任域。
|
||||
|
||||
## 2. 源码隔离部署:先 dry-run,再 stage
|
||||
|
||||
以下命令从已解压完整包的顶层执行。路径变量只用于这次安装;所有目标目录必须全新,父目录须存在。
|
||||
|
||||
```bash
|
||||
export AIOS_PACKAGE_ROOT="$PWD"
|
||||
export AIOS_BASE_REPO="$AIOS_PACKAGE_ROOT/clean-baseline"
|
||||
export AIOS_STAGE="$AIOS_PACKAGE_ROOT/staged-baseline-1.1"
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" verify \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip"
|
||||
|
||||
git init "$AIOS_BASE_REPO"
|
||||
git -C "$AIOS_BASE_REPO" fetch "$AIOS_PACKAGE_ROOT/source-baseline.git.bundle" HEAD
|
||||
git -C "$AIOS_BASE_REPO" checkout --detach FETCH_HEAD
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" stage \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip" \
|
||||
--source "$AIOS_BASE_REPO" --destination "$AIOS_STAGE" --dry-run
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" stage \
|
||||
"$AIOS_PACKAGE_ROOT/source-changes.zip" \
|
||||
--source "$AIOS_BASE_REPO" --destination "$AIOS_STAGE"
|
||||
```
|
||||
|
||||
`stage` 不修改 `--source`。它校验源仓库 clean、HEAD、所有基线受跟踪文件的 hash/mode,再创建独立副本;应用 manifest 后保留基线 HEAD 和可审查的工作区差异。它拒绝既有目标、未知目标覆盖、符号链接、路径穿越、ZIP 未声明成员、缺失文件、错基线及字节篡改。不会启动 systemd、写线上权威库、启用 legacy global write 或运行仓库脚本。
|
||||
|
||||
已有真实仓库与导入基线不一致时,`stage` 拒绝是预期行为。不要使用 `--force`、重写线上 HEAD 或伪造 before hash。将包内变化在真实仓库的独立 worktree 中审查/移植,重新建立该仓库的源码与验收身份,再走原有变更治理链。本工具没有绕过开关。
|
||||
|
||||
Git 忽略文件不属于基线内容,不会复制到候选目录。不要在源目录依赖未声明的 overlay、运行态数据库或私有凭证。工具当前以普通文件和可执行普通文件为交付对象,遇到符号链接、Git submodule、异常文件布局或超限包会安全拒绝。
|
||||
|
||||
## 3. 执行本地验收
|
||||
|
||||
在已准备依赖的独立 Python 环境中运行:
|
||||
|
||||
```bash
|
||||
cd "$AIOS_STAGE"
|
||||
export PYTHONPATH="$AIOS_STAGE/active-runtime/app"
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
python3 -m pytest -q tests/baseline_v1_1
|
||||
```
|
||||
|
||||
本地测试包含真实 SQLite、Ed25519 签名、临时 Git 仓库、HTTP transport 和受限子进程等行为测试;其中可控替身用于模拟远端系统和故障。测试通过不代表已连接用户主机、不代表主机断电与磁盘耗尽已现场复现,也不代表生产业务验收通过。具体 AT-01~AT-23 范围见 `ACCEPTANCE_MATRIX.md` 和本次实际测试结果。
|
||||
|
||||
保存测试命令、Python/依赖版本、源码身份、配置摘要和退出码;不要把 token 或私钥放进验收报告。任何源代码或运行配置变化后,旧证据不能无条件用于放行新候选。
|
||||
|
||||
## 4. 跑第一条真实非发布闭环
|
||||
|
||||
选择当前 Linux 主机中一个已存在、允许当前用户只读查询的服务,并将下方 `YOUR_EXISTING_SERVICE.service` 替换成准确的 unit 名。此步骤只调用固定的 `systemctl show` 状态读取;不重启服务。
|
||||
|
||||
```bash
|
||||
export AIOS_VALIDATION_DIR="$AIOS_PACKAGE_ROOT/validation-run"
|
||||
python3 -m aios_baseline.cli init-validation \
|
||||
--directory "$AIOS_VALIDATION_DIR" \
|
||||
--service-unit YOUR_EXISTING_SERVICE.service
|
||||
|
||||
python3 -m aios_baseline.cli preflight --config "$AIOS_VALIDATION_DIR/server.json"
|
||||
python3 -m aios_baseline.cli grant-import \
|
||||
--config "$AIOS_VALIDATION_DIR/server.json" --grant "$AIOS_VALIDATION_DIR/grant.json"
|
||||
```
|
||||
|
||||
`init-validation` 仅为新建的本地验证实例生成测试身份和一个最多 20 个 effect、限定 `service-status/sample/staging` 的 Grant。它不会覆盖既有目录。实际部署使用已安装的信任身份和明确签署的业务授权,不复用这里生成的密钥。`preflight` 返回 `CONFIG_VALID` 仅表示配置检查通过,同时会明确 `online_authority=NOT_VERIFIED`、`production_activation=BLOCKED`。
|
||||
|
||||
在终端 A 启动服务:
|
||||
|
||||
```bash
|
||||
python3 -m aios_baseline.cli server --config "$AIOS_VALIDATION_DIR/server.json"
|
||||
```
|
||||
|
||||
在已设置相同路径变量和 `PYTHONPATH` 的终端 B 提交目标并运行真实 Worker:
|
||||
|
||||
```bash
|
||||
python3 -m aios_baseline.cli goal-submit \
|
||||
--token-file "$AIOS_VALIDATION_DIR/user.token" \
|
||||
--request "$AIOS_VALIDATION_DIR/goal.json" \
|
||||
--idempotency-key first-service-diagnostic
|
||||
|
||||
python3 -m aios_baseline.cli worker \
|
||||
--config "$AIOS_VALIDATION_DIR/worker.json" --once
|
||||
|
||||
python3 -m aios_baseline.cli goal-explain \
|
||||
--token-file "$AIOS_VALIDATION_DIR/user.token" \
|
||||
--mission-id validation-mission
|
||||
```
|
||||
|
||||
关联链应包含 Task、effect/job 和经验证的诊断 receipt;这是非发布任务,不应虚构 Release/Deployment/Acceptance。进程完成“诊断”与被诊断服务 `healthy=true` 是两个不同事实。服务不健康时仍须返回真实 observation,不能将诊断执行成功冒充组件健康。
|
||||
|
||||
常驻 Worker 使用相同命令去掉 `--once`。聊天中的 GPT 没有此 Worker 的常驻身份、heartbeat 或 transport,不是 fallback worker。将受信 Worker 配置的 `fallback_executor` 设为 true 后,调度按 readiness 时间扩大其 eligibility,仍走同一 CAS、lease 和 fencing。
|
||||
|
||||
## 5. systemd 验证实例与现场门禁
|
||||
|
||||
候选 unit 位于 `deploy/baseline-v1.1/`,均以 `.example` 结尾。API 与 Worker 使用独立 UID、各自 credential projection、固定 loopback transport 和有限资源。替换 `@RELEASE_ROOT@` 和 `@PYTHON_EXECUTABLE@`(已验收的独立 Python 环境)、配置只读发行目录、既有信任身份与独立状态目录之后,先执行 `systemd-analyze verify` 检查已渲染 unit。详细凭证名称及隔离规则见该目录的 `CREDENTIALS.md`。
|
||||
|
||||
本包不会自动创建系统用户、复制凭证、安装或启用 unit。上述操作由实际安装角色在已批准的主机范围内完成。它们是配置绑定与部署行为,不要求重新签署仍有效的业务 Grant;若实际组件、环境、破坏性级别或预算超出授权,才进入真实 Owner 边界。
|
||||
|
||||
将 `ACTIVATION_GATES.json` 复制为本次验收记录并逐项填入证据引用。不得把 `NOT_RUN` 批量改成 PASS。生产接入需要另外完成官方 Task authority adapter、Release recipe 的实际受治理 operation adapter、可信业务验收 verifier、单一活跃写权威切换和恢复演练。本地 validation profile 明确拒绝 production 配置。
|
||||
|
||||
## 6. 回滚与存储恢复
|
||||
|
||||
仅回滚隔离候选源码:
|
||||
|
||||
```bash
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" rollback \
|
||||
--directory "$AIOS_STAGE" --dry-run
|
||||
|
||||
python3 "$AIOS_PACKAGE_ROOT/source/aios/tools/baseline_v1_1/bundle.py" rollback \
|
||||
--directory "$AIOS_STAGE"
|
||||
```
|
||||
|
||||
工具在首次写入前检查所有受影响文件当前仍为已部署 after hash/mode,并要求 HEAD 未产生后续提交;发现本地修改、符号链接或对象冲突即拒绝,不覆盖后改内容。回滚只恢复包内源码,不撤销远端副作用、不回退数据库、不修改 systemd。执行回滚前停止使用该候选目录的验证进程;对运行中的发行目录不使用本工具原地回滚。
|
||||
|
||||
源码回退与业务补偿是不同操作。Release 是否允许回退由目标 artifact、数据兼容性和已验证 rollback operation 决定。存储恢复需先停止新 effect 准入,使用 SQLite backup API 或停止写者后的完整一致副本,同时恢复 Git/receipt/关联身份;只复制活跃 SQLite 主 DB 文件不构成可靠备份。恢复后先对账未决 effect,再恢复调度,记录实测 RPO/RTO。无法可靠记录 intent/lease/receipt 时,不得继续产生新副作用。
|
||||
|
||||
## 7. 重新生成变更包
|
||||
|
||||
由维护者在受审查的修改后源码仓库中执行;输出必须位于源码目录之外,且不能覆盖既有文件:
|
||||
|
||||
```bash
|
||||
python3 tools/baseline_v1_1/bundle.py create \
|
||||
--repo "$AIOS_STAGE" \
|
||||
--base f5d015249604ce5bdacfd707608f83e8a3875f60 \
|
||||
--output "$AIOS_PACKAGE_ROOT/reviewed-source-changes.zip"
|
||||
```
|
||||
|
||||
输出 manifest 保存每个 added/modified/deleted 文件的 before/after SHA-256 与 mode,并包含精确的旧、新内容。重新生成后须重新验收和记录新包身份;不能沿用旧包的验收结论。
|
||||
@@ -0,0 +1,91 @@
|
||||
# Job safety implementation report
|
||||
|
||||
## Scope
|
||||
|
||||
Task 1 hardens the existing SQLite Job controller and campaign executor. Legacy
|
||||
campaign, repository, branch, authorization-digest, protected-branch and
|
||||
machine-identity restrictions remain in force.
|
||||
|
||||
## Delivered behavior
|
||||
|
||||
- Branch and typed resource leases require an `AUTHORIZED` Job, a timezone-aware
|
||||
future deadline no more than 300 seconds away, and revision CAS. Effect
|
||||
receivers recheck the active lease ID, expiry and generation.
|
||||
- Generic registered operations can persist an `aios.operation-job.v1` binding
|
||||
in the existing JobStore. Resource generations are allocated monotonically per
|
||||
`(resource_kind, resource_id)` and are available through
|
||||
`get_execution_lease()`.
|
||||
- Generic Jobs use an explicit transition graph. `LOCAL_VERIFIED` and
|
||||
`COMPLETED` require a request-bound, lease-bound operation receipt accepted by
|
||||
the mandatory `generic_receipt_verifier`; direct `REQUESTED` to `COMPLETED`
|
||||
transitions and self-asserted booleans are rejected.
|
||||
- Reconciliation uses a durable claim ID, incrementing epoch, base revision and
|
||||
short deadline. An expired `RECONCILING` claim can be taken over; late observer
|
||||
results fail with `RECONCILIATION_CLAIM_LOST`.
|
||||
- Every campaign executor operation passes through the same dry-run boundary.
|
||||
Dry-run performs validation/read-only planning and leaves the Job in
|
||||
`REQUESTED`; no mutating adapter is called.
|
||||
- Real execution persists `EXECUTING / INTENT_COMMITTED` before an effect,
|
||||
checks the lease at the effect boundary, performs exact readback and commits a
|
||||
receipt with effect ID, operation and observation. An adapter without exact
|
||||
readback yields `RECONCILIATION_REQUIRED`, `UNKNOWN` and the explicit
|
||||
`EXACT_READBACK_UNSUPPORTED` blocker; it does not fabricate completion.
|
||||
- A persistence failure latches the executor against further effects. Durable
|
||||
in-flight/UNKNOWN Jobs also block new-effect admission after restart through
|
||||
`assert_effect_admission()`. Explicit reconciliation is the recovery path.
|
||||
- COMMIT_FILE now restores its content-addressed `payload_ref` and `payload_size`
|
||||
from the durable request. Its current Authority adapter cannot identify an
|
||||
unreferenced commit from intent alone, so execution reports the explicit exact
|
||||
readback blocker instead of risking a duplicate commit.
|
||||
|
||||
Campaign readback support is explicit: branch advance reads Authority and Gitea,
|
||||
branch sync reads Gitea, and main reconciliation reads Authority. PR, status and
|
||||
merge operations require adapter methods `read_pr`, `read_status` and
|
||||
`read_merge`; absent methods yield the blocker above before an effect. This is a
|
||||
safe integration requirement for the current adapters, not a success claim.
|
||||
|
||||
## Integration API
|
||||
|
||||
For a registered non-Git operation:
|
||||
|
||||
1. `submit()` with schema `aios.operation-job.v1` and tenant, subject, mission,
|
||||
grant, task, effect, operation/version, resource kind/ID and input digest.
|
||||
2. `advance(job_id, AUTHORIZED, ..., expected_revision=...)`.
|
||||
3. `lease_resource_job(..., generation=None, lease_expires_at=..., expected_revision=...)`.
|
||||
4. Read the assigned fence through `get_execution_lease(job_id)`.
|
||||
5. Call `assert_effect_admission(exclude_job_id=job_id)` before admitting a new
|
||||
effect and `assert_execution_lease(job_id, lease_id=..., generation=...)`
|
||||
immediately before the handler.
|
||||
6. Persist `EXECUTING` intent before the handler and a verified receipt plus
|
||||
`COMPLETED` state after exact readback. On ambiguity call `reconcile()` with a
|
||||
read-only observer; do not repeat the handler.
|
||||
|
||||
Construct the controller with
|
||||
`generic_receipt_verifier=<trusted Ed25519 envelope verifier>`. Generic operation
|
||||
completion is unavailable when this verifier is absent.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
Failure-first run before implementation: `13 failed` in the new safety suite,
|
||||
all at the missing clock/reconciliation/executor boundaries.
|
||||
|
||||
Latest focused run before this report:
|
||||
|
||||
```text
|
||||
................................................ [100%]
|
||||
48 passed in 1.13s
|
||||
```
|
||||
|
||||
This combines 16 Task 1 safety tests (including AT-22 subprocess crash and
|
||||
AT-23 persistence fault injection) with the pre-existing selected 32 Job tests.
|
||||
No network or production system was used.
|
||||
|
||||
The separate completed-replay regression suite reports `11 passed in 1.16s`.
|
||||
The generic operation loop integration reports `6 passed in 0.55s`.
|
||||
|
||||
Final combined run:
|
||||
|
||||
```text
|
||||
................................................................. [100%]
|
||||
65 passed in 1.71s
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# M0–M5 实施手册与退出标准
|
||||
|
||||
本手册区分已交付本地代码、尚待接线的现场能力和明确剩余实现。先按 `INSTALLATION.md` 校验包、建立独立 stage 并执行实际测试命令;所有阶段保留源码、配置、授权及证据身份。线上部署仍沿用既有批准的变更边界,不能把 validation profile 当作正式 authority adapter。
|
||||
|
||||
当前使用者可立即执行本地 CLI 诊断闭环、测试和源码包工具。标为“剩余实现”的项目必须完成代码及针对性测试;标为“现场绑定”的项目必须提交真实目标证据,不能仅填写配置后标为完成。同一有效 Grant 内的机器证明续期和恢复不重复业务审批。
|
||||
|
||||
## M0 — Truth & Identity
|
||||
|
||||
**已交付代码:** `runtime_contract.py` 校验静态合同并拒绝动态 desired;目标/观测/provenance 评价;缺失 reconciliation 观测不再默认健康;运行 snapshot 的可信状态修正;消费服务自身 encrypted credential projection;源码 before/after hash 工具。
|
||||
|
||||
**输入:** 原始源码/制品身份、组件清单、正式入口、权威角色、命名资源和凭证引用、受信配置来源。
|
||||
|
||||
**执行:** 先只读采集 authority map;分别建立 Contract、DeploymentTarget、Observation 和 provenance。核对目标资源一致及新鲜度,显式保留 UNKNOWN。对已渲染配置执行 preflight;检查每个服务自身的凭证投影,按组件登记缺失项。
|
||||
|
||||
**剩余实现/现场绑定:** 正式在线 Resolver、按操作 Doctor 和自动修复目录;真实 package/build identity 与运行内容对照;现场已有目标映射到 tenant/component/environment 后的冲突检测。当前固定绑定不是通用 Resolver 已完成。
|
||||
|
||||
**退出标准:** 执行者能机器回答对象、入口、desired、observed、artifact/source、CredentialReference 和证据年龄;AT-01/02/13/15 对应范围有证据。main 前进不误判 drift,UNKNOWN 不放行。**回滚:** 撤销候选解析/配置切换,保留旧权威读取;不批量迁移目录,不重写动态 target 来迎合旧观测。
|
||||
|
||||
## M1 — Authorization & Effect Safety
|
||||
|
||||
**已交付代码:** 新 Grant/OperationalEpoch/Principal 签名链、撤销和 effect 预算;真实资源 lease 及单调 fencing;Job intent/receipt、可接管对账;统一 dry-run;持久未决 effect 阻止新 effect 的恢复门禁。
|
||||
|
||||
**输入:** 已安装 Owner 公钥、机器签名身份、明确有效 Grant、OperationDefinition、精确资源及读回接口。
|
||||
|
||||
**执行:** 注册已验签 Grant;自动派生收窄 Principal;在同一 intent 下申请真实 lease;执行前再次检查根授权/预算/接收端 fence。验证签名伪造、撤销、过期、scope 重放、CAS 竞争及 dry-run 无副作用;注入成功后 receipt 丢失、存储失败和对账 observer 乱序。
|
||||
|
||||
**剩余实现/现场绑定:** 每个真实写 adapter 的 effect-keyed readback 与接收端 fencing;COMMIT_FILE 未引用 commit 的可定位恢复;实际密钥轮换、掉电、ENOSPC/WAL 故障。缺能力时维持显式 blocker,不能自动退回旧 endpoint。
|
||||
|
||||
**退出标准:** AT-03~07、17、22、23 的适用项通过;intent 无法提交不执行,未知结果不盲重试。**回滚:** 停止新 effect 准入,保留 Grant 撤销记录、intent 和 receipt,先对账再恢复;代码回退不能删掉未决 Job 或重新解释旧签名。
|
||||
|
||||
## M2 — Real Autonomous Loop
|
||||
|
||||
**已交付代码:** 认证 HTTP 薄入口、既有 TaskPlaneStore 适配、Worker transport/heartbeat、能力与 fallback 策略、固定参数 Host status executor、签名诊断 receipt、Task 结果和 `goal-explain`。
|
||||
|
||||
**输入:** M0/M1 基线、现存可读 `.service`、隔离状态目录、用户/Worker 固定认证身份、显式声明的 Worker scopes。
|
||||
|
||||
**执行:** 按安装手册执行 `init-validation → preflight → grant-import`;启动 server,提交 goal,运行 Worker,查询任务及 mission。尽早通过这条真实非发布闭环接线;之后测试重复请求、跨租户访问、取消、超时、重启以及 ready+600 秒 fallback 竞争。
|
||||
|
||||
**剩余实现/现场绑定:** 正式线上 Task API adapter;完整 blocked Doctor/修复/升级;目标级总计算和模型费用计量;artifact/cache 隔离;通知投递器/inbox 的故障闭环。现有 effect 数和任务 attempt 预算不是全部成本预算。
|
||||
|
||||
**退出标准:** 真实常驻 Worker 经正式 admission/claim 执行已注册诊断并返回可归因 receipt;无伪造 Release;AT-08~12/14 的实施范围清晰。**回滚:** 停止候选 admission/Worker,恢复未决 claim/Job;已有 Task 不因切换丢失,不建立第二套线上 Task 表。
|
||||
|
||||
## M3 — Release Closure
|
||||
|
||||
**已交付代码:** `LineageLedger` 不可改绑关系;`ReleaseLedger` 动态 target CAS、不可变 recipe、稳定阶段 effect、saga 恢复;独立签名 receipt/acceptance verifier。ReleaseEpoch 语义由 release 身份及发布记录承载,不新增通用超级状态表。
|
||||
|
||||
**输入:** 真实 Task/Job/Release 权威绑定、组件部署资源键、受保护 merge/build/deploy 操作、已验签 receipt、业务验收 verifier,以及同一 Grant 覆盖的准确阶段范围。
|
||||
|
||||
**执行:** 先核验资源主键并补齐正式适配器。将每个 recipe 阶段接到正式 Job 权威,显式传递 task/grant/effect/job 身份,先持久 effect 后调用;验证 readback、权威 Job 和签名绑定,再推进 saga。执行同 Grant 的 patch→tests→受保护 merge→build→staging→业务验收→production→生产业务验收;至少注入一次 ambiguous publication 或 Worker crash 并恢复。
|
||||
|
||||
**剩余实现/现场绑定:** 以上真实 mutation receiver、精确 publication readback、受信验收来源与完整发布链。本包不将历史 HTTP 写端点包成绕过新授权的 adapter;模拟 callback 成功仅证明状态机约束。
|
||||
|
||||
**退出标准:** AT-16/17/19/20 有真实链路证据;acceptance 精确绑定 deployment+release+artifact。当前状态为生产发布 **NOT_VERIFIED**。**回滚:** 仅使用预先声明兼容的 rollback release/artifact 与受治理补偿操作;不可逆迁移走前向修复,保留失败部署事实。
|
||||
|
||||
## M4 — Surface Reduction
|
||||
|
||||
**已交付代码:** 不可改绑身份模型、源码 manifest、隔离 stage/冲突保护 rollback、部署门禁清单。它们支持切换审查,不自动停用线上写者。
|
||||
|
||||
**输入:** 实际活跃 writer 清单、旧任务/schema/签名、迁移映射、接入路由、备份和最小恢复入口。
|
||||
|
||||
**执行:** 按事实域和资源范围列出当前写入口;先验证新正式入口,再停止旧入口接受新任务,保留旧任务 handler 或进行明确迁移。逐一删除旁路调用、跨服务凭证路径和 production legacy imports。使用 SQLite backup API 或停止写者后的完整一致副本,在隔离环境恢复 SQLite/Git/receipt 并对账未决效果。
|
||||
|
||||
**剩余实现/现场绑定:** 实际旧 writers 排空和路由切换、历史任务映射、数据库兼容迁移、整机一致备份与恢复脚本/演练。不能伪造历史缺失的 Task 或 receipt。
|
||||
|
||||
**退出标准:** 每类事实 active write authority=1;旧 Task/Release writers 和 legacy 新任务入口均为 0;cross-service credential path、production legacy imports 为 0;AT-19~21 完成并记录实测 RPO/RTO。当前未宣称达标。**回滚:** 仅按已验证迁移/handler 兼容计划恢复单一入口;不同时重开新旧 writers,不将只复制 SQLite 主文件当一致恢复。
|
||||
|
||||
## M5 — Optimization
|
||||
|
||||
**已交付代码:** 签名 Envelope 校验、封闭低风险参数目录、提案持久化、可信实验 receipt、质量方向/底线判断、无改善停止,以及只生成受治理晋升 Task 的接口。
|
||||
|
||||
**输入:** 已稳定通过前述闭环的运行证据、显式 Envelope、基线/候选指标、计算预算、强制测试/质量门、回退计划。
|
||||
|
||||
**执行:** 首批选 retry/backoff、有限 Worker routing、证据复用、timeout、poll、通知去重等可测参数。记录提案,提交实验 Task,消费可信结果;只有满足质量底线、预算和最小改善才提交晋升 Task,再按普通治理链落地。
|
||||
|
||||
**剩余实现/现场绑定:** 真实实验执行、费用计量、运行参数应用和回退 adapter;模型成本路由及经验适用范围管理。首版不自动重写架构核心代码或生产迁移规则。
|
||||
|
||||
**退出标准:** AT-18 通过;授权政策、信任根、隔离、验收标准和 Grant 语义始终不在可优化范围;提案不能直接写 policy/runtime DB。**回滚:** 停止新提案与晋升,按受治理 Task 恢复已记录旧值;保留失败实验和停止原因,不降低验收标准换取成功。
|
||||
@@ -0,0 +1,105 @@
|
||||
# Bounded Optimization Implementation Report
|
||||
|
||||
## Scope
|
||||
|
||||
Baseline 1.1 adds a proposal-only optimization controller. It can record a
|
||||
candidate and ask the authenticated task service to evaluate or promote it. It
|
||||
has no adapter for policy, authorization, trust-root, tenant-isolation,
|
||||
acceptance, destructive-operation, OwnerGrant, production-migration, or runtime
|
||||
configuration writes.
|
||||
|
||||
The implemented public interfaces are:
|
||||
|
||||
- `OptimizationEnvelope.from_dict(data, *, verify_envelope)`
|
||||
- `validate_proposal(envelope, proposal)`
|
||||
- `OptimizationController(path, envelope, submit_task, *, verify_receipt)`
|
||||
- `OptimizationController.propose(proposal, *, authenticated_context)`
|
||||
- `OptimizationController.record_evaluation(proposal_id, evaluation, *, authenticated_context)`
|
||||
- `OptimizationController.promote(proposal_id, promotion, *, authenticated_context)`
|
||||
- `OptimizationController.get_proposal(proposal_id, *, authenticated_context)`
|
||||
|
||||
## Trust and authority boundaries
|
||||
|
||||
Envelope parsing requires a verifier callback. The callback receives the key
|
||||
ID, canonical unsigned JSON bytes, and signature. A missing verifier, a
|
||||
verification exception, a non-`True` result, or any post-signature change is
|
||||
rejected. Semantic validation happens after signature verification and limits a
|
||||
signed envelope to a built-in set of low-risk parameter paths and hard numeric
|
||||
ranges. A trusted signer therefore cannot accidentally turn the envelope into a
|
||||
generic patch or broad capability mechanism.
|
||||
|
||||
Task receipts are authenticated through a separate required verifier. The
|
||||
controller then checks the receipt schema, evaluation task ID, proposal ID,
|
||||
proposal digest, action, success result, and digest of the complete quality
|
||||
result. Promotion re-verifies the stored receipt and its quality-result binding.
|
||||
|
||||
The authenticated root-service context supplies tenant, subject, grant, and
|
||||
mission IDs. All reads and state transitions require the same bindings that
|
||||
created the proposal. The controller includes those IDs in each task request.
|
||||
The task callback is the only outward effect.
|
||||
|
||||
## Bounded behavior
|
||||
|
||||
The closed parameter registry covers finite retry, backoff, limited candidate
|
||||
routing weight, tenant-scoped evidence reuse TTL, operation timeout, poll
|
||||
interval, notification-deduplication window, and optional-test sampling. Exact
|
||||
paths are required. Unknown paths and generic code-patch paths are rejected.
|
||||
|
||||
Each proposal must remain within its envelope's component, non-production
|
||||
environment, change-count, compute-budget, and per-parameter bounds. Mandatory
|
||||
tests and quality gates must be present in both the proposed evaluation and the
|
||||
verified result. Evidence reuse, when supplied, must match the exact tenant,
|
||||
component, and environment; it cannot request a global or cross-tenant cache.
|
||||
A required rollback plan can only reference restoration of previous values and
|
||||
cannot embed a command.
|
||||
|
||||
Direction-aware quality floors support both higher-is-better and
|
||||
lower-is-better metrics. The SQLite record contains the baseline, candidate,
|
||||
and normalized effect comparison. A floor violation, regression, missing
|
||||
mandatory check, budget overrun, or insufficient objective improvement moves
|
||||
the proposal to a terminal `STOPPED_*` state. Only an `ELIGIBLE` proposal can
|
||||
submit a promotion task. Promotion still does not apply the candidate directly.
|
||||
|
||||
## Durable states
|
||||
|
||||
The SQLite workflow uses these states:
|
||||
|
||||
1. `PROPOSAL_RECORDED`
|
||||
2. `EVALUATION_SUBMITTED`
|
||||
3. `ELIGIBLE` or terminal `STOPPED_*`
|
||||
4. `PROMOTION_SUBMITTED`
|
||||
|
||||
Invalid proposals with a safe, tenant-matching proposal ID are retained as
|
||||
`STOPPED_INVALID` without calling the task submitter. Proposal IDs are
|
||||
digest-bound, so reuse with different content is rejected. Evaluation and stop
|
||||
states cannot be overwritten by later evaluation submissions.
|
||||
|
||||
## Test evidence
|
||||
|
||||
The failure-first run was:
|
||||
|
||||
```text
|
||||
python3 -m pytest -q tests/baseline_v1_1/test_optimization.py
|
||||
ERROR tests/baseline_v1_1/test_optimization.py
|
||||
ModuleNotFoundError: No module named 'aios_baseline.optimization'
|
||||
```
|
||||
|
||||
After implementation and the terminal-state regression fix, the focused run
|
||||
was:
|
||||
|
||||
```text
|
||||
19 passed in 0.13s
|
||||
```
|
||||
|
||||
The tests cover signature tampering and missing verification, forbidden and
|
||||
generic paths, numeric limits, mandatory tests and gates, evidence-isolation
|
||||
scope, identity-bound task submission, invalid-candidate stopping, cross-tenant
|
||||
and cross-subject denial, untrusted and wrong-task receipts, quality regression,
|
||||
no improvement, terminal stop behavior, task-only promotion, and restart
|
||||
persistence of baseline/candidate/effect data.
|
||||
|
||||
The separately assigned loopback transport suite also completed with 7 passed.
|
||||
It covers the real submit-to-receipt HTTP flow, cancel/query/explain, server-side
|
||||
token identity, tenant-spoof rejection, strict body/deadline/JSON handling,
|
||||
sanitized failures, redirect and non-loopback rejection, and the interruptible
|
||||
resident worker loop.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Release and Lineage Implementation Report
|
||||
|
||||
## Delivered authority boundaries
|
||||
|
||||
`aios_baseline.lineage.LineageLedger` is a SQLite relationship authority. It
|
||||
does not own Task or Job state. Its constructor requires Task and Job authorities, and
|
||||
every `link_task` resolves the real Task before writing. The returned Task must
|
||||
carry the requested tenant and subject in its issuer or baseline metadata;
|
||||
mission, component, and Grant metadata must also match. Every `link_effect`
|
||||
resolves the durable Job and verifies its tenant, subject, mission, Grant,
|
||||
Task, and effect binding before recording the relationship.
|
||||
|
||||
Opaque Task, effect, Job, Release, deployment, and acceptance IDs are unique
|
||||
within a tenant. The same opaque ID may exist in another tenant. Composite
|
||||
foreign keys also bind subject, mission, component, Grant, Release artifact, and deployment at
|
||||
the SQLite layer. The API treats an exact replay as idempotent and rejects any
|
||||
attempt to rebind:
|
||||
|
||||
- a Task to another subject, mission, Grant, or source Task;
|
||||
- an effect to another Task or Job;
|
||||
- a Job to another effect/Task;
|
||||
- a release-producing effect to a second Release;
|
||||
- a Deployment to another Release, artifact, target revision, or environment;
|
||||
- an Acceptance to another Deployment, Release, artifact, tenant, or subject.
|
||||
|
||||
`explain(tenant_id, subject_id, mission_id)` uses equality predicates for all
|
||||
three scope fields. It returns Tasks, effects/Jobs, Releases, Deployments, and
|
||||
Acceptances only for that exact mission.
|
||||
|
||||
`aios_baseline.release.ReleaseLedger` owns dynamic
|
||||
`EnvironmentDeploymentTarget` records. A target is never written into a
|
||||
`RuntimeContract`. One target row exists per `(tenant, component, environment)`;
|
||||
the subject is the authenticated actor recorded for audit. Every target action
|
||||
passes through a configured resolver with tenant, subject, component,
|
||||
environment, and action. Multiple authorized subjects therefore share one CAS
|
||||
revision instead of creating conflicting desired state. `set_target` uses an expected-revision CAS; `None` creates
|
||||
revision 1 and later writes require the exact current revision. A `STAGED`
|
||||
target records source and observed-main revisions independently, so main being
|
||||
ahead of the staged source is valid. A `ROLLBACK_STAGED` target is admitted only
|
||||
when the current record has an explicit compatible rollback Release and exact
|
||||
artifact binding. The outer artifact object rejects recursive self-hash fields.
|
||||
|
||||
Recipes and their canonical SHA-256 identities are immutable. A saga persists
|
||||
its recipe identity, component and source Task/effect/Grant, stable per-stage effect ID, effect intent, raw receiver
|
||||
receipt state, typed verified receipt binding, deployment facts, target
|
||||
revision, and CAS revision. The adapter must first create or resolve a real
|
||||
Job via `prepare`; only then does the lineage authority link the effect. An
|
||||
operation exception leaves `EFFECT_UNKNOWN`. Recovery calls adapter readback,
|
||||
and missing readback fails with `RECONCILIATION_REQUIRED` without repeating the
|
||||
effect. A returned but unverified receipt is persisted
|
||||
as `RECEIPT_UNVERIFIED`; it is reverified rather than causing the effect to be
|
||||
issued again. Only an injected verifier returning `VerifiedReceipt` with the
|
||||
exact component, Task, source effect, Grant, Job, effect, operation, tenant,
|
||||
subject, Deployment, Release, and artifact can
|
||||
advance a stage. Acceptance follows the same pattern through
|
||||
`VerifiedAcceptanceEvidence` and includes an explicit `acceptance_id`.
|
||||
|
||||
## Public interfaces
|
||||
|
||||
```python
|
||||
LineageLedger(path, task_authority, job_authority)
|
||||
ledger.link_task(tenant_id, subject_id, mission_id, grant_id, task_id,
|
||||
source_task_id=None, metadata=None)
|
||||
ledger.link_effect(tenant_id, subject_id, mission_id, task_id, effect_id,
|
||||
job_id, source_effect_id=None, metadata=None)
|
||||
ledger.link_release(tenant_id, subject_id, mission_id, effect_id, release_id,
|
||||
artifact, metadata=None)
|
||||
ledger.link_deployment(tenant_id, subject_id, mission_id, release_id,
|
||||
deployment_id, component_id, environment, target_revision, artifact,
|
||||
metadata=None)
|
||||
ledger.record_acceptance(tenant_id, subject_id, mission_id, component_id, acceptance_id,
|
||||
deployment_id, release_id, artifact, accepted,
|
||||
evidence=raw_evidence, verifier=trusted_verifier)
|
||||
ledger.explain(tenant_id, subject_id, mission_id)
|
||||
|
||||
ReleaseLedger(path, lineage=ledger, receipt_verifier=trusted_verifier,
|
||||
target_authorizer=authorization_resolver)
|
||||
releases.register_recipe(recipe_id, stages)
|
||||
releases.set_target(tenant_id, subject_id, component_id, environment, release_id, artifact,
|
||||
expected_revision=revision_or_none, stage="STAGED", ...)
|
||||
releases.start_saga(tenant_id, subject_id, mission_id, component_id, saga_id, release_id,
|
||||
deployment_id, environment, artifact, recipe_id,
|
||||
expected_target_revision=revision)
|
||||
releases.advance_saga(tenant_id, subject_id, saga_id, expected_revision,
|
||||
governed_operation_adapter)
|
||||
releases.get_saga(tenant_id, subject_id, saga_id)
|
||||
releases.deployment_facts(tenant_id, subject_id, component_id, deployment_id)
|
||||
```
|
||||
|
||||
An artifact argument may be a non-empty string or canonical JSON object. The
|
||||
object is compared by canonical JSON identity; outer self-hash fields such as
|
||||
`manifest_sha256` are forbidden.
|
||||
|
||||
## Existing Bridge and governance audit
|
||||
|
||||
The existing loopback Bridge exposes `/v2/release/artifact`,
|
||||
`/v2/release/execute`, `/v2/release/consistency`, and
|
||||
`/v2/release/acceptance`. These endpoints use the historical static tuple
|
||||
`version`, `target_oid`, `evidence_closure_oid`, and `approval_plan_id` (plus
|
||||
`execute` where applicable). Static source review found that this protocol is
|
||||
not a conforming ReleaseLedger operation receiver:
|
||||
|
||||
1. The Bridge allowlist rejects the new tenant, subject, mission, component, Grant, Task,
|
||||
Job, effect, Deployment, target-revision, and idempotency fields.
|
||||
2. Release routes are outside the Bridge's TaskPrincipal-protected mutating
|
||||
operation set. They therefore cannot demonstrate the new Grant/Task/Job
|
||||
authorization chain.
|
||||
3. Historical release responses are unsigned and are not bound to the stable
|
||||
saga effect ID, Deployment ID, Release ID, subject, or canonical artifact.
|
||||
A `status`, `accepted`, or `ACTIVE_VERIFIED` field is not verifier authority.
|
||||
4. The trusted-plan executor deliberately rejects production execution with
|
||||
`PRODUCTION_EXECUTION_REQUIRES_APPROVED_RELEASE_EXECUTOR`. The legacy path
|
||||
invokes its pre-existing privileged production adapter, but it is not routed
|
||||
through the new Grant/Job/effect protocol and must not be repurposed as that
|
||||
protocol.
|
||||
5. The Bridge has no effect-keyed readback endpoint that can reconcile an
|
||||
unknown ReleaseLedger stage after response loss.
|
||||
6. Historical acceptance is not bound to an explicit acceptance and
|
||||
Deployment identity and does not return verifier-authenticated evidence for
|
||||
the new ledger.
|
||||
|
||||
## Configured Ed25519 verification
|
||||
|
||||
`aios_baseline.verifiers` provides verification-only factories. Trusted public
|
||||
keys are supplied by local configuration when the factory is built and copied
|
||||
into an immutable map. A request cannot add or replace a key. Values may be an
|
||||
`Ed25519PublicKey`, 32 raw public-key bytes, or an unpadded base64url public key.
|
||||
|
||||
```python
|
||||
from aios_baseline.release import ReleaseLedger
|
||||
from aios_baseline.verifiers import (
|
||||
ed25519_acceptance_evidence_verifier,
|
||||
ed25519_release_receipt_verifier,
|
||||
)
|
||||
|
||||
trusted_release_receivers = {
|
||||
"staging-receiver-2026": configured_ed25519_public_key,
|
||||
}
|
||||
trusted_acceptance_observers = {
|
||||
"staging-observer-2026": configured_acceptance_public_key,
|
||||
}
|
||||
|
||||
releases = ReleaseLedger(
|
||||
state_path,
|
||||
lineage=lineage,
|
||||
receipt_verifier=ed25519_release_receipt_verifier(
|
||||
trusted_release_receivers
|
||||
),
|
||||
target_authorizer=configured_component_target_authorizer,
|
||||
)
|
||||
acceptance_verifier = ed25519_acceptance_evidence_verifier(
|
||||
trusted_acceptance_observers
|
||||
)
|
||||
```
|
||||
|
||||
Operation receipts use envelope schema
|
||||
`aios.release-operation-receipt-envelope.v1` and signature domain
|
||||
`aios.release-operation-receipt.v1`. Acceptance evidence uses envelope schema
|
||||
`aios.release-acceptance-evidence-envelope.v1` and the separate signature domain
|
||||
`aios.release-acceptance-evidence.v1`. The signature input is the ASCII domain,
|
||||
a zero byte, and strict canonical JSON for the payload. The verifier rejects
|
||||
unknown envelope/payload fields, unknown key IDs, caller-provided public-key
|
||||
fields, cross-domain signatures, a non-`SUCCEEDED` operation outcome, a
|
||||
non-Boolean acceptance decision, and any component/Task/Grant/Job/effect/
|
||||
Deployment/Release/artifact binding mismatch. This key
|
||||
set and both domains are separate from the diagnostic `evidence.ReceiptSigner`.
|
||||
|
||||
For that reason, this package does not add a loopback HTTP writer around the
|
||||
historical endpoints. Such a wrapper would create a governance bypass while
|
||||
appearing to satisfy the new contract. The currently safe, observation-only
|
||||
projection is:
|
||||
|
||||
```json
|
||||
{
|
||||
"transport": "loopback-http",
|
||||
"base_url": "http://127.0.0.1:18892",
|
||||
"operations": {
|
||||
"release.consistency.observe": {
|
||||
"method": "POST",
|
||||
"path": "/v2/release/consistency",
|
||||
"request_projection": [
|
||||
"version",
|
||||
"target_oid",
|
||||
"evidence_closure_oid",
|
||||
"approval_plan_id"
|
||||
],
|
||||
"receipt_use": "diagnostic-only-unverified"
|
||||
}
|
||||
},
|
||||
"mutating_operations": {},
|
||||
"verified_receipt_operations": {}
|
||||
}
|
||||
```
|
||||
|
||||
A future conforming receiver must admit a new OperationalEpoch/TaskPrincipal,
|
||||
create or reuse the authoritative Job before the effect, fence idempotency by
|
||||
`effect_id`, expose effect-keyed readback, and return a signature-verified
|
||||
receipt over all ReleaseLedger binding fields. That receiver belongs at the
|
||||
existing Job and approved-release authority boundary, not in this relationship
|
||||
ledger.
|
||||
|
||||
## Verification and honest status
|
||||
|
||||
Failure-first execution initially failed at import because the modules did not
|
||||
exist. After implementation:
|
||||
|
||||
- `test_release_lineage.py`: **15 passed**. This uses a local SQLite file and
|
||||
reopens both ledgers across simulated process boundaries. Task authority and
|
||||
receipt verifiers are controlled callback fixtures; the operation callback
|
||||
is a fake configured sink used only to test fencing and verification. Two
|
||||
additional tests use real Ed25519 keys and cryptographic verification for
|
||||
valid signatures, signature tampering, cross-domain replay, caller key
|
||||
injection, decision mismatch, and Deployment mismatch. The valid signed
|
||||
acceptance is also persisted through the real SQLite ledger path.
|
||||
- Candidate-schema tests verify independent targets for two components in one
|
||||
environment, one shared CAS across authorized subjects, denial of an
|
||||
unauthorized subject, cross-component receipt rejection, refusal to repeat
|
||||
an unknown effect without readback, and fail-closed rejection of an
|
||||
unversioned candidate database. These schemas have not been released, so no
|
||||
implicit legacy migration is provided.
|
||||
- A targeted local loop test passed with the real `TaskPlaneStore` and
|
||||
`JobStore.read` authorities, including parsing the durable Job's
|
||||
`request_json.binding` and checking tenant/subject/mission/Grant/Task/effect.
|
||||
|
||||
No Bridge server, remote Git authority, production executor, production health
|
||||
endpoint, or installed signing identity was exercised. Remote deployment and
|
||||
acceptance are **NOT_VERIFIED**. No production success or live activation is
|
||||
claimed.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 实际任务闭环、集成边界与审查修正
|
||||
|
||||
## 可执行入口
|
||||
|
||||
`python -m aios_baseline.cli` 提供 `init-validation`、`preflight`、`grant-import`、`grant-revoke`、`server`、`worker`、`goal-submit`、`goal-explain`、`task-get`、`task-cancel`。真实 transport 是 IPv4 loopback HTTP;身份来自服务端持有的 token 映射。Worker 进程登记、heartbeat、claim、renew、execute,不能提交自行声称成功的 receipt,也不能从请求指定 tenant/subject 或任意命令。
|
||||
|
||||
Task/claim/outbox 使用既有 `TaskPlaneStore`;新增 runtime 信息在同一 Task 数据库中,仅含 readiness、blocker、effect/job 关联。Generic Job 使用既有 JobController/JobStore,独立资源围栏按 tenant/component/environment 分配单调 generation。Lineage 查询真实 Task 和 Job 权威后才保存关系;它不创建这些事实。
|
||||
|
||||
配置文件、私钥、token 和公钥均通过 no-follow 打开并对实际文件描述符检查所有者、权限、大小;已有非空状态目录必须带精确的隔离权威标识。Owner key 显式绑定 tenant/subject scope。业务 Grant 与机器密钥分离;机器 receipt key 轮换通过受信历史公钥保留旧证据,不能因签名密钥变化就重新执行已完成 effect。
|
||||
|
||||
## 第一个纵向闭环的真实范围
|
||||
|
||||
`goal-submit → Task admission → HTTP Worker claim → Job intent → Grant/lease/fencing → 受限 systemctl show 子进程 → 签名 receipt → Task completion → goal-explain`。
|
||||
|
||||
`service-status` 是内置 READ_ONLY operation,组件绑定必须精确到 `tenant:environment:component`,不会尝试路径、扫描所有凭证或访问 SSH。测试启动真实 HTTP server、独立子进程、SQLite 和 Ed25519;可控 systemctl 程序用于构造稳定观测。现场运行使用 `/usr/bin/systemctl` 与已登记 unit,实际目标健康、systemd 权限及凭证解密仍需现场证据。
|
||||
|
||||
“完成诊断”不等于“组件健康”。receipt 保留实际 `healthy` 和 systemd 观测;没有有效观测不会生成成功 receipt。任务查询同时返回 Job state、side-effect state、blocker 和剩余 Task budget。
|
||||
|
||||
当前 server 对 host operation 采用有界串行执行,HTTP 并发连接最多 16 个,token 长度统一为 32~512 字符。HTTP deadline 限制执行锁等待并传到 receiver;host 子进程固定 argv、无继承凭证环境、固定可执行文件身份、累计 stdout/stderr 上限 64 KiB、超时杀进程组。单机首次实现采用全 Job 域的保守停止准入策略:未决效果会阻止新的效果;物理存储故障保持全局停止。更细的租户/资源并行度属于后续可测优化,不以牺牲恢复判断换吞吐。
|
||||
|
||||
## 本次审查发现并已修复的具体缺陷
|
||||
|
||||
| 缺陷 | 修正 | 行为测试 |
|
||||
| --- | --- | --- |
|
||||
| 每任务 attempt 从 1 开始,被误用于全资源围栏 | Job 权威按资源分配 generation;旧持有者被拒绝 | 连续两任务围栏代数为 1、2 |
|
||||
| Generic Job 可直接 REQUESTED→COMPLETED | 显式状态转换、已持久 lease、强制可信 receipt verifier | 越级完成、缺 verifier、改绑均拒绝 |
|
||||
| 签名正确但 job/mission/grant 等关联错误仍能完成 Task | 对照实际 Job request/lease/Task 逐项验证身份、输入摘要和 observation | 多字段签名错绑拒绝 |
|
||||
| 取消后恢复可将任务重新完成或置为可重试 | Task 权威在恢复事务内保留终态;heartbeat 也拒绝终态和到期 lease | 已取消 expired claim 不复活 |
|
||||
| 跨租户安装的 Owner key 自动获得全租户签发权 | 显式 owner_key_scopes,并在每次持久读取验证 | 跨 tenant/subject、重启收窄 trust 均拒绝 |
|
||||
| 已完成 receipt 的密钥轮换被误判成 effect 未发生 | receipt 带 key_id,显式旧公钥信任;无法验签进入未知而非重做 | 旧证据仅在明确受信时可用 |
|
||||
| 排队请求可越过客户端 deadline 开始执行 | 有期限的执行锁及 receiver deadline、claim/fence 二次核验 | 过期排队请求不启动 host |
|
||||
| 子进程输出在读完后才限额 | 增量有界读取,超限立即终止进程组 | 输出洪泛与 binary 被替换时拒绝 |
|
||||
| 生产 snapshot 固定 ACTIVE 和旧版本 | 验证动态 observation/target/provenance;缺失或过期为 UNKNOWN | 新鲜、错绑、缺失、过期观测 |
|
||||
| staging 消费另一服务临时 credential 路径 | 每消费者直接投影同名 encrypted credential,使用自身 `%d` | unit 投影身份与独立路径断言 |
|
||||
| DeploymentTarget 按 subject 分裂、缺组件维度 | tenant/component/environment 资源主键、统一 CAS 与独立授权 | 两组件目标、同组件多操作者和拒权 |
|
||||
| Release 把计划 Job ID 当事实,未知时可退回 execute | adapter prepare 后查询真实 Job;UNKNOWN 只走 reconcile | 虚假 Job、无 reconcile、错组件验收拒绝 |
|
||||
|
||||
## 尚未接入现场的部分
|
||||
|
||||
正式 Task API adapter、完整 Doctor 修复/升级、整条真实发布 recipe、独立业务 acceptance executor、目标级模型/费用计量、artifact/cache/工作目录的全部生产路径、旧 writer 排空及整机一致备份恢复,都需要具体现场绑定或剩余适配代码。对应 M0–M5 runbook 与 Release 报告已列出输入、职责和门禁。本包不会通过扩大角色权限、伪造远端回执或启用 legacy global write 来掩盖缺口。
|
||||
|
||||
新 Lineage/Release 候选数据库带 schema version;未知或早期无版本候选库明确拒绝。包从新隔离目录启动,不静默修改线上数据库。历史 epoch 签名格式和受保护分支政策保留原语义。
|
||||
|
||||
实际统一验收的命令、版本、源码逐文件 SHA、JUnit 和退出码在完整包 `acceptance/`;各模块报告中的早期数量只用于记录开发过程,以最后统一结果为准。
|
||||
@@ -0,0 +1,666 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.authorization import (
|
||||
AuthorizationError,
|
||||
AuthorizationService,
|
||||
sign_owner_delegation_grant,
|
||||
)
|
||||
from aios_baseline.operations import OperationDefinition
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self, value: int = 2_000_000_000) -> None:
|
||||
self.value = value
|
||||
|
||||
def __call__(self) -> int:
|
||||
return self.value
|
||||
|
||||
|
||||
def grant(
|
||||
owner_key: Ed25519PrivateKey,
|
||||
clock: Clock,
|
||||
*,
|
||||
grant_id: str = "grant-0001",
|
||||
generation: int = 1,
|
||||
tenant_id: str = "tenant-a",
|
||||
subject_id: str = "owner-a",
|
||||
task_ids: tuple[str, ...] = ("task-a",),
|
||||
operations: tuple[str, ...] = ("deploy",),
|
||||
expires_at: int | None = None,
|
||||
max_effects: int | None = None,
|
||||
destructive_ceiling: str = "NONE",
|
||||
) -> dict:
|
||||
return sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
grant_id=grant_id,
|
||||
generation=generation,
|
||||
tenant_id=tenant_id,
|
||||
subject_id=subject_id,
|
||||
mission_ids=("mission-a",),
|
||||
task_ids=task_ids,
|
||||
operations=operations,
|
||||
component_ids=("component-a",),
|
||||
environments=("staging",),
|
||||
issued_at=clock(),
|
||||
validity_mode="EXPIRES_AT" if expires_at is not None else "UNTIL_REVOKED",
|
||||
expires_at=expires_at,
|
||||
max_effects=max_effects,
|
||||
destructive_ceiling=destructive_ceiling,
|
||||
)
|
||||
|
||||
|
||||
def service(tmp_path, owner_key: Ed25519PrivateKey, machine_key: Ed25519PrivateKey, clock: Clock, **kwargs):
|
||||
return AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
SCOPE = {
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "owner-a",
|
||||
"mission_id": "mission-a",
|
||||
"task_id": "task-a",
|
||||
"operation": "deploy",
|
||||
"component_id": "component-a",
|
||||
"environment": "staging",
|
||||
}
|
||||
|
||||
|
||||
def test_signed_chain_authorizes_exact_narrowed_scope(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
|
||||
registered = auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert registered == signed_grant
|
||||
assert principal["schema"] == "aios.task-principal.v1"
|
||||
assert principal["operational_epoch"]["schema"] == "aios.operational-epoch.v1"
|
||||
assert principal["payload"]["root_grant_digest"] == auth.grant_digest(signed_grant)
|
||||
assert principal["payload"]["root_grant_generation"] == 1
|
||||
assert isinstance(principal["payload"]["credential_id"], str)
|
||||
assert isinstance(principal["payload"]["nonce"], str)
|
||||
assert auth.authorize(principal, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "field", "replacement"),
|
||||
[
|
||||
("grant", "tenant_id", "tenant-b"),
|
||||
("epoch", "task_id", "task-b"),
|
||||
("principal", "operation", "delete"),
|
||||
],
|
||||
)
|
||||
def test_any_signature_tampering_is_rejected(tmp_path, target, field, replacement):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
|
||||
if target == "grant":
|
||||
tampered = copy.deepcopy(signed_grant)
|
||||
tampered["payload"][field] = replacement
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(tampered)
|
||||
return
|
||||
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
tampered = copy.deepcopy(principal)
|
||||
if target == "epoch":
|
||||
tampered["operational_epoch"]["payload"][field] = replacement
|
||||
else:
|
||||
tampered["payload"][field] = replacement
|
||||
with pytest.raises(AuthorizationError, match="signature|parent"):
|
||||
auth.authorize(tampered, **SCOPE)
|
||||
|
||||
|
||||
def test_unknown_owner_key_and_unsigned_grant_are_rejected(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
unknown = grant(owner_key, clock)
|
||||
unknown["signing"]["key_id"] = "attacker-key"
|
||||
|
||||
with pytest.raises(AuthorizationError, match="trusted owner key"):
|
||||
auth.register_grant(unknown)
|
||||
with pytest.raises(AuthorizationError, match="fields"):
|
||||
auth.register_grant(grant(owner_key, clock)["payload"])
|
||||
|
||||
|
||||
def test_fake_mapping_parent_and_parent_digest_substitution_are_rejected(tmp_path):
|
||||
class FakeParent(dict):
|
||||
pass
|
||||
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
fake = copy.deepcopy(principal)
|
||||
fake["operational_epoch"] = FakeParent(fake["operational_epoch"])
|
||||
with pytest.raises(AuthorizationError, match="plain object"):
|
||||
auth.authorize(fake, **SCOPE)
|
||||
|
||||
other = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
substituted = copy.deepcopy(principal)
|
||||
substituted["operational_epoch"] = other["operational_epoch"]
|
||||
with pytest.raises(AuthorizationError, match="parent|signature"):
|
||||
auth.authorize(substituted, **SCOPE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "wrong"),
|
||||
[
|
||||
("tenant_id", "tenant-b"),
|
||||
("subject_id", "owner-b"),
|
||||
("mission_id", "mission-b"),
|
||||
("task_id", "task-b"),
|
||||
("operation", "delete"),
|
||||
("component_id", "component-b"),
|
||||
("environment", "production"),
|
||||
],
|
||||
)
|
||||
def test_authorize_denies_cross_scope_use(tmp_path, field, wrong):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
attempted = dict(SCOPE)
|
||||
attempted[field] = wrong
|
||||
|
||||
with pytest.raises(AuthorizationError, match="scope"):
|
||||
auth.authorize(principal, **attempted)
|
||||
|
||||
|
||||
def test_principal_issuance_must_narrow_root_scope(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, task_ids=("task-a", "task-b")))
|
||||
|
||||
denied = dict(SCOPE)
|
||||
denied["task_id"] = "task-c"
|
||||
with pytest.raises(AuthorizationError, match="grant scope"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=30, **denied)
|
||||
|
||||
narrowed = dict(SCOPE)
|
||||
narrowed["task_id"] = "task-b"
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **narrowed)
|
||||
assert auth.authorize(principal, **narrowed)["payload"]["grant_id"] == "grant-0001"
|
||||
|
||||
|
||||
def test_revocation_is_durable_and_checked_during_proof_lifetime(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
trusted_context = object()
|
||||
verifier = lambda value: value is trusted_context
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=verifier,
|
||||
)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=60, **SCOPE)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="trusted control context"):
|
||||
auth.revoke_grant("grant-0001", control_context=object())
|
||||
assert auth.revoke_grant("grant-0001", control_context=trusted_context) == 2
|
||||
with pytest.raises(AuthorizationError, match="generation|revoked"):
|
||||
auth.authorize(principal, **SCOPE)
|
||||
|
||||
restarted = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=verifier,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="generation|revoked"):
|
||||
restarted.authorize(principal, **SCOPE)
|
||||
|
||||
|
||||
def test_expired_proof_refreshes_without_owner_reapproval_and_survives_restart(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
auth.register_grant(signed_grant)
|
||||
expired = auth.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
clock.value += 5
|
||||
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.authorize(expired, **SCOPE)
|
||||
|
||||
restarted = service(tmp_path, owner_key, machine_key, clock)
|
||||
refreshed = restarted.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
assert refreshed != expired
|
||||
assert restarted.authorize(refreshed, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
def test_explicit_grant_expiry_caps_child_lease_and_cannot_be_refreshed(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, expires_at=clock() + 7))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert principal["payload"]["expires_at"] == clock() + 7
|
||||
assert principal["operational_epoch"]["payload"]["expires_at"] == clock() + 7
|
||||
clock.value += 7
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.authorize(principal, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="expired"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=5, **SCOPE)
|
||||
|
||||
|
||||
def test_higher_owner_signed_generation_reactivates_but_stale_proofs_stay_invalid(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
control = object()
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=lambda value: value is control,
|
||||
)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
old = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
auth.revoke_grant("grant-0001", control_context=control)
|
||||
|
||||
new_grant = grant(owner_key, clock, generation=3)
|
||||
auth.register_grant(new_grant)
|
||||
fresh = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="generation"):
|
||||
auth.authorize(old, **SCOPE)
|
||||
assert auth.authorize(fresh, **SCOPE) == new_grant
|
||||
|
||||
|
||||
def test_strict_schema_types_and_signature_encoding(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["generation"] = True
|
||||
with pytest.raises(AuthorizationError, match="generation"):
|
||||
auth.register_grant(malformed)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["signing"]["signature"] = base64.b64encode(b"short").decode("ascii")
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(malformed)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="ttl_seconds"):
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
auth.issue_principal("grant-0001", ttl_seconds=True, **SCOPE)
|
||||
|
||||
|
||||
def test_grant_helper_requires_an_explicit_validity_mode():
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
arguments = {
|
||||
"grant_id": "grant-0001",
|
||||
"generation": 1,
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "owner-a",
|
||||
"mission_ids": ("mission-a",),
|
||||
"task_ids": ("task-a",),
|
||||
"operations": ("deploy",),
|
||||
"component_ids": ("component-a",),
|
||||
"environments": ("staging",),
|
||||
"issued_at": clock(),
|
||||
}
|
||||
with pytest.raises(TypeError):
|
||||
sign_owner_delegation_grant(owner_key, "owner-key-1", **arguments)
|
||||
with pytest.raises(AuthorizationError, match="expires_at"):
|
||||
sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
validity_mode="EXPIRES_AT",
|
||||
expires_at=None,
|
||||
**arguments,
|
||||
)
|
||||
|
||||
string_scope = dict(arguments)
|
||||
string_scope["mission_ids"] = "abc"
|
||||
with pytest.raises(AuthorizationError, match="mission_ids.*list or tuple"):
|
||||
sign_owner_delegation_grant(
|
||||
owner_key,
|
||||
"owner-key-1",
|
||||
validity_mode="UNTIL_REVOKED",
|
||||
**string_scope,
|
||||
)
|
||||
|
||||
|
||||
def test_effect_budget_reserves_each_stable_effect_id_once_and_persists(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, max_effects=1))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is False
|
||||
|
||||
restarted = service(tmp_path, owner_key, machine_key, clock)
|
||||
with pytest.raises(AuthorizationError, match="effect budget"):
|
||||
restarted.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE)
|
||||
|
||||
|
||||
def test_unbounded_grant_leaves_mission_budget_to_caller(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
assert auth.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE) is True
|
||||
|
||||
|
||||
def test_old_machine_proof_can_be_verified_during_machine_key_rotation(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
old_machine_key = Ed25519PrivateKey.generate()
|
||||
new_machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
original = service(tmp_path, owner_key, old_machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
original.register_grant(signed_grant)
|
||||
old_principal = original.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
rotated = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
new_machine_key,
|
||||
"machine-key-2",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
trusted_machine_public_keys={
|
||||
"machine-key-1": old_machine_key.public_key(),
|
||||
"machine-key-2": new_machine_key.public_key(),
|
||||
},
|
||||
)
|
||||
assert rotated.authorize(old_principal, **SCOPE) == signed_grant
|
||||
|
||||
|
||||
def test_revocation_generation_cannot_reset_budget_or_revive_immutable_grant(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
control = object()
|
||||
auth = service(
|
||||
tmp_path,
|
||||
owner_key,
|
||||
machine_key,
|
||||
clock,
|
||||
control_context_verifier=lambda value: value is control,
|
||||
)
|
||||
signed_grant = grant(owner_key, clock, max_effects=1)
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
assert auth.reserve_effect("grant-0001", "effect-0001", principal, **SCOPE) is True
|
||||
|
||||
assert auth.revoke_grant("grant-0001", control_context=control) == 2
|
||||
with pytest.raises(AuthorizationError, match="stale"):
|
||||
auth.register_grant(signed_grant)
|
||||
with pytest.raises(AuthorizationError, match="revoked|generation"):
|
||||
auth.reserve_effect("grant-0001", "effect-0002", principal, **SCOPE)
|
||||
with pytest.raises(AuthorizationError, match="revoked"):
|
||||
auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
|
||||
def test_rejects_nan_empty_identity_and_untrusted_retired_machine_key(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
old_machine_key = Ed25519PrivateKey.generate()
|
||||
new_machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
original = service(tmp_path, owner_key, old_machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
original.register_grant(signed_grant)
|
||||
old_principal = original.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["issued_at"] = float("nan")
|
||||
with pytest.raises(AuthorizationError, match="issued_at"):
|
||||
original.register_grant(malformed)
|
||||
malformed = grant(owner_key, clock)
|
||||
malformed["payload"]["tenant_id"] = ""
|
||||
with pytest.raises(AuthorizationError, match="tenant_id"):
|
||||
original.register_grant(malformed)
|
||||
|
||||
rotated_without_old_trust = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
new_machine_key,
|
||||
"machine-key-2",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="trusted machine key"):
|
||||
rotated_without_old_trust.authorize(old_principal, **SCOPE)
|
||||
|
||||
|
||||
def operation_definition(effect_class: str, *, reversible: bool = False) -> OperationDefinition:
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "deploy",
|
||||
"version": 1,
|
||||
"effect_class": effect_class,
|
||||
"handler_ref": "trusted.deploy",
|
||||
"input_schema": "aios.deploy.input.v1",
|
||||
"required_permissions": ["deploy"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "EXACT", "required": True},
|
||||
"retry": {"mode": "AFTER_READBACK", "max_attempts": 1},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "OPERATION", "operation_id": "deploy.rollback"}
|
||||
if reversible
|
||||
else {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_signed_destructive_ceiling_defaults_none_and_denies_mutating_definition(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
signed_grant = grant(owner_key, clock)
|
||||
assert signed_grant["payload"]["destructive_ceiling"] == "NONE"
|
||||
auth.register_grant(signed_grant)
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
assert auth.assert_effect_bounds(
|
||||
principal, operation_definition("READ_ONLY"), **SCOPE
|
||||
) == signed_grant
|
||||
with pytest.raises(AuthorizationError, match="destructive ceiling"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("IDEMPOTENT"), **SCOPE
|
||||
)
|
||||
|
||||
|
||||
def test_reversible_ceiling_requires_rollback_and_irreversible_ceiling_is_explicit(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
auth.register_grant(grant(owner_key, clock, destructive_ceiling="REVERSIBLE"))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
|
||||
with pytest.raises(AuthorizationError, match="rollback"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("RECONCILABLE", reversible=False), **SCOPE
|
||||
)
|
||||
assert auth.assert_effect_bounds(
|
||||
principal, operation_definition("RECONCILABLE", reversible=True), **SCOPE
|
||||
)["payload"]["destructive_ceiling"] == "REVERSIBLE"
|
||||
with pytest.raises(AuthorizationError, match="irreversible"):
|
||||
auth.assert_effect_bounds(
|
||||
principal, operation_definition("IRREVERSIBLE", reversible=False), **SCOPE
|
||||
)
|
||||
|
||||
owner_key_2 = Ed25519PrivateKey.generate()
|
||||
auth_2 = AuthorizationService(
|
||||
tmp_path / "authorization-2.sqlite3",
|
||||
{"owner-key-1": owner_key_2.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
auth_2.register_grant(grant(owner_key_2, clock, destructive_ceiling="IRREVERSIBLE"))
|
||||
principal_2 = auth_2.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
assert auth_2.assert_effect_bounds(
|
||||
principal_2, operation_definition("IRREVERSIBLE"), **SCOPE
|
||||
)["payload"]["destructive_ceiling"] == "IRREVERSIBLE"
|
||||
|
||||
|
||||
def test_destructive_ceiling_is_signed_and_strictly_enumerated(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
tampered = grant(owner_key, clock)
|
||||
tampered["payload"]["destructive_ceiling"] = "IRREVERSIBLE"
|
||||
with pytest.raises(AuthorizationError, match="signature"):
|
||||
auth.register_grant(tampered)
|
||||
with pytest.raises(AuthorizationError, match="destructive_ceiling"):
|
||||
grant(owner_key, clock, destructive_ceiling="UNBOUNDED")
|
||||
|
||||
|
||||
def test_owner_key_trust_is_explicitly_bound_to_tenant_and_subject(tmp_path):
|
||||
owner_key = Ed25519PrivateKey.generate()
|
||||
machine_key = Ed25519PrivateKey.generate()
|
||||
clock = Clock()
|
||||
|
||||
with pytest.raises(TypeError, match="owner_key_scopes"):
|
||||
AuthorizationService(
|
||||
tmp_path / "missing-scopes.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
auth = service(tmp_path, owner_key, machine_key, clock)
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
auth.register_grant(grant(owner_key, clock, tenant_id="tenant-b"))
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
auth.register_grant(grant(owner_key, clock, subject_id="owner-b"))
|
||||
|
||||
auth.register_grant(grant(owner_key, clock))
|
||||
principal = auth.issue_principal("grant-0001", ttl_seconds=30, **SCOPE)
|
||||
reopened_with_narrower_trust = AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": ["owner-b"],
|
||||
}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
with pytest.raises(AuthorizationError, match="Owner key scope"):
|
||||
reopened_with_narrower_trust.authorize(principal, **SCOPE)
|
||||
|
||||
globally_scoped = AuthorizationService(
|
||||
tmp_path / "explicit-global.sqlite3",
|
||||
{"owner-key-1": owner_key.public_key()},
|
||||
machine_key,
|
||||
"machine-key-1",
|
||||
owner_key_scopes={
|
||||
"owner-key-1": {"tenant_ids": ["*"], "subject_ids": ["*"]}
|
||||
},
|
||||
clock=clock,
|
||||
)
|
||||
assert globally_scoped.register_grant(
|
||||
grant(owner_key, clock, tenant_id="tenant-b", subject_id="owner-b")
|
||||
)["payload"]["tenant_id"] == "tenant-b"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scopes",
|
||||
[
|
||||
{},
|
||||
{"owner-key-1": {"tenant_ids": ["tenant-a"]}},
|
||||
{
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["*", "tenant-a"],
|
||||
"subject_ids": ["owner-a"],
|
||||
}
|
||||
},
|
||||
{
|
||||
"owner-key-1": {
|
||||
"tenant_ids": ["tenant-a"],
|
||||
"subject_ids": [],
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_owner_key_scope_configuration_is_exact_and_fail_closed(tmp_path, scopes):
|
||||
with pytest.raises(AuthorizationError, match="owner_key_scopes"):
|
||||
AuthorizationService(
|
||||
tmp_path / "authorization.sqlite3",
|
||||
{"owner-key-1": Ed25519PrivateKey.generate().public_key()},
|
||||
Ed25519PrivateKey.generate(),
|
||||
"machine-key-1",
|
||||
owner_key_scopes=scopes,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from task_plane.api import TaskPlaneService
|
||||
from task_plane.errors import ConflictError, LockedError
|
||||
from task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
IDENTITY = "spiffe://aios/agent/heartbeat"
|
||||
WORKER_ID = "heartbeat-worker"
|
||||
|
||||
|
||||
def claimed_task():
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(
|
||||
store,
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
identity_capabilities={"*": set(ALL_ACTION_CAPABILITIES)},
|
||||
),
|
||||
)
|
||||
service.register_worker(
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"worker_type": "test",
|
||||
"identity": IDENTITY,
|
||||
"capabilities": ["code.read"],
|
||||
"trust_zone": "test",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
task = service.create_task(
|
||||
{"task_id": "task-heartbeat", "required_capabilities": ["code.read"]}
|
||||
)
|
||||
claim = service.claim(
|
||||
task["task_id"],
|
||||
{
|
||||
"worker_id": WORKER_ID,
|
||||
"actor_identity": IDENTITY,
|
||||
"lease_seconds": 30,
|
||||
},
|
||||
)
|
||||
return store, task, claim
|
||||
|
||||
|
||||
def test_cancelled_task_cannot_renew_a_still_active_claim():
|
||||
store, task, claim = claimed_task()
|
||||
original_lease = store.get_claim(claim["claim_id"]).lease_until
|
||||
current = store.get_task(task["task_id"])
|
||||
store.cancel_task_with_event(task["task_id"], current.revision, "owner cancelled")
|
||||
|
||||
with pytest.raises(ConflictError, match="task is terminal"):
|
||||
store.heartbeat_claim(task["task_id"], claim["claim_id"], 60)
|
||||
|
||||
assert store.get_claim(claim["claim_id"]).lease_until == original_lease
|
||||
assert store.get_task(task["task_id"]).status == "CANCELLED"
|
||||
|
||||
|
||||
def test_lease_at_current_time_is_expired_and_cannot_be_renewed():
|
||||
store, task, claim = claimed_task()
|
||||
boundary = store.get_claim(claim["claim_id"]).lease_until
|
||||
|
||||
with mock.patch("task_plane.store._now", return_value=boundary):
|
||||
with pytest.raises(LockedError, match="lease expired"):
|
||||
store.heartbeat_claim(task["task_id"], claim["claim_id"], 30)
|
||||
|
||||
assert store.get_claim(claim["claim_id"]).lease_until == boundary
|
||||
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from aios_baseline.cli import initialize_validation, load_configuration, build_service
|
||||
|
||||
|
||||
def test_generated_validation_configuration_is_usable_and_not_production(tmp_path):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
config,tokens=load_configuration(root/'server.json')
|
||||
assert config['profile']=='isolated-validation'
|
||||
assert len(tokens)==2
|
||||
assert not (root/'state').exists() # preflight is read only
|
||||
service=build_service(config)
|
||||
assert service.jobs.store is not None
|
||||
assert (root/'owner-private.pem').stat().st_mode & 0o077 == 0
|
||||
raw=json.loads((root/'server.json').read_text())
|
||||
raw['profile']='production'
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='PROFILE'): load_configuration(root/'server.json')
|
||||
|
||||
|
||||
def test_config_cannot_register_duplicate_tokens_or_expand_secret_variables(tmp_path,monkeypatch):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
raw=json.loads((root/'server.json').read_text())
|
||||
raw['identities'][1]['token_file']=raw['identities'][0]['token_file']
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='TOKEN'): load_configuration(root/'server.json')
|
||||
raw['identities'][1]['token_file']='$HOME/token'
|
||||
(root/'server.json').write_text(json.dumps(raw))
|
||||
with pytest.raises(ValueError,match='PATH'): load_configuration(root/'server.json')
|
||||
|
||||
|
||||
def test_config_rejects_symlink_public_config_and_untrusted_state_marker(tmp_path):
|
||||
root=tmp_path/'validation'
|
||||
initialize_validation(root,unit='sample.service')
|
||||
alias=tmp_path/'alias.json';alias.symlink_to(root/'server.json')
|
||||
with pytest.raises(OSError): load_configuration(alias)
|
||||
config,_=load_configuration(root/'server.json')
|
||||
state=root/'state';state.mkdir(mode=0o700)
|
||||
(state/'ISOLATED_VALIDATION_AUTHORITY').write_text('different-authority\n')
|
||||
with pytest.raises(ValueError,match='MARKER'): build_service(config)
|
||||
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_baseline.operations import (
|
||||
OperationDefinition,
|
||||
OperationRegistry,
|
||||
operation_timeout_action,
|
||||
verify_service_restart,
|
||||
)
|
||||
from aios_baseline.runtime_contract import evaluate_deployment, validate_contract
|
||||
from aios_chatgpt_bridge_v2_server import (
|
||||
GovernedRuntime,
|
||||
RuntimeConfig,
|
||||
load_production_observation,
|
||||
)
|
||||
from reconciliation_controller import ReconciliationController
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 7, 12, 0, tzinfo=timezone.utc)
|
||||
DIGEST = "sha256:" + "a" * 64
|
||||
SOURCE_DIGEST = "sha256:" + "b" * 64
|
||||
BUILDER_DIGEST = "sha256:" + "c" * 64
|
||||
|
||||
|
||||
def runtime_contract() -> dict:
|
||||
return {
|
||||
"contract_schema": "aios.runtime-contract.v1",
|
||||
"contract_revision": 3,
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"tenant_scope": "tenant-a",
|
||||
"lifecycle": "active",
|
||||
"entrypoints": {
|
||||
"task_api": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://task-plane/task-api",
|
||||
}
|
||||
},
|
||||
"bindings": {
|
||||
"service": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"resolver_ref": "service://task-plane/worker",
|
||||
}
|
||||
},
|
||||
"resource_refs": {
|
||||
"state_root": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"path_ref": "runtime/state/task-plane",
|
||||
}
|
||||
},
|
||||
"credential_refs": {
|
||||
"task_api": {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"credential_ref": "systemd-credential:task-plane-api",
|
||||
}
|
||||
},
|
||||
"operations": ["host.status.v1", "task.claim.v1"],
|
||||
}
|
||||
|
||||
|
||||
def deployment_target() -> dict:
|
||||
return {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"release_id": "release-17",
|
||||
"deployment_id": "deployment-22",
|
||||
"artifact_digest": DIGEST,
|
||||
"revision": 4,
|
||||
}
|
||||
|
||||
|
||||
def observation() -> dict:
|
||||
return {
|
||||
"component_id": "task-plane",
|
||||
"environment": "staging",
|
||||
"deployment_id": "deployment-22",
|
||||
"observed_at": "2026-09-07T11:59:30Z",
|
||||
"max_age_seconds": 60,
|
||||
"source": "systemd-readback.v1",
|
||||
"actual_artifact_digest": DIGEST,
|
||||
"revision": 4,
|
||||
"healthy": True,
|
||||
}
|
||||
|
||||
|
||||
def provenance() -> dict:
|
||||
return {
|
||||
"artifact_digest": DIGEST,
|
||||
"source_digest": SOURCE_DIGEST,
|
||||
"builder_digest": BUILDER_DIGEST,
|
||||
}
|
||||
|
||||
|
||||
def operation(**updates: object) -> dict:
|
||||
data = {
|
||||
"operation_id": "service.restart.v1",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": "host.service_restart",
|
||||
"input_schema": "aios.operation.service-restart.input.v1",
|
||||
"required_permissions": ["host:service:restart"],
|
||||
"timeout_seconds": 30,
|
||||
"readback": {"mode": "EXACT", "required": True},
|
||||
"retry": {"mode": "AFTER_READBACK", "max_attempts": 2},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "OPERATION", "operation_id": "service.restore.v1"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
data.update(updates)
|
||||
return data
|
||||
|
||||
|
||||
def test_structural_contract_is_normalized_without_dynamic_target() -> None:
|
||||
result = validate_contract(runtime_contract())
|
||||
assert result["component_id"] == "task-plane"
|
||||
assert result["contract_revision"] == 3
|
||||
assert result["operations"] == ["host.status.v1", "task.claim.v1"]
|
||||
|
||||
|
||||
def test_structural_contract_allows_empty_optional_reference_groups() -> None:
|
||||
document = runtime_contract()
|
||||
document["bindings"] = {}
|
||||
document["resource_refs"] = {}
|
||||
document["credential_refs"] = {}
|
||||
assert validate_contract(document)["credential_refs"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
["desired", "desired_commit", "desired_release", "desired_target", "desired_targets"],
|
||||
)
|
||||
def test_structural_contract_rejects_dynamic_desired_fields_at_any_depth(field: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["bindings"]["service"][field] = "must-live-in-release-target"
|
||||
with pytest.raises(ValueError, match="dynamic_field_forbidden"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_resolver_binding_must_match_component_and_environment() -> None:
|
||||
document = runtime_contract()
|
||||
document["resource_refs"]["state_root"]["environment"] = "production"
|
||||
with pytest.raises(ValueError, match="binding_environment_mismatch"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_rejects_cross_tenant_resolver_binding() -> None:
|
||||
document = runtime_contract()
|
||||
document["entrypoints"]["task_api"]["tenant_scope"] = "tenant-b"
|
||||
with pytest.raises(ValueError, match="binding_tenant_mismatch"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path_ref", ["/etc/aios/state", "../other/state", "runtime/../secret", "bad\x00ref"])
|
||||
def test_contract_rejects_unsafe_path_references(path_ref: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["resource_refs"]["state_root"]["path_ref"] = path_ref
|
||||
with pytest.raises(ValueError, match="unsafe_path_ref"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"credential_ref",
|
||||
[
|
||||
"/run/credentials/other-service/admin-token",
|
||||
"plain-secret-bytes",
|
||||
],
|
||||
)
|
||||
def test_contract_rejects_cross_service_or_inline_credentials(credential_ref: str) -> None:
|
||||
document = runtime_contract()
|
||||
document["credential_refs"]["task_api"]["credential_ref"] = credential_ref
|
||||
with pytest.raises(ValueError, match="credential_ref"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
def test_contract_rejects_secret_value_fields_even_when_reference_is_present() -> None:
|
||||
document = runtime_contract()
|
||||
document["credential_refs"]["task_api"]["token"] = "secret-bytes"
|
||||
with pytest.raises(ValueError, match="secret_material_forbidden"):
|
||||
validate_contract(document)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "observed", "proof", "reason"),
|
||||
[
|
||||
(None, observation(), provenance(), "target_missing"),
|
||||
(deployment_target(), None, provenance(), "observation_missing"),
|
||||
(deployment_target(), observation(), None, "provenance_missing"),
|
||||
],
|
||||
)
|
||||
def test_missing_evidence_is_unknown(target, observed, proof, reason: str) -> None:
|
||||
result = evaluate_deployment(runtime_contract(), target, observed, proof, NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert result["state"] == "UNKNOWN"
|
||||
assert reason in result["reasons"]
|
||||
|
||||
|
||||
def test_stale_observation_is_unknown_with_injected_clock() -> None:
|
||||
observed = observation()
|
||||
observed["observed_at"] = "2026-09-07T11:58:59Z"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "observation_stale" in result["reasons"]
|
||||
|
||||
|
||||
def test_nullable_health_and_wrong_types_are_unknown() -> None:
|
||||
observed = observation()
|
||||
observed["healthy"] = None
|
||||
assert evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)["status"] == "UNKNOWN"
|
||||
observed["healthy"] = "true"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "observation_healthy_invalid" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_incompatible_target_is_drift() -> None:
|
||||
target = deployment_target()
|
||||
target["environment"] = "production"
|
||||
observed = observation()
|
||||
observed["environment"] = "production"
|
||||
result = evaluate_deployment(runtime_contract(), target, observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "target_environment_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_cross_tenant_target_is_drift() -> None:
|
||||
target = deployment_target()
|
||||
target["tenant_scope"] = "tenant-b"
|
||||
result = evaluate_deployment(runtime_contract(), target, observation(), provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "target_tenant_scope_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_complete_cross_tenant_observation_is_drift_without_target_tenant_field() -> None:
|
||||
observed = observation()
|
||||
observed["tenant_scope"] = "tenant-b"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "observation_tenant_scope_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_artifact_mismatch_is_drift_only_when_evidence_is_complete() -> None:
|
||||
observed = observation()
|
||||
observed["actual_artifact_digest"] = "sha256:" + "d" * 64
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "DRIFT"
|
||||
assert "artifact_mismatch" in result["reasons"]
|
||||
|
||||
|
||||
def test_matching_bindings_are_verified_and_converged_despite_unrelated_main_head() -> None:
|
||||
observed = observation()
|
||||
observed["main_head"] = "different-from-release-source"
|
||||
result = evaluate_deployment(runtime_contract(), deployment_target(), observed, provenance(), NOW)
|
||||
assert result["status"] == "VERIFIED"
|
||||
assert result["state"] == "CONVERGED"
|
||||
assert result["verification"] == "VERIFIED"
|
||||
assert result["converged"] is True
|
||||
|
||||
|
||||
def test_operation_definition_keeps_effect_and_safety_policies_orthogonal() -> None:
|
||||
definition = OperationDefinition.from_dict(operation())
|
||||
assert definition.effect_class == "RECONCILABLE"
|
||||
assert definition.retry["mode"] == "AFTER_READBACK"
|
||||
assert definition.rollback["mode"] == "OPERATION"
|
||||
assert definition.owner_boundary["mode"] == "WITHIN_GRANT"
|
||||
|
||||
|
||||
def test_read_only_definition_accepts_permissions_alias_and_registry_lists_definitions() -> None:
|
||||
data = {
|
||||
"operation_id": "service.status.v1",
|
||||
"version": 1,
|
||||
"effect_class": "READ_ONLY",
|
||||
"handler_ref": "host.service_status",
|
||||
"input_schema": "aios.operation.service-status.input.v1",
|
||||
"permissions": ["service-status"],
|
||||
"timeout_seconds": 10,
|
||||
"readback": {"mode": "NONE", "required": False},
|
||||
"retry": {"mode": "SAFE", "max_attempts": 2},
|
||||
"reconcile": {"mode": "NONE"},
|
||||
"rollback": {"mode": "NONE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
definition = OperationDefinition.from_dict(data)
|
||||
registry = OperationRegistry([definition])
|
||||
assert definition.required_permissions == ("service-status",)
|
||||
assert definition.permissions == ("service-status",)
|
||||
assert registry.definitions == (definition,)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effect_class", ["READ_ONLY", "IDEMPOTENT", "CONDITIONAL", "RECONCILABLE", "IRREVERSIBLE"])
|
||||
def test_registry_accepts_all_primary_effect_classes(effect_class: str) -> None:
|
||||
data = operation(
|
||||
operation_id=f"effect.{effect_class.lower()}.v1",
|
||||
effect_class=effect_class,
|
||||
retry={"mode": "NEVER", "max_attempts": 1},
|
||||
rollback={"mode": "NONE"},
|
||||
)
|
||||
if effect_class == "IRREVERSIBLE":
|
||||
data["owner_boundary"] = {"mode": "EXPLICIT_AUTHORIZATION"}
|
||||
definition = OperationDefinition.from_dict(data)
|
||||
registry = OperationRegistry([definition])
|
||||
assert registry.get(data["operation_id"]) is definition
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forbidden", ["command", "shell", "argv", "executable", "script"])
|
||||
def test_registry_definitions_cannot_embed_arbitrary_execution(forbidden: str) -> None:
|
||||
data = operation()
|
||||
data[forbidden] = "sh -c whoami"
|
||||
with pytest.raises(ValueError, match="arbitrary_execution_forbidden"):
|
||||
OperationDefinition.from_dict(data)
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_operation_ids() -> None:
|
||||
definition = OperationDefinition.from_dict(operation())
|
||||
with pytest.raises(ValueError, match="duplicate_operation_id"):
|
||||
OperationRegistry([definition, definition])
|
||||
|
||||
|
||||
def test_timeout_never_blindly_retries_irreversible_or_ambiguous_effects() -> None:
|
||||
irreversible = OperationDefinition.from_dict(
|
||||
operation(
|
||||
effect_class="IRREVERSIBLE",
|
||||
retry={"mode": "SAFE", "max_attempts": 4},
|
||||
rollback={"mode": "NONE"},
|
||||
owner_boundary={"mode": "EXPLICIT_AUTHORIZATION"},
|
||||
)
|
||||
)
|
||||
assert operation_timeout_action(irreversible, outcome="NOT_OBSERVED") == "STOP"
|
||||
assert operation_timeout_action(OperationDefinition.from_dict(operation()), outcome="AMBIGUOUS") == "RECONCILE"
|
||||
|
||||
|
||||
def test_service_active_state_alone_cannot_prove_a_restart() -> None:
|
||||
result = verify_service_restart(
|
||||
{"active": True, "instance_id": "before", "started_at": "2026-09-07T11:00:00Z"},
|
||||
{"active": True},
|
||||
)
|
||||
assert result["status"] == "UNKNOWN"
|
||||
assert "restart_identity_missing" in result["reasons"]
|
||||
|
||||
|
||||
def test_service_restart_is_verified_by_changed_instance_and_fresh_start_time() -> None:
|
||||
result = verify_service_restart(
|
||||
{"active": True, "instance_id": "before", "started_at": "2026-09-07T11:00:00Z"},
|
||||
{"active": True, "instance_id": "after", "started_at": "2026-09-07T11:59:00Z"},
|
||||
)
|
||||
assert result == {"status": "VERIFIED", "reasons": []}
|
||||
|
||||
|
||||
def production_observation_bundle() -> dict:
|
||||
contract = runtime_contract()
|
||||
contract["environment"] = "production"
|
||||
for group in ("entrypoints", "bindings", "resource_refs", "credential_refs"):
|
||||
for binding in contract[group].values():
|
||||
binding["environment"] = "production"
|
||||
target = deployment_target()
|
||||
target.update(
|
||||
environment="production",
|
||||
release_id="V2R1C9",
|
||||
target_oid="d" * 40,
|
||||
)
|
||||
observed = observation()
|
||||
observed.update(environment="production", observed_at="2026-09-07T11:59:45Z")
|
||||
return {
|
||||
"contract": contract,
|
||||
"target": target,
|
||||
"observation": observed,
|
||||
"provenance": provenance(),
|
||||
}
|
||||
|
||||
|
||||
def test_missing_production_observation_is_unknown(tmp_path) -> None:
|
||||
assert load_production_observation(tmp_path / "missing.json", NOW) == {
|
||||
"baseline_version": None,
|
||||
"target_oid": None,
|
||||
"deployment_state": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def test_stale_production_observation_cannot_claim_active(tmp_path) -> None:
|
||||
bundle = production_observation_bundle()
|
||||
bundle["observation"]["observed_at"] = "2026-09-07T11:50:00Z"
|
||||
path = tmp_path / "production-observation.json"
|
||||
path.write_text(__import__("json").dumps(bundle), encoding="utf-8")
|
||||
assert load_production_observation(path, NOW)["deployment_state"] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_verified_production_observation_projects_compatible_snapshot_fields(tmp_path) -> None:
|
||||
path = tmp_path / "production-observation.json"
|
||||
path.write_text(__import__("json").dumps(production_observation_bundle()), encoding="utf-8")
|
||||
assert load_production_observation(path, NOW) == {
|
||||
"baseline_version": "V2R1C9",
|
||||
"target_oid": "d" * 40,
|
||||
"deployment_state": "ACTIVE",
|
||||
}
|
||||
|
||||
|
||||
def test_bridge_snapshot_defaults_production_to_unknown(tmp_path) -> None:
|
||||
runtime = object.__new__(GovernedRuntime)
|
||||
runtime.config = RuntimeConfig(
|
||||
target_oid="e" * 40,
|
||||
authoritative_git_dir=tmp_path / "repo.git",
|
||||
state_root=tmp_path,
|
||||
v2_job_root=tmp_path / "jobs",
|
||||
token_path=tmp_path / "token",
|
||||
ssh_key_path=tmp_path / "ssh",
|
||||
)
|
||||
runtime.authority = type("Authority", (), {"read_ref": lambda self, ref: "f" * 40})()
|
||||
runtime._snapshot_task = lambda: {}
|
||||
runtime.runtime_status = lambda: {
|
||||
"bridge_schema_version": "aios.chatgpt-gitops-bridge.v2",
|
||||
"control_plane_release_version": "V2R2C1",
|
||||
"runtime_release_commit": "e" * 40,
|
||||
"runtime_path": str(tmp_path),
|
||||
"artifact_sha256": None,
|
||||
"drift_status": "PASS",
|
||||
"governed_writes_enabled": False,
|
||||
}
|
||||
assert runtime.snapshot()["production"] == {
|
||||
"baseline_version": None,
|
||||
"target_oid": None,
|
||||
"deployment_state": "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
def test_staging_unit_uses_its_own_encrypted_credential_projection() -> None:
|
||||
unit = (
|
||||
__import__("pathlib").Path(__file__).resolve().parents[2]
|
||||
/ "deploy/systemd/aios-chatgpt-bridge-v2-staging.service"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "LoadCredentialEncrypted=gitea-governance-executor-token:/etc/credstore.encrypted/gitea-governance-executor-token" in unit
|
||||
assert "Environment=AIOS_GITEA_TOKEN_FILE=%d/gitea-governance-executor-token" in unit
|
||||
assert "/run/credentials/aios-governance-executor.service/" not in unit
|
||||
|
||||
|
||||
def test_empty_or_nullable_reconciliation_evidence_is_not_healthy() -> None:
|
||||
controller = ReconciliationController()
|
||||
empty = controller.reconcile({})
|
||||
assert empty.healthy is False
|
||||
assert set(empty.drift) == {label for _, _, label in controller.CHECKS}
|
||||
|
||||
nullable = controller.reconcile(
|
||||
{
|
||||
key: None
|
||||
for pair in controller.CHECKS
|
||||
for key in pair[:2]
|
||||
}
|
||||
)
|
||||
assert nullable.healthy is False
|
||||
|
||||
|
||||
def test_body_claiming_production_state_is_not_blanket_truth() -> None:
|
||||
report = ReconciliationController().reconcile({"body": {"production_state": "ACTIVE"}})
|
||||
assert report.healthy is False
|
||||
@@ -0,0 +1,582 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
import aios_gitops_job_executor as executor_module
|
||||
from aios_gitops_job_controller import (
|
||||
GitOpsJobController,
|
||||
JobError,
|
||||
JobState,
|
||||
JobStore,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
|
||||
|
||||
OLD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
NEW = "3fbbc18fb2131bbbd9e511004a59ee95cf3e86b7"
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = datetime(2026, 9, 8, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.value
|
||||
|
||||
|
||||
class Authority:
|
||||
def __init__(self) -> None:
|
||||
self.branch = OLD
|
||||
self.main = OLD
|
||||
self.mutations = 0
|
||||
|
||||
def read_ref(self, ref: str):
|
||||
return self.main if ref == "refs/heads/main" else self.branch
|
||||
|
||||
def ancestry_check(self, older: str, newer: str) -> bool:
|
||||
return older == newer or (older, newer) == (OLD, NEW)
|
||||
|
||||
def advance_task_branch_CAS(self, _branch: str, expected: str, new: str) -> str:
|
||||
assert self.branch == expected
|
||||
self.mutations += 1
|
||||
self.branch = new
|
||||
return new
|
||||
|
||||
def ff_CAS_main_after_verified_protected_merge(self, expected: str, new: str) -> str:
|
||||
assert self.main == expected
|
||||
self.mutations += 1
|
||||
self.main = new
|
||||
return new
|
||||
|
||||
def commit_file_in_isolated_worktree(self, **_kwargs) -> str:
|
||||
self.mutations += 1
|
||||
return NEW
|
||||
|
||||
|
||||
class Broker:
|
||||
def __init__(self) -> None:
|
||||
self.remote = OLD
|
||||
self.mutations = 0
|
||||
|
||||
def verify_identity(self):
|
||||
return {"login": "aios-autonomy-agent", "repository": executor_module.REPOSITORY}
|
||||
|
||||
def readback(self):
|
||||
return self.remote
|
||||
|
||||
def push_branch(self, new: str) -> str:
|
||||
self.mutations += 1
|
||||
self.remote = new
|
||||
return new
|
||||
|
||||
def create_pr(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"number": 1}
|
||||
|
||||
def status(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"status": "success"}
|
||||
|
||||
def merge(self, *_args):
|
||||
self.mutations += 1
|
||||
return {"merged": True}
|
||||
|
||||
|
||||
def campaign_request(operation: str = "SYNC_TASK_BRANCH_TO_GITEA", **updates):
|
||||
request = {
|
||||
"campaign_id": executor_module.CAMPAIGN_ID,
|
||||
"repository": executor_module.REPOSITORY,
|
||||
"operation": operation,
|
||||
"branch": executor_module.BRANCH,
|
||||
"expected_branch_oid": OLD if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH" else NEW,
|
||||
"payload_digest": "0" * 64,
|
||||
"task_id": executor_module.TASK_ID,
|
||||
"new_branch_oid": NEW,
|
||||
"authorization_recovery_digest": executor_module.AUTHORIZATION_DIGEST,
|
||||
}
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
request["expected_main_oid"] = OLD
|
||||
request.update(updates)
|
||||
return request
|
||||
|
||||
|
||||
def recovery():
|
||||
return {
|
||||
"digest": executor_module.AUTHORIZATION_DIGEST,
|
||||
"campaign_id": executor_module.CAMPAIGN_ID,
|
||||
"task_id": executor_module.TASK_ID,
|
||||
"repository": executor_module.REPOSITORY,
|
||||
"branch": executor_module.BRANCH,
|
||||
"expected_branch_oid": OLD,
|
||||
}
|
||||
|
||||
|
||||
def generic_request(**updates):
|
||||
request = {
|
||||
"schema": "aios.operation-job.v1",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "subject-a",
|
||||
"mission_id": "mission-a",
|
||||
"grant_id": "grant-a",
|
||||
"task_id": "task-a",
|
||||
"effect_id": "effect-a",
|
||||
"operation": "host.status.v1",
|
||||
"operation_version": "1",
|
||||
"resource_kind": "host",
|
||||
"resource_id": "tenant-a/staging/local-host",
|
||||
"input_digest": hashlib.sha256(b"{}").hexdigest(),
|
||||
}
|
||||
request.update(updates)
|
||||
return request
|
||||
|
||||
|
||||
def receipt_signer():
|
||||
private = Ed25519PrivateKey.generate()
|
||||
|
||||
def sign(payload):
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
return {
|
||||
"schema": "aios.operation-receipt.v1",
|
||||
"key_id": "test-machine",
|
||||
"payload": payload,
|
||||
"signature": base64.b64encode(private.sign(raw)).decode(),
|
||||
}
|
||||
|
||||
def verify(envelope):
|
||||
try:
|
||||
raw = json.dumps(envelope["payload"], sort_keys=True, separators=(",", ":")).encode()
|
||||
private.public_key().verify(base64.b64decode(envelope["signature"], validate=True), raw)
|
||||
return envelope.get("key_id") == "test-machine"
|
||||
except (InvalidSignature, KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
return sign, verify
|
||||
|
||||
|
||||
def signed_generic_receipt(sign, request, job_id, lease):
|
||||
return sign({
|
||||
**{key: request[key] for key in (
|
||||
"tenant_id", "subject_id", "mission_id", "grant_id", "task_id",
|
||||
"effect_id", "operation", "operation_version", "resource_kind",
|
||||
"resource_id", "input_digest",
|
||||
)},
|
||||
"job_id": job_id,
|
||||
"claim_id": lease["lease_id"],
|
||||
"generation": int(lease["generation"]),
|
||||
"observation": {"active": True},
|
||||
"verified": True,
|
||||
})
|
||||
|
||||
|
||||
def make_executor(tmp_path: Path, monkeypatch, operation="SYNC_TASK_BRANCH_TO_GITEA"):
|
||||
monkeypatch.setattr(executor_module, "load_recovered_authorization", lambda _path: recovery())
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
job = controller.submit(campaign_request(operation))
|
||||
authority, broker = Authority(), Broker()
|
||||
if operation != "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
authority.branch = NEW
|
||||
executor = executor_module.DurableJobExecutor(
|
||||
controller, authority, broker, tmp_path / "recovery.json", clock=clock
|
||||
)
|
||||
return clock, controller, job, authority, broker, executor
|
||||
|
||||
|
||||
def test_lease_requires_authorized_live_job_and_bounded_future_deadline(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
job = controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
kwargs = dict(
|
||||
lease_id="lease-1",
|
||||
lineage_anchor_oid=OLD,
|
||||
current_head_oid=OLD,
|
||||
generation=1,
|
||||
expected_revision=job.revision,
|
||||
)
|
||||
with pytest.raises(JobError, match="LEASE_JOB_STATE_INVALID"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(), **kwargs)
|
||||
authorized = controller.advance(job.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
kwargs["expected_revision"] = authorized.revision
|
||||
with pytest.raises(JobError, match="LEASE_EXPIRED"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value - timedelta(seconds=1)).isoformat(), **kwargs)
|
||||
with pytest.raises(JobError, match="LEASE_DURATION_EXCEEDED"):
|
||||
controller.lease_job(job.job_id, lease_expires_at=(clock.value + timedelta(hours=1)).isoformat(), **kwargs)
|
||||
|
||||
terminal_controller = GitOpsJobController(JobStore(tmp_path / "terminal"), clock=clock)
|
||||
terminal = terminal_controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
terminal = terminal_controller.advance(
|
||||
terminal.job_id,
|
||||
JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
side_effect_state="MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
)
|
||||
with pytest.raises(JobError, match="LEASE_JOB_STATE_INVALID"):
|
||||
terminal_controller.lease_job(
|
||||
terminal.job_id,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
**{**kwargs, "expected_revision": terminal.revision},
|
||||
)
|
||||
|
||||
|
||||
def test_resource_job_profile_and_lease_are_typed_and_fenced(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "jobs"), clock=clock)
|
||||
request = {
|
||||
"schema": "aios.operation-job.v1",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "subject-a",
|
||||
"mission_id": "mission-a",
|
||||
"grant_id": "grant-a",
|
||||
"task_id": "task-a",
|
||||
"effect_id": "effect-a",
|
||||
"operation": "host.status.v1",
|
||||
"operation_version": "1",
|
||||
"resource_kind": "host",
|
||||
"resource_id": "local-host",
|
||||
"input_digest": hashlib.sha256(b"{}").hexdigest(),
|
||||
}
|
||||
submitted = controller.submit(request)
|
||||
authorized = controller.advance(submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id,
|
||||
lease_id="resource-lease-1",
|
||||
resource_kind="host",
|
||||
resource_id="local-host",
|
||||
generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
assert leased.state is JobState.LEASED
|
||||
first_lease = controller.get_execution_lease(submitted.job_id)
|
||||
assert first_lease["generation"] == 1
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=1)
|
||||
with pytest.raises(JobError, match="LEASE_FENCE_REJECTED"):
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=0)
|
||||
|
||||
clock.value += timedelta(seconds=31)
|
||||
second_request = {**request, "task_id": "task-b", "effect_id": "effect-b"}
|
||||
second = controller.submit(second_request)
|
||||
second = controller.advance(second.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
controller.lease_resource_job(
|
||||
second.job_id,
|
||||
lease_id="resource-lease-2",
|
||||
resource_kind="host",
|
||||
resource_id="local-host",
|
||||
generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=second.revision,
|
||||
)
|
||||
assert controller.get_execution_lease(second.job_id)["generation"] == 2
|
||||
with pytest.raises(JobError, match="LEASE_FENCE_REJECTED"):
|
||||
controller.assert_execution_lease(submitted.job_id, lease_id="resource-lease-1", generation=1)
|
||||
|
||||
|
||||
def test_generic_job_cannot_skip_intent_readback_or_trusted_receipt(tmp_path):
|
||||
clock = Clock()
|
||||
sign, verify = receipt_signer()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, generic_receipt_verifier=verify
|
||||
)
|
||||
request = generic_request()
|
||||
submitted = controller.submit(request)
|
||||
invented = sign({"verified": True})
|
||||
with pytest.raises(JobError, match="GENERIC_JOB_TRANSITION_INVALID"):
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.COMPLETED,
|
||||
side_effect_state="VERIFIED", receipt=invented,
|
||||
)
|
||||
assert controller.store.read(submitted.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
authorized = controller.advance(
|
||||
submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED"
|
||||
)
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id, lease_id="claim-a", resource_kind=request["resource_kind"],
|
||||
resource_id=request["resource_id"], generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.EXECUTING,
|
||||
side_effect_state="INTENT_COMMITTED", expected_revision=leased.revision,
|
||||
)
|
||||
lease = controller.get_execution_lease(submitted.job_id)
|
||||
valid_receipt = signed_generic_receipt(sign, request, submitted.job_id, lease)
|
||||
tampered = {**valid_receipt, "payload": {**valid_receipt["payload"], "input_digest": "f" * 64}}
|
||||
with pytest.raises(JobError, match="GENERIC_RECEIPT_BINDING_MISMATCH"):
|
||||
controller.advance(
|
||||
submitted.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=tampered,
|
||||
)
|
||||
local = controller.advance(
|
||||
submitted.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=valid_receipt,
|
||||
)
|
||||
completed = controller.advance(
|
||||
submitted.job_id, JobState.COMPLETED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=valid_receipt,
|
||||
expected_revision=local.revision,
|
||||
)
|
||||
assert completed.state is JobState.COMPLETED
|
||||
|
||||
|
||||
def test_generic_complete_requires_configured_verifier_and_recovery_accepts_bound_receipt(tmp_path):
|
||||
clock = Clock()
|
||||
sign, verify = receipt_signer()
|
||||
request = generic_request()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, generic_receipt_verifier=verify,
|
||||
)
|
||||
submitted = controller.submit(request)
|
||||
authorized = controller.advance(submitted.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_resource_job(
|
||||
submitted.job_id, lease_id="claim-a", resource_kind=request["resource_kind"],
|
||||
resource_id=request["resource_id"], generation=None,
|
||||
lease_expires_at=(clock.value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=authorized.revision,
|
||||
)
|
||||
executing = controller.advance(
|
||||
submitted.job_id, JobState.EXECUTING,
|
||||
side_effect_state="INTENT_COMMITTED", expected_revision=leased.revision,
|
||||
)
|
||||
receipt = signed_generic_receipt(
|
||||
sign, request, submitted.job_id, controller.get_execution_lease(submitted.job_id)
|
||||
)
|
||||
pending = controller.advance(
|
||||
submitted.job_id, JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN", expected_revision=executing.revision,
|
||||
)
|
||||
clock.value += timedelta(seconds=31)
|
||||
completed = controller.reconcile(
|
||||
submitted.job_id,
|
||||
lambda _row: {
|
||||
"state": JobState.COMPLETED.value,
|
||||
"side_effect_state": "READBACK_VERIFIED",
|
||||
"receipt": receipt,
|
||||
},
|
||||
)
|
||||
assert completed.state is JobState.COMPLETED
|
||||
|
||||
untrusted = GitOpsJobController(JobStore(tmp_path / "untrusted"), clock=Clock())
|
||||
other_request = generic_request(effect_id="effect-b", task_id="task-b")
|
||||
other = untrusted.submit(other_request)
|
||||
other = untrusted.advance(other.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
other = untrusted.lease_resource_job(
|
||||
other.job_id, lease_id="claim-b", resource_kind=other_request["resource_kind"],
|
||||
resource_id=other_request["resource_id"], generation=None,
|
||||
lease_expires_at=(Clock().value + timedelta(seconds=30)).isoformat(),
|
||||
expected_revision=other.revision,
|
||||
)
|
||||
untrusted.advance(other.job_id, JobState.EXECUTING, side_effect_state="INTENT_COMMITTED")
|
||||
other_receipt = signed_generic_receipt(
|
||||
sign, other_request, other.job_id, untrusted.get_execution_lease(other.job_id)
|
||||
)
|
||||
with pytest.raises(JobError, match="GENERIC_RECEIPT_VERIFIER_REQUIRED"):
|
||||
untrusted.advance(
|
||||
other.job_id, JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="READBACK_VERIFIED", receipt=other_receipt,
|
||||
)
|
||||
|
||||
|
||||
def test_reconcile_crash_can_be_taken_over_and_stale_observer_cannot_commit(tmp_path):
|
||||
clock = Clock()
|
||||
controller = GitOpsJobController(
|
||||
JobStore(tmp_path / "jobs"), clock=clock, reconcile_lease_seconds=10
|
||||
)
|
||||
job = controller.submit(campaign_request("ADVANCE_EXISTING_CAMPAIGN_BRANCH"))
|
||||
controller.advance(job.job_id, JobState.RECOVERABLE_FAILURE, side_effect_state="UNKNOWN")
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
controller.reconcile(job.job_id, lambda _row: (_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.RECONCILING.value
|
||||
with pytest.raises(JobError, match="RECONCILIATION_CLAIM_ACTIVE"):
|
||||
controller.reconcile(job.job_id, lambda _row: {"state": "REQUESTED"})
|
||||
|
||||
clock.value += timedelta(seconds=11)
|
||||
|
||||
def stale_observer(_row):
|
||||
clock.value += timedelta(seconds=11)
|
||||
winner = controller.reconcile(
|
||||
job.job_id,
|
||||
lambda _new: {"state": "REQUESTED", "side_effect_state": "RECONCILED_ABSENT"},
|
||||
)
|
||||
assert winner.state is JobState.REQUESTED
|
||||
return {"state": "COMPLETED", "side_effect_state": "VERIFIED"}
|
||||
|
||||
with pytest.raises(JobError, match="RECONCILIATION_CLAIM_LOST"):
|
||||
controller.reconcile(job.job_id, stale_observer)
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operation",
|
||||
[
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA",
|
||||
"CREATE_PR",
|
||||
"PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE",
|
||||
"RECONCILE_AUTHORITY_MAIN",
|
||||
"COMMIT_FILE",
|
||||
],
|
||||
)
|
||||
def test_dry_run_never_calls_a_mutating_adapter(tmp_path, monkeypatch, operation):
|
||||
_clock, controller, job, authority, broker, executor = make_executor(
|
||||
tmp_path, monkeypatch, operation
|
||||
)
|
||||
if operation == "COMMIT_FILE":
|
||||
payload = b'{"path":"x.txt","content_base64":"eA=="}'
|
||||
controller = GitOpsJobController(JobStore(tmp_path / "commit-jobs"), clock=Clock())
|
||||
job = controller.submit(
|
||||
campaign_request(operation, payload_digest=hashlib.sha256(payload).hexdigest()),
|
||||
payload=payload,
|
||||
)
|
||||
executor = executor_module.DurableJobExecutor(
|
||||
controller, authority, broker, tmp_path / "recovery.json", clock=Clock()
|
||||
)
|
||||
result = executor.execute(job.job_id, dry_run=True)
|
||||
assert result["mode"] == "DRY_RUN"
|
||||
assert authority.mutations == broker.mutations == 0
|
||||
assert controller.store.read(job.job_id)["state"] == JobState.REQUESTED.value
|
||||
|
||||
|
||||
def test_execution_does_not_call_effect_when_intent_commit_is_refused(tmp_path, monkeypatch):
|
||||
_clock, controller, job, authority, broker, executor = make_executor(tmp_path, monkeypatch)
|
||||
original = controller.advance
|
||||
|
||||
def refuse(job_id, state, **kwargs):
|
||||
if state is JobState.EXECUTING:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE")
|
||||
return original(job_id, state, **kwargs)
|
||||
|
||||
monkeypatch.setattr(controller, "advance", refuse)
|
||||
with pytest.raises(ServiceUnavailable, match="JOB_DB_UNAVAILABLE"):
|
||||
executor.execute(job.job_id)
|
||||
assert authority.mutations == broker.mutations == 0
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(job.job_id)
|
||||
|
||||
|
||||
def test_external_success_receipt_failure_latches_then_readback_recovers_without_repeat(tmp_path, monkeypatch):
|
||||
_clock, controller, job, _authority, broker, executor = make_executor(tmp_path, monkeypatch)
|
||||
original = controller.advance
|
||||
fail = {"once": True}
|
||||
|
||||
def refuse_receipt(job_id, state, **kwargs):
|
||||
if state is JobState.COMPLETED and fail["once"]:
|
||||
fail["once"] = False
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE")
|
||||
return original(job_id, state, **kwargs)
|
||||
|
||||
monkeypatch.setattr(controller, "advance", refuse_receipt)
|
||||
with pytest.raises(ServiceUnavailable, match="JOB_DB_UNAVAILABLE"):
|
||||
executor.execute(job.job_id)
|
||||
assert broker.mutations == 1
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(job.job_id)
|
||||
next_job = controller.submit(campaign_request("CREATE_PR", idempotency_key="after-receipt-failure"))
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
executor.execute(next_job.job_id)
|
||||
assert broker.mutations == 1
|
||||
|
||||
restarted = executor_module.DurableJobExecutor(
|
||||
controller, executor.authority, broker, tmp_path / "recovery.json", clock=executor.clock
|
||||
)
|
||||
with pytest.raises(executor_module.JobExecutionError, match="PERSISTENCE_FAILURE_LATCHED"):
|
||||
restarted.execute(job.job_id)
|
||||
|
||||
recovered = executor.reconcile(job.job_id)
|
||||
assert recovered["state"] == JobState.COMPLETED.value
|
||||
assert broker.mutations == 1
|
||||
assert executor.execute(job.job_id)["state"] == JobState.COMPLETED.value
|
||||
|
||||
|
||||
def test_unsupported_exact_readback_reports_unknown_without_fake_success_or_effect(tmp_path, monkeypatch):
|
||||
_clock, controller, job, _authority, broker, executor = make_executor(
|
||||
tmp_path, monkeypatch, "PUBLISH_CHECK"
|
||||
)
|
||||
result = executor.execute(job.job_id)
|
||||
assert result["state"] == JobState.RECONCILIATION_REQUIRED.value
|
||||
assert result["receipt"]["verification"] == "UNKNOWN"
|
||||
assert result["receipt"]["blocker"] == "EXACT_READBACK_UNSUPPORTED"
|
||||
assert broker.mutations == 0
|
||||
again = executor.execute(job.job_id)
|
||||
assert again["state"] == JobState.RECONCILIATION_REQUIRED.value
|
||||
assert broker.mutations == 0
|
||||
|
||||
|
||||
def test_subprocess_crash_after_durable_sink_success_recovers_without_repeat(tmp_path):
|
||||
"""AT-22: process death after effect durability cannot repeat the effect."""
|
||||
job_root = tmp_path / "jobs"
|
||||
sink = tmp_path / "effect-sink.json"
|
||||
recovery_path = tmp_path / "recovery.json"
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
import aios_gitops_job_executor as m
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
|
||||
OLD = {OLD!r}; NEW = {NEW!r}
|
||||
sink = Path({str(sink)!r})
|
||||
root = Path({str(job_root)!r})
|
||||
m.load_recovered_authorization = lambda _path: {{
|
||||
'digest': m.AUTHORIZATION_DIGEST, 'campaign_id': m.CAMPAIGN_ID,
|
||||
'task_id': m.TASK_ID, 'repository': m.REPOSITORY,
|
||||
'branch': m.BRANCH, 'expected_branch_oid': OLD,
|
||||
}}
|
||||
|
||||
class Authority:
|
||||
def read_ref(self, _ref): return NEW
|
||||
def ancestry_check(self, older, newer): return older == newer or (older == OLD and newer == NEW)
|
||||
|
||||
class Broker:
|
||||
def verify_identity(self): return {{'login':'aios-autonomy-agent','repository':m.REPOSITORY}}
|
||||
def readback(self):
|
||||
if sink.exists():
|
||||
if os.environ.get('CRASH_AFTER_EFFECT') == '1': os._exit(77)
|
||||
return json.loads(sink.read_text())['oid']
|
||||
return OLD
|
||||
def push_branch(self, oid):
|
||||
previous = json.loads(sink.read_text()) if sink.exists() else {{'count': 0}}
|
||||
payload = {{'count': previous['count'] + 1, 'oid': oid}}
|
||||
with sink.open('w') as stream:
|
||||
json.dump(payload, stream); stream.flush(); os.fsync(stream.fileno())
|
||||
return oid
|
||||
|
||||
controller = GitOpsJobController(JobStore(root))
|
||||
request = {campaign_request()!r}
|
||||
job = controller.submit(request)
|
||||
executor = m.DurableJobExecutor(controller, Authority(), Broker(), Path({str(recovery_path)!r}))
|
||||
if os.environ['ACTION'] == 'execute':
|
||||
executor.execute(job.job_id)
|
||||
else:
|
||||
print(json.dumps(executor.reconcile(job.job_id), sort_keys=True))
|
||||
"""
|
||||
)
|
||||
environment = os.environ.copy()
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
environment["ACTION"] = "execute"
|
||||
environment["CRASH_AFTER_EFFECT"] = "1"
|
||||
crashed = subprocess.run([sys.executable, "-c", script], env=environment, check=False)
|
||||
assert crashed.returncode == 77
|
||||
assert json.loads(sink.read_text()) == {"count": 1, "oid": NEW}
|
||||
|
||||
environment["ACTION"] = "recover"
|
||||
environment["CRASH_AFTER_EFFECT"] = "0"
|
||||
recovered = subprocess.run(
|
||||
[sys.executable, "-c", script], env=environment, check=True,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(recovered.stdout)["state"] == JobState.COMPLETED.value
|
||||
assert json.loads(sink.read_text()) == {"count": 1, "oid": NEW}
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from aios_gitops_job_controller import JobStore
|
||||
from aios_baseline.authorization import AuthorizationService, sign_owner_delegation_grant
|
||||
from aios_baseline.host import SystemdHost
|
||||
from aios_baseline.lineage import LineageLedger
|
||||
from aios_baseline.operations import OperationRegistry
|
||||
from aios_baseline.service import BaselineService, Identity, ServiceError
|
||||
from aios_baseline.worker_policy import WorkerProfile
|
||||
|
||||
|
||||
def fixture_service(tmp_path):
|
||||
owner = Ed25519PrivateKey.generate()
|
||||
machine = Ed25519PrivateKey.generate()
|
||||
auth = AuthorizationService(tmp_path/'auth.db', {'owner':owner.public_key()}, machine, 'machine',
|
||||
owner_key_scopes={'owner':{'tenant_ids':['tenant-a'],'subject_ids':['user-a']}})
|
||||
auth.register_grant(sign_owner_delegation_grant(owner,'owner',grant_id='grant-a',generation=1,
|
||||
tenant_id='tenant-a',subject_id='user-a',mission_ids=('mission-a',),task_ids=('*',),
|
||||
operations=('service-status',),component_ids=('sample',),environments=('staging',),
|
||||
issued_at=int(time.time())-1,validity_mode='UNTIL_REVOKED',max_effects=10))
|
||||
store=TaskPlaneStore(str(tmp_path/'tasks.db'),high_assurance=True)
|
||||
worker=WorkerProfile.from_dict(dict(worker_id='worker-a',identity='machine-a',tenant_scope=['tenant-a'],
|
||||
subject_scope=['user-a'],capabilities=['service-status'],priority=10,fallback_executor=False,
|
||||
heartbeat_seconds=15,max_concurrency=1))
|
||||
program=tmp_path/'systemctl-fixture'
|
||||
program.write_text('#!'+sys.executable+'\nprint("Id=sample.service\\nActiveState=active\\nSubState=running\\nMainPID=123\\nInvocationID=abc")\n')
|
||||
program.chmod(0o700)
|
||||
host=SystemdHost({'tenant-a:staging:sample':'sample.service'},executable=str(program))
|
||||
operation=dict(operation_id='service-status',version=1,effect_class='READ_ONLY',handler_ref='host.service_status',
|
||||
input_schema='aios.operation.service-status.input.v1',required_permissions=['service-status'],timeout_seconds=10,
|
||||
readback={'mode':'NONE','required':False},retry={'mode':'SAFE','max_attempts':2},
|
||||
reconcile={'mode':'NONE'},rollback={'mode':'NONE'},owner_boundary={'mode':'WITHIN_GRANT'})
|
||||
contract=dict(contract_schema='aios.runtime-contract.v1',contract_revision=1,component_id='sample',
|
||||
environment='staging',tenant_scope='tenant-a',lifecycle='active',
|
||||
entrypoints={'host':{'component_id':'sample','environment':'staging','resolver_ref':'service://sample/status'}},
|
||||
bindings={},resource_refs={},credential_refs={},operations=['service-status'])
|
||||
service=BaselineService(store, tmp_path/'jobs', auth, [contract], OperationRegistry([operation]),
|
||||
{'worker-a':worker},host,LineageLedger(tmp_path/'lineage.db',store,JobStore(tmp_path/'jobs').read),machine)
|
||||
return service
|
||||
|
||||
|
||||
USER=Identity('tenant-a','user-a','user')
|
||||
WORKER=Identity('tenant-a','machine-a','worker','worker-a')
|
||||
REQUEST=dict(mission_id='mission-a',grant_id='grant-a',operation='service-status',component_id='sample',
|
||||
environment='staging',inputs={},intent='检查 staging 服务并形成诊断结果')
|
||||
|
||||
|
||||
def test_real_local_task_goes_through_claim_job_receipt_and_lineage(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='request-a')
|
||||
duplicate=service.submit_goal(USER,REQUEST,idempotency_key='request-a')
|
||||
assert duplicate['task_id']==created['task_id']
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
assert claim['task_id']==created['task_id']
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
assert result['status']=='COMPLETED'
|
||||
assert result['receipt']['observation']['properties']['MainPID']=='123'
|
||||
assert service.get_task(USER,created['task_id'])['status']=='COMPLETED'
|
||||
assert service.explain(USER,'mission-a')['mission_id']=='mission-a'
|
||||
assert service.claim_next(WORKER) is None
|
||||
|
||||
|
||||
def test_spoofed_tenant_cross_subject_and_worker_results_are_denied(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
with pytest.raises(ServiceError): service.submit_goal(USER,{**REQUEST,'tenant_id':'tenant-b'},idempotency_key='a')
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='a')
|
||||
for outsider in (Identity('tenant-b','user-a','user'),Identity('tenant-a','user-b','user')):
|
||||
with pytest.raises(ServiceError): service.get_task(outsider,created['task_id'])
|
||||
with pytest.raises(ServiceError): service.cancel_task(outsider,created['task_id'])
|
||||
with pytest.raises(ServiceError): service.register_worker(Identity('tenant-b','machine-a','worker','worker-a'))
|
||||
with pytest.raises(ServiceError): service.execute_claim(USER,created['task_id'],'invented')
|
||||
|
||||
|
||||
def test_changed_payload_with_same_request_key_cannot_change_task(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='a')
|
||||
with pytest.raises(ServiceError): service.submit_goal(USER,{**REQUEST,'intent':'different goal'},idempotency_key='a')
|
||||
|
||||
|
||||
def test_cancelled_expired_claim_is_not_resurrected_by_recovery(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
created=service.submit_goal(USER,REQUEST,idempotency_key='cancel')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
service.cancel_task(USER,created['task_id'])
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE claims SET lease_until=? WHERE claim_id=?',(time.time()-1,claim['claim_id']))
|
||||
service.recover()
|
||||
assert service.get_task(USER,created['task_id'])['status']=='CANCELLED'
|
||||
assert service.claim_next(WORKER) is None
|
||||
|
||||
|
||||
def test_multiple_tasks_share_monotonic_resource_fence_without_sharing_state(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.register_worker(WORKER)
|
||||
generations=[]
|
||||
for request_key in ('first','second'):
|
||||
service.submit_goal(USER,REQUEST,idempotency_key=request_key)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
assert result['status']=='COMPLETED'
|
||||
generations.append(result['receipt']['generation'])
|
||||
assert generations==[1,2]
|
||||
|
||||
|
||||
def test_persisted_receipt_closes_expired_task_without_repeating_host_diagnostic(tmp_path,monkeypatch):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='recover')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
original=service._close_task
|
||||
def lose_power(*args): raise OSError('simulated process exit before Task acknowledgment')
|
||||
monkeypatch.setattr(service,'_close_task',lose_power)
|
||||
with pytest.raises(ServiceError): service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE claims SET lease_until=? WHERE claim_id=?',(time.time()-1,claim['claim_id']))
|
||||
monkeypatch.setattr(service,'_close_task',original)
|
||||
def must_not_execute(*args,**kwargs): raise AssertionError('must use durable receipt')
|
||||
monkeypatch.setattr(service.host,'status',must_not_execute)
|
||||
service.recover()
|
||||
assert service.get_task(USER,claim['task_id'])['status']=='COMPLETED'
|
||||
|
||||
|
||||
def test_wrong_signed_lineage_cannot_be_attached_to_real_task(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='scope')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
task=service.store.get_task(claim['task_id'])
|
||||
assert service._receipt_valid(task,service.signer.sign(result['receipt']))
|
||||
for field in ('job_id','mission_id','grant_id','claim_id','generation','operation','input_digest','resource_id'):
|
||||
forged=service.signer.sign({**result['receipt'],field:'wrong'})
|
||||
assert not service._receipt_valid(task,forged),field
|
||||
|
||||
|
||||
def test_receipt_key_rotation_preserves_old_evidence_only_with_explicit_trust(tmp_path):
|
||||
from aios_baseline.evidence import ReceiptSigner
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='rotation')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
result=service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
old=service.signer
|
||||
envelope=old.sign(result['receipt'])
|
||||
new_key=Ed25519PrivateKey.generate()
|
||||
assert not ReceiptSigner(new_key,key_id='next').verify(envelope)
|
||||
rotated=ReceiptSigner(new_key,key_id='next',trusted_public_keys={old.key_id:old.public_key})
|
||||
service.signer=rotated
|
||||
assert service._receipt_valid(service.store.get_task(claim['task_id']),envelope)
|
||||
|
||||
|
||||
def test_fallback_is_normal_worker_eligibility_with_priority_and_same_claim_protocol(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
task=service.submit_goal(USER,REQUEST,idempotency_key='fallback')
|
||||
service.register_worker(WORKER)
|
||||
profile=service.worker_profiles['worker-a']
|
||||
fallback=WorkerProfile.from_dict({**profile.__dict__,'worker_id':'worker-fallback','identity':'machine-fallback',
|
||||
'fallback_executor':True,'priority':100})
|
||||
service.worker_profiles[fallback.worker_id]=fallback
|
||||
identity=Identity('tenant-a','machine-fallback','worker','worker-fallback')
|
||||
service.register_worker(identity)
|
||||
assert service.claim_next(identity) is None
|
||||
with service.store._transaction():
|
||||
service.store.conn.execute('UPDATE baseline_task_runtime SET ready_at=? WHERE task_id=?',(time.time()-601,task['task_id']))
|
||||
assert service.claim_next(WORKER) is None
|
||||
claim=service.claim_next(identity)
|
||||
assert claim['task_id']==task['task_id']
|
||||
assert service.execute_claim(identity,claim['task_id'],claim['claim_id'])['status']=='COMPLETED'
|
||||
|
||||
|
||||
def test_deadline_expiring_while_waiting_for_executor_never_starts_host(tmp_path,monkeypatch):
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='deadline')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
def unexpected(*args,**kwargs): raise AssertionError('expired call executed')
|
||||
monkeypatch.setattr(service.host,'status',unexpected)
|
||||
service._execute_lock.acquire()
|
||||
try:
|
||||
with pytest.raises(ServiceError,match='DEADLINE'):
|
||||
service.execute_claim(WORKER,claim['task_id'],claim['claim_id'],deadline=time.time()+0.05)
|
||||
finally: service._execute_lock.release()
|
||||
assert service.get_task(USER,claim['task_id'])['job_id'] is None
|
||||
@@ -0,0 +1,435 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.optimization import (
|
||||
OptimizationController,
|
||||
OptimizationEnvelope,
|
||||
OptimizationError,
|
||||
canonical_json_bytes,
|
||||
validate_proposal,
|
||||
)
|
||||
|
||||
|
||||
def _signed_envelope(**overrides):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
document = {
|
||||
"schema": "aios.optimization-envelope.v1",
|
||||
"envelope_id": "opt-envelope-1",
|
||||
"tenant_id": "tenant-a",
|
||||
"allowed_parameters": {
|
||||
"retry.max_attempts": {"minimum": 1, "maximum": 5, "integer": True},
|
||||
"timeout.operation_seconds": {"minimum": 5, "maximum": 60},
|
||||
"evidence_reuse.max_age_seconds": {
|
||||
"minimum": 0,
|
||||
"maximum": 600,
|
||||
"integer": True,
|
||||
},
|
||||
},
|
||||
"allowed_components": ["task-worker"],
|
||||
"allowed_environments": ["staging"],
|
||||
"max_change_size": 2,
|
||||
"max_compute_budget": 20,
|
||||
"quality_floor": {
|
||||
"success_rate": {"direction": "higher", "threshold": 0.95},
|
||||
"error_rate": {"direction": "lower", "threshold": 0.05},
|
||||
},
|
||||
"rollback_requirement": True,
|
||||
"promotion_rules": {
|
||||
"objective_metric": "success_rate",
|
||||
"minimum_improvement": 0.01,
|
||||
"mandatory_tests": ["test_worker_safety"],
|
||||
"mandatory_quality_gates": ["no_tenant_leak"],
|
||||
"required_receipt_schema": "aios.governed-task-receipt.v1",
|
||||
},
|
||||
"source_config_digest": "sha256:" + "a" * 64,
|
||||
"key_id": "optimization-config-key-1",
|
||||
}
|
||||
document.update(overrides)
|
||||
signature = private_key.sign(canonical_json_bytes(document))
|
||||
document["signature"] = base64.b64encode(signature).decode("ascii")
|
||||
|
||||
def verify(key_id, payload, encoded_signature):
|
||||
assert key_id == "optimization-config-key-1"
|
||||
try:
|
||||
private_key.public_key().verify(base64.b64decode(encoded_signature), payload)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
return document, verify
|
||||
|
||||
|
||||
def _envelope():
|
||||
document, verifier = _signed_envelope()
|
||||
return OptimizationEnvelope.from_dict(document, verify_envelope=verifier)
|
||||
|
||||
|
||||
def _context(**overrides):
|
||||
value = {
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "optimizer-service",
|
||||
"grant_id": "grant-123",
|
||||
"mission_id": "mission-456",
|
||||
"service_identity": "aios-root-service",
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _proposal(**overrides):
|
||||
value = {
|
||||
"schema": "aios.optimization-proposal.v1",
|
||||
"proposal_id": "proposal-1",
|
||||
"envelope_id": "opt-envelope-1",
|
||||
"tenant_id": "tenant-a",
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
"changes": [
|
||||
{"path": "retry.max_attempts", "value": 4},
|
||||
{"path": "timeout.operation_seconds", "value": 30},
|
||||
],
|
||||
"compute_budget": 10,
|
||||
"tests": ["test_worker_safety", "test_candidate_latency"],
|
||||
"quality_gates": ["no_tenant_leak"],
|
||||
"baseline_metrics": {"success_rate": 0.95, "error_rate": 0.04},
|
||||
"rollback_plan": {
|
||||
"strategy": "restore_previous_values",
|
||||
"reference": "baseline-config-17",
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _signed_receipt_verifier():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
|
||||
def sign(receipt):
|
||||
signed = dict(receipt)
|
||||
signed["key_id"] = "governed-receipt-key-1"
|
||||
signed["signature"] = base64.b64encode(
|
||||
private_key.sign(canonical_json_bytes(signed))
|
||||
).decode("ascii")
|
||||
return signed
|
||||
|
||||
def verify(receipt):
|
||||
unsigned = dict(receipt)
|
||||
signature = unsigned.pop("signature", "")
|
||||
if unsigned.get("key_id") != "governed-receipt-key-1":
|
||||
return False
|
||||
try:
|
||||
private_key.public_key().verify(
|
||||
base64.b64decode(signature), canonical_json_bytes(unsigned)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
return sign, verify
|
||||
|
||||
|
||||
def _controller(tmp_path: Path):
|
||||
submitted = []
|
||||
sign, verify = _signed_receipt_verifier()
|
||||
|
||||
def submit_task(task):
|
||||
submitted.append(task)
|
||||
return {"task_id": "evaluation-task-1", "state": "QUEUED"}
|
||||
|
||||
controller = OptimizationController(
|
||||
tmp_path / "optimization.sqlite3",
|
||||
_envelope(),
|
||||
submit_task,
|
||||
verify_receipt=verify,
|
||||
)
|
||||
return controller, submitted, sign
|
||||
|
||||
|
||||
def _evaluation(proposal_record, sign, **overrides):
|
||||
result = {
|
||||
"candidate_metrics": {"success_rate": 0.97, "error_rate": 0.03},
|
||||
"compute_used": 8,
|
||||
"tests_passed": ["test_worker_safety", "test_candidate_latency"],
|
||||
"quality_gates_passed": ["no_tenant_leak"],
|
||||
}
|
||||
result.update(overrides)
|
||||
receipt = sign(
|
||||
{
|
||||
"schema": "aios.governed-task-receipt.v1",
|
||||
"task_id": proposal_record["evaluation_task_id"],
|
||||
"proposal_id": proposal_record["proposal_id"],
|
||||
"proposal_digest": proposal_record["proposal_digest"],
|
||||
"action": "evaluate",
|
||||
"success": True,
|
||||
"result_digest": "sha256:"
|
||||
+ hashlib.sha256(canonical_json_bytes(result)).hexdigest(),
|
||||
}
|
||||
)
|
||||
return {**result, "receipt": receipt}
|
||||
|
||||
|
||||
def test_envelope_requires_a_valid_signature_over_the_exact_payload():
|
||||
document, verifier = _signed_envelope()
|
||||
document["max_compute_budget"] = 10_000
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=verifier)
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_ENVELOPE_VERIFIER_REQUIRED"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"authorization.accept_without_grant",
|
||||
"trust_root.key",
|
||||
"tenant_isolation.enabled",
|
||||
"acceptance.threshold",
|
||||
"destructive_capability.enabled",
|
||||
"owner_grant.semantics",
|
||||
"production_migration.auto_apply",
|
||||
"code.patch",
|
||||
],
|
||||
)
|
||||
def test_envelope_rejects_forbidden_or_generic_parameter_paths(path):
|
||||
document, verifier = _signed_envelope(
|
||||
allowed_parameters={path: {"minimum": 0, "maximum": 1}}
|
||||
)
|
||||
# Re-sign the changed trusted document so this reaches semantic validation.
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
unsigned = dict(document)
|
||||
unsigned.pop("signature")
|
||||
document["signature"] = base64.b64encode(
|
||||
private_key.sign(canonical_json_bytes(unsigned))
|
||||
).decode("ascii")
|
||||
|
||||
def changed_verifier(_key_id, payload, signature):
|
||||
try:
|
||||
private_key.public_key().verify(base64.b64decode(signature), payload)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_PARAMETER_PATH_FORBIDDEN"):
|
||||
OptimizationEnvelope.from_dict(document, verify_envelope=changed_verifier)
|
||||
|
||||
|
||||
def test_proposal_is_bounded_and_cannot_remove_mandatory_checks():
|
||||
envelope = _envelope()
|
||||
normalized = validate_proposal(envelope, _proposal())
|
||||
assert normalized["changes"][0] == {"path": "retry.max_attempts", "value": 4}
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_PARAMETER_OUT_OF_RANGE"):
|
||||
validate_proposal(
|
||||
envelope,
|
||||
_proposal(changes=[{"path": "retry.max_attempts", "value": 99}]),
|
||||
)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_MANDATORY_TEST_MISSING"):
|
||||
validate_proposal(envelope, _proposal(tests=["test_candidate_latency"]))
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_MANDATORY_GATE_MISSING"):
|
||||
validate_proposal(envelope, _proposal(quality_gates=[]))
|
||||
|
||||
|
||||
def test_evidence_reuse_cannot_widen_tenant_component_or_environment_scope():
|
||||
envelope = _envelope()
|
||||
proposal = _proposal(
|
||||
changes=[{"path": "evidence_reuse.max_age_seconds", "value": 300}],
|
||||
evidence_reuse={
|
||||
"scope": {
|
||||
"tenant_id": "tenant-b",
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
},
|
||||
"evidence_ids": ["evidence-1"],
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_EVIDENCE_SCOPE_MISMATCH"):
|
||||
validate_proposal(envelope, proposal)
|
||||
|
||||
|
||||
def test_propose_only_persists_a_record_and_submits_an_identity_bound_task(tmp_path):
|
||||
controller, submitted, _ = _controller(tmp_path)
|
||||
record = controller.propose(_proposal(), authenticated_context=_context())
|
||||
|
||||
assert record["status"] == "EVALUATION_SUBMITTED"
|
||||
assert record["evaluation_task_id"] == "evaluation-task-1"
|
||||
assert submitted == [
|
||||
{
|
||||
"schema": "aios.optimization-task-request.v1",
|
||||
"task_id": "optimization-evaluate-proposal-1",
|
||||
"action": "evaluate",
|
||||
"tenant_id": "tenant-a",
|
||||
"subject_id": "optimizer-service",
|
||||
"grant_id": "grant-123",
|
||||
"mission_id": "mission-456",
|
||||
"proposal_id": "proposal-1",
|
||||
"proposal_digest": record["proposal_digest"],
|
||||
"component": "task-worker",
|
||||
"environment": "staging",
|
||||
"compute_budget": 10.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_invalid_candidate_is_stopped_without_task_submission(tmp_path):
|
||||
controller, submitted, _ = _controller(tmp_path)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_COMPONENT_FORBIDDEN"):
|
||||
controller.propose(
|
||||
_proposal(component="authorization-service"),
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
|
||||
stopped = controller.get_proposal("proposal-1", authenticated_context=_context())
|
||||
assert stopped["status"] == "STOPPED_INVALID"
|
||||
assert submitted == []
|
||||
|
||||
|
||||
def test_cross_tenant_or_subject_access_is_denied(tmp_path):
|
||||
controller, _, _ = _controller(tmp_path)
|
||||
controller.propose(_proposal(), authenticated_context=_context())
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_CONTEXT_TENANT_MISMATCH"):
|
||||
controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context(tenant_id="tenant-b")
|
||||
)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_CONTEXT_SUBJECT_MISMATCH"):
|
||||
controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context(subject_id="other-service")
|
||||
)
|
||||
|
||||
|
||||
def test_forged_or_wrong_task_receipt_is_rejected(tmp_path):
|
||||
controller, _, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(proposal, sign)
|
||||
evaluation["receipt"]["task_id"] = "some-other-task"
|
||||
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_RECEIPT_UNTRUSTED"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
|
||||
validly_signed_wrong_binding = _evaluation(proposal, sign)
|
||||
unsigned = dict(validly_signed_wrong_binding["receipt"])
|
||||
unsigned["task_id"] = "some-other-task"
|
||||
unsigned.pop("signature")
|
||||
validly_signed_wrong_binding["receipt"] = sign(unsigned)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_RECEIPT_TASK_MISMATCH"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", validly_signed_wrong_binding, authenticated_context=_context()
|
||||
)
|
||||
|
||||
|
||||
def test_no_improvement_stops_promotion(tmp_path):
|
||||
controller, submitted, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(
|
||||
proposal,
|
||||
sign,
|
||||
candidate_metrics={"success_rate": 0.95, "error_rate": 0.04},
|
||||
)
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
|
||||
assert result["status"] == "STOPPED_NO_IMPROVEMENT"
|
||||
assert result["effect_comparison"] == {
|
||||
"error_rate": 0.0,
|
||||
"success_rate": 0.0,
|
||||
}
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_NOT_PROMOTABLE"):
|
||||
controller.promote(
|
||||
"proposal-1",
|
||||
{
|
||||
"evaluation_task_id": "evaluation-task-1",
|
||||
"proposal_digest": proposal["proposal_digest"],
|
||||
},
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert len(submitted) == 1
|
||||
|
||||
|
||||
def test_quality_floor_or_baseline_regression_stops_promotion(tmp_path):
|
||||
controller, _, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
evaluation = _evaluation(
|
||||
proposal,
|
||||
sign,
|
||||
candidate_metrics={"success_rate": 0.97, "error_rate": 0.05},
|
||||
)
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1", evaluation, authenticated_context=_context()
|
||||
)
|
||||
assert result["status"] == "STOPPED_QUALITY_REGRESSION"
|
||||
assert result["effect_comparison"]["error_rate"] == pytest.approx(-0.01)
|
||||
|
||||
improved = _evaluation(proposal, sign)
|
||||
with pytest.raises(OptimizationError, match="OPTIMIZATION_EVALUATION_STATE_INVALID"):
|
||||
controller.record_evaluation(
|
||||
"proposal-1", improved, authenticated_context=_context()
|
||||
)
|
||||
assert controller.get_proposal(
|
||||
"proposal-1", authenticated_context=_context()
|
||||
)["status"] == "STOPPED_QUALITY_REGRESSION"
|
||||
|
||||
|
||||
def test_eligible_candidate_promotion_is_another_governed_task_only(tmp_path):
|
||||
controller, submitted, sign = _controller(tmp_path)
|
||||
proposal = controller.propose(_proposal(), authenticated_context=_context())
|
||||
result = controller.record_evaluation(
|
||||
"proposal-1",
|
||||
_evaluation(proposal, sign),
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert result["status"] == "ELIGIBLE"
|
||||
|
||||
promoted = controller.promote(
|
||||
"proposal-1",
|
||||
{
|
||||
"evaluation_task_id": "evaluation-task-1",
|
||||
"proposal_digest": proposal["proposal_digest"],
|
||||
},
|
||||
authenticated_context=_context(),
|
||||
)
|
||||
assert promoted["status"] == "PROMOTION_SUBMITTED"
|
||||
assert len(submitted) == 2
|
||||
promotion_task = submitted[-1]
|
||||
assert promotion_task["action"] == "promote"
|
||||
assert promotion_task["tenant_id"] == "tenant-a"
|
||||
assert promotion_task["grant_id"] == "grant-123"
|
||||
assert promotion_task["mission_id"] == "mission-456"
|
||||
assert "changes" not in promotion_task
|
||||
|
||||
|
||||
def test_restart_reads_persisted_baseline_candidate_and_effect(tmp_path):
|
||||
path = tmp_path / "optimization.sqlite3"
|
||||
submitted = []
|
||||
sign, verify = _signed_receipt_verifier()
|
||||
|
||||
def submit_task(task):
|
||||
submitted.append(task)
|
||||
return {"task_id": "evaluation-task-1"}
|
||||
|
||||
first = OptimizationController(path, _envelope(), submit_task, verify_receipt=verify)
|
||||
proposal = first.propose(_proposal(), authenticated_context=_context())
|
||||
first.record_evaluation(
|
||||
"proposal-1", _evaluation(proposal, sign), authenticated_context=_context()
|
||||
)
|
||||
|
||||
restarted = OptimizationController(path, _envelope(), submit_task, verify_receipt=verify)
|
||||
restored = restarted.get_proposal("proposal-1", authenticated_context=_context())
|
||||
assert restored["baseline_metrics"] == {"error_rate": 0.04, "success_rate": 0.95}
|
||||
assert restored["candidate_metrics"] == {"error_rate": 0.03, "success_rate": 0.97}
|
||||
assert restored["effect_comparison"] == {
|
||||
"error_rate": pytest.approx(0.01),
|
||||
"success_rate": pytest.approx(0.02),
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Exercise package installation against real temporary Git repositories."""
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location("baseline_bundle", ROOT / "tools/baseline_v1_1/bundle.py")
|
||||
bundle = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(bundle)
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
git(source, "init", "--quiet")
|
||||
(source / "app.py").write_text("print('before')\n")
|
||||
(source / "retired.txt").write_text("historical\n")
|
||||
git(source, "add", ".")
|
||||
git(source, "-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "--quiet", "-m", "base")
|
||||
revision = git(source, "rev-parse", "HEAD")
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", "--quiet", str(source), str(work)], check=True)
|
||||
(work / "app.py").write_text("print('after')\n")
|
||||
(work / "retired.txt").unlink()
|
||||
(work / "new").mkdir()
|
||||
(work / "new/tool.py").write_text("print('new')\n")
|
||||
artifact = tmp_path / "change.zip"
|
||||
bundle.create_bundle(work, revision, artifact)
|
||||
return source, work, revision, artifact
|
||||
|
||||
|
||||
def test_stage_dry_run_install_and_conflict_safe_rollback(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
result = bundle.stage_bundle(artifact, source, target, dry_run=True)
|
||||
assert result["status"] == "DRY_RUN"
|
||||
assert not target.exists()
|
||||
assert (source / "app.py").read_text() == "print('before')\n"
|
||||
result = bundle.stage_bundle(artifact, source, target)
|
||||
assert result["status"] == "STAGED"
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
assert not (target / "retired.txt").exists()
|
||||
assert (target / "new/tool.py").is_file()
|
||||
assert git(target, "rev-parse", "HEAD") == revision
|
||||
with pytest.raises(bundle.PackageError, match="exists"):
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
(target / "app.py").write_text("owner edited\n")
|
||||
with pytest.raises(bundle.PackageError, match="changed"):
|
||||
bundle.rollback(target)
|
||||
assert (target / "app.py").read_text() == "owner edited\n"
|
||||
assert (target / "new/tool.py").exists()
|
||||
(target / "app.py").write_text("print('after')\n")
|
||||
assert bundle.rollback(target, dry_run=True)["status"] == "DRY_RUN"
|
||||
assert bundle.rollback(target)["status"] == "ROLLED_BACK"
|
||||
assert not (target / "new/tool.py").exists()
|
||||
assert (target / "retired.txt").read_text() == "historical\n"
|
||||
assert not git(target, "status", "--porcelain")
|
||||
|
||||
|
||||
def test_dirty_wrong_base_and_unknown_destination_refused(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
(source / "local.txt").write_text("do not overwrite")
|
||||
with pytest.raises(bundle.PackageError, match="clean"):
|
||||
bundle.stage_bundle(artifact, source, tmp_path / "one")
|
||||
(source / "local.txt").unlink()
|
||||
(source / "app.py").write_text("another base")
|
||||
git(source, "add", ".")
|
||||
git(source, "-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "--quiet", "-m", "different")
|
||||
with pytest.raises(bundle.PackageError, match="baseline"):
|
||||
bundle.stage_bundle(artifact, source, tmp_path / "two")
|
||||
assert not (tmp_path / "two").exists()
|
||||
|
||||
|
||||
def test_tamper_traversal_extra_entries_and_symlink_refused(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
with zipfile.ZipFile(artifact) as z:
|
||||
entries = {name: z.read(name) for name in z.namelist()}
|
||||
for index, change in enumerate((
|
||||
{"new/app.py": b"tampered"},
|
||||
{"../escape": b"bad"},
|
||||
{"unlisted.py": b"bad"},
|
||||
)):
|
||||
bad = tmp_path / f"bad-{index}.zip"
|
||||
with zipfile.ZipFile(bad, "w") as z:
|
||||
for name, body in (entries | change).items():
|
||||
z.writestr(name, body)
|
||||
with pytest.raises(bundle.PackageError):
|
||||
bundle.verify_bundle(bad)
|
||||
(work / "escape.py").symlink_to(source / "app.py")
|
||||
with pytest.raises(bundle.PackageError, match="symlink"):
|
||||
bundle.create_bundle(work, revision, tmp_path / "links.zip")
|
||||
|
||||
|
||||
def test_rollback_refuses_added_file_symlink_without_touching_referent(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_text("owner data")
|
||||
(target / "new/tool.py").unlink()
|
||||
(target / "new/tool.py").symlink_to(outside)
|
||||
with pytest.raises(bundle.PackageError, match="symlink"):
|
||||
bundle.rollback(target)
|
||||
assert outside.read_text() == "owner data"
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
|
||||
|
||||
def test_manifest_semantic_tampering_is_rejected(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
with zipfile.ZipFile(artifact) as z:
|
||||
entries = {name: z.read(name) for name in z.namelist()}
|
||||
document = json.loads(entries["manifest.json"])
|
||||
document["changes"][0]["before_sha256"] = "0" * 64
|
||||
entries["manifest.json"] = json.dumps(document).encode()
|
||||
bad = tmp_path / "bad-manifest.zip"
|
||||
with zipfile.ZipFile(bad, "w") as z:
|
||||
for name, body in entries.items():
|
||||
z.writestr(name, body)
|
||||
with pytest.raises(bundle.PackageError, match="before|base"):
|
||||
bundle.verify_bundle(bad)
|
||||
|
||||
|
||||
def test_rollback_preserves_user_staged_changes(repos, tmp_path):
|
||||
source, work, revision, artifact = repos
|
||||
target = tmp_path / "staged"
|
||||
bundle.stage_bundle(artifact, source, target)
|
||||
git(target, "add", "app.py")
|
||||
with pytest.raises(bundle.PackageError, match="index changed"):
|
||||
bundle.rollback(target)
|
||||
assert (target / "app.py").read_text() == "print('after')\n"
|
||||
assert git(target, "diff", "--cached", "--name-only") == "app.py"
|
||||
@@ -0,0 +1,583 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_baseline.lineage import (
|
||||
LineageConflict,
|
||||
LineageLedger,
|
||||
LineageVerificationError,
|
||||
VerifiedAcceptanceEvidence,
|
||||
)
|
||||
from aios_baseline.release import (
|
||||
ReleaseAuthorizationError,
|
||||
ReleaseConflict,
|
||||
ReleaseLedger,
|
||||
ReleaseReconciliationRequired,
|
||||
ReleaseVerificationError,
|
||||
VerifiedReceipt,
|
||||
)
|
||||
from aios_baseline.verifiers import (
|
||||
ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
RELEASE_RECEIPT_DOMAIN,
|
||||
SignatureVerificationError,
|
||||
ed25519_acceptance_evidence_verifier,
|
||||
ed25519_release_receipt_verifier,
|
||||
)
|
||||
|
||||
|
||||
class TaskAuthority:
|
||||
def __init__(self):
|
||||
self.tasks = {}
|
||||
self.jobs = {}
|
||||
|
||||
def add(self, task_id, tenant_id="tenant-a", subject_id="user-a", **metadata):
|
||||
metadata.setdefault("component_id", "component-a")
|
||||
self.tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"plan": {
|
||||
"issuer": {"tenant_id": tenant_id, "subject_id": subject_id},
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
|
||||
def get_task(self, task_id):
|
||||
return self.tasks[task_id]
|
||||
|
||||
def add_job(
|
||||
self, job_id, task_id, effect_id, tenant_id="tenant-a", subject_id="user-a",
|
||||
mission_id="mission-1", grant_id="grant-1",
|
||||
):
|
||||
self.jobs[job_id] = {
|
||||
"job_id": job_id,
|
||||
"request": {
|
||||
"tenant_id": tenant_id, "subject_id": subject_id,
|
||||
"mission_id": mission_id, "grant_id": grant_id,
|
||||
"task_id": task_id, "effect_id": effect_id,
|
||||
},
|
||||
}
|
||||
|
||||
def read(self, job_id):
|
||||
return self.jobs[job_id]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authority():
|
||||
value = TaskAuthority()
|
||||
value.add("task-1", mission_id="mission-1", grant_id="grant-1")
|
||||
value.add("task-2", mission_id="mission-1", grant_id="grant-1")
|
||||
value.add_job("job-1", "task-1", "effect-1")
|
||||
value.add_job("job-2", "task-2", "effect-1")
|
||||
return value
|
||||
|
||||
|
||||
def build_lineage(path, authority):
|
||||
ledger = LineageLedger(path, authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1"
|
||||
)
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1", {"sha256": "abc"}
|
||||
)
|
||||
return ledger
|
||||
|
||||
|
||||
def test_lineage_is_durable_and_explains_only_exact_mission(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
ledger = build_lineage(db, authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
ledger.close()
|
||||
|
||||
reopened = LineageLedger(db, authority, authority)
|
||||
graph = reopened.explain("tenant-a", "user-a", "mission-1")
|
||||
assert [item["task_id"] for item in graph["tasks"]] == ["task-1"]
|
||||
assert graph["effects"][0]["job_id"] == "job-1"
|
||||
assert graph["releases"][0]["source_task_id"] == "task-1"
|
||||
assert graph["deployments"][0]["release_id"] == "release-1"
|
||||
assert reopened.explain("tenant-a", "user-a", "mission-10")["tasks"] == []
|
||||
|
||||
|
||||
def test_task_authority_and_global_within_tenant_identity_are_enforced(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
with pytest.raises(LineageVerificationError, match="task_authority"):
|
||||
ledger.link_task("tenant-a", "intruder", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_task("tenant-a", "other-user", "other", "other", "task-1")
|
||||
|
||||
# A tenant boundary permits the same opaque ID without leaking either graph.
|
||||
authority.add("shared", tenant_id="tenant-a", subject_id="user-a")
|
||||
ledger.link_task("tenant-a", "user-a", "m-a", "g-a", "shared")
|
||||
authority.add("shared", tenant_id="tenant-b", subject_id="user-b")
|
||||
ledger.link_task("tenant-b", "user-b", "m-b", "g-b", "shared")
|
||||
assert len(ledger.explain("tenant-a", "user-a", "m-a")["tasks"]) == 1
|
||||
assert len(ledger.explain("tenant-b", "user-b", "m-b")["tasks"]) == 1
|
||||
|
||||
|
||||
def test_job_effect_and_release_bindings_are_immutable(tmp_path, authority):
|
||||
ledger = build_lineage(tmp_path / "lineage.sqlite", authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-2")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-2", "effect-2", "job-1"
|
||||
)
|
||||
with pytest.raises(LineageConflict, match="release-producing effect"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-2", {"sha256": "def"}
|
||||
)
|
||||
with pytest.raises(LineageConflict, match="effect_id"):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-2", "effect-1", "job-2"
|
||||
)
|
||||
|
||||
authority.add("task-other-subject", tenant_id="tenant-a", subject_id="user-b")
|
||||
ledger.link_task("tenant-a", "user-b", "mission-b", "grant-b", "task-other-subject")
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-b", "mission-b", "task-other-subject", "effect-b", "job-1"
|
||||
)
|
||||
|
||||
|
||||
def _acceptance_verifier(raw, expected):
|
||||
if raw != {"proof": "trusted"}:
|
||||
raise ValueError("untrusted")
|
||||
return VerifiedAcceptanceEvidence(
|
||||
acceptance_id=expected["acceptance_id"],
|
||||
tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"],
|
||||
deployment_id=expected["deployment_id"],
|
||||
release_id=expected["release_id"],
|
||||
component_id=expected["component_id"],
|
||||
artifact=expected["artifact"],
|
||||
accepted=True,
|
||||
evidence_id="evidence-1",
|
||||
)
|
||||
|
||||
|
||||
def test_acceptance_rejects_wrong_deployment_release_or_artifact(tmp_path, authority):
|
||||
ledger = build_lineage(tmp_path / "lineage.sqlite", authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
for deployment, release, artifact in (
|
||||
("deployment-x", "release-1", {"sha256": "abc"}),
|
||||
("deployment-1", "release-x", {"sha256": "abc"}),
|
||||
("deployment-1", "release-1", {"sha256": "wrong"}),
|
||||
):
|
||||
with pytest.raises((LineageConflict, LineageVerificationError)):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-x",
|
||||
deployment, release, artifact, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
accepted = ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-1",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
assert accepted["acceptance_id"] == "acceptance-1"
|
||||
with pytest.raises(LineageConflict, match="component"):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-b", "acceptance-component",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=_acceptance_verifier,
|
||||
)
|
||||
|
||||
def wrong_binding(raw, expected):
|
||||
verified = _acceptance_verifier(raw, expected)
|
||||
return VerifiedAcceptanceEvidence(
|
||||
**{**verified.__dict__, "deployment_id": "deployment-other"}
|
||||
)
|
||||
|
||||
with pytest.raises(LineageVerificationError, match="binding"):
|
||||
ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-2",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence={"proof": "trusted"}, verifier=wrong_binding,
|
||||
)
|
||||
|
||||
|
||||
def _receipt_verifier(raw, expected):
|
||||
if raw.get("signed_by") != "configured-sink":
|
||||
raise ValueError("untrusted receipt")
|
||||
return VerifiedReceipt(
|
||||
receipt_id=raw["receipt_id"],
|
||||
tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"],
|
||||
component_id=expected["component_id"],
|
||||
task_id=expected["task_id"],
|
||||
source_task_id=expected["source_task_id"],
|
||||
source_effect_id=expected["source_effect_id"],
|
||||
grant_id=expected["grant_id"],
|
||||
job_id=expected["job_id"],
|
||||
effect_id=raw["effect_id"],
|
||||
operation_id=expected["operation_id"],
|
||||
deployment_id=expected["deployment_id"],
|
||||
release_id=expected["release_id"],
|
||||
artifact=expected["artifact"],
|
||||
observed={"healthy": True},
|
||||
)
|
||||
|
||||
|
||||
def _target_authorizer(tenant_id, subject_id, component_id, environment, action):
|
||||
return (
|
||||
tenant_id == "tenant-a"
|
||||
and subject_id in {"user-a", "operator-a"}
|
||||
and component_id in {"component-a", "component-b"}
|
||||
and environment == "staging"
|
||||
and action in {"read", "write", "deploy", "accept"}
|
||||
)
|
||||
|
||||
|
||||
class GovernedAdapter:
|
||||
def __init__(self, authority, execute, reconcile=None):
|
||||
self.authority = authority
|
||||
self._execute = execute
|
||||
if reconcile is not None:
|
||||
self.reconcile = reconcile
|
||||
|
||||
def prepare(self, operation):
|
||||
job_id = f"real-job:{operation['saga_id']}:{operation['stage_index']}"
|
||||
self.authority.add_job(
|
||||
job_id, operation["task_id"], operation["effect_id"],
|
||||
tenant_id=operation["tenant_id"], subject_id=operation["subject_id"],
|
||||
mission_id=operation["mission_id"], grant_id=operation["grant_id"],
|
||||
)
|
||||
return {"job_id": job_id}
|
||||
|
||||
def execute(self, operation):
|
||||
return self._execute(operation)
|
||||
|
||||
|
||||
def _release_ledger(path, lineage):
|
||||
release = ReleaseLedger(
|
||||
path, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
release.register_recipe(
|
||||
"recipe-1",
|
||||
[
|
||||
{"stage_id": "install", "operation_id": "deploy.install"},
|
||||
{"stage_id": "health", "operation_id": "deploy.health"},
|
||||
],
|
||||
)
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=None, stage="STAGED", source_revision="source-5",
|
||||
observed_main_revision="main-9", rollback_compatible=True,
|
||||
)
|
||||
release.start_saga(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "saga-1", "release-1",
|
||||
"deployment-1", "staging", {"sha256": "abc"}, "recipe-1",
|
||||
expected_target_revision=1,
|
||||
)
|
||||
return release
|
||||
|
||||
|
||||
def test_release_target_is_persistent_cas_and_allows_main_ahead(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
target = release.get_target("tenant-a", "user-a", "component-a", "staging")
|
||||
assert target.revision == 1
|
||||
assert target.source_revision == "source-5"
|
||||
assert target.observed_main_revision == "main-9" # main may be ahead while staged
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=0,
|
||||
)
|
||||
reopened = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
assert reopened.get_target("tenant-a", "user-a", "component-a", "staging").revision == 1
|
||||
assert reopened.get_saga("tenant-a", "user-a", "saga-1")["stage_id"] == "install"
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
reopened.get_saga("tenant-a", "intruder", "saga-1")
|
||||
|
||||
|
||||
def test_targets_are_component_scoped_and_subject_is_authorized_actor(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
authority.add("task-b", component_id="component-b", mission_id="mission-b", grant_id="grant-b")
|
||||
authority.add_job("job-b", "task-b", "effect-b", mission_id="mission-b", grant_id="grant-b")
|
||||
lineage.link_task("tenant-a", "user-a", "mission-b", "grant-b", "task-b")
|
||||
lineage.link_effect("tenant-a", "user-a", "mission-b", "task-b", "effect-b", "job-b")
|
||||
lineage.link_release(
|
||||
"tenant-a", "user-a", "mission-b", "effect-b", "release-b", {"sha256": "bbb"}
|
||||
)
|
||||
release = _release_ledger(db, lineage)
|
||||
second = release.set_target(
|
||||
"tenant-a", "user-a", "component-b", "staging", "release-b", {"sha256": "bbb"},
|
||||
expected_revision=None,
|
||||
)
|
||||
assert second.revision == 1
|
||||
assert release.get_target("tenant-a", "user-a", "component-a", "staging").release_id == "release-1"
|
||||
assert release.get_target("tenant-a", "user-a", "component-b", "staging").release_id == "release-b"
|
||||
|
||||
shared = release.set_target(
|
||||
"tenant-a", "operator-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1,
|
||||
)
|
||||
assert shared.revision == 2
|
||||
assert shared.updated_by_subject_id == "operator-a"
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1,
|
||||
)
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
release.get_target("tenant-a", "intruder", "component-a", "staging")
|
||||
with pytest.raises(ReleaseAuthorizationError):
|
||||
release.set_target(
|
||||
"tenant-a", "intruder", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=2,
|
||||
)
|
||||
|
||||
|
||||
def test_unversioned_candidate_release_schema_is_rejected(tmp_path, authority):
|
||||
db = tmp_path / "legacy-candidate.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
with sqlite3.connect(db) as connection:
|
||||
connection.execute("CREATE TABLE release_targets(tenant_id TEXT, environment TEXT)")
|
||||
with pytest.raises(ReleaseConflict, match="unversioned"):
|
||||
ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
|
||||
|
||||
def test_target_rollback_requires_the_persisted_compatible_binding(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
with pytest.raises(ReleaseConflict, match="rollback target"):
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=1, stage="ROLLBACK_STAGED",
|
||||
)
|
||||
|
||||
|
||||
def test_outer_artifact_manifest_must_not_hash_itself(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect(
|
||||
"tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="self hash"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1",
|
||||
{"files": [], "manifest_sha256": "recursive-impossibility"},
|
||||
)
|
||||
|
||||
|
||||
def test_release_saga_reuses_effect_after_crash_and_advances_only_verified_receipt(
|
||||
tmp_path, authority
|
||||
):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
seen = []
|
||||
|
||||
def crash(operation):
|
||||
seen.append(operation["effect_id"])
|
||||
raise RuntimeError("process crashed after durable external sink write")
|
||||
|
||||
with pytest.raises(RuntimeError, match="crashed"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, crash))
|
||||
assert release.get_saga("tenant-a", "user-a", "saga-1")["revision"] == 0
|
||||
|
||||
# A new process/object observes the intent and keeps its stable effect fence.
|
||||
release = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=_receipt_verifier,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
with pytest.raises(ReleaseReconciliationRequired, match="RECONCILIATION_REQUIRED"):
|
||||
release.advance_saga(
|
||||
"tenant-a", "user-a", "saga-1", 0,
|
||||
GovernedAdapter(authority, lambda _operation: pytest.fail("unknown effect repeated")),
|
||||
)
|
||||
|
||||
def resume(operation):
|
||||
seen.append(operation["effect_id"])
|
||||
return {
|
||||
"signed_by": "configured-sink",
|
||||
"receipt_id": "receipt-install",
|
||||
"effect_id": operation["effect_id"],
|
||||
}
|
||||
|
||||
adapter=GovernedAdapter(authority, lambda _operation: pytest.fail("unknown effect repeated"), reconcile=resume)
|
||||
advanced = release.advance_saga("tenant-a", "user-a", "saga-1", 0, adapter)
|
||||
assert seen[0] == seen[1]
|
||||
assert advanced["stage_id"] == "health"
|
||||
assert advanced["revision"] == 1
|
||||
|
||||
with pytest.raises(ReleaseConflict, match="expected revision"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, adapter)
|
||||
|
||||
|
||||
def test_release_rejects_receipt_for_wrong_effect(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
release = _release_ledger(db, lineage)
|
||||
|
||||
calls = []
|
||||
|
||||
def wrong(operation):
|
||||
calls.append(operation["effect_id"])
|
||||
return {
|
||||
"signed_by": "configured-sink",
|
||||
"receipt_id": "receipt-wrong",
|
||||
"effect_id": "different-effect",
|
||||
}
|
||||
|
||||
with pytest.raises(ReleaseVerificationError, match="effect_id"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, wrong))
|
||||
with pytest.raises(ReleaseVerificationError, match="effect_id"):
|
||||
release.advance_saga("tenant-a", "user-a", "saga-1", 0, GovernedAdapter(authority, wrong))
|
||||
assert len(calls) == 1 # the durable unverified receipt prevents effect replay
|
||||
assert release.get_saga("tenant-a", "user-a", "saga-1")["revision"] == 0
|
||||
|
||||
|
||||
def test_release_rejects_verified_receipt_for_other_component(tmp_path, authority):
|
||||
db = tmp_path / "domain.sqlite"
|
||||
lineage = build_lineage(db, authority)
|
||||
|
||||
def wrong_component(raw, expected):
|
||||
return VerifiedReceipt(
|
||||
receipt_id="receipt-wrong-component", tenant_id=expected["tenant_id"],
|
||||
subject_id=expected["subject_id"], component_id="component-b",
|
||||
task_id=expected["task_id"],source_task_id=expected["source_task_id"],
|
||||
source_effect_id=expected["source_effect_id"],grant_id=expected["grant_id"],job_id=expected["job_id"],
|
||||
effect_id=expected["effect_id"], operation_id=expected["operation_id"],
|
||||
deployment_id=expected["deployment_id"], release_id=expected["release_id"],
|
||||
artifact=expected["artifact"], observed={"healthy": True},
|
||||
)
|
||||
|
||||
release = ReleaseLedger(
|
||||
db, lineage=lineage, receipt_verifier=wrong_component,
|
||||
target_authorizer=_target_authorizer,
|
||||
)
|
||||
release.register_recipe("recipe", [{"stage_id": "install", "operation_id": "deploy.install"}])
|
||||
release.set_target(
|
||||
"tenant-a", "user-a", "component-a", "staging", "release-1", {"sha256": "abc"},
|
||||
expected_revision=None,
|
||||
)
|
||||
release.start_saga(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "saga", "release-1",
|
||||
"deployment", "staging", {"sha256": "abc"}, "recipe", expected_target_revision=1,
|
||||
)
|
||||
with pytest.raises(ReleaseVerificationError, match="component_id"):
|
||||
release.advance_saga(
|
||||
"tenant-a", "user-a", "saga", 0,
|
||||
GovernedAdapter(authority, lambda operation: {"effect_id": operation["effect_id"]}),
|
||||
)
|
||||
|
||||
|
||||
def test_outer_artifact_manifest_cannot_hash_itself(tmp_path, authority):
|
||||
ledger = LineageLedger(tmp_path / "lineage.sqlite", authority, authority)
|
||||
ledger.link_task("tenant-a", "user-a", "mission-1", "grant-1", "task-1")
|
||||
ledger.link_effect("tenant-a", "user-a", "mission-1", "task-1", "effect-1", "job-1")
|
||||
with pytest.raises(ValueError, match="self hash"):
|
||||
ledger.link_release(
|
||||
"tenant-a", "user-a", "mission-1", "effect-1", "release-1",
|
||||
{"sha256": "abc", "manifest_sha256": "self"},
|
||||
)
|
||||
|
||||
|
||||
def _signed_envelope(private_key, domain, schema, payload, key_id="configured-key"):
|
||||
canonical = json.dumps(
|
||||
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
).encode()
|
||||
signature = private_key.sign(domain + b"\x00" + canonical)
|
||||
return {
|
||||
"schema": schema,
|
||||
"key_id": key_id,
|
||||
"payload": payload,
|
||||
"signature": base64.urlsafe_b64encode(signature).rstrip(b"=").decode(),
|
||||
}
|
||||
|
||||
|
||||
def test_ed25519_release_verifier_authenticates_domain_and_every_binding():
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
verifier = ed25519_release_receipt_verifier(
|
||||
{"configured-key": private_key.public_key()}
|
||||
)
|
||||
expected = {
|
||||
"tenant_id": "tenant-a", "subject_id": "user-a", "effect_id": "effect-a",
|
||||
"component_id": "component-a", "task_id": "task-a", "source_task_id": "task-a",
|
||||
"source_effect_id": "source-effect-a", "grant_id": "grant-a", "job_id": "job-a",
|
||||
"operation_id": "deploy.install", "deployment_id": "deployment-a",
|
||||
"release_id": "release-a", "component_id": "component-a",
|
||||
"artifact": {"sha256": "abc"},
|
||||
}
|
||||
payload = {
|
||||
**expected, "receipt_id": "receipt-a", "outcome": "SUCCEEDED",
|
||||
"observed": {"installed": True}, "observed_at": "2026-09-07T20:00:00Z",
|
||||
}
|
||||
envelope = _signed_envelope(
|
||||
private_key, RELEASE_RECEIPT_DOMAIN,
|
||||
"aios.release-operation-receipt-envelope.v1", payload,
|
||||
)
|
||||
assert verifier(envelope, expected).effect_id == "effect-a"
|
||||
|
||||
tampered = {**envelope, "payload": {**payload, "deployment_id": "other"}}
|
||||
with pytest.raises(SignatureVerificationError, match="signature"):
|
||||
verifier(tampered, expected)
|
||||
cross_domain = _signed_envelope(
|
||||
private_key, ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
"aios.release-operation-receipt-envelope.v1", payload,
|
||||
)
|
||||
with pytest.raises(SignatureVerificationError, match="signature"):
|
||||
verifier(cross_domain, expected)
|
||||
with pytest.raises(SignatureVerificationError, match="fields"):
|
||||
verifier({**envelope, "public_key": "caller-controlled"}, expected)
|
||||
|
||||
|
||||
def test_ed25519_acceptance_verifier_binds_decision_and_deployment(tmp_path, authority):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
verifier = ed25519_acceptance_evidence_verifier(
|
||||
{"acceptance-key": private_key.public_key()}
|
||||
)
|
||||
expected = {
|
||||
"acceptance_id": "acceptance-crypto", "tenant_id": "tenant-a",
|
||||
"subject_id": "user-a", "deployment_id": "deployment-1",
|
||||
"release_id": "release-1", "component_id": "component-a",
|
||||
"artifact": {"sha256": "abc"}, "accepted": True,
|
||||
}
|
||||
payload = {
|
||||
**expected, "evidence_id": "evidence-a", "observed": {"healthy": True},
|
||||
"observed_at": "2026-09-07T20:00:00Z",
|
||||
}
|
||||
envelope = _signed_envelope(
|
||||
private_key, ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
"aios.release-acceptance-evidence-envelope.v1", payload, key_id="acceptance-key",
|
||||
)
|
||||
assert verifier(envelope, expected).accepted is True
|
||||
ledger = build_lineage(tmp_path / "signed-acceptance.sqlite", authority)
|
||||
ledger.link_deployment(
|
||||
"tenant-a", "user-a", "mission-1", "release-1", "deployment-1",
|
||||
"component-a", "staging", 1, {"sha256": "abc"},
|
||||
)
|
||||
stored = ledger.record_acceptance(
|
||||
"tenant-a", "user-a", "mission-1", "component-a", "acceptance-crypto",
|
||||
"deployment-1", "release-1", {"sha256": "abc"}, True,
|
||||
evidence=envelope, verifier=verifier,
|
||||
)
|
||||
assert stored["evidence_id"] == "evidence-a"
|
||||
with pytest.raises(SignatureVerificationError, match="accepted"):
|
||||
verifier(envelope, {**expected, "accepted": False})
|
||||
with pytest.raises(SignatureVerificationError, match="deployment_id"):
|
||||
verifier(envelope, {**expected, "deployment_id": "deployment-other"})
|
||||
with pytest.raises(SignatureVerificationError, match="component_id"):
|
||||
verifier(envelope, {**expected, "component_id": "component-b"})
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_baseline.service import Identity
|
||||
from aios_baseline.transport import HttpTransport, TransportError, serve
|
||||
from aios_baseline.worker import OperationWorker
|
||||
from tests.baseline_v1_1.test_loop import REQUEST, USER, WORKER, fixture_service
|
||||
|
||||
|
||||
def _running_server(service):
|
||||
server = serve(
|
||||
service,
|
||||
{"user-token-012345678901234567890123456789": USER, "worker-token-012345678901234567890123456789": WORKER},
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, thread
|
||||
|
||||
|
||||
def _stop(server, thread):
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_real_http_goal_worker_receipt_query_and_explain(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
worker_transport = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
created = user.submit_goal(REQUEST, idempotency_key="transport-a")
|
||||
|
||||
worker = OperationWorker(worker_transport, poll_seconds=0.01)
|
||||
completed = worker.run_once()
|
||||
|
||||
assert completed["task_id"] == created["task_id"]
|
||||
assert completed["status"] == "COMPLETED"
|
||||
assert completed["receipt"]["observation"]["properties"]["MainPID"] == "123"
|
||||
assert user.get_task(created["task_id"])["status"] == "COMPLETED"
|
||||
explained = user.explain("mission-a")
|
||||
assert explained["mission_id"] == "mission-a"
|
||||
assert [item["task_id"] for item in explained["tasks_runtime"]] == [created["task_id"]]
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_cancel_and_worker_claim_have_no_client_completion_or_receipt_route(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
worker = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
created = user.submit_goal(REQUEST, idempotency_key="transport-cancel")
|
||||
cancelled = user.cancel_task(created["task_id"])
|
||||
worker.register_worker()
|
||||
worker.heartbeat()
|
||||
assert cancelled["status"] == "CANCELLED"
|
||||
assert worker.claim_next() is None
|
||||
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
body = json.dumps({"task_id": created["task_id"], "receipt": {"success": True}})
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/workers/complete",
|
||||
body=body,
|
||||
headers={
|
||||
"Authorization": "Bearer worker-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body.encode())),
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
connection.close()
|
||||
assert response.status == 404
|
||||
assert user.get_task(created["task_id"])["status"] == "CANCELLED"
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_server_uses_token_identity_and_rejects_body_scope_claims(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
user = HttpTransport(base_url, "user-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_IDENTITY_FIELD_FORBIDDEN") as exc:
|
||||
user.submit_goal({**REQUEST, "tenant_id": "tenant-b"}, idempotency_key="spoof")
|
||||
assert exc.value.status == 400
|
||||
|
||||
worker = HttpTransport(base_url, "worker-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_IDENTITY_FIELD_FORBIDDEN"):
|
||||
worker._request("POST", "/v1/workers/register", {"tenant_id": "tenant-b"})
|
||||
|
||||
outsider = HttpTransport(base_url, "not-a-token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="HTTP_UNAUTHORIZED") as unauthorized:
|
||||
outsider.submit_goal(REQUEST, idempotency_key="unauthorized")
|
||||
assert unauthorized.value.status == 401
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_http_boundary_rejects_large_malformed_or_expired_requests(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, thread = _running_server(service)
|
||||
try:
|
||||
def post(raw, *, deadline):
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/goals/submit",
|
||||
body=raw,
|
||||
headers={
|
||||
"Authorization": "Bearer user-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(raw)),
|
||||
"X-AIOS-Deadline": deadline,
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
connection.close()
|
||||
return response.status, payload
|
||||
|
||||
status, payload = post(b"{" + b"x" * 70_000, deadline=str(time.time() + 3))
|
||||
assert status == 413
|
||||
assert b"HTTP_BODY_TOO_LARGE" in payload
|
||||
|
||||
status, payload = post(b"not-json", deadline=str(time.time() + 3))
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_INVALID" in payload
|
||||
|
||||
status, payload = post(
|
||||
b'{"request":{},"request":{},"idempotency_key":"duplicate"}',
|
||||
deadline=str(time.time() + 3),
|
||||
)
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_DUPLICATE_KEY" in payload
|
||||
|
||||
status, payload = post(
|
||||
b'{"request":{},"idempotency_key":NaN}', deadline=str(time.time() + 3)
|
||||
)
|
||||
assert status == 400
|
||||
assert b"HTTP_JSON_NON_FINITE" in payload
|
||||
|
||||
status, payload = post(b"{}", deadline=str(time.time() - 1))
|
||||
assert status == 408
|
||||
assert b"HTTP_DEADLINE_EXPIRED" in payload
|
||||
finally:
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_internal_errors_do_not_return_tracebacks_exception_text_or_tokens(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
original = service.get_task
|
||||
|
||||
def fail(_identity, _task_id):
|
||||
raise RuntimeError("user-token-012345678901234567890123456789 super-secret traceback bait")
|
||||
|
||||
service.get_task = fail
|
||||
server, thread = _running_server(service)
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"GET",
|
||||
"/v1/tasks/task-example",
|
||||
headers={
|
||||
"Authorization": "Bearer user-token-012345678901234567890123456789",
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read().decode()
|
||||
connection.close()
|
||||
assert response.status == 500
|
||||
assert json.loads(payload) == {"error": {"code": "HTTP_INTERNAL_ERROR"}}
|
||||
assert "secret" not in payload.lower()
|
||||
assert "traceback" not in payload.lower()
|
||||
assert "user-token-012345678901234567890123456789" not in payload
|
||||
finally:
|
||||
service.get_task = original
|
||||
_stop(server, thread)
|
||||
|
||||
|
||||
def test_transport_is_loopback_only_and_rejects_redirects():
|
||||
with pytest.raises(TransportError, match="TRANSPORT_LOOPBACK_REQUIRED"):
|
||||
HttpTransport("http://example.com:8080", "token-012345678901234567890123456789")
|
||||
|
||||
aios_server = serve(object(), {"worker-token-012345678901234567890123456789": WORKER}, port=0)
|
||||
aios_thread = threading.Thread(target=aios_server.serve_forever, daemon=True)
|
||||
aios_thread.start()
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", aios_server.server_port, timeout=3)
|
||||
connection.request(
|
||||
"PUT",
|
||||
"/v1/workers/execute",
|
||||
body=b"{}",
|
||||
headers={
|
||||
"Authorization": "Bearer worker-token-012345678901234567890123456789",
|
||||
"Content-Type": "application/json",
|
||||
"X-AIOS-Deadline": str(time.time() + 3),
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
connection.close()
|
||||
assert response.status == 405
|
||||
assert json.loads(payload) == {"error": {"code": "HTTP_METHOD_NOT_ALLOWED"}}
|
||||
assert response.headers["Content-Type"] == "application/json"
|
||||
finally:
|
||||
_stop(aios_server, aios_thread)
|
||||
|
||||
class Redirect(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/elsewhere")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *_args):
|
||||
return
|
||||
|
||||
redirector = ThreadingHTTPServer(("127.0.0.1", 0), Redirect)
|
||||
thread = threading.Thread(target=redirector.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
transport = HttpTransport(f"http://127.0.0.1:{redirector.server_port}", "token-012345678901234567890123456789")
|
||||
with pytest.raises(TransportError, match="TRANSPORT_REDIRECT_REJECTED"):
|
||||
transport.get_task("task-a")
|
||||
finally:
|
||||
_stop(redirector, thread)
|
||||
|
||||
|
||||
def test_worker_run_forever_uses_stop_event_wait_and_stops(tmp_path):
|
||||
service = fixture_service(tmp_path)
|
||||
server, server_thread = _running_server(service)
|
||||
stop = threading.Event()
|
||||
worker = OperationWorker(
|
||||
HttpTransport(f"http://127.0.0.1:{server.server_port}", "worker-token-012345678901234567890123456789"),
|
||||
poll_seconds=0.01,
|
||||
stop_event=stop,
|
||||
)
|
||||
thread = threading.Thread(target=worker.run_forever, daemon=True)
|
||||
try:
|
||||
thread.start()
|
||||
deadline = time.time() + 2
|
||||
while not worker.registered and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert worker.registered is True
|
||||
stop.set()
|
||||
thread.join(timeout=1)
|
||||
assert not thread.is_alive()
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=1)
|
||||
_stop(server, server_thread)
|
||||
@@ -0,0 +1,71 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from aios_baseline.worker_policy import WorkerProfile, eligible
|
||||
from aios_baseline.host import SystemdHost, HostError
|
||||
|
||||
|
||||
def profile(**changes):
|
||||
value = dict(worker_id='worker-a', identity='machine-a', tenant_scope=['tenant-a'],
|
||||
subject_scope=['user-a'], capabilities=['service-status'], priority=10,
|
||||
fallback_executor=True, heartbeat_seconds=15, max_concurrency=1)
|
||||
value.update(changes)
|
||||
return WorkerProfile.from_dict(value)
|
||||
|
||||
|
||||
def test_fallback_expands_only_after_ready_deadline():
|
||||
task = dict(tenant_id='tenant-a', subject_id='user-a', operation='service-status', ready_at=100)
|
||||
assert not eligible(profile(), task, now=699, last_heartbeat=699)
|
||||
assert eligible(profile(), task, now=700, last_heartbeat=700)
|
||||
assert eligible(profile(fallback_executor=False), task, now=101, last_heartbeat=101)
|
||||
assert not eligible(profile(), task, now=700, last_heartbeat=640)
|
||||
|
||||
|
||||
def test_fallback_cannot_expand_tenant_subject_or_capability():
|
||||
task = dict(tenant_id='tenant-a', subject_id='user-b', operation='service-status', ready_at=100)
|
||||
assert not eligible(profile(), task, now=800, last_heartbeat=800)
|
||||
assert not eligible(profile(), {**task, 'subject_id':'user-a','tenant_id':'tenant-b'}, now=800, last_heartbeat=800)
|
||||
assert not eligible(profile(), {**task, 'subject_id':'user-a','operation':'service-restart'}, now=800, last_heartbeat=800)
|
||||
|
||||
|
||||
def systemctl(tmp_path, body):
|
||||
path = tmp_path / 'systemctl-fixture'
|
||||
path.write_text('#!' + sys.executable + '\n' + body)
|
||||
path.chmod(0o700)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_host_status_uses_real_bounded_process_and_no_ambient_credentials(tmp_path, monkeypatch):
|
||||
executable = systemctl(tmp_path, "import os,sys\nassert 'AIOS_ADMIN_TOKEN' not in os.environ\nassert sys.argv[-1] == 'sample.service'\nprint('Id=sample.service\\nActiveState=active\\nSubState=running\\nMainPID=123\\nInvocationID=abc')\n")
|
||||
monkeypatch.setenv('AIOS_ADMIN_TOKEN','must-not-leak')
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable)
|
||||
result = host.status('sample', 'staging',tenant_id='tenant-a')
|
||||
assert result['healthy'] is True
|
||||
assert result['properties']['MainPID'] == '123'
|
||||
assert result['unit'] == 'sample.service'
|
||||
assert 'must-not-leak' not in str(result)
|
||||
with pytest.raises(HostError): host.status('sample; rm -rf /', 'staging',tenant_id='tenant-a')
|
||||
|
||||
|
||||
def test_host_nonzero_missing_properties_and_timeout_never_healthy(tmp_path):
|
||||
executable = systemctl(tmp_path, "import sys\nprint('Id=sample.service')\nsys.exit(1)\n")
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable)
|
||||
with pytest.raises(HostError): host.status('sample','staging',tenant_id='tenant-a')
|
||||
executable = systemctl(tmp_path, "print('Id=sample.service')\n")
|
||||
with pytest.raises(HostError): host.status('sample','staging',tenant_id='tenant-a')
|
||||
executable = systemctl(tmp_path, "import time\ntime.sleep(5)\n")
|
||||
host = SystemdHost({'tenant-a:staging:sample':'sample.service'}, executable=executable, timeout_seconds=0.1)
|
||||
start = time.monotonic()
|
||||
with pytest.raises(HostError, match='TIMEOUT'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
assert time.monotonic()-start < 2
|
||||
|
||||
|
||||
def test_host_kills_output_flood_and_rejects_changed_executable(tmp_path):
|
||||
executable=systemctl(tmp_path,"import sys\nsys.stdout.write('X'*200000)\n")
|
||||
host=SystemdHost({'tenant-a:staging:sample':'sample.service'},executable=executable)
|
||||
with pytest.raises(HostError,match='TOO_LARGE'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
Path(executable).write_text('#!'+sys.executable+'\nraise Exception("new unexpected program")\n')
|
||||
with pytest.raises(HostError,match='IDENTITY_CHANGED'): host.status('sample','staging',tenant_id='tenant-a')
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -20,7 +21,9 @@ COMMIT = "1" * 40
|
||||
|
||||
def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
store = JobStore(tmp_path / "job-root")
|
||||
controller = GitOpsJobController(store)
|
||||
controller = GitOpsJobController(
|
||||
store, clock=lambda: datetime(2026, 9, 8, tzinfo=timezone.utc)
|
||||
)
|
||||
request = {
|
||||
"idempotency_key": "v7-replay",
|
||||
"campaign_id": TASK,
|
||||
@@ -50,7 +53,7 @@ def make_store(tmp_path: Path) -> tuple[GitOpsJobController, str, dict]:
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=2,
|
||||
lease_expires_at="2099-01-01T00:00:00Z",
|
||||
lease_expires_at="2026-09-08T00:01:00Z",
|
||||
expected_revision=row["revision"],
|
||||
)
|
||||
with store._connect() as connection:
|
||||
@@ -295,12 +298,25 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
|
||||
observed = {}
|
||||
class Adapter:
|
||||
def getRuntimeStatus(self):
|
||||
return {
|
||||
"governed_writes_enabled": True,
|
||||
"legacy_global_writes_enabled": False,
|
||||
"task_principal_writes_enabled": True,
|
||||
}
|
||||
|
||||
def commitFilesGoverned(self, body):
|
||||
observed.update(body)
|
||||
return {"job_id": body["job_id"], "current_head_oid": COMMIT}
|
||||
|
||||
def verify_principal(transport, request):
|
||||
assert transport["operation"] == "commitFilesGoverned"
|
||||
assert transport["headers"]["authorization"] == "Bearer fixture-principal"
|
||||
return {"task_id": request["task_id"], "tenant_id": "fixture"}
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), BridgeHandler)
|
||||
BridgeHandler.adapter = Adapter()
|
||||
BridgeHandler.principal_verifier = verify_principal
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
body = {
|
||||
@@ -317,7 +333,15 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
}
|
||||
try:
|
||||
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||||
connection.request("POST", "/v2/commit-files", body=json.dumps(body), headers={"Content-Type": "application/json"})
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v2/commit-files",
|
||||
body=json.dumps(body),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer fixture-principal",
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
payload = json.loads(response.read().decode())
|
||||
connection.close()
|
||||
@@ -326,6 +350,7 @@ def test_real_http_post_carries_external_replay_identity(tmp_path: Path):
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
BridgeHandler.adapter = None
|
||||
BridgeHandler.principal_verifier = None
|
||||
assert response.status == 200
|
||||
assert observed["job_id"] == EXTERNAL
|
||||
assert observed["source_lease_epoch"] == 3
|
||||
|
||||
@@ -218,10 +218,14 @@ def test_revision_cas_and_receipt_are_transactional():
|
||||
|
||||
def test_job_lease_mutation_is_transactional_and_revision_bound():
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
controller = GitOpsJobController(JobStore(temp))
|
||||
controller = GitOpsJobController(
|
||||
JobStore(temp),
|
||||
clock=lambda: datetime(2026, 8, 7, 23, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
req = {"campaign_id": "campaign", "repository": "aios/aios", "operation": "commit-file", "branch": request().target_branch, "expected_branch_oid": CAMPAIGN_HEAD, "payload_digest": "b" * 64}
|
||||
job = controller.submit(req)
|
||||
leased = controller.lease_job(job.job_id, lease_id="lease-1", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=CAMPAIGN_HEAD, generation=1, lease_expires_at="2026-08-08T00:00:00Z", expected_revision=0)
|
||||
authorized = controller.advance(job.job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED")
|
||||
leased = controller.lease_job(job.job_id, lease_id="lease-1", lineage_anchor_oid=LINEAGE_ANCHOR, current_head_oid=CAMPAIGN_HEAD, generation=1, lease_expires_at="2026-08-08T00:00:00Z", expected_revision=authorized.revision)
|
||||
assert leased.state == JobState.LEASED
|
||||
controller.update_lease("lease-1", expected_revision=0, current_head_oid="c" * 40, generation=1)
|
||||
with pytest.raises(JobError, match="LEASE_REVISION_CAS_CONFLICT"):
|
||||
|
||||
@@ -229,6 +229,10 @@ class ExactPathPolicyTests(unittest.TestCase):
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "other.service"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy.grp,
|
||||
"getgrnam",
|
||||
return_value=mock.Mock(gr_gid=os.getgid()),
|
||||
), mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
@@ -242,6 +246,10 @@ class ExactPathPolicyTests(unittest.TestCase):
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/other"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy.grp,
|
||||
"getgrnam",
|
||||
return_value=mock.Mock(gr_gid=os.getgid()),
|
||||
), mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
import ast
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -26,7 +27,18 @@ def test_bridge_unit_keeps_strict_sandbox_and_required_projection():
|
||||
).read_text()
|
||||
assert "ProtectSystem=strict" in text
|
||||
assert "ProtectHome=true" in text
|
||||
assert (
|
||||
"LoadCredential=gitea_token:"
|
||||
) in text
|
||||
projection = re.search(
|
||||
r"^LoadCredentialEncrypted=([^:]+):/etc/credstore\.encrypted/([^\n]+)$",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
consumer = re.search(
|
||||
r"^Environment=AIOS_GITEA_TOKEN_FILE=%d/([^\n]+)$",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert projection is not None
|
||||
assert consumer is not None
|
||||
assert projection.group(1) == projection.group(2) == consumer.group(1)
|
||||
assert "/run/credentials/aios-governance-executor.service/" not in text
|
||||
assert "/data/aios-gitops/repo.git/worktrees" in text
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create, inspect and stage an AIOS source change package. Never activate services.
|
||||
|
||||
The package is a reproducible collection of byte-level changes, not an authorization
|
||||
credential. Obtain it through a trusted delivery channel. SHA-256 detects corruption;
|
||||
it does not establish who approved the package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
SCHEMA = "aios.source-change-bundle.v1"
|
||||
MAX_MEMBER = 32 * 1024 * 1024
|
||||
MAX_TOTAL = 256 * 1024 * 1024
|
||||
MAX_ENTRIES = 20000
|
||||
HASH = re.compile(r"^[0-9a-f]{64}$")
|
||||
REVISION = re.compile(r"^[0-9a-f]{40,64}$")
|
||||
MODES = {"100644", "100755"}
|
||||
|
||||
|
||||
class PackageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _hash(body):
|
||||
return hashlib.sha256(body).hexdigest()
|
||||
|
||||
|
||||
def _json(body):
|
||||
return json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def _path(value):
|
||||
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value or ":" in value:
|
||||
raise PackageError("unsafe package path")
|
||||
parts = value.split("/")
|
||||
if any(part in {"", ".", "..", ".git"} for part in parts) or PurePosixPath(value).is_absolute():
|
||||
raise PackageError(f"unsafe package path: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _absolute(value):
|
||||
path = Path(os.path.abspath(value))
|
||||
for current in (path, *path.parents):
|
||||
if current.is_symlink():
|
||||
raise PackageError(f"symlink refused: {current}")
|
||||
return path
|
||||
|
||||
|
||||
def _member(root, relative):
|
||||
return _absolute(root / _path(relative))
|
||||
|
||||
|
||||
def _git(repo, *arguments, text=False):
|
||||
command = ["git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", str(repo), *arguments]
|
||||
try:
|
||||
result = subprocess.run(command, stdin=subprocess.DEVNULL, capture_output=True, check=True)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise PackageError(f"Git precondition failed: {error.stderr.decode('utf-8', 'replace')[:500]}") from error
|
||||
return result.stdout.decode("utf-8").strip() if text else result.stdout
|
||||
|
||||
|
||||
def _base_tree(repo, revision):
|
||||
actual = _git(repo, "rev-parse", "--verify", "--end-of-options", f"{revision}^{{commit}}", text=True)
|
||||
if not REVISION.fullmatch(actual):
|
||||
raise PackageError("invalid baseline revision")
|
||||
files, bodies = {}, {}
|
||||
for entry in _git(repo, "ls-tree", "-rz", "--full-tree", actual).split(b"\x00"):
|
||||
if not entry:
|
||||
continue
|
||||
header, raw_path = entry.split(b"\t", 1)
|
||||
mode, kind, object_id = header.decode().split(" ")
|
||||
path = _path(raw_path.decode("utf-8"))
|
||||
if mode not in MODES or kind != "blob":
|
||||
raise PackageError(f"symlink or non-regular Git entry refused: {path}")
|
||||
body = _git(repo, "cat-file", "blob", object_id)
|
||||
if len(body) > MAX_MEMBER:
|
||||
raise PackageError(f"oversized source member: {path}")
|
||||
files[path] = {"sha256": _hash(body), "mode": mode}
|
||||
bodies[path] = body
|
||||
return actual, files, bodies
|
||||
|
||||
|
||||
def _read_file(path):
|
||||
path = _absolute(path)
|
||||
if not path.exists():
|
||||
return None, None
|
||||
file_stat = path.stat()
|
||||
if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_size > MAX_MEMBER:
|
||||
raise PackageError(f"non-regular or oversized source file: {path}")
|
||||
return path.read_bytes(), "100755" if file_stat.st_mode & 0o111 else "100644"
|
||||
|
||||
|
||||
def create_bundle(repo, base_revision, output):
|
||||
repo, output = _absolute(repo), _absolute(output)
|
||||
if output.exists():
|
||||
raise PackageError("output already exists")
|
||||
if repo in output.parents:
|
||||
raise PackageError("bundle output must be outside source repository")
|
||||
revision, base_files, base_bodies = _base_tree(repo, base_revision)
|
||||
paths = set(base_files)
|
||||
for raw in _git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard").split(b"\x00"):
|
||||
if raw:
|
||||
paths.add(_path(raw.decode("utf-8")))
|
||||
changes, payloads = [], {}
|
||||
for path in sorted(paths):
|
||||
body, mode = _read_file(_member(repo, path))
|
||||
before = base_files.get(path)
|
||||
after = {"sha256": _hash(body), "mode": mode} if body is not None else None
|
||||
if before == after:
|
||||
continue
|
||||
if before is None and after is None:
|
||||
continue
|
||||
action = "add" if before is None else "delete" if after is None else "modify"
|
||||
changes.append({"path": path, "action": action,
|
||||
"before_sha256": before["sha256"] if before else None,
|
||||
"before_mode": before["mode"] if before else None,
|
||||
"after_sha256": after["sha256"] if after else None,
|
||||
"after_mode": after["mode"] if after else None})
|
||||
if before:
|
||||
payloads["old/" + path] = base_bodies[path]
|
||||
if after:
|
||||
payloads["new/" + path] = body
|
||||
if not changes:
|
||||
raise PackageError("source has no changes relative to baseline")
|
||||
manifest = {"schema": SCHEMA, "base_revision": revision, "base_files": base_files,
|
||||
"changes": changes, "payload_sha256": {name: _hash(body) for name, body in sorted(payloads.items())}}
|
||||
payloads["manifest.json"] = _json(manifest)
|
||||
if len(payloads) > MAX_ENTRIES or sum(map(len, payloads.values())) > MAX_TOTAL:
|
||||
raise PackageError("package exceeds supported size")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=output.parent, prefix=".aios-bundle-", delete=False) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, body in sorted(payloads.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = (stat.S_IFREG | 0o600) << 16
|
||||
archive.writestr(info, body)
|
||||
verify_bundle(temporary_path)
|
||||
# Hard-link publication is atomic and cannot overwrite an existing file.
|
||||
os.link(temporary_path, output)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
return {"status": "CREATED", "bundle": str(output), "sha256": _hash(output.read_bytes()),
|
||||
"base_revision": revision, "changes": len(changes)}
|
||||
|
||||
|
||||
def _load_bundle(filename):
|
||||
filename = _absolute(filename)
|
||||
try:
|
||||
with zipfile.ZipFile(filename) as archive:
|
||||
entries = archive.infolist()
|
||||
if len(entries) > MAX_ENTRIES or sum(item.file_size for item in entries) > MAX_TOTAL:
|
||||
raise PackageError("package exceeds supported size")
|
||||
bodies = {}
|
||||
for entry in entries:
|
||||
name = _path(entry.filename)
|
||||
kind = stat.S_IFMT(entry.external_attr >> 16)
|
||||
if name in bodies or entry.is_dir() or kind not in {0, stat.S_IFREG} or entry.flag_bits & 1:
|
||||
raise PackageError("duplicate, encrypted, directory or symlink ZIP entry")
|
||||
if entry.file_size > MAX_MEMBER:
|
||||
raise PackageError("oversized ZIP entry")
|
||||
bodies[name] = archive.read(entry)
|
||||
document = json.loads(bodies.pop("manifest.json"))
|
||||
except (OSError, zipfile.BadZipFile, KeyError, json.JSONDecodeError, UnicodeDecodeError) as error:
|
||||
raise PackageError(f"invalid bundle: {type(error).__name__}") from error
|
||||
expected_fields = {"schema", "base_revision", "base_files", "changes", "payload_sha256"}
|
||||
if not isinstance(document, dict) or set(document) != expected_fields or document["schema"] != SCHEMA:
|
||||
raise PackageError("unsupported manifest schema or fields")
|
||||
if not isinstance(document["base_revision"], str) or not REVISION.fullmatch(document["base_revision"]):
|
||||
raise PackageError("invalid baseline revision")
|
||||
base, changes, declared = document["base_files"], document["changes"], document["payload_sha256"]
|
||||
if not isinstance(base, dict) or not isinstance(changes, list) or not changes or not isinstance(declared, dict):
|
||||
raise PackageError("invalid manifest collections")
|
||||
for path, item in base.items():
|
||||
_path(path)
|
||||
if not isinstance(item, dict) or set(item) != {"sha256", "mode"} or not isinstance(item["mode"], str) or item["mode"] not in MODES or not isinstance(item["sha256"], str) or not HASH.fullmatch(item["sha256"]):
|
||||
raise PackageError("invalid baseline file identity")
|
||||
required_payloads, seen = {}, set()
|
||||
for change in changes:
|
||||
if not isinstance(change, dict) or set(change) != {"path", "action", "before_sha256", "before_mode", "after_sha256", "after_mode"}:
|
||||
raise PackageError("invalid change fields")
|
||||
path = _path(change["path"])
|
||||
if path in seen:
|
||||
raise PackageError("duplicate change path")
|
||||
seen.add(path)
|
||||
action = change["action"]
|
||||
if not isinstance(action, str) or action not in {"add", "modify", "delete"}:
|
||||
raise PackageError("invalid change action")
|
||||
before = base.get(path)
|
||||
if action == "add":
|
||||
if before is not None or change["before_sha256"] is not None or change["before_mode"] is not None:
|
||||
raise PackageError("add conflicts with baseline")
|
||||
elif before != {"sha256": change["before_sha256"], "mode": change["before_mode"]}:
|
||||
raise PackageError("before identity differs from baseline")
|
||||
for prefix, present in (("before", action != "add"), ("after", action != "delete")):
|
||||
sha, mode = change[prefix + "_sha256"], change[prefix + "_mode"]
|
||||
if present:
|
||||
if not isinstance(sha, str) or not HASH.fullmatch(sha) or not isinstance(mode, str) or mode not in MODES:
|
||||
raise PackageError("invalid change content identity")
|
||||
required_payloads[("old/" if prefix == "before" else "new/") + path] = sha
|
||||
elif sha is not None or mode is not None:
|
||||
raise PackageError("deleted or absent content must have null identity")
|
||||
if declared != required_payloads or set(bodies) != set(required_payloads):
|
||||
raise PackageError("unexpected or missing payload")
|
||||
for name, sha in required_payloads.items():
|
||||
if _hash(bodies[name]) != sha:
|
||||
raise PackageError(f"payload checksum mismatch: {name}")
|
||||
return document, bodies
|
||||
|
||||
|
||||
def verify_bundle(filename):
|
||||
document, _ = _load_bundle(filename)
|
||||
return {"status": "VERIFIED_BYTES", "base_revision": document["base_revision"],
|
||||
"changes": len(document["changes"]), "sha256": _hash(Path(filename).read_bytes()),
|
||||
"authorization": "NOT_ESTABLISHED_BY_CHECKSUM"}
|
||||
|
||||
|
||||
def _check_clean_base(source, document):
|
||||
if _git(source, "status", "--porcelain", "--untracked-files=all", text=True):
|
||||
raise PackageError("source repository must be clean, including untracked files")
|
||||
if _git(source, "rev-parse", "HEAD", text=True) != document["base_revision"]:
|
||||
raise PackageError("source HEAD differs from package baseline")
|
||||
revision, files, _ = _base_tree(source, document["base_revision"])
|
||||
if files != document["base_files"]:
|
||||
raise PackageError("manifest baseline differs from source Git tree")
|
||||
for path, item in files.items():
|
||||
body, mode = _read_file(_member(source, path))
|
||||
if body is None or _hash(body) != item["sha256"] or mode != item["mode"]:
|
||||
raise PackageError(f"baseline working file differs: {path}")
|
||||
|
||||
|
||||
def _write(root, path, body, mode):
|
||||
target = _member(root, path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".aios-write-", delete=False) as temporary:
|
||||
temporary.write(body)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
temporary_path = Path(temporary.name)
|
||||
try:
|
||||
temporary_path.chmod(0o755 if mode == "100755" else 0o644)
|
||||
os.replace(temporary_path, target)
|
||||
finally:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def stage_bundle(filename, source, destination, *, dry_run=False):
|
||||
filename, source, destination = _absolute(filename), _absolute(source), _absolute(destination)
|
||||
document, bodies = _load_bundle(filename)
|
||||
if destination.exists():
|
||||
raise PackageError("destination already exists; use a new isolated directory")
|
||||
if source == destination or source in destination.parents or destination in source.parents:
|
||||
raise PackageError("source and destination must be separate trees")
|
||||
if not destination.parent.is_dir():
|
||||
raise PackageError("destination parent must already exist")
|
||||
_check_clean_base(source, document)
|
||||
result = {"status": "DRY_RUN" if dry_run else "STAGED", "source": str(source),
|
||||
"destination": str(destination), "changes": len(document["changes"]),
|
||||
"services_activated": False}
|
||||
if dry_run:
|
||||
return result
|
||||
lock = destination.parent / ("." + destination.name + ".aios-stage.lock")
|
||||
try:
|
||||
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError as error:
|
||||
raise PackageError("another stage operation holds destination lock") from error
|
||||
os.close(descriptor)
|
||||
temporary = Path(tempfile.mkdtemp(dir=destination.parent, prefix=".aios-stage-"))
|
||||
try:
|
||||
# Clone metadata without checkout: no repository content is executed and no
|
||||
# checkout/smudge filters are used. Materialize verified Git blobs ourselves.
|
||||
_git(destination.parent, "clone", "--quiet", "--no-checkout", "--no-local", "--no-hardlinks", str(source), str(temporary))
|
||||
_git(temporary, "symbolic-ref", "HEAD", "refs/heads/baseline-1.1-staged")
|
||||
_git(temporary, "update-ref", "HEAD", document["base_revision"])
|
||||
_git(temporary, "read-tree", document["base_revision"])
|
||||
_, _, base_bodies = _base_tree(source, document["base_revision"])
|
||||
for path, body in base_bodies.items():
|
||||
_write(temporary, path, body, document["base_files"][path]["mode"])
|
||||
for change in document["changes"]:
|
||||
path = change["path"]
|
||||
if change["action"] == "delete":
|
||||
_remove(temporary, path)
|
||||
for change in document["changes"]:
|
||||
path = change["path"]
|
||||
if change["action"] != "delete":
|
||||
_write(temporary, path, bodies["new/" + path], change["after_mode"])
|
||||
metadata = temporary / ".git" / "aios-baseline-1.1"
|
||||
metadata.mkdir(mode=0o700)
|
||||
shutil.copyfile(filename, metadata / "bundle.zip")
|
||||
receipt = {"schema": "aios.source-stage-receipt.v1", "state": "STAGED",
|
||||
"bundle_sha256": _hash(filename.read_bytes()), "base_revision": document["base_revision"]}
|
||||
(metadata / "receipt.json").write_bytes(_json(receipt))
|
||||
if destination.exists() or destination.is_symlink():
|
||||
raise PackageError("destination appeared during staging")
|
||||
os.rename(temporary, destination)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary)
|
||||
lock.unlink(missing_ok=True)
|
||||
return result
|
||||
|
||||
|
||||
def _remove(root, relative):
|
||||
target = _member(root, relative)
|
||||
target.unlink()
|
||||
parent = target.parent
|
||||
while parent != root:
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
parent = parent.parent
|
||||
|
||||
|
||||
def rollback(directory, *, dry_run=False):
|
||||
directory = _absolute(directory)
|
||||
metadata = _absolute(directory / ".git" / "aios-baseline-1.1")
|
||||
try:
|
||||
receipt = json.loads(_absolute(metadata / "receipt.json").read_bytes())
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise PackageError("no valid stage receipt") from error
|
||||
if not isinstance(receipt, dict) or receipt.get("schema") != "aios.source-stage-receipt.v1" or receipt.get("state") != "STAGED":
|
||||
raise PackageError("installation is not in STAGED state")
|
||||
filename = _absolute(metadata / "bundle.zip")
|
||||
document, bodies = _load_bundle(filename)
|
||||
if _hash(filename.read_bytes()) != receipt.get("bundle_sha256"):
|
||||
raise PackageError("stored bundle differs from stage receipt")
|
||||
if _git(directory, "rev-parse", "HEAD", text=True) != document["base_revision"]:
|
||||
raise PackageError("Git HEAD changed after staging; preserve local commits")
|
||||
if _git(directory, "diff", "--cached", "--name-only", "-z"):
|
||||
raise PackageError("Git index changed after staging; preserve local staged changes")
|
||||
# Complete preflight before the first write: preserve all local post-stage edits.
|
||||
for change in document["changes"]:
|
||||
body, mode = _read_file(_member(directory, change["path"]))
|
||||
expected = change["after_sha256"]
|
||||
if (body is None) != (expected is None) or (body is not None and (_hash(body) != expected or mode != change["after_mode"])):
|
||||
raise PackageError(f"staged file changed; rollback refused: {change['path']}")
|
||||
if dry_run:
|
||||
return {"status": "DRY_RUN", "directory": str(directory), "changes": len(document["changes"])}
|
||||
for change in document["changes"]:
|
||||
path = change["path"]
|
||||
if change["action"] == "add":
|
||||
_remove(directory, path)
|
||||
for change in document["changes"]:
|
||||
path = change["path"]
|
||||
if change["action"] != "add":
|
||||
_write(directory, path, bodies["old/" + path], change["before_mode"])
|
||||
receipt["state"] = "ROLLED_BACK"
|
||||
_write(metadata, "receipt.json", _json(receipt), "100644")
|
||||
return {"status": "ROLLED_BACK", "directory": str(directory), "services_activated": False}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
create = commands.add_parser("create", help="build source changes from a local Git baseline")
|
||||
create.add_argument("--repo", required=True)
|
||||
create.add_argument("--base", required=True)
|
||||
create.add_argument("--output", required=True)
|
||||
verify = commands.add_parser("verify", help="read-only byte and manifest verification")
|
||||
verify.add_argument("bundle")
|
||||
stage = commands.add_parser("stage", help="create a new isolated staged copy; never alter source")
|
||||
stage.add_argument("bundle")
|
||||
stage.add_argument("--source", required=True)
|
||||
stage.add_argument("--destination", required=True)
|
||||
stage.add_argument("--dry-run", action="store_true")
|
||||
undo = commands.add_parser("rollback", help="restore staged source only if it is unchanged")
|
||||
undo.add_argument("--directory", required=True)
|
||||
undo.add_argument("--dry-run", action="store_true")
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
if arguments.command == "create":
|
||||
result = create_bundle(arguments.repo, arguments.base, arguments.output)
|
||||
elif arguments.command == "verify":
|
||||
result = verify_bundle(arguments.bundle)
|
||||
elif arguments.command == "stage":
|
||||
result = stage_bundle(arguments.bundle, arguments.source, arguments.destination, dry_run=arguments.dry_run)
|
||||
else:
|
||||
result = rollback(arguments.directory, dry_run=arguments.dry_run)
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
except (PackageError, OSError, ValueError) as error:
|
||||
print(json.dumps({"status": "REFUSED", "reason": str(error)}, ensure_ascii=False, sort_keys=True))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tested offline wheel set: CPython 3.12, Linux x86_64, glibc >= 2.34.
|
||||
cffi==1.17.1 --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93
|
||||
cryptography==46.0.0 --hash=sha256:d5c0cbb2fb522f7e39b59a5482a1c9c5923b7c506cfe96a1b8e7368c31617ac0
|
||||
iniconfig==2.3.0 --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
pycparser==2.23 --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
|
||||
Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
|
||||
pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run local acceptance and record actual exit status; never activates a service."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--profile',choices=('baseline','all'),default='baseline')
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
args=parser.parse_args()
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
output=args.output.absolute()
|
||||
output.mkdir(parents=True,exist_ok=True)
|
||||
source={}
|
||||
for path in sorted(root.rglob('*.py')):
|
||||
if not path.is_symlink() and not {'.git','__pycache__','.venv','execution'}.intersection(path.relative_to(root).parts):
|
||||
source[str(path.relative_to(root))]=hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
started=datetime.now(timezone.utc).isoformat()
|
||||
# Some historical tests write to relative runtime/ paths. Exercise an exact
|
||||
# disposable source copy so verification never dirties the delivered source.
|
||||
with tempfile.TemporaryDirectory(prefix='aios-acceptance-') as temporary:
|
||||
workspace=Path(temporary)/'source'
|
||||
shutil.copytree(root,workspace,ignore=shutil.ignore_patterns('.git','__pycache__','.pytest_cache'))
|
||||
# Offline acceptance never inherits an ambient opt-in to a deployed signer.
|
||||
(workspace/'aios_offline_test_gate.py').write_text(
|
||||
'import pytest\n'
|
||||
'def pytest_collection_modifyitems(items):\n'
|
||||
' for item in items:\n'
|
||||
' if item.nodeid.endswith("::test_real_signer_help_and_self_test_are_available"):\n'
|
||||
' item.add_marker(pytest.mark.skip(reason="offline acceptance: deployed signer requires separate site run"))\n')
|
||||
command=[sys.executable,'-m','pytest','-q','tests/baseline_v1_1' if args.profile=='baseline' else 'tests',
|
||||
'-p','aios_offline_test_gate','--junitxml='+str(output/'junit.xml')]
|
||||
environment=os.environ.copy()
|
||||
environment['PYTHONPATH']=os.pathsep.join((str(workspace),str(workspace/'active-runtime/app')))
|
||||
environment['PYTHONDONTWRITEBYTECODE']='1'
|
||||
environment['AIOS_GITOPS_LIVE_GITEA_TEST']='0'
|
||||
environment['AIOS_RUN_REAL_SIGNER_TESTS']='0'
|
||||
with (output/'pytest.log').open('w') as log:
|
||||
completed=subprocess.run(command,cwd=workspace,env=environment,stdout=log,stderr=subprocess.STDOUT)
|
||||
counts={'tests':0,'failures':0,'errors':0,'skipped':0}
|
||||
if (output/'junit.xml').exists():
|
||||
tree=ET.parse(output/'junit.xml').getroot()
|
||||
for suite in tree.iter('testsuite'):
|
||||
for key in counts: counts[key]+=int(suite.attrib.get(key,'0'))
|
||||
result={'schema':'aios.local-acceptance-report.v1','profile':args.profile,
|
||||
'started_at':started,'finished_at':datetime.now(timezone.utc).isoformat(),
|
||||
'python':platform.python_version(),'platform':platform.platform(),
|
||||
'dependencies':{name:importlib.metadata.version(name) for name in ('pytest','cryptography','cffi')},
|
||||
'command':command,'exit_code':completed.returncode,'counts':counts,
|
||||
'junit_counts_include_subtests':True,
|
||||
'test_workspace_isolated':True,
|
||||
'status':'LOCAL_TESTS_PASSED' if completed.returncode==0 and counts['tests']>0 else 'FAILED',
|
||||
'live_activation':'NOT_PERFORMED','source_files_sha256':source}
|
||||
(output/'result.json').write_text(json.dumps(result,ensure_ascii=False,sort_keys=True,indent=2)+'\n')
|
||||
print(json.dumps({key:result[key] for key in ('status','exit_code','counts','live_activation')}))
|
||||
return completed.returncode if counts['tests'] else 2
|
||||
|
||||
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
Reference in New Issue
Block a user