diff --git a/Dockerfile b/Dockerfile index faeeec4d..4f9cc8be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ +# Envoy version — keep in sync with cli/planoai/consts.py ENVOY_VERSION +ARG ENVOY_VERSION=v1.37.0 + # --- Dependency cache --- FROM rust:1.93.0 AS deps RUN rustup -v target add wasm32-wasip1 @@ -40,7 +43,7 @@ COPY crates/brightstaff/src brightstaff/src RUN find common hermesllm brightstaff -name "*.rs" -exec touch {} + RUN cargo build --release -p brightstaff -FROM docker.io/envoyproxy/envoy:v1.37.0 AS envoy +FROM docker.io/envoyproxy/envoy:${ENVOY_VERSION} AS envoy FROM python:3.14-slim AS arch diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py index 0cc5c3dd..8ef11c86 100644 --- a/cli/planoai/consts.py +++ b/cli/planoai/consts.py @@ -7,3 +7,12 @@ SERVICE_NAME_ARCHGW = "plano" PLANO_DOCKER_NAME = "plano" PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.9") DEFAULT_OTEL_TRACING_GRPC_ENDPOINT = "http://host.docker.internal:4317" + +# Native mode constants +PLANO_HOME = os.path.join(os.path.expanduser("~"), ".plano") +PLANO_RUN_DIR = os.path.join(PLANO_HOME, "run") +PLANO_BIN_DIR = os.path.join(PLANO_HOME, "bin") +PLANO_PLUGINS_DIR = os.path.join(PLANO_HOME, "plugins") +ENVOY_VERSION = "v1.37.0" # keep in sync with Dockerfile ARG ENVOY_VERSION +NATIVE_PID_FILE = os.path.join(PLANO_RUN_DIR, "plano.pid") +DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT = "http://localhost:4317" diff --git a/cli/planoai/core.py b/cli/planoai/core.py index b7195f46..e9ddc7bd 100644 --- a/cli/planoai/core.py +++ b/cli/planoai/core.py @@ -33,8 +33,11 @@ def _get_gateway_ports(plano_config_file: str) -> list[int]: with open(plano_config_file) as f: plano_config_dict = yaml.safe_load(f) + model_providers = plano_config_dict.get("llm_providers") or plano_config_dict.get( + "model_providers" + ) listeners, _, _ = convert_legacy_listeners( - plano_config_dict.get("listeners"), plano_config_dict.get("llm_providers") + plano_config_dict.get("listeners"), model_providers ) all_ports = [listener.get("port") for listener in listeners] diff --git a/cli/planoai/main.py b/cli/planoai/main.py index a93e4c4d..78002ec7 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -30,6 +30,7 @@ from planoai.init_cmd import init as init_cmd from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background from planoai.consts import ( DEFAULT_OTEL_TRACING_GRPC_ENDPOINT, + DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT, PLANO_DOCKER_IMAGE, PLANO_DOCKER_NAME, ) @@ -130,7 +131,13 @@ def main(ctx, version): @click.command() -def build(): +@click.option( + "--native", + default=False, + help="Build WASM plugins and brightstaff natively (requires Rust toolchain).", + is_flag=True, +) +def build(native): """Build Plano from source. Works from any directory within the repo.""" # Find the repo root @@ -141,6 +148,68 @@ def build(): ) sys.exit(1) + if native: + import shutil + + crates_dir = os.path.join(repo_root, "crates") + console = _console() + _print_cli_header(console) + + if not shutil.which("cargo"): + console.print( + "[red]✗[/red] [bold]cargo[/bold] not found. " + "Install Rust: [cyan]https://rustup.rs[/cyan]" + ) + sys.exit(1) + + console.print("[dim]Building WASM plugins (wasm32-wasip1)...[/dim]") + try: + subprocess.run( + [ + "cargo", + "build", + "--release", + "--target", + "wasm32-wasip1", + "-p", + "llm_gateway", + "-p", + "prompt_gateway", + ], + cwd=crates_dir, + check=True, + ) + console.print("[green]✓[/green] WASM plugins built") + except subprocess.CalledProcessError as e: + console.print(f"[red]✗[/red] WASM build failed: {e}") + sys.exit(1) + + console.print("[dim]Building brightstaff (native)...[/dim]") + try: + subprocess.run( + [ + "cargo", + "build", + "--release", + "-p", + "brightstaff", + ], + cwd=crates_dir, + check=True, + ) + console.print("[green]✓[/green] brightstaff built") + except subprocess.CalledProcessError as e: + console.print(f"[red]✗[/red] brightstaff build failed: {e}") + sys.exit(1) + + wasm_dir = os.path.join(crates_dir, "target", "wasm32-wasip1", "release") + native_dir = os.path.join(crates_dir, "target", "release") + console.print(f"\n[bold]Build artifacts:[/bold]") + console.print(f" {os.path.join(wasm_dir, 'prompt_gateway.wasm')}") + console.print(f" {os.path.join(wasm_dir, 'llm_gateway.wasm')}") + console.print(f" {os.path.join(native_dir, 'brightstaff')}") + return + dockerfile_path = os.path.join(repo_root, "Dockerfile") if not os.path.exists(dockerfile_path): @@ -192,7 +261,13 @@ def build(): help="Port for the OTLP trace collector (default: 4317).", show_default=True, ) -def up(file, path, foreground, with_tracing, tracing_port): +@click.option( + "--native", + default=False, + help="Run Plano natively without Docker (requires prior 'planoai build --native').", + is_flag=True, +) +def up(file, path, foreground, with_tracing, tracing_port, native): """Starts Plano.""" from rich.status import Status @@ -209,26 +284,49 @@ def up(file, path, foreground, with_tracing, tracing_port): ) sys.exit(1) - with Status( - "[dim]Validating configuration[/dim]", spinner="dots", spinner_style="dim" - ): - ( - validation_return_code, - _, - validation_stderr, - ) = docker_validate_plano_schema(plano_config_file) + if native: + from planoai.native_runner import native_validate_config - if validation_return_code != 0: - console.print(f"[red]✗[/red] Validation failed") - if validation_stderr: - console.print(f" [dim]{validation_stderr.strip()}[/dim]") - sys.exit(1) + with Status( + "[dim]Validating configuration (native)[/dim]", + spinner="dots", + spinner_style="dim", + ): + try: + native_validate_config(plano_config_file) + except SystemExit: + console.print(f"[red]✗[/red] Validation failed") + sys.exit(1) + except Exception as e: + console.print(f"[red]✗[/red] Validation failed") + console.print(f" [dim]{str(e).strip()}[/dim]") + sys.exit(1) + else: + with Status( + "[dim]Validating configuration[/dim]", spinner="dots", spinner_style="dim" + ): + ( + validation_return_code, + _, + validation_stderr, + ) = docker_validate_plano_schema(plano_config_file) + + if validation_return_code != 0: + console.print(f"[red]✗[/red] Validation failed") + if validation_stderr: + console.print(f" [dim]{validation_stderr.strip()}[/dim]") + sys.exit(1) console.print(f"[green]✓[/green] Configuration valid") # Set up environment + default_otel = ( + DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT + if native + else DEFAULT_OTEL_TRACING_GRPC_ENDPOINT + ) env_stage = { - "OTEL_TRACING_GRPC_ENDPOINT": DEFAULT_OTEL_TRACING_GRPC_ENDPOINT, + "OTEL_TRACING_GRPC_ENDPOINT": default_otel, } env = os.environ.copy() env.pop("PATH", None) @@ -296,13 +394,20 @@ def up(file, path, foreground, with_tracing, tracing_port): sys.exit(1) # Update the OTEL endpoint so the gateway sends traces to the right port - env_stage[ - "OTEL_TRACING_GRPC_ENDPOINT" - ] = f"http://host.docker.internal:{tracing_port}" + tracing_host = "localhost" if native else "host.docker.internal" + otel_endpoint = f"http://{tracing_host}:{tracing_port}" + env_stage["OTEL_TRACING_GRPC_ENDPOINT"] = otel_endpoint env.update(env_stage) try: - start_plano(plano_config_file, env, foreground=foreground) + if native: + from planoai.native_runner import start_native + + start_native( + plano_config_file, env, foreground=foreground, with_tracing=with_tracing + ) + else: + start_plano(plano_config_file, env, foreground=foreground) # When tracing is enabled but --foreground is not, keep the process # alive so the OTLP collector continues to receive spans. @@ -320,15 +425,30 @@ def up(file, path, foreground, with_tracing, tracing_port): @click.command() -def down(): +@click.option( + "--native", + default=False, + help="Stop a natively-running Plano instance.", + is_flag=True, +) +def down(native): """Stops Plano.""" console = _console() _print_cli_header(console) - with console.status( - f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", spinner="dots" - ): - stop_docker_container() + if native: + from planoai.native_runner import stop_native + + with console.status( + f"[{PLANO_COLOR}]Shutting down Plano (native)...[/{PLANO_COLOR}]", + spinner="dots", + ): + stop_native() + else: + with console.status( + f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", spinner="dots" + ): + stop_docker_container() @click.command() diff --git a/cli/planoai/native_binaries.py b/cli/planoai/native_binaries.py new file mode 100644 index 00000000..6bb4af49 --- /dev/null +++ b/cli/planoai/native_binaries.py @@ -0,0 +1,174 @@ +import os +import platform +import subprocess +import sys +import tarfile +import tempfile + +from planoai.consts import ( + ENVOY_VERSION, + PLANO_BIN_DIR, +) +from planoai.utils import find_repo_root, getLogger + +log = getLogger(__name__) + + +def _get_platform_slug(): + """Return the platform slug for Envoy binary downloads.""" + system = platform.system().lower() + machine = platform.machine().lower() + + mapping = { + ("linux", "x86_64"): "linux-amd64", + ("linux", "aarch64"): "linux-arm64", + ("darwin", "arm64"): "darwin-arm64", + } + + slug = mapping.get((system, machine)) + if slug is None: + if system == "darwin" and machine == "x86_64": + print( + "Error: macOS x86_64 (Intel) is not supported. " + "Pre-built Envoy binaries are only available for Apple Silicon (arm64)." + ) + sys.exit(1) + print( + f"Error: Unsupported platform {system}/{machine}. " + "Supported platforms: linux-amd64, linux-arm64, darwin-arm64" + ) + sys.exit(1) + + return slug + + +def ensure_envoy_binary(): + """Download Envoy binary if not already present or version changed. Returns path to binary.""" + envoy_path = os.path.join(PLANO_BIN_DIR, "envoy") + version_path = os.path.join(PLANO_BIN_DIR, "envoy.version") + + if os.path.exists(envoy_path) and os.access(envoy_path, os.X_OK): + # Check if cached binary matches the pinned version + if os.path.exists(version_path): + with open(version_path, "r") as f: + cached_version = f.read().strip() + if cached_version == ENVOY_VERSION: + log.info(f"Envoy {ENVOY_VERSION} found at {envoy_path}") + return envoy_path + print( + f"Envoy version changed ({cached_version} → {ENVOY_VERSION}), re-downloading..." + ) + else: + log.info( + f"Envoy binary found at {envoy_path} (unknown version, re-downloading...)" + ) + + slug = _get_platform_slug() + url = ( + f"https://github.com/tetratelabs/archive-envoy/releases/download/" + f"{ENVOY_VERSION}/envoy-{ENVOY_VERSION}-{slug}.tar.xz" + ) + + os.makedirs(PLANO_BIN_DIR, exist_ok=True) + + print(f"Downloading Envoy {ENVOY_VERSION} for {slug}...") + print(f" URL: {url}") + + with tempfile.NamedTemporaryFile(suffix=".tar.xz", delete=False) as tmp: + tmp_path = tmp.name + + try: + subprocess.run( + ["curl", "-fSL", "-o", tmp_path, url], + check=True, + ) + + print("Extracting Envoy binary...") + with tarfile.open(tmp_path, "r:xz") as tar: + # Find the envoy binary inside the archive + envoy_member = None + for member in tar.getmembers(): + if member.name.endswith("/bin/envoy") or member.name == "bin/envoy": + envoy_member = member + break + + if envoy_member is None: + print("Error: Could not find envoy binary in the downloaded archive.") + print("Archive contents:") + for member in tar.getmembers(): + print(f" {member.name}") + sys.exit(1) + + # Extract just the binary + f = tar.extractfile(envoy_member) + if f is None: + print("Error: Could not extract envoy binary from archive.") + sys.exit(1) + + with open(envoy_path, "wb") as out: + out.write(f.read()) + + os.chmod(envoy_path, 0o755) + with open(version_path, "w") as f: + f.write(ENVOY_VERSION) + print(f"Envoy {ENVOY_VERSION} installed at {envoy_path}") + return envoy_path + + except subprocess.CalledProcessError as e: + print(f"Error downloading Envoy: {e}") + print(f"URL: {url}") + print("Please check your internet connection and try again.") + sys.exit(1) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def find_wasm_plugins(): + """Find WASM plugin files built from source. Returns (prompt_gateway_path, llm_gateway_path).""" + repo_root = find_repo_root() + if not repo_root: + print( + "Error: Could not find repository root. " + "Make sure you're inside the plano repository." + ) + sys.exit(1) + + wasm_dir = os.path.join(repo_root, "crates", "target", "wasm32-wasip1", "release") + prompt_gw = os.path.join(wasm_dir, "prompt_gateway.wasm") + llm_gw = os.path.join(wasm_dir, "llm_gateway.wasm") + + missing = [] + if not os.path.exists(prompt_gw): + missing.append("prompt_gateway.wasm") + if not os.path.exists(llm_gw): + missing.append("llm_gateway.wasm") + + if missing: + print(f"Error: WASM plugins not found: {', '.join(missing)}") + print(f" Expected at: {wasm_dir}/") + print(" Run 'planoai build --native' first to build them.") + sys.exit(1) + + return prompt_gw, llm_gw + + +def find_brightstaff_binary(): + """Find the brightstaff binary built from source. Returns path.""" + repo_root = find_repo_root() + if not repo_root: + print( + "Error: Could not find repository root. " + "Make sure you're inside the plano repository." + ) + sys.exit(1) + + brightstaff_path = os.path.join( + repo_root, "crates", "target", "release", "brightstaff" + ) + if not os.path.exists(brightstaff_path): + print(f"Error: brightstaff binary not found at {brightstaff_path}") + print(" Run 'planoai build --native' first to build it.") + sys.exit(1) + + return brightstaff_path diff --git a/cli/planoai/native_runner.py b/cli/planoai/native_runner.py new file mode 100644 index 00000000..997161f3 --- /dev/null +++ b/cli/planoai/native_runner.py @@ -0,0 +1,413 @@ +import contextlib +import io +import json +import os +import signal +import subprocess +import sys +import time + +from planoai.consts import ( + NATIVE_PID_FILE, + PLANO_RUN_DIR, +) +from planoai.docker_cli import health_check_endpoint +from planoai.native_binaries import ( + ensure_envoy_binary, + find_brightstaff_binary, + find_wasm_plugins, +) +from planoai.utils import find_repo_root, getLogger + +log = getLogger(__name__) + + +@contextlib.contextmanager +def _temporary_env(overrides): + """Context manager that sets env vars from *overrides* and restores originals on exit.""" + saved = {} + for key, value in overrides.items(): + saved[key] = os.environ.get(key) + os.environ[key] = value + try: + yield + finally: + for key, original in saved.items(): + if original is None: + os.environ.pop(key, None) + else: + os.environ[key] = original + + +def render_native_config(plano_config_file, env, with_tracing=False): + """Render envoy and plano configs for native mode. Returns (envoy_config_path, plano_config_rendered_path).""" + import yaml + + repo_root = find_repo_root() + if not repo_root: + print( + "Error: Could not find repository root. " + "Make sure you're inside the plano repository." + ) + sys.exit(1) + + os.makedirs(PLANO_RUN_DIR, exist_ok=True) + + prompt_gw_path, llm_gw_path = find_wasm_plugins() + + # If --with-tracing, inject tracing config if not already present + effective_config_file = os.path.abspath(plano_config_file) + if with_tracing: + with open(plano_config_file, "r") as f: + config_data = yaml.safe_load(f) + tracing = config_data.get("tracing", {}) + if not tracing.get("random_sampling"): + tracing["random_sampling"] = 100 + config_data["tracing"] = tracing + effective_config_file = os.path.join( + PLANO_RUN_DIR, "config_with_tracing.yaml" + ) + with open(effective_config_file, "w") as f: + yaml.dump(config_data, f, default_flow_style=False) + + envoy_config_path = os.path.join(PLANO_RUN_DIR, "envoy.yaml") + plano_config_rendered_path = os.path.join( + PLANO_RUN_DIR, "plano_config_rendered.yaml" + ) + + # Set environment variables that config_generator.validate_and_render_schema() reads + config_dir = os.path.join(repo_root, "config") + overrides = { + "PLANO_CONFIG_FILE": effective_config_file, + "PLANO_CONFIG_SCHEMA_FILE": os.path.join( + config_dir, "plano_config_schema.yaml" + ), + "TEMPLATE_ROOT": config_dir, + "ENVOY_CONFIG_TEMPLATE_FILE": "envoy.template.yaml", + "PLANO_CONFIG_FILE_RENDERED": plano_config_rendered_path, + "ENVOY_CONFIG_FILE_RENDERED": envoy_config_path, + } + + # Also propagate caller env vars (API keys, OTEL endpoint, etc.) + for key, value in env.items(): + if key not in overrides: + overrides[key] = value + + with _temporary_env(overrides): + from planoai.config_generator import validate_and_render_schema + + # Suppress verbose print output from config_generator + with contextlib.redirect_stdout(io.StringIO()): + validate_and_render_schema() + + # Post-process envoy.yaml: replace Docker WASM plugin paths with local paths + with open(envoy_config_path, "r") as f: + envoy_content = f.read() + + envoy_content = envoy_content.replace( + "/etc/envoy/proxy-wasm-plugins/prompt_gateway.wasm", prompt_gw_path + ) + envoy_content = envoy_content.replace( + "/etc/envoy/proxy-wasm-plugins/llm_gateway.wasm", llm_gw_path + ) + + # Replace /var/log/ paths with local log directory (non-root friendly) + log_dir = os.path.join(PLANO_RUN_DIR, "logs") + os.makedirs(log_dir, exist_ok=True) + envoy_content = envoy_content.replace("/var/log/", log_dir + "/") + + with open(envoy_config_path, "w") as f: + f.write(envoy_content) + + # Run envsubst-equivalent on both rendered files using the caller's env + with _temporary_env(env): + for filepath in [envoy_config_path, plano_config_rendered_path]: + with open(filepath, "r") as f: + content = f.read() + content = os.path.expandvars(content) + with open(filepath, "w") as f: + f.write(content) + + return envoy_config_path, plano_config_rendered_path + + +def start_native(plano_config_file, env, foreground=False, with_tracing=False): + """Start Envoy and brightstaff natively.""" + from planoai.core import _get_gateway_ports + + console = None + try: + from rich.console import Console + + console = Console() + except ImportError: + pass + + def status_print(msg): + if console: + console.print(msg) + else: + print(msg) + + envoy_path = ensure_envoy_binary() + find_wasm_plugins() # validate they exist + brightstaff_path = find_brightstaff_binary() + envoy_config_path, plano_config_rendered_path = render_native_config( + plano_config_file, env, with_tracing=with_tracing + ) + + status_print(f"[green]✓[/green] Configuration rendered") + + log_dir = os.path.join(PLANO_RUN_DIR, "logs") + os.makedirs(log_dir, exist_ok=True) + + log_level = env.get("LOG_LEVEL", "info") + + # Start brightstaff + brightstaff_env = os.environ.copy() + brightstaff_env["RUST_LOG"] = log_level + brightstaff_env["PLANO_CONFIG_PATH_RENDERED"] = plano_config_rendered_path + # Propagate API keys and other env vars + for key, value in env.items(): + brightstaff_env[key] = value + + brightstaff_pid = _daemon_exec( + [brightstaff_path], + brightstaff_env, + os.path.join(log_dir, "brightstaff.log"), + ) + log.info(f"Started brightstaff (PID {brightstaff_pid})") + + # Start envoy + envoy_pid = _daemon_exec( + [ + envoy_path, + "-c", + envoy_config_path, + "--component-log-level", + f"wasm:{log_level}", + "--log-format", + "[%Y-%m-%d %T.%e][%l] %v", + ], + brightstaff_env, + os.path.join(log_dir, "envoy.log"), + ) + log.info(f"Started envoy (PID {envoy_pid})") + + # Save PIDs + os.makedirs(PLANO_RUN_DIR, exist_ok=True) + with open(NATIVE_PID_FILE, "w") as f: + json.dump( + { + "envoy_pid": envoy_pid, + "brightstaff_pid": brightstaff_pid, + }, + f, + ) + + # Health check + gateway_ports = _get_gateway_ports(plano_config_file) + status_print(f"[dim]Waiting for listeners to become healthy...[/dim]") + + start_time = time.time() + timeout = 60 + while True: + all_healthy = True + for port in gateway_ports: + if not health_check_endpoint(f"http://localhost:{port}/healthz"): + all_healthy = False + + if all_healthy: + status_print(f"[green]✓[/green] Plano is running (native mode)") + for port in gateway_ports: + status_print(f" [cyan]http://localhost:{port}[/cyan]") + break + + # Check if processes are still alive + if not _is_pid_alive(brightstaff_pid): + status_print("[red]✗[/red] brightstaff exited unexpectedly") + status_print(f" Check logs: {os.path.join(log_dir, 'brightstaff.log')}") + _kill_pid(envoy_pid) + sys.exit(1) + + if not _is_pid_alive(envoy_pid): + status_print("[red]✗[/red] envoy exited unexpectedly") + status_print(f" Check logs: {os.path.join(log_dir, 'envoy.log')}") + _kill_pid(brightstaff_pid) + sys.exit(1) + + if time.time() - start_time > timeout: + status_print(f"[red]✗[/red] Health check timed out after {timeout}s") + status_print(f" Check logs in: {log_dir}") + stop_native() + sys.exit(1) + + time.sleep(1) + + if foreground: + status_print(f"[dim]Running in foreground. Press Ctrl+C to stop.[/dim]") + status_print(f"[dim]Logs: {log_dir}[/dim]") + try: + # Tail both log files + tail_proc = subprocess.Popen( + [ + "tail", + "-f", + os.path.join(log_dir, "envoy.log"), + os.path.join(log_dir, "brightstaff.log"), + ], + stdout=sys.stdout, + stderr=sys.stderr, + ) + tail_proc.wait() + except KeyboardInterrupt: + status_print(f"\n[dim]Stopping Plano...[/dim]") + if tail_proc.poll() is None: + tail_proc.terminate() + stop_native() + else: + status_print(f"[dim]Logs: {log_dir}[/dim]") + status_print(f"[dim]Run 'planoai down --native' to stop.[/dim]") + + +def _daemon_exec(args, env, log_path): + """Start a fully daemonized process via double-fork. Returns the child PID.""" + log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + + pid = os.fork() + if pid > 0: + # Parent: close our copy of the log fd and wait for intermediate child + os.close(log_fd) + os.waitpid(pid, 0) + # Read the grandchild PID from the pipe + grandchild_pid_path = os.path.join(PLANO_RUN_DIR, f".daemon_pid_{pid}") + deadline = time.time() + 5 + while time.time() < deadline: + if os.path.exists(grandchild_pid_path): + with open(grandchild_pid_path, "r") as f: + grandchild_pid = int(f.read().strip()) + os.unlink(grandchild_pid_path) + return grandchild_pid + time.sleep(0.05) + raise RuntimeError(f"Timed out waiting for daemon PID from {args[0]}") + + # First child: create new session and fork again + os.setsid() + grandchild_pid = os.fork() + if grandchild_pid > 0: + # Intermediate child: write grandchild PID and exit + pid_path = os.path.join(PLANO_RUN_DIR, f".daemon_pid_{os.getpid()}") + with open(pid_path, "w") as f: + f.write(str(grandchild_pid)) + os._exit(0) + + # Grandchild: this is the actual daemon + os.dup2(log_fd, 1) # stdout -> log + os.dup2(log_fd, 2) # stderr -> log + os.close(log_fd) + # Close stdin + devnull = os.open(os.devnull, os.O_RDONLY) + os.dup2(devnull, 0) + os.close(devnull) + + os.execve(args[0], args, env) + + +def _is_pid_alive(pid): + """Check if a process with the given PID is still running.""" + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True # Process exists but we can't signal it + + +def _kill_pid(pid): + """Send SIGTERM to a PID, ignoring errors.""" + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + pass + + +def stop_native(): + """Stop natively-running Envoy and brightstaff processes.""" + if not os.path.exists(NATIVE_PID_FILE): + print("No native Plano instance found (PID file missing).") + return + + with open(NATIVE_PID_FILE, "r") as f: + pids = json.load(f) + + envoy_pid = pids.get("envoy_pid") + brightstaff_pid = pids.get("brightstaff_pid") + + for name, pid in [("envoy", envoy_pid), ("brightstaff", brightstaff_pid)]: + if pid is None: + continue + try: + os.kill(pid, signal.SIGTERM) + log.info(f"Sent SIGTERM to {name} (PID {pid})") + except ProcessLookupError: + log.info(f"{name} (PID {pid}) already stopped") + continue + except PermissionError: + log.info(f"Permission denied stopping {name} (PID {pid})") + continue + + # Wait for graceful shutdown + deadline = time.time() + 10 + while time.time() < deadline: + try: + os.kill(pid, 0) # Check if still alive + time.sleep(0.5) + except ProcessLookupError: + break + else: + # Still alive after timeout, force kill + try: + os.kill(pid, signal.SIGKILL) + log.info(f"Sent SIGKILL to {name} (PID {pid})") + except ProcessLookupError: + pass + + os.unlink(NATIVE_PID_FILE) + print("Plano stopped (native mode).") + + +def native_validate_config(plano_config_file): + """Validate config in-process without Docker.""" + repo_root = find_repo_root() + if not repo_root: + print( + "Error: Could not find repository root. " + "Make sure you're inside the plano repository." + ) + sys.exit(1) + + config_dir = os.path.join(repo_root, "config") + + # Create temp dir for rendered output (we just want validation) + os.makedirs(PLANO_RUN_DIR, exist_ok=True) + + overrides = { + "PLANO_CONFIG_FILE": os.path.abspath(plano_config_file), + "PLANO_CONFIG_SCHEMA_FILE": os.path.join( + config_dir, "plano_config_schema.yaml" + ), + "TEMPLATE_ROOT": config_dir, + "ENVOY_CONFIG_TEMPLATE_FILE": "envoy.template.yaml", + "PLANO_CONFIG_FILE_RENDERED": os.path.join( + PLANO_RUN_DIR, "plano_config_rendered.yaml" + ), + "ENVOY_CONFIG_FILE_RENDERED": os.path.join(PLANO_RUN_DIR, "envoy.yaml"), + } + + with _temporary_env(overrides): + from planoai.config_generator import validate_and_render_schema + + # Suppress verbose print output from config_generator + with contextlib.redirect_stdout(io.StringIO()): + validate_and_render_schema() diff --git a/demos/native_run/README.md b/demos/native_run/README.md new file mode 100644 index 00000000..89956f1d --- /dev/null +++ b/demos/native_run/README.md @@ -0,0 +1,178 @@ +# Running Plano Natively (without Docker) + +Run Plano directly on your machine — no Docker required. Envoy is auto-downloaded on first run, and WASM plugins + brightstaff are compiled from source. + +## Prerequisites + +- **Rust** with the `wasm32-wasip1` target: + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + rustup target add wasm32-wasip1 + ``` +- **OpenSSL dev headers** (for brightstaff): + ```bash + # Debian/Ubuntu + sudo apt-get install libssl-dev pkg-config + + # macOS + brew install openssl + ``` +- **planoai CLI**: + ```bash + cd cli && uv sync + ``` + +## Quick Start + +```bash +# 1. Build WASM plugins and brightstaff from source +planoai build --native + +# 2. Set your API key (or create a .env file in this directory) +export OPENAI_API_KEY="sk-..." + +# 3. Start Plano +planoai up demos/native_run/config.yaml --native + +# 4. Send a request +curl -s http://localhost:12000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}]}' + +# 5. Stop Plano +planoai down --native +``` + +## Commands + +### `planoai build --native` + +Compiles the Rust crates from source: +- WASM plugins (`prompt_gateway.wasm`, `llm_gateway.wasm`) targeting `wasm32-wasip1` +- `brightstaff` binary (native target) + +Build artifacts are placed under `crates/target/`. + +### `planoai up --native` + +Starts Plano natively: +1. Validates the config file (in-process, no Docker) +2. Downloads Envoy if not cached at `~/.plano/bin/envoy` (from [tetratelabs/archive-envoy](https://github.com/tetratelabs/archive-envoy)) +3. Renders the Envoy config with local WASM plugin paths +4. Starts brightstaff and envoy as background daemons +5. Health-checks listener ports until ready + +Options: +- `--foreground` — stay attached and stream logs (Ctrl+C to stop) +- `--with-tracing` — start a local OTLP trace collector + +Runtime files are stored in `~/.plano/run/`: +``` +~/.plano/ +├── bin/ +│ ├── envoy # cached envoy binary +│ └── envoy.version # pinned version tag +└── run/ + ├── envoy.yaml # rendered envoy config + ├── arch_config_rendered.yaml + ├── plano.pid # process IDs for shutdown + └── logs/ + ├── envoy.log + ├── brightstaff.log + └── access_*.log +``` + +### `planoai down --native` + +Sends SIGTERM to envoy and brightstaff, waits for graceful shutdown, and cleans up the PID file. + +## Tracing + +The demo config includes `tracing: random_sampling: 100` which enables full trace collection. To view traces: + +```bash +# Start with tracing (starts an in-process OTLP collector on port 4317) +planoai up demos/native_run/config.yaml --native --with-tracing + +# Send a request +curl -s http://localhost:12000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}]}' + +# View the last trace +planoai trace + +# List all traces +planoai trace --list + +# View traces as JSON +planoai trace --json +``` + +If your config doesn't have a `tracing` section, `--with-tracing` automatically injects `random_sampling: 100` so traces are collected without any config changes. + +## Config Files + +### `config.yaml` — Server-side API key + +Plano injects the API key from the environment (or `.env` file). Clients don't need to send auth headers: + +```yaml +version: v0.3.0 +listeners: + egress_traffic: + port: 12000 +model_providers: + - name: openai-main + model: openai/gpt-4o + access_key: $OPENAI_API_KEY +tracing: + random_sampling: 100 +``` + +```bash +# No Authorization header needed — Plano injects it +curl -s http://localhost:12000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}]}' +``` + +### `config_passthrough.yaml` — Client-side API key + +Plano forwards the client's Authorization header to the provider as-is: + +```yaml +version: v0.3.0 +listeners: + egress_traffic: + port: 12000 +model_providers: + - name: openai-passthrough + model: openai/gpt-4o + passthrough_auth: true +``` + +```bash +# Client provides the key +curl -s http://localhost:12000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-..." \ + -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}]}' +``` + +## Supported Platforms + +| Platform | Architecture | Status | +|----------|-------------|--------| +| Linux | x86_64 | Supported | +| Linux | aarch64 | Supported | +| macOS | Apple Silicon (arm64) | Supported | +| macOS | Intel (x86_64) | Not available (no upstream Envoy binary) | + +## Automated Demo + +Run the full demo (build + start) with: + +```bash +./demo.sh +``` diff --git a/demos/native_run/config.yaml b/demos/native_run/config.yaml new file mode 100644 index 00000000..0dc2b8e1 --- /dev/null +++ b/demos/native_run/config.yaml @@ -0,0 +1,13 @@ +version: v0.3.0 + +listeners: + egress_traffic: + port: 12000 + +model_providers: + - name: openai-main + model: openai/gpt-4o + access_key: $OPENAI_API_KEY + +tracing: + random_sampling: 100 diff --git a/demos/native_run/config_passthrough.yaml b/demos/native_run/config_passthrough.yaml new file mode 100644 index 00000000..39409339 --- /dev/null +++ b/demos/native_run/config_passthrough.yaml @@ -0,0 +1,10 @@ +version: v0.3.0 + +listeners: + egress_traffic: + port: 12000 + +model_providers: + - name: openai-passthrough + model: openai/gpt-4o + passthrough_auth: true diff --git a/demos/native_run/demo.sh b/demos/native_run/demo.sh new file mode 100755 index 00000000..ae6ea3ae --- /dev/null +++ b/demos/native_run/demo.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Demo: Run Plano natively (without Docker) +# +# Prerequisites: +# - Rust toolchain with wasm32-wasip1 target: +# rustup target add wasm32-wasip1 +# - planoai CLI installed: +# cd cli && uv sync +# - OPENAI_API_KEY set in environment or .env file + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=== Plano Native Mode Demo ===" +echo "" + +# Check prerequisites +if ! command -v cargo &>/dev/null; then + echo "Error: cargo not found. Install Rust: https://rustup.rs" + exit 1 +fi + +if ! rustup target list --installed | grep -q wasm32-wasip1; then + echo "Error: wasm32-wasip1 target not installed." + echo " Run: rustup target add wasm32-wasip1" + exit 1 +fi + +if ! command -v planoai &>/dev/null; then + echo "Error: planoai CLI not found." + echo " Run: cd cli && uv sync && uv run planoai --help" + exit 1 +fi + +# Step 1: Build native artifacts +echo "Step 1: Building WASM plugins and brightstaff..." +planoai build --native +echo "" + +# Step 2: Start Plano natively +echo "Step 2: Starting Plano in native mode..." +echo " Config: ${SCRIPT_DIR}/config.yaml" +echo "" +planoai up "${SCRIPT_DIR}/config.yaml" --native --foreground