Prepare scoped GitOps adoption and tested immutable image releases
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
parent
5d5ee84d70
commit
c12a8fbcfb
12 changed files with 2585 additions and 731 deletions
63
scripts/check_gitops_promotion.py
Normal file
63
scripts/check_gitops_promotion.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Fail-closed admission check for a candidate image-only GitOps release.
|
||||
|
||||
This is a validator, not an authority issuer or a cluster/Git credential broker.
|
||||
Evidence must be supplied by the separately admitted release identity.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import yaml
|
||||
IMAGE=re.compile(r'forgejo\.coulomb\.social/coulomb/activity-core@sha256:[0-9a-f]{64}')
|
||||
SHA=re.compile(r'[0-9a-f]{40}')
|
||||
DEPLOYS={'actcore-api','actcore-worker','actcore-event-router'}
|
||||
def require(condition,message):
|
||||
if not condition: raise ValueError(message)
|
||||
def indexed(docs):
|
||||
out={}
|
||||
for d in docs:
|
||||
require(isinstance(d,dict),'invalid resource')
|
||||
key=(d['kind'],d['metadata']['name'])
|
||||
require(key not in out and d['metadata'].get('namespace')=='activity-core','duplicate or foreign resource')
|
||||
out[key]=copy.deepcopy(d)
|
||||
return out
|
||||
|
||||
def validate(before,after,evidence,now=None):
|
||||
now=now or datetime.now(timezone.utc)
|
||||
require(evidence.get('schema_version')==1,'unknown evidence schema')
|
||||
require(evidence.get('identity_admitted') is True,'release identity not admitted')
|
||||
require(evidence.get('authority')=='ACTIVITY-WP-0041-image-only-v1','unknown authority')
|
||||
require(evidence.get('review_result')=='pass' and evidence.get('checks')=='pass','checks/review not passed')
|
||||
require(evidence.get('reviewer') and evidence.get('reviewer')!=evidence.get('producer'),'independent review required')
|
||||
require(bool(SHA.fullmatch(evidence.get('candidate_commit',''))),'full candidate revision required')
|
||||
require(bool(SHA.fullmatch(evidence.get('rollback_commit',''))),'rollback revision required')
|
||||
require(evidence['candidate_commit']!=evidence['rollback_commit'],'rollback must name prior revision')
|
||||
observed=datetime.fromisoformat(evidence['healthy_since'].replace('Z','+00:00'))
|
||||
measured=datetime.fromisoformat(evidence['observed_at'].replace('Z','+00:00'))
|
||||
require(observed.tzinfo is not None and measured.tzinfo is not None,'timestamps need timezone')
|
||||
require(now-timedelta(minutes=5)<=measured<=now,'health evidence stale or future')
|
||||
require(measured-observed>=timedelta(hours=24),'24-hour healthy observation period incomplete')
|
||||
require(evidence.get('argo_synced') is True and evidence.get('argo_healthy') is True,'Argo not healthy/synced')
|
||||
left,right=indexed(before),indexed(after)
|
||||
require(left.keys()==right.keys(),'resource inventory change')
|
||||
changed=[]
|
||||
for key,a in left.items():
|
||||
b=right[key]
|
||||
if a==b: continue
|
||||
require(key[0]=='Deployment' and key[1] in DEPLOYS,'only runtime images may change')
|
||||
ac=a['spec']['template']['spec']['containers'];bc=b['spec']['template']['spec']['containers']
|
||||
require(len(ac)==len(bc)==1,'container inventory change')
|
||||
require(bool(IMAGE.fullmatch(bc[0].get('image',''))),'registry digest required')
|
||||
require(bc[0].get('imagePullPolicy')=='IfNotPresent','pull policy must support registry recovery')
|
||||
ac[0]['image']=bc[0]['image'];ac[0]['imagePullPolicy']=bc[0]['imagePullPolicy']
|
||||
require(a==b,'non-image deployment change')
|
||||
changed.append(key[1])
|
||||
require(bool(changed),'no release change')
|
||||
return {'admitted':True,'deployments':changed,'candidate_commit':evidence['candidate_commit'],'rollback_commit':evidence['rollback_commit']}
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('before',type=Path);p.add_argument('after',type=Path);p.add_argument('evidence',type=Path);a=p.parse_args()
|
||||
try: print(json.dumps(validate(list(yaml.safe_load_all(a.before.read_text())),list(yaml.safe_load_all(a.after.read_text())),json.loads(a.evidence.read_text()))))
|
||||
except (ValueError,KeyError,TypeError) as e: raise SystemExit(f'refused: {e}')
|
||||
30
scripts/render_gitops.py
Normal file
30
scripts/render_gitops.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Render only the reviewed application resource set, excluding jobs and custody."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MANAGED={
|
||||
'ConfigMap': ['actcore-runtime-config','actcore-external-activity-definitions','actcore-report-schemas','actcore-ops-service-inventory'],
|
||||
'Service': ['actcore-api','actcore-worker-metrics'],
|
||||
'Deployment': ['actcore-api','actcore-worker','actcore-event-router'],
|
||||
}
|
||||
class Dumper(yaml.SafeDumper): pass
|
||||
def strings(d,v): return d.represent_scalar('tag:yaml.org,2002:str',v,style='|' if '\n' in v else None)
|
||||
Dumper.add_representer(str,strings)
|
||||
def render():
|
||||
docs=list(yaml.safe_load_all((ROOT/'k8s/railiance/20-runtime.yaml').read_text()))
|
||||
selected=[]
|
||||
for kind,names in MANAGED.items():
|
||||
for name in names:
|
||||
matches=[d for d in docs if d and d['kind']==kind and d['metadata']['name']==name]
|
||||
if len(matches)!=1: raise ValueError(f'expected exactly one {kind}/{name}')
|
||||
d=matches[0]
|
||||
if d['metadata'].get('namespace')!='activity-core': raise ValueError('namespace outside grant')
|
||||
selected.append(d)
|
||||
return '# Generated by scripts/render_gitops.py; do not edit directly.\n'+'---\n'.join(yaml.dump(d,Dumper=Dumper,sort_keys=False) for d in selected)
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(); parser.add_argument('--check',action='store_true'); args=parser.parse_args()
|
||||
path=ROOT/'k8s/gitops/runtime.yaml'; text=render()
|
||||
if args.check:
|
||||
if path.read_text()!=text: raise SystemExit('GitOps projection stale; run scripts/render_gitops.py')
|
||||
else: path.write_text(text)
|
||||
Loading…
Add table
Add a link
Reference in a new issue