282 lines
17 KiB
Python
282 lines
17 KiB
Python
"""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())
|