95 lines
4.6 KiB
Python
95 lines
4.6 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Prepare exact identity backup coverage from read-only workload/PVC metadata.
|
||
|
|
|
||
|
|
This produces a proposal, never reads Secrets, executes in pods, changes cluster
|
||
|
|
resources, captures private files, uploads, schedules or grants credentials.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
from datetime import datetime,timezone
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import subprocess
|
||
|
|
|
||
|
|
ROOT=Path(__file__).resolve().parents[2]
|
||
|
|
CLUSTER_UID='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||
|
|
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||
|
|
|
||
|
|
|
||
|
|
class Refused(Exception):pass
|
||
|
|
|
||
|
|
|
||
|
|
def query(namespace,kind,name):
|
||
|
|
# Closed read-only types; Secret/ConfigMap data and arbitrary API paths have
|
||
|
|
# no transport in this preparation command.
|
||
|
|
if kind not in {'namespace','deployment','pvc'}:raise Refused()
|
||
|
|
args=['kubectl','--request-timeout=20s']
|
||
|
|
if namespace:args+=['-n',namespace]
|
||
|
|
r=subprocess.run(args+['get',kind,name,'-o','json'],capture_output=True,timeout=25)
|
||
|
|
if r.returncode:raise Refused()
|
||
|
|
return json.loads(r.stdout)
|
||
|
|
|
||
|
|
|
||
|
|
def references(spec):
|
||
|
|
secrets=set();configmaps=set();pvcs=set()
|
||
|
|
for c in spec.get('containers',[])+spec.get('initContainers',[]):
|
||
|
|
for e in c.get('env',[]):
|
||
|
|
v=e.get('valueFrom',{})
|
||
|
|
if 'secretKeyRef' in v:secrets.add(v['secretKeyRef']['name'])
|
||
|
|
if 'configMapKeyRef' in v:configmaps.add(v['configMapKeyRef']['name'])
|
||
|
|
for e in c.get('envFrom',[]):
|
||
|
|
if 'secretRef' in e:secrets.add(e['secretRef']['name'])
|
||
|
|
if 'configMapRef' in e:configmaps.add(e['configMapRef']['name'])
|
||
|
|
for v in spec.get('volumes',[]):
|
||
|
|
if 'secret' in v:secrets.add(v['secret']['secretName'])
|
||
|
|
if 'configMap' in v:configmaps.add(v['configMap']['name'])
|
||
|
|
if 'persistentVolumeClaim' in v:pvcs.add(v['persistentVolumeClaim']['claimName'])
|
||
|
|
for p in v.get('projected',{}).get('sources',[]):
|
||
|
|
if 'secret' in p:secrets.add(p['secret']['name'])
|
||
|
|
if 'configMap' in p:configmaps.add(p['configMap']['name'])
|
||
|
|
return secrets,configmaps,pvcs
|
||
|
|
|
||
|
|
|
||
|
|
def prepare(profile,get=query):
|
||
|
|
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||
|
|
bindings=[]
|
||
|
|
for component in profile['components']:
|
||
|
|
ns=component['namespace'];name=component['deployment']
|
||
|
|
d=get(ns,'deployment',name);m=d['metadata']
|
||
|
|
if (m['uid']!=component['deployment_uid'] or m.get('deletionTimestamp') or
|
||
|
|
d['status'].get('observedGeneration')!=m['generation'] or d['status'].get('readyReplicas')!=1):raise Refused()
|
||
|
|
refs=references(d['spec']['template']['spec'])
|
||
|
|
expected_pvcs={p['name']:p['uid'] for p in component['pvc_refs']}
|
||
|
|
if refs[0]!=set(component['secret_refs']) or refs[1]!=set(component['configmap_refs']) or refs[2]!=set(expected_pvcs):raise Refused()
|
||
|
|
pvcs=[]
|
||
|
|
for pvc,expected in expected_pvcs.items():
|
||
|
|
obj=get(ns,'pvc',pvc)
|
||
|
|
if obj['metadata']['uid']!=expected or obj['metadata'].get('deletionTimestamp') or obj['status'].get('phase')!='Bound':raise Refused()
|
||
|
|
pvcs.append({'name':pvc,'uid':expected})
|
||
|
|
bindings.append({'namespace':ns,'deployment':name,'uid':m['uid'],'generation':m['generation'],
|
||
|
|
'pvc_bindings':pvcs,'secret_names_only':sorted(refs[0]),'configmap_names_only':sorted(refs[1])})
|
||
|
|
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||
|
|
return {'schema':'netkingdom.identity-backup-preparation.v1','status':'prepared_not_admitted',
|
||
|
|
'observed_at':datetime.now(timezone.utc).isoformat(),'cluster_uid':CLUSTER_UID,
|
||
|
|
'profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||
|
|
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||
|
|
'bindings':bindings,'secret_values_read':False,'backup_captured':False,'uploaded':False,
|
||
|
|
'runtime_changed':False,'credential_grant_created':False,
|
||
|
|
'owner_admission_required':profile['admission_gates']}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||
|
|
a=p.parse_args()
|
||
|
|
try:
|
||
|
|
r=prepare(json.loads(PROFILE.read_text()))
|
||
|
|
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||
|
|
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||
|
|
except Exception:
|
||
|
|
print('ERROR: coverage preparation refused; private diagnostics suppressed.');return 1
|
||
|
|
print('OK: exact coverage proposal prepared; capture, delivery and owner admission remain pending.');return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__=='__main__':raise SystemExit(main())
|