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
211 lines
13 KiB
Python
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())
|