net-kingdom/sso-mfa/k8s/keycape/portal-client-rollout.py
tegwick 6cd89f0a7a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
fix: restore native user portal login and track tenant integration
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-11 20:55:30 +02:00

136 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Restore the approved public portal client without exporting Secret values."""
from __future__ import annotations
import argparse
import base64
import copy
import importlib.util
import json
from pathlib import Path
import subprocess
import yaml
ROOT = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape')
def module(name, filename):
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
pin = module('issuer_pin', 'openbao-client-config.py')
portal = module('portal_registration', 'register-user-engine-portal.py')
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
class Refused(Exception):
"""Only fixed reason codes may leave the operation."""
def require(condition, reason):
if not condition:
raise Refused(reason)
def replacement(secret):
raw, config, _ = pin.issuer_document(secret)
clients = config.get('clients')
require(isinstance(clients, list) and clients, 'client_sequence_required')
ids = [client['clientId'] for client in clients]
require(len(ids) == len(set(ids)), 'duplicate_client_id')
if portal.CLIENT_ID in ids:
require(clients[ids.index(portal.CLIENT_ID)] == portal.CLIENT,
'existing_registration_differs_requires_reconciliation')
return secret['data']['config.yaml'], False
root = yaml.compose(raw)
node = next(value for key, value in root.value if key.value == 'clients')
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
'block_client_sequence_required')
index = node.end_mark.index
line_start = raw.rfind('\n', 0, index) + 1
if not raw[line_start:index].strip():
index = line_start
indent = ' ' * node.start_mark.column
addition = yaml.safe_dump([portal.CLIENT], sort_keys=False)
addition = ''.join(indent + line if line.strip() else line
for line in addition.splitlines(True))
if index and raw[index - 1] != '\n':
addition = '\n' + addition
updated = raw[:index] + addition + raw[index:]
expected = copy.deepcopy(config)
expected['clients'].append(portal.CLIENT)
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected,
'unrelated_configuration_changed')
return base64.b64encode(updated.encode()).decode(), True
def kubectl(arguments, payload=None):
args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments]
result = subprocess.run(args, input=None if payload is None else json.dumps(payload),
text=True, capture_output=True, timeout=30)
require(result.returncode == 0, 'kubernetes_operation_failed')
return json.loads(result.stdout)
def run(args):
cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json'])
require(cluster['metadata']['uid'] == CLUSTER_UID, 'cluster_identity_changed')
before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json'])
require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret')
metadata = pin.safe_metadata(before)
encoded, changed = replacement(before)
receipt = {'target': 'sso/keycape-config', 'before': metadata,
'client': portal.CLIENT, 'change_needed': changed,
'mode': args.mode, 'values_emitted': False, 'changed': False}
if args.mode == 'inspect':
return receipt
require(metadata == {'uid': args.expected_uid,
'resource_version': args.expected_resource_version},
'observed_revision_changed')
if changed:
patch = [
{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
{'op': 'replace', 'path': '/data/config.yaml', 'value': encoded},
]
command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json',
'--patch-file=/dev/stdin', '-o', 'json']
if args.mode == 'dry-run':
command += ['--dry-run=server']
result = kubectl(command, patch)
require(result['data'] == dict(before['data'], **{'config.yaml': encoded}),
'patch_response_mismatch')
if args.mode == 'apply':
after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json'])
require(after['metadata']['uid'] == metadata['uid'] and
after['data'] == dict(before['data'], **{'config.yaml': encoded}),
'readback_mismatch_stop_without_stale_replay')
receipt.update(changed=changed, after=pin.safe_metadata(after))
receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True)
return receipt
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply'])
parser.add_argument('--expected-uid')
parser.add_argument('--expected-resource-version')
args = parser.parse_args()
try:
print(json.dumps(run(args), sort_keys=True))
return 0
except Refused as error:
print(json.dumps({'status': 'refused', 'reason': str(error)}))
except Exception:
# Parser and Kubernetes errors can contain full Secret material.
print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'}))
return 1
if __name__ == '__main__':
raise SystemExit(main())