#!/usr/bin/env python3 """Regenerate proxy/nginx.conf from the real deployment's ingress table. The live instance is a Kubernetes ingress that splits one origin across the legacy client, the new SPA and several APIs. Which path goes where is not documented in prose — it is the table in dof_app_deploy/ansible/group_vars/all/x_ingress.yml plus a per-path ingress in each service repo. Transcribing 46 rules by hand invites exactly the drift that would make this instance lie about the real one, so we generate them. Usage: python3 scripts/gen-proxy-conf.py > proxy/nginx.conf Needs the upstream clones in ../vendor (see README.md). """ import pathlib import sys try: import yaml except ImportError: sys.exit('needs PyYAML: pip install pyyaml') VENDOR = pathlib.Path(__file__).resolve().parents[2] / 'vendor' INGRESS = VENDOR / 'dof_app_deploy/ansible/group_vars/all/x_ingress.yml' # Kubernetes service name -> compose upstream. None means the real deployment # deliberately 404s that path. UPSTREAM = { 'client-svc': 'client:3100', 'nuxtclient-svc': 'nuxt:4000', 'api-svc': 'api:3030', 'default-backend-404-svc': None, 'version-aggregator-svc': None, # replaced by our own /version below None: None, } # Routes that live in the individual service repos' own ingress templates # rather than the shared table, plus the two websocket endpoints. # (path, upstream, websocket, rewrite-or-None) EXTRA = [ ('/api/v3/file/', 'file-storage:4444', False, None), ('/api/v3/wopi/', 'file-storage:4444', False, None), ('/api/v3/h5p-editor/h5pstatics/', 'h5p-staticfiles:8080', False, '^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1'), ('/api/v3/h5p-editor/', 'h5p-editor:4448', False, None), ('/api/v3/', 'api:3030', False, None), ('/admin/api/v1', 'admin-api:4030', False, None), ('/board-collaboration', 'board-collaboration:4450', True, None), ('/tldraw-server', 'tldraw-server:3345', True, None), ('/api/tldraw', 'tldraw-server:3345', False, None), ] PREAMBLE = '''# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand. # # One origin in front of the whole stack, the way the real instance is fronted # by its Kubernetes ingress. The path split between the legacy client and the # new SPA is not cosmetic: get it wrong and you are testing a different # application from the one the students use. map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 4400; server_name localhost; # Docker's embedded DNS. Every proxy_pass below goes through a variable so # that names resolve per request rather than at startup — otherwise this # container refuses to boot whenever an optional profile (tools, av) is # down, which is the normal case. resolver 127.0.0.11 valid=10s ipv6=off; # Course files and H5P uploads are large; the ingress allows the same. client_max_body_size 2600m; proxy_read_timeout 300s; proxy_send_timeout 300s; # version-aggregator-svc upstream; /serverversion and /nuxtversion are the # real per-app endpoints and are routed below. location = /version { default_type application/json; return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}'; } ''' COMMON = ''' proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; ''' WS = ''' proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; ''' GONE = ''' location {path} {{ return 404; }} ''' # Etherpad is mounted under a prefix it knows nothing about, so the deployment # runs a dedicated nginx in front of it that rewrites the prefix away and # proxies socket.io separately. Copied from # dof_app_deploy/ansible/roles/dof_etherpad/templates/nginx-configmap-files.yml.j2 # — a plain proxy_pass gets you a pad that loads and then never syncs. ETHERPAD = ''' location ^~ /etherpad/admin { return 404; } location ^~ /etherpad/stats { return 404; } location /etherpad/socket.io { set $up_etherpad etherpad:9001; rewrite /etherpad/socket.io/(.*) /socket.io/$1 break; proxy_pass http://$up_etherpad; proxy_redirect / /etherpad/; proxy_set_header Host $http_host; proxy_buffering off; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } location /etherpad { set $up_etherpad etherpad:9001; rewrite ^/etherpad$ /etherpad/ permanent; rewrite /etherpad/(.*) /$1 break; proxy_pass http://$up_etherpad; proxy_pass_header Server; proxy_redirect / /etherpad/; proxy_set_header Host $host; proxy_buffering off; } ''' def block(path: str, upstream: str, ws: bool, rewrite: str | None, var: str) -> str: out = [f'\n\tlocation {path} {{\n', f'\t\tset ${var} {upstream};\n'] if rewrite: out.append(f'\t\trewrite {rewrite} break;\n') out.append(f'\t\tproxy_pass http://${var};\n') out.append(COMMON) if ws: out.append(WS) out.append('\t}\n') return ''.join(out) def varname(path: str) -> str: safe = ''.join(c if c.isalnum() else '_' for c in path.strip('/')) or 'root' return f'up_{safe}' def main() -> None: table = yaml.safe_load(INGRESS.read_text())['default_ingress'] seen: set[str] = set() out = [PREAMBLE] out.append('\n\t# --- service-owned ingresses and websockets ---\n') for path, upstream, ws, rewrite in EXTRA: seen.add(path) out.append(block(path, upstream, ws, rewrite, varname(path))) out.append('\n\t# --- from dof_app_deploy x_ingress.yml ---\n') for name, entry in table.items(): path = entry.get('path') if path is None or path in seen: continue seen.add(path) if path == '/etherpad': out.append(ETHERPAD) continue upstream = UPSTREAM.get(entry.get('serviceName'), 'MISSING') if upstream == 'MISSING': sys.exit(f'unknown serviceName for {name}: {entry.get("serviceName")}') out.append(f'\n\t# {name}') out.append(GONE.format(path=path) if upstream is None else block(path, upstream, False, None, varname(path))) out.append('}\n') sys.stdout.write(''.join(out)) if __name__ == '__main__': main()