net-kingdom/sso-mfa/k8s/privacyidea/deploy-factor-recovery.py

97 lines
6.4 KiB
Python
Raw Normal View History

"""Deploy the private P04 service using existing provider custody and CAS guards."""
import argparse
import hashlib
import json
import subprocess
from pathlib import Path
CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786'
PROVIDER='58c7f96d-61cb-4dd4-bca2-54661c0ac375'
PORTAL='e1c4274a-4b6d-4ea2-b619-e09630b745b0'
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
def kube(*args, body=None):
r=subprocess.run(['kubectl',*args],input=json.dumps(body).encode() if body is not None else None,
capture_output=True,timeout=45)
if r.returncode:raise RuntimeError('Kubernetes operation failed; inspect resource state')
return json.loads(r.stdout) if r.stdout.strip().startswith(b'{') else None
def peer(namespace,name):
return {'namespaceSelector':{'matchLabels':{'kubernetes.io/metadata.name':namespace}},
'podSelector':{'matchLabels':{'app.kubernetes.io/name':name}}}
def resources(source):
data={name:(source/name).read_text() for name in ('factor_recovery.py','recovery_service.py')}
digest=hashlib.sha256(json.dumps(data,sort_keys=True).encode()).hexdigest()[:16]
name='factor-recovery-'+digest
docs=[{'apiVersion':'v1','kind':'ConfigMap','metadata':{'name':name,'namespace':'mfa'},'immutable':True,'data':data},
{'apiVersion':'v1','kind':'Service','metadata':{'name':'factor-recovery','namespace':'mfa'},
'spec':{'selector':{'app.kubernetes.io/name':'privacyidea'},'ports':[{'name':'recovery','port':8091,'targetPort':8091}]}}]
for ns,selector,direction,remote,port in [
('mfa','privacyidea','ingress',peer('user-engine','user-engine'),8091),
('user-engine','user-engine','egress',peer('mfa','privacyidea'),8091),
('mfa','privacyidea','egress',peer('sso','keycape'),8080),
('sso','keycape','ingress',peer('mfa','privacyidea'),8080)]:
docs.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy',
'metadata':{'name':'factor-recovery-'+direction+'-'+str(port),'namespace':ns},
'spec':{'podSelector':{'matchLabels':{'app.kubernetes.io/name':selector}},
'policyTypes':[direction.title()],direction:[{'from' if direction=='ingress' else 'to':[remote],
'ports':[{'protocol':'TCP','port':port}]}]}})
return name,docs
def main():
p=argparse.ArgumentParser()
p.add_argument('--source',type=Path,required=True)
p.add_argument('--apply',action='store_true')
p.add_argument('--portal-image')
a=p.parse_args()
if kube('get','ns','kube-system','-o','json')['metadata']['uid']!=CLUSTER:raise RuntimeError('wrong cluster')
provider=kube('-n','mfa','get','deploy','privacyidea','-o','json')
if provider['metadata']['uid']!=PROVIDER:raise RuntimeError('provider identity changed')
name,docs=resources(a.source)
spec=provider['spec']['template']['spec']
main=next(c for c in spec['containers'] if c['name']=='privacyidea')
sidecar={'name':'factor-recovery','image':IMAGE,'command':['python3','/recovery/recovery_service.py'],
'env':[{'name':'PRIVACYIDEA_CONFIGFILE','value':'/etc/privacyidea/pi.cfg'},
{'name':'PYTHONDONTWRITEBYTECODE','value':'1'},
{'name':'RECOVERY_ISSUER','value':'https://kc.coulomb.social'},
{'name':'RECOVERY_AUDIENCE','value':'user-engine-portal'},
{'name':'RECOVERY_JWKS_URL','value':'http://keycape.sso.svc.cluster.local:8080/jwks'}],
'envFrom':main['envFrom'],
'volumeMounts':[dict(m,readOnly=m['name']!='logs') for m in main['volumeMounts']]+[{'name':'factor-recovery-code','mountPath':'/recovery','readOnly':True}],
'securityContext':{'runAsUser':65534,'runAsGroup':999,'runAsNonRoot':True,'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}},
'ports':[{'name':'recovery','containerPort':8091}],
'resources':{'requests':{'cpu':'10m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}},
'readinessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':10,'periodSeconds':10},
'livenessProbe':{'httpGet':{'path':'/healthz','port':8091},'initialDelaySeconds':60,'periodSeconds':20}}
sidecar['env'] += [entry for entry in main.get('env',[]) if entry['name']=='PYTHONPATH']
containers=[c for c in spec['containers'] if c['name']!='factor-recovery']+[sidecar]
volumes=[v for v in spec['volumes'] if v['name']!='factor-recovery-code']+[{'name':'factor-recovery-code','configMap':{'name':name}}]
patch=[{'op':'test','path':'/metadata/resourceVersion','value':provider['metadata']['resourceVersion']},
{'op':'replace','path':'/spec/template/spec/containers','value':containers},
{'op':'replace','path':'/spec/template/spec/volumes','value':volumes}]
print(json.dumps({'config':name,'resources':[(d['kind'],d['metadata']['name']) for d in docs],'provider_sidecar':True,'portal_update':bool(a.portal_image),'apply':a.apply}))
if not a.apply:return
for doc in docs:kube('apply','-f','-',body=doc)
kube('-n','mfa','patch','deploy','privacyidea','--type=json','-p',json.dumps(patch))
if a.portal_image:
if not a.portal_image.startswith('forgejo.coulomb.social/coulomb/user-engine@sha256:') or len(a.portal_image.rsplit(':',1)[1])!=64:raise ValueError('digest-pinned portal required')
portal=kube('-n','user-engine','get','deploy','user-engine','-o','json')
if portal['metadata']['uid']!=PORTAL:raise RuntimeError('portal identity changed')
items=portal['spec']['template']['spec']['containers']
c=next(c for c in items if c['name']=='portal');c['image']=a.portal_image
names={'USER_ENGINE_FACTOR_RECOVERY_URL','USER_ENGINE_MFA_MANAGEMENT_URL'}
c['env']=[e for e in c['env'] if e['name'] not in names]+[
{'name':'USER_ENGINE_FACTOR_RECOVERY_URL','value':'http://factor-recovery.mfa.svc.cluster.local:8091'},
{'name':'USER_ENGINE_MFA_MANAGEMENT_URL','value':'https://pink-account.coulomb.social'}]
kube('-n','user-engine','patch','deploy','user-engine','--type=json','-p',json.dumps([
{'op':'test','path':'/metadata/resourceVersion','value':portal['metadata']['resourceVersion']},
{'op':'replace','path':'/spec/template/spec/containers','value':items}]))
print('Recovery deployment applied; verify rollout and authenticated acceptance.')
if __name__=='__main__':main()