key-cape/scripts/provider_browser_acceptance.py
tegwick e0b3c25f06
All checks were successful
Authentication acceptance / acceptance (push) Successful in 1m22s
Authentication acceptance / provider-contract (push) Successful in 14s
Build and Publish Container Image / build-and-push (push) Successful in 41s
Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
2026-09-14 00:00:05 +02:00

95 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Run installed-provider browser and KeyCape adapter acceptance in a disposable Job.
No production environment, service account, databases or real users are mounted.
Requires kubectl, Chromium, Node and Go; prints checks, never OTP material.
"""
from pathlib import Path
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
import uuid
ROOT=Path(__file__).resolve().parents[1]
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
def main():
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
if not chrome:
candidates=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome'))
chrome=str(candidates[-1]) if candidates else None
if not chrome or not shutil.which('node') or not shutil.which('go'):
raise RuntimeError('Chromium, Node and Go are required; acceptance was not run')
guard=(ROOT/'scripts/keycape_onboarding_guard.py').read_text()
source='import sys,types\ng=types.ModuleType("keycape_onboarding_guard");sys.modules["keycape_onboarding_guard"]=g\nexec('+repr(guard)+',g.__dict__)\n'+(ROOT/'scripts/provider-browser-fixture.py').read_text()
name='provider-p06-browser-'+uuid.uuid4().hex[:8]
job={'apiVersion':'batch/v1','kind':'Job','metadata':{'name':name,'namespace':'mfa'},'spec':{'activeDeadlineSeconds':600,'ttlSecondsAfterFinished':3600,'template':{'metadata':{'labels':{'app.kubernetes.io/name':'provider-p06-browser'}},'spec':{'automountServiceAccountToken':False,'restartPolicy':'Never','securityContext':{'runAsUser':65534,'runAsGroup':65534,'runAsNonRoot':True,'fsGroup':65534},'containers':[{'name':'fixture','image':IMAGE,'command':['python3','-c',source],'env':[{'name':'PYTHONDONTWRITEBYTECODE','value':'1'}],'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,'capabilities':{'drop':['ALL']}},'resources':{'requests':{'cpu':'1m','memory':'128Mi'},'limits':{'cpu':'500m','memory':'384Mi'}},'volumeMounts':[{'name':'scratch','mountPath':'/tmp'}]}],'volumes':[{'name':'scratch','emptyDir':{}}]}}}}
created=subprocess.run(['kubectl','create','-f','-'],input=json.dumps(job).encode(),capture_output=True)
if created.returncode:raise RuntimeError('Fixture creation failed')
forward=None
forward_log=tempfile.TemporaryFile(mode="w+t")
try:
for _ in range(120):
result=subprocess.run(['kubectl','-n','mfa','get','pods','-l','job-name='+name,'-o','json'],capture_output=True)
pods=json.loads(result.stdout).get('items',[]) if result.returncode==0 else []
if pods and pods[0]['status']['phase']=='Running':break
time.sleep(.5)
else:raise RuntimeError('Fixture did not start')
# A Running pod is not an HTTP-ready provider. Connecting too early can
# terminate kubectl port-forward on the first refused upstream socket.
for _ in range(120):
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
events=[]
for line in logs.stdout.splitlines():
try: events.append(json.loads(line))
except ValueError: pass
if any(event.get('fixture_ready') is True for event in events if isinstance(event,dict)):break
if any('fixture_failure' in event for event in events if isinstance(event,dict)):raise RuntimeError('Fixture initialization failed')
time.sleep(.5)
else:raise RuntimeError('Fixture initialization timed out')
with socket.socket() as listener:listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
forward=subprocess.Popen(['kubectl','-n','mfa','port-forward','job/'+name,str(port)+':8088','--address=127.0.0.1'],stdout=subprocess.DEVNULL,stderr=forward_log)
base='http://127.0.0.1:'+str(port)
for _ in range(120):
if forward.poll() is not None:raise RuntimeError('Fixture forwarding stopped')
try:
with urllib.request.urlopen(base+'/',timeout=2) as response:
if response.status==200:break
except Exception:time.sleep(.25)
else:raise RuntimeError('Fixture HTTP unavailable')
environment=dict(os.environ,P06_NATIVE_PROVIDER_URL=base)
subprocess.run(['go','test','./internal/server/oidc','-run','^TestNativeOptionalEnrollmentAndOldSession$','-count=1','-v'],cwd=ROOT/'src',env=environment,check=True,timeout=120)
with tempfile.TemporaryDirectory(prefix='p06-provider-browser-') as profile:
browser=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
marker=Path(profile)/'DevToolsActivePort'
for _ in range(100):
if marker.exists():break
time.sleep(.1)
debug='http://127.0.0.1:'+marker.read_text().splitlines()[0]
subprocess.run(['node',str(ROOT/'scripts/provider_browser.mjs'),debug,base],check=True,timeout=240)
finally:browser.terminate();browser.wait(timeout=5)
print(json.dumps({'success':True,'job':name,'provider_image':IMAGE,'production_accounts_changed':False}))
except Exception:
if forward and forward.poll() is not None:
forward_log.seek(0)
message=forward_log.read(1000)
print(json.dumps({"forward_exit":forward.returncode,"reason": "pod_not_running" if "not running" in message else "permission" if "permission" in message.lower() else "port_in_use" if "address already in use" in message else "forward_failed"}),flush=True)
logs=subprocess.run(['kubectl','-n','mfa','logs','job/'+name],capture_output=True,text=True)
for line in logs.stdout.splitlines():
try:
event=json.loads(line)
if 'fixture_failure' in event:print(json.dumps({k:event[k] for k in ['fixture_failure','phase']}),flush=True)
except Exception:pass
raise
finally:
if forward:forward.terminate();forward.wait(timeout=5)
forward_log.close()
subprocess.run(['kubectl','-n','mfa','delete','job',name,'--wait=false'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if __name__=='__main__':main()