activity-core/src/activity_core/release_sampler.py
tegwick 52dcc08f23
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 8s
Build and Publish Container Image / build-and-push (push) Successful in 1m39s
Implement bounded release runtime and durable native health sampler
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 23:26:03 +02:00

136 lines
6.9 KiB
Python

"""Executable read-only sampler for the existing durable release HealthObserver.
No signer, identity admission or mutation is provided. Each failed/unavailable
probe records False, missed intervals reset, and a new ledger starts now.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import ipaddress
import json
from pathlib import Path
import time
import urllib.request
import yaml
from .release_observer import HealthObserver
from .release_operations import revision, update_child
from .release_transport import run
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
BASELINE = 'a86a14332d76d92a7ed176ea34dca4429902c396'
DEPLOYMENTS = {'actcore-api', 'actcore-worker', 'actcore-event-router'}
RESOURCE_SET = {
('ConfigMap', name) for name in ('actcore-runtime-config','actcore-external-activity-definitions','actcore-report-schemas','actcore-ops-service-inventory')
} | {('Service',name) for name in ('actcore-api','actcore-worker-metrics')} | {('Deployment',name) for name in DEPLOYMENTS}
def http_health(url):
with urllib.request.urlopen(url, timeout=20) as response:
if response.status != 200:
raise ValueError('invariant service unavailable')
body = response.read(16385)
if len(body) > 16384:
raise ValueError('invariant response exceeds fixed bound')
return json.loads(body)
def invariant_matches(value, now):
if (not isinstance(value, dict) or value.get('schema_version') != 'activity-core-release-health/v1'
or value.get('healthy') is not True or value.get('definitions_and_report_sinks_match') is not True
or value.get('schedules_match') is not True or value.get('definition_count') != 3
or value.get('schedule_count') != 3 or 'error' in value):
return False
at = datetime.fromisoformat(value['observed_at'].replace('Z', '+00:00'))
# Same estate observer should use synchronized clocks; unknown/future time
# is a failed sample, never a manufactured healthy interval.
return at.tzinfo is not None and 0 <= (now-at).total_seconds() <= 90
class Probe:
"""Only fixed GETs, a cluster-pinned service origin and public boolean health."""
def __init__(self, runner=run, reader=http_health):
self.runner, self.reader = runner, reader
def get(self, *args):
return json.loads(self.runner(['kubectl','--request-timeout=20s',*args,'-o','json'],timeout=25))
def __call__(self):
nodes = self.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')
if self.get('get','namespace','kube-system')['metadata']['uid'] != CLUSTER_UID:
raise ValueError('cluster UID mismatch')
app = self.get('-n','argocd','get','application','activity-core')
target = revision(app['spec']['source']['targetRevision'])
update_child(yaml.safe_dump(app),target,target)
status = app.get('status',{})
healthy = (not app.get('operation') and status.get('sync',{}).get('status') == 'Synced'
and status.get('sync',{}).get('revision') == target
and status.get('health',{}).get('status') == 'Healthy' and not status.get('conditions'))
resources = status.get('resources',[])
healthy &= (len(resources) == 9 and {(r['kind'],r['name']) for r in resources} == RESOURCE_SET
and all(r.get('namespace') == 'activity-core' and r.get('status') == 'Synced'
and (r.get('group') or '') == ('apps' if r['kind']=='Deployment' else '') for r in resources))
for name in sorted(DEPLOYMENTS):
deployment = self.get('-n','activity-core','get','deployment',name)
desired = deployment['spec'].get('replicas',1);ds=deployment.get('status',{})
healthy &= (type(desired) is int and desired>0 and ds.get('observedGeneration') == deployment['metadata']['generation']
and all(ds.get(k,0) == desired for k in ('readyReplicas','updatedReplicas','availableReplicas')))
service = self.get('-n','activity-core','get','service','actcore-api')
address = ipaddress.ip_address(service['spec']['clusterIP'])
if not any(p.get('port') == 8010 for p in service['spec'].get('ports',[])):
raise ValueError('fixed invariant API port unavailable')
host = f'[{address}]' if address.version == 6 else str(address)
value = self.reader(f'http://{host}:8010/release/health')
healthy &= invariant_matches(value,datetime.now(timezone.utc))
return target,bool(healthy)
class Sampler:
def __init__(self, database, probe, *, anchor_revision=BASELINE):
self.observer=HealthObserver(database,max_gap_seconds=90)
self.probe=probe;self.anchor=revision(anchor_revision)
def sample(self,now=None):
explicit_now=now
try:
target,healthy=self.probe()
revision(target)
if type(healthy) is not bool:raise ValueError('explicit health required')
except Exception:
# Use the last durably seen identity only for a failed sample. No
# raw error, fabricated timestamp or successful identity is emitted.
with self.observer.connect() as db:
row=db.execute('SELECT revision FROM observations ORDER BY rowid DESC LIMIT 1').fetchone()
target=row[0] if row else self.anchor;healthy=False
# Measure after the probe, not before a slow/failed network operation.
measured=explicit_now if explicit_now is not None else datetime.now(timezone.utc)
since=self.observer.observe(target,healthy,measured)
try:attestation=self.observer.attestation(target,measured)
except ValueError:attestation=None
return {'schema_version':'activity-core-release-sample/v1','revision':target,
'observed_at':measured.isoformat(),'healthy':healthy,'healthy_since':since,
'continuous_24h_ready':attestation is not None,'attestation':attestation,
'native_mutation':False}
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database',type=Path,required=True)
parser.add_argument('--receipt',type=Path,required=True)
parser.add_argument('--watch',action='store_true',help='read-only 60s sampler; requires an owner-deployed process')
args=parser.parse_args()
sampler=Sampler(args.database,Probe())
while True:
try:result=sampler.sample()
except Exception:result={'schema_version':'activity-core-release-sample/v1','healthy':False,'continuous_24h_ready':False,'error':'durable observation unavailable','native_mutation':False}
args.receipt.write_text(json.dumps(result,sort_keys=True)+'\n')
if not args.watch:
print(json.dumps(result,sort_keys=True));return 0 if result.get('healthy') else 1
time.sleep(60)
if __name__=='__main__':raise SystemExit(main())