"""Pinned dev-box Istio and catalogue routes, through the owned controller tunnel.""" import json import sys from .common import ANSIBLE, run from . import storage from platform_edge import artifacts, catalogue def main(target): storage.require_collection() target.assert_cluster() host = json.loads((target.directory / 'inventory.json').read_text())['all']['hosts']['devbox'] paths = {'istio_k3s_data_path': target.settings.get('k3sDataPath', '/var/lib/pharos-metallo/k3s'), 'istio_cni_bin_dir': target.settings.get('cniBinPath', '/var/lib/pharos-metallo/k3s/data/cni'), 'istio_cni_conf_dir': target.settings.get('cniConfPath', '/var/lib/pharos-metallo/k3s/agent/etc/cni/net.d')} if target.settings.get('lifecycle', {}).get('k3sMode') == 'connect': paths['istio_gateway_service_type'] = 'ClusterIP' if target.settings.get('lifecycle', {}).get('imageProfile') == 'arm64-singledrive': paths['istio_cni_selinux_options'] = {'type': 'spc_t'} inventory = {'all': {'vars': paths, 'hosts': {'devbox': host, 'mesh_controller': { 'ansible_connection': 'local', 'ansible_python_interpreter': sys.executable}}}} path = target.directory / 'mesh-inventory.json' target.write(path, inventory) run('ansible-playbook', '-i', str(path), str(ANSIBLE / 'playbooks/istio.yml')) target.write(target.directory / 'mesh.json', {'version': '1.30.2', 'node_port': None if paths.get('istio_gateway_service_type') == 'ClusterIP' else 30088}) def edge(target): if target.settings.get('lifecycle', {}).get('k3sMode') == 'connect': from pharos_network import edge_inventory edge_inventory(target) run('ansible-playbook', '-i', str(target.directory / 'inventory.json'), str(ANSIBLE / 'playbooks/edge.yml'), '-e', 'edge_argocd_deploy_pending=true') def checks(target): from themelio_dx.compat import profiles from local_aai.up import selection executable = artifacts.binary(target.directory / 'istio-cache') scopes = [('-A',)] if target.settings.get('lifecycle', {}).get('k3sMode') == 'connect': from pharos_network import mesh_namespaces scopes = [('--namespace', name) for name in mesh_namespaces()] for scope in scopes: run(str(executable), '--kubeconfig', str(target.kubeconfig), 'analyze', *scope, '--failure-threshold', 'Error') plan = catalogue.resolve(catalogue.load(catalogue.CATALOGUE), catalogue.load(catalogue.ENVIRONMENT), profiles(target)) if selection(target)['mode'] == 'external': plan['servers'] = {host: [r for r in routes if r['name'] != 'aai'] for host, routes in plan['servers'].items()} probe(target, plan['servers']) # A route's own prefix may have no page; probe a known path behind it. ROUTE_PATHS = {'aai': '/auth/realms/pharos/.well-known/openid-configuration', 'mcp-gateway': '/mcp', 'mcp-discovery': '/.well-known/oauth-protected-resource/mcp', 'mcp-broker': '/health/ready', 'api': '/api/v1/party/current', 'api-auth': '/auth/session', 'api-health': '/healthz', 'argocd': '/api/v1/applications', 'waldur-api': '/api/configuration/', 'waldur-api-auth': '/api-auth/password/', 'waldur-static': '/static/admin/css/base.css', 'site-api-commands': '/v1/site/commands/pull', 'site-api-run-logs': '/v1/site/commands/run-logs/00000000-0000-4000-8000-000000000001', 'site-api-ssh-access': '/v1/site/ssh-access/pull', 'site-api-executions': '/v1/site/executions/authorise', 'site-api-repositories': '/v1/site/repositories/issue', # Zammad's page is at /, its WebSocket at /ws behind the same route. 'zammad': '/ws', # The API route is the /v1/ prefix only; a keyless request to a model list is refused. 'llm-api': '/v1/models'} # The Site API answers only authenticated POSTs; a GET reaching it is refused by method. The five inference paths # behind the API origin are POST-only too: a 405 there means the edge forwarded the request to LiteLLM, which is # the point of the probe, while the management paths answer 404 at the edge and never reach it. ROUTE_STATUSES = {'mcp-gateway': {401}, 'api': {401}, 'api-auth': {401}, 'argocd': {401}, 'waldur-api-auth': {405}, 'site-api-commands': {405}, 'site-api-run-logs': {405}, 'site-api-ssh-access': {405}, 'llm-api': {401}, 'site-api-executions': {405}, 'site-api-repositories': {405}, 'llm-api-chat': {405}, 'llm-api-completions': {405}, 'llm-api-embeddings': {405}, 'llm-api-responses': {405}, 'llm-api-messages': {405}} def probe(target, servers, paths=None, statuses=None): """Probe each route through the host edge; a laboratory probes its own routes the same way.""" paths = {**ROUTE_PATHS, **(paths or {})} statuses = {**ROUTE_STATUSES, **(statuses or {})} # Probe only installed backends: a fresh platform has storage before its AAI # application profile. Missing applications are not a passing probe. from .remote import ssh_options for hostname, routes in servers.items(): for route in routes: backend = route['backend'] present = storage.kubectl(target, '-n', backend['namespace'], 'get', 'service', backend['service'], '--ignore-not-found', '-o', 'name', capture=True).stdout.strip() if not present: print('Route probe deferred: backend not installed: ' + route['name']) continue if route['transport'] == 'grpc': import shlex result = run('ssh', *ssh_options(), target.settings['ssh'], shlex.join([ 'openssl', 's_client', '-connect', hostname + ':8443', '-servername', hostname, '-alpn', 'h2', '-verify_return_error', '-verify_hostname', hostname, '-CAfile', '/opt/pharos-local-aai/tls/ca.crt']), input='', capture=True, # The server's first HTTP/2 frames may reach stdout as binary. errors='replace') if 'ALPN protocol: h2' not in result.stdout: raise ValueError('gRPC edge did not negotiate HTTP/2: ' + route['name']) print('gRPC TLS/HTTP2 trust verified: ' + route['name']) continue path = paths.get(route['name'], route['path']) # Public trust is on the host; SSH carries only the hostname/path. import shlex arguments = [ 'curl', '--silent', '--show-error', '--max-time', '10', '--cacert', '/opt/pharos-local-aai/tls/ca.crt', '--resolve', hostname + ':8443:127.0.0.1', '--output', '/dev/null', '--write-out', '%{http_code}', 'https://' + hostname + ':8443' + path] if route.get('browser_host') == hostname: arguments.extend(['--header', 'Host: ' + hostname]) # WebSocket support on a UI origin also admits ordinary HTTP pages. # Retained HMR exists only during takeover and has a browser acceptance check. # Open WebUI's sockets live at /ws/socket.io, so an upgrade at its root reaches a static mount and fails. websocket = route['websocket'] and route['name'] not in { 'argocd', 'themelio-compat', 'web', 'platform', 'open-webui', 'kubeflow'} if websocket: arguments.extend(['--http1.1', '--header', 'Connection: Upgrade', '--header', 'Upgrade: websocket', '--header', 'Sec-WebSocket-Version: 13', '--header', 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==']) import subprocess response = subprocess.run(['ssh', *ssh_options(), target.settings['ssh'], shlex.join(arguments)], text=True, capture_output=True) expected = statuses.get(route['name'], {101} if websocket else {200, 302, 307, 308}) # MCP enrols one exact public Host; its long development alias is deliberately refused. if route.get('profile') == 'mcp' and hostname != route.get('browser_host'): expected = {421} if route['name'] == 'mcp-broker' else {400} if not response.stdout.isdecimal() or int(response.stdout) not in expected: raise ValueError('Route probe failed: ' + route['name'] + ' HTTP ' + response.stdout) print('Route probe passed: ' + route['name'] + ' HTTP ' + response.stdout)