#!/usr/bin/env python3 """Manage the local AppService and frontend; never operate L0 or install metadata.""" import argparse import os import tempfile from pathlib import Path from local_deployment import RUNTIME_RPC_SECONDS, bind, cli, compose, image_file, phase, read_env, read_json, wait_http, write, write_env, write_json def l1_compose(root, settings): l0 = root / "l0" l1 = root / "l1" return {"name": settings["project"] + "-l1", "services": { "app-service": { "image": settings["app_image"], "user": f"{os.getuid()}:{os.getgid()}", "restart": "unless-stopped", "env_file": [str(l1 / "app.env")], "ports": [f"127.0.0.1:{settings['app_port']}:8080"], "volumes": [bind(l1 / "workload.token", "/run/secrets/workload.token"), bind(l0 / "secrets/tls/server.crt", "/run/trust/runtime.crt")], }, "frontend": {"image": "nginx:1.27.5-alpine", "restart": "unless-stopped", "ports": [f"127.0.0.1:{settings['public_port']}:80"], "volumes": [bind(settings["frontend_path"], "/usr/share/nginx/html"), bind(l1 / "nginx.conf", "/etc/nginx/conf.d/default.conf")]}, }, "networks": {"default": {"name": settings["project"] + "-backend", "external": True}}} def nginx_config(): return """map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; root /usr/share/nginx/html; client_max_body_size 26m; location / { try_files $uri $uri/ /index.html; } location ~ ^/(services|api|oauth|oauth2|product|ws|\\.well-known|downloads|mcp|health|readyz|livez)(/|$) { proxy_pass http://app-service:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_read_timeout 90s; } } """ def extract_frontend(image, l1): frontend = Path(tempfile.mkdtemp(prefix="frontend-", dir=l1)) image_file(image, "/usr/share/nexuscrm/frontend/.", frontend) frontend.chmod(0o755) return frontend def deploy(args, root): l0 = root / "l0" l1 = root / "l1" l0_settings = read_json(l0 / "settings.json") settings_path = l1 / "settings.json" settings = read_json(settings_path) if settings_path.exists() else { "project": l0_settings["project"], "app_port": args.app_port, "public_port": args.public_port} if args.app_image: settings["app_image"] = args.app_image if args.frontend_image: settings["frontend_image"] = args.frontend_image if not settings.get("app_image") or not settings.get("frontend_image"): raise ValueError("first deploy requires --app-image and --frontend-image") serving = read_json(l0 / "config/serving.json") identities = read_json(l0 / "secrets/workload-identities.json") token = next(row["token"] for row in identities["identities"] if row["workloadId"] == "sfcrm-app-service") write(l1 / "workload.token", token + "\n") values = read_env(root / "database.env") origin = values["PUBLIC_BASE_URL"] app_env = { "SFCRM_HTTP_ADDRESS": ":8080", "SFCRM_RUNTIME_TARGET": "runtime-api:9090", "SFCRM_APP_INSTALLATION_ID": serving["app_installation_id"], "SFCRM_ORGANIZATION_ID": serving["org_id"], "SFCRM_PUBLIC_ORIGIN": origin, "SFCRM_SESSION_CONTROL_ORIGINS": origin, "SFCRM_WORKLOAD_TOKEN_FILE": "/run/secrets/workload.token", "SFCRM_RUNTIME_CA_FILE": "/run/trust/runtime.crt", "SFCRM_RUNTIME_SERVER_NAME": "localhost", "SFCRM_REQUEST_TIMEOUT": "90s", "SFCRM_RUNTIME_ATTEMPT_TIMEOUT": f"{RUNTIME_RPC_SECONDS}s", "SFCRM_RUNTIME_STARTUP_TIMEOUT": "60s", "SFCRM_SHUTDOWN_TIMEOUT": "10s", "SFCRM_READ_HEADER_TIMEOUT": "5s", "SFCRM_IDLE_TIMEOUT": "60s", "SFCRM_GOOGLE_TIMEOUT": "10s", "SFCRM_MAX_REQUEST_BODY_BYTES": str(26 * 1024 * 1024), "NEXUS_LOGIN_METHODS": "password", "NEXUS_PASSWORD_GRANT_ENABLED": "true", } write_env(l1 / "app.env", app_env) with phase(root, "l1-deploy"): # Each deployment mounts a fresh directory; a failed extraction cannot # modify the assets served by the running frontend container. frontend = extract_frontend(settings["frontend_image"], l1) settings["frontend_path"] = str(frontend) write(l1 / "nginx.conf", nginx_config()) write_json(settings_path, settings) write_json(l1 / "compose.json", l1_compose(root, settings)) compose(root, "l1", "up", "-d") wait_http(f"http://127.0.0.1:{settings['app_port']}/readyz") wait_http(origin + "/") print(origin) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=("deploy", "start", "stop", "status", "down")) parser.add_argument("--root", type=Path, required=True) parser.add_argument("--app-image") parser.add_argument("--frontend-image") parser.add_argument("--app-port", type=int, default=3302) parser.add_argument("--public-port", type=int, default=3300) args = parser.parse_args() root = args.root.resolve() if args.command == "deploy": deploy(args, root) else: compose(root, "l1", {"start": "start", "stop": "stop", "status": "ps", "down": "down"}[args.command]) if __name__ == "__main__": cli(main)