railiance-platform/scripts/activity_promotion_readiness.py
codex 1228cd8407
All checks were successful
CCR intake gate / gate (push) Successful in 2m11s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
RPF-WP-0044-T06: founder cluster route and attended ESO adoption script
KUBECTL_ROUTE (agent default, founder-sudo = ssh railiance01 sudo -n) for the
bounded proof scripts; one attended script with go/no-go gates, typed ADOPT
confirmation, prune/force-off SSA sync and post-checks; evaluator and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 527080@bnt-lap001
Assistant-Session: 5a0e864a-67be-4605-a77a-16635f7dd292
2026-10-21 01:23:16 +02:00

211 lines
13 KiB
Python

#!/usr/bin/env python3
"""Read-only activity-core promotion gate; signed owner acceptance is mandatory.
D2 grants scoped activation after proof, never substitutes for release-principal
custody, authenticated issuers or rollback. This checker cannot activate a worker,
issue credentials, change Argo policy or admit its own caller as a trusted signer.
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime, timezone, timedelta
import hashlib
import json
from pathlib import Path
import re
import subprocess
PIN = 'a86a14332d76d92a7ed176ea34dca4429902c396'
OWNER_TASK = 'ACTIVITY-WP-0041-T03'
AUTHORITY = 'ACTIVITY-WP-0041-image-only-v1'
APPLICATION_PATH = 'argocd/railiance01/applications/activity-core.application.yaml'
RESOURCES = {
('', 'ConfigMap', 'activity-core', name) for name in
('actcore-runtime-config', 'actcore-external-activity-definitions', 'actcore-report-schemas', 'actcore-ops-service-inventory')
} | {('', 'Service', 'activity-core', name) for name in ('actcore-api', 'actcore-worker-metrics')} | {
('apps', 'Deployment', 'activity-core', name) for name in ('actcore-api', 'actcore-worker', 'actcore-event-router')
}
CHECKS = {
'principal_scope_enforced', 'credential_custody_verified', 'revocation_denial_proven',
'independent_receipt_issuers_verified', 'authenticated_ci_digest_binding_proven',
'continuous_health_observer_active', 'live_and_rollback_retention_verified',
'image_only_admission_enforced', 'source_cas_and_single_flight_proven',
'restart_and_lost_response_recovery_proven', 'out_of_scope_denials_proven',
'forged_stale_mismatched_receipt_denials_proven', 'failed_health_rollback_proven',
'temporal_dispatch_registered', 'durable_audit_sink_verified', 'report_schedule_invariants_verified',
}
TARGETS = {
'platform_repository': 'coulomb/railiance-platform', 'branch': 'main',
'child_path': APPLICATION_PATH, 'child_field': '/spec/source/targetRevision',
'root_application': 'railiance-apps-root', 'root_sync_selects_only': 'activity-core',
'child_application': 'activity-core', 'prune': False,
}
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode()
def stamp(value):
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
if result.tzinfo is None:
raise ValueError('timezone required')
return result
def run(argv, *, input=None):
try:
result = subprocess.run(argv, input=input, capture_output=True, timeout=100)
if result.returncode:
raise ValueError('metadata read unverified; protected output suppressed')
return result.stdout
except (OSError, subprocess.SubprocessError):
raise ValueError('metadata read unverified; protected output suppressed') from None
def collect():
def get(args):
return json.loads(run(['kubectl', *args, '-o', 'json']))
nodes = get(['get', 'nodes'])
if '92.205.62.239' not in [a['address'] for n in nodes['items'] for a in n['status']['addresses']]:
raise ValueError('estate identity mismatch')
namespace = get(['get', 'namespace', 'kube-system'])
obj = get(['-n', 'argocd', 'get', 'application', 'activity-core'])
spec, status = obj['spec'], obj.get('status', {})
expected_source = {'repoURL': 'https://forgejo.coulomb.social/coulomb/activity-core.git',
'path': 'k8s/gitops', 'targetRevision': PIN}
if spec.get('source') != expected_source:
raise ValueError('exact activity source unverified')
op = status.get('operationState', {})
deployments = []
for name in ('actcore-api', 'actcore-worker', 'actcore-event-router'):
deployment = get(['-n', 'activity-core', 'get', 'deployment', name])
generation = deployment['metadata']['generation']; ds = deployment.get('status', {})
desired = deployment['spec'].get('replicas', 1)
deployments.append({'name': name, 'generation_observed': ds.get('observedGeneration') == generation,
'replicas': desired, 'ready': ds.get('readyReplicas', 0),
'updated': ds.get('updatedReplicas', 0), 'available': ds.get('availableReplicas', 0)})
project = get(['-n', 'argocd', 'get', 'appproject', 'activity-core'])['spec']
return {'captured_at': datetime.now(timezone.utc).isoformat(), 'estate_ip': '92.205.62.239',
'cluster_uid': namespace['metadata']['uid'], 'application_uid': obj['metadata']['uid'],
'source': expected_source, 'project': spec.get('project'),
'destination': spec.get('destination'), 'automated_present': 'automated' in spec.get('syncPolicy', {}),
'sync_options': spec.get('syncPolicy', {}).get('syncOptions', []),
'pending_operation': bool(obj.get('operation')), 'sync': status.get('sync', {}).get('status'),
'revision': status.get('sync', {}).get('revision'), 'health': status.get('health', {}).get('status'),
'operation': {k: op.get(k) for k in ('phase', 'finishedAt')},
'condition_types': [c.get('type') for c in status.get('conditions', [])],
'resources': [{k: r.get(k) for k in ('group', 'kind', 'namespace', 'name', 'status')} for r in status.get('resources', [])],
'project_scope_matches': (project.get('sourceRepos') == ['https://forgejo.coulomb.social/coulomb/activity-core.git']
and project.get('destinations') == [{'server': 'https://kubernetes.default.svc', 'namespace': 'activity-core'}]
and project.get('clusterResourceWhitelist', []) == []
and {(r.get('group') or '',r['kind']) for r in project.get('namespaceResourceWhitelist', [])}
== {('', 'ConfigMap'),('', 'Service'),('apps','Deployment')}),
'deployments': deployments}
def adoption_checks(observation, now):
if (not 0 <= (now-stamp(observation['captured_at'])).total_seconds() <= 300
or observation['estate_ip'] != '92.205.62.239' or not observation['cluster_uid']
or not observation['application_uid']):
raise ValueError('fresh exact estate observation required')
if (observation['source'] != {'repoURL':'https://forgejo.coulomb.social/coulomb/activity-core.git','path':'k8s/gitops','targetRevision':PIN}
or observation['project'] != 'activity-core' or observation['revision'] != PIN
or observation['destination'] != {'server':'https://kubernetes.default.svc','namespace':'activity-core'}
or observation['automated_present'] is not False or observation['pending_operation'] is not False
or set(observation['sync_options']) != {'CreateNamespace=false','ApplyOutOfSyncOnly=true','PruneLast=true','FailOnSharedResource=true'}
or observation['sync'] != 'Synced' or observation['health'] != 'Healthy'
or observation['condition_types'] or observation['project_scope_matches'] is not True):
raise ValueError('bounded manual adoption scope unverified')
op = observation['operation']
if op['phase'] != 'Succeeded' or (stamp(observation['captured_at'])-stamp(op['finishedAt'])).total_seconds() < 86400:
raise ValueError('24-hour adoption eligibility unverified')
resources = observation['resources']
if (len(resources) != 9 or {tuple(r.get(k) or '' for k in ('group','kind','namespace','name')) for r in resources} != RESOURCES
or any(r['status'] != 'Synced' for r in resources)):
raise ValueError('nine-resource runtime inventory unverified')
deployments = observation['deployments']
if (len(deployments) != 3 or {d['name'] for d in deployments} != {'actcore-api','actcore-worker','actcore-event-router'}
or any(d['generation_observed'] is not True or type(d['replicas']) is not int or d['replicas'] < 1
or any(d[k] != d['replicas'] for k in ('ready','updated','available')) for d in deployments)):
raise ValueError('deployment generation/readiness unverified')
def owner_acceptance(envelope, trusted_key, trusted_principal, observation, now):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
if set(envelope) != {'payload','signature'} or not trusted_principal or len(trusted_key) != 32:
raise ValueError('trusted owner acceptance configuration required')
payload = envelope['payload']
Ed25519PublicKey.from_public_bytes(trusted_key).verify(base64.b64decode(envelope['signature'],validate=True), canonical(payload))
if (payload.get('schema_version') != 'activity-core-release-acceptance/v1'
or payload.get('owner_task') != OWNER_TASK or payload.get('issuer') != trusted_principal
or payload.get('authority') != AUTHORITY or payload.get('application') != 'activity-core'
or payload.get('cluster_uid') != observation['cluster_uid'] or payload.get('adoption_revision') != PIN
or payload.get('targets') != TARGETS or not payload.get('release_principal')
or not re.fullmatch('[a-f0-9]{40}',payload.get('broker_revision',''))
or set(payload.get('checks',{})) != CHECKS
or any(value is not True for value in payload['checks'].values())):
raise ValueError('owner acceptance scope or machine proof incomplete')
issued, expires = stamp(payload['issued_at']), stamp(payload['expires_at'])
if not 0 <= (now-issued).total_seconds() <= 300 or not now < expires <= issued + timedelta(minutes=5):
raise ValueError('owner acceptance stale or expired')
health = payload['continuous_health']
if (health.get('revision') != PIN or health.get('continuous') is not True
or type(health.get('max_gap_seconds')) is not int or not 0 < health['max_gap_seconds'] <= 90
or not 0 <= (now-stamp(health['observed_at'])).total_seconds() <= 90
or (stamp(health['observed_at'])-stamp(health['healthy_since'])).total_seconds() < 86400):
raise ValueError('owner continuous observation proof incomplete')
return payload
def readiness(observation, *, envelope=None, trusted_key=None, trusted_principal=None, now=None):
now = now or datetime.now(timezone.utc)
base = {'schema_version':'activity-promotion-readiness/v1','task':'RPF-WP-0048-T02',
'owner_dependency':OWNER_TASK,'native_mutation':False,'automatic_sync_candidate':False,
'observed_at':now.isoformat(),'checker_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
try:
adoption_checks(observation,now)
except Exception:
return base | {'status':'unverified','reason':'adoption_metadata_unverified'}
base |= {'adoption_24h_eligible':True,'adoption_snapshot_is_not_continuous_observation':True,
'observation_sha256':hashlib.sha256(canonical(observation)).hexdigest()}
if envelope is None or trusted_key is None or trusted_principal is None:
return base | {'status':'waiting','reason':'authenticated_owner_activation_acceptance_missing',
'depends_on':[OWNER_TASK],'needs_human':False}
try:
payload = owner_acceptance(envelope,trusted_key,trusted_principal,observation,now)
except Exception:
return base | {'status':'unverified','reason':'owner_activation_acceptance_unverified'}
return base | {'status':'ready','owner_acceptance_sha256':hashlib.sha256(canonical(envelope)).hexdigest(),
'release_principal':payload['release_principal'],'broker_revision':payload['broker_revision'],
'scope':AUTHORITY,'rollback':'disable dedicated release dispatcher admission; retain manual child policy and prior source pin',
'abort_owner':'railiance-platform'}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--live',action='store_true');parser.add_argument('--remote',action='store_true',help=argparse.SUPPRESS)
parser.add_argument('--observation',type=Path);parser.add_argument('--receipt',type=Path)
parser.add_argument('--owner-acceptance',type=Path)
parser.add_argument('--trusted-owner-key',type=Path,help='operator-owned raw Ed25519 public key; never receipt-provided')
parser.add_argument('--trusted-owner-principal',help='operator-owned issuer binding')
args=parser.parse_args()
try:
if args.remote:
print(json.dumps(collect(),sort_keys=True));return 0
if args.live:
import sys;sys.path.insert(0,str(Path(__file__).resolve().parent));import kube_route
observation=json.loads(run(kube_route.ssh_argv(['python3','-','--remote']),input=Path(__file__).read_bytes()))
observation['remote_captured_at']=observation['captured_at']
observation['captured_at']=datetime.now(timezone.utc).isoformat()
if args.observation:args.observation.write_text(json.dumps(observation,indent=2)+'\n')
else:observation=json.loads(args.observation.read_text())
envelope=json.loads(args.owner_acceptance.read_text()) if args.owner_acceptance else None
key=args.trusted_owner_key.read_bytes() if args.trusted_owner_key else None
result=readiness(observation,envelope=envelope,trusted_key=key,trusted_principal=args.trusted_owner_principal)
except Exception:result={'schema_version':'activity-promotion-readiness/v1','status':'unverified','reason':'protected_read_or_inputs_unverified','native_mutation':False}
if args.receipt:args.receipt.write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps(result,sort_keys=True));return 0 if result['status']=='ready' else 1
if __name__=='__main__':raise SystemExit(main())