diff --git a/.github/workflows/publish-binaries.yml b/.github/workflows/publish-binaries.yml new file mode 100644 index 00000000..c211a5f7 --- /dev/null +++ b/.github/workflows/publish-binaries.yml @@ -0,0 +1,99 @@ +name: Publish pre-compiled binaries (release) + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build-wasm-plugins: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + + - name: Build WASM plugins + working-directory: crates + run: cargo build --release --target wasm32-wasip1 -p llm_gateway -p prompt_gateway + + - name: Upload WASM plugins to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + crates/target/wasm32-wasip1/release/prompt_gateway.wasm \ + crates/target/wasm32-wasip1/release/llm_gateway.wasm \ + --clobber + + build-brightstaff-linux-amd64: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build brightstaff + working-directory: crates + run: cargo build --release -p brightstaff + + - name: Upload brightstaff to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cp crates/target/release/brightstaff brightstaff-linux-amd64 + gh release upload "${{ github.event.release.tag_name }}" \ + brightstaff-linux-amd64 \ + --clobber + + build-brightstaff-linux-arm64: + runs-on: [linux-arm64] + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build brightstaff + working-directory: crates + run: cargo build --release -p brightstaff + + - name: Upload brightstaff to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cp crates/target/release/brightstaff brightstaff-linux-arm64 + gh release upload "${{ github.event.release.tag_name }}" \ + brightstaff-linux-arm64 \ + --clobber + + build-brightstaff-darwin-arm64: + runs-on: macos-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build brightstaff + working-directory: crates + run: cargo build --release -p brightstaff + + - name: Upload brightstaff to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cp crates/target/release/brightstaff brightstaff-darwin-arm64 + gh release upload "${{ github.event.release.tag_name }}" \ + brightstaff-darwin-arm64 \ + --clobber diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py index 8ef11c86..53cdd51c 100644 --- a/cli/planoai/consts.py +++ b/cli/planoai/consts.py @@ -16,3 +16,6 @@ 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" + +PLANO_GITHUB_REPO = "katanemo/archgw" +PLANO_RELEASE_BASE_URL = f"https://github.com/{PLANO_GITHUB_REPO}/releases/download" diff --git a/cli/planoai/main.py b/cli/planoai/main.py index 78002ec7..c3f941e7 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -262,12 +262,12 @@ def build(native): show_default=True, ) @click.option( - "--native", + "--docker", default=False, - help="Run Plano natively without Docker (requires prior 'planoai build --native').", + help="Run Plano inside Docker instead of natively.", is_flag=True, ) -def up(file, path, foreground, with_tracing, tracing_port, native): +def up(file, path, foreground, with_tracing, tracing_port, docker): """Starts Plano.""" from rich.status import Status @@ -284,11 +284,11 @@ def up(file, path, foreground, with_tracing, tracing_port, native): ) sys.exit(1) - if native: + if not docker: from planoai.native_runner import native_validate_config with Status( - "[dim]Validating configuration (native)[/dim]", + "[dim]Validating configuration[/dim]", spinner="dots", spinner_style="dim", ): @@ -303,7 +303,9 @@ def up(file, path, foreground, with_tracing, tracing_port, native): sys.exit(1) else: with Status( - "[dim]Validating configuration[/dim]", spinner="dots", spinner_style="dim" + "[dim]Validating configuration (Docker)[/dim]", + spinner="dots", + spinner_style="dim", ): ( validation_return_code, @@ -321,9 +323,9 @@ def up(file, path, foreground, with_tracing, tracing_port, native): # Set up environment default_otel = ( - DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT - if native - else DEFAULT_OTEL_TRACING_GRPC_ENDPOINT + DEFAULT_OTEL_TRACING_GRPC_ENDPOINT + if docker + else DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT ) env_stage = { "OTEL_TRACING_GRPC_ENDPOINT": default_otel, @@ -394,13 +396,13 @@ def up(file, path, foreground, with_tracing, tracing_port, native): sys.exit(1) # Update the OTEL endpoint so the gateway sends traces to the right port - tracing_host = "localhost" if native else "host.docker.internal" + tracing_host = "host.docker.internal" if docker else "localhost" otel_endpoint = f"http://{tracing_host}:{tracing_port}" env_stage["OTEL_TRACING_GRPC_ENDPOINT"] = otel_endpoint env.update(env_stage) try: - if native: + if not docker: from planoai.native_runner import start_native start_native( @@ -426,27 +428,28 @@ def up(file, path, foreground, with_tracing, tracing_port, native): @click.command() @click.option( - "--native", + "--docker", default=False, - help="Stop a natively-running Plano instance.", + help="Stop a Docker-based Plano instance.", is_flag=True, ) -def down(native): +def down(docker): """Stops Plano.""" console = _console() _print_cli_header(console) - if native: + if not docker: from planoai.native_runner import stop_native with console.status( - f"[{PLANO_COLOR}]Shutting down Plano (native)...[/{PLANO_COLOR}]", + f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", spinner="dots", ): stop_native() else: with console.status( - f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", spinner="dots" + f"[{PLANO_COLOR}]Shutting down Plano (Docker)...[/{PLANO_COLOR}]", + spinner="dots", ): stop_docker_container() diff --git a/cli/planoai/native_binaries.py b/cli/planoai/native_binaries.py index 6bb4af49..310c53be 100644 --- a/cli/planoai/native_binaries.py +++ b/cli/planoai/native_binaries.py @@ -5,9 +5,12 @@ import sys import tarfile import tempfile +import planoai from planoai.consts import ( ENVOY_VERSION, PLANO_BIN_DIR, + PLANO_PLUGINS_DIR, + PLANO_RELEASE_BASE_URL, ) from planoai.utils import find_repo_root, getLogger @@ -15,7 +18,7 @@ log = getLogger(__name__) def _get_platform_slug(): - """Return the platform slug for Envoy binary downloads.""" + """Return the platform slug for binary downloads.""" system = platform.system().lower() machine = platform.machine().lower() @@ -30,7 +33,7 @@ def _get_platform_slug(): 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)." + "Pre-built binaries are only available for Apple Silicon (arm64)." ) sys.exit(1) print( @@ -42,6 +45,20 @@ def _get_platform_slug(): return slug +def _download_file(url, dest): + """Download a file from *url* to *dest* using curl.""" + try: + subprocess.run( + ["curl", "-fSL", "-o", dest, url], + check=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error downloading: {e}") + print(f"URL: {url}") + print("Please check your internet connection and try again.") + sys.exit(1) + + 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") @@ -78,10 +95,7 @@ def ensure_envoy_binary(): tmp_path = tmp.name try: - subprocess.run( - ["curl", "-fSL", "-o", tmp_path, url], - check=True, - ) + _download_file(url, tmp_path) print("Extracting Envoy binary...") with tarfile.open(tmp_path, "r:xz") as tar: @@ -114,16 +128,86 @@ def ensure_envoy_binary(): 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 ensure_wasm_plugins(): + """Download WASM plugins if not cached or version changed. Returns (prompt_gateway_path, llm_gateway_path).""" + version = planoai.__version__ + version_path = os.path.join(PLANO_PLUGINS_DIR, "wasm.version") + + prompt_gw_path = os.path.join(PLANO_PLUGINS_DIR, "prompt_gateway.wasm") + llm_gw_path = os.path.join(PLANO_PLUGINS_DIR, "llm_gateway.wasm") + + if os.path.exists(prompt_gw_path) and os.path.exists(llm_gw_path): + if os.path.exists(version_path): + with open(version_path, "r") as f: + cached_version = f.read().strip() + if cached_version == version: + log.info(f"WASM plugins {version} found at {PLANO_PLUGINS_DIR}") + return prompt_gw_path, llm_gw_path + print( + f"WASM plugins version changed ({cached_version} → {version}), re-downloading..." + ) + else: + log.info("WASM plugins found (unknown version, re-downloading...)") + + os.makedirs(PLANO_PLUGINS_DIR, exist_ok=True) + + for name, dest in [ + ("prompt_gateway.wasm", prompt_gw_path), + ("llm_gateway.wasm", llm_gw_path), + ]: + url = f"{PLANO_RELEASE_BASE_URL}/{version}/{name}" + print(f"Downloading {name} ({version})...") + print(f" URL: {url}") + _download_file(url, dest) + print(f" Saved to {dest}") + + with open(version_path, "w") as f: + f.write(version) + + return prompt_gw_path, llm_gw_path + + +def ensure_brightstaff_binary(): + """Download brightstaff binary if not cached or version changed. Returns path to binary.""" + version = planoai.__version__ + brightstaff_path = os.path.join(PLANO_BIN_DIR, "brightstaff") + version_path = os.path.join(PLANO_BIN_DIR, "brightstaff.version") + + if os.path.exists(brightstaff_path) and os.access(brightstaff_path, os.X_OK): + if os.path.exists(version_path): + with open(version_path, "r") as f: + cached_version = f.read().strip() + if cached_version == version: + log.info(f"brightstaff {version} found at {brightstaff_path}") + return brightstaff_path + print( + f"brightstaff version changed ({cached_version} → {version}), re-downloading..." + ) + else: + log.info("brightstaff found (unknown version, re-downloading...)") + + slug = _get_platform_slug() + filename = f"brightstaff-{slug}" + url = f"{PLANO_RELEASE_BASE_URL}/{version}/{filename}" + + os.makedirs(PLANO_BIN_DIR, exist_ok=True) + + print(f"Downloading brightstaff ({version}) for {slug}...") + print(f" URL: {url}") + _download_file(url, brightstaff_path) + + os.chmod(brightstaff_path, 0o755) + with open(version_path, "w") as f: + f.write(version) + print(f"brightstaff {version} installed at {brightstaff_path}") + return brightstaff_path + + def find_wasm_plugins(): """Find WASM plugin files built from source. Returns (prompt_gateway_path, llm_gateway_path).""" repo_root = find_repo_root() diff --git a/cli/planoai/native_runner.py b/cli/planoai/native_runner.py index 997161f3..40bd0627 100644 --- a/cli/planoai/native_runner.py +++ b/cli/planoai/native_runner.py @@ -13,15 +13,41 @@ from planoai.consts import ( ) from planoai.docker_cli import health_check_endpoint from planoai.native_binaries import ( + ensure_brightstaff_binary, ensure_envoy_binary, - find_brightstaff_binary, - find_wasm_plugins, + ensure_wasm_plugins, ) from planoai.utils import find_repo_root, getLogger log = getLogger(__name__) +def _find_config_dir(): + """Locate the directory containing plano_config_schema.yaml and envoy.template.yaml. + + Checks package data first (pip-installed), then falls back to the repo checkout. + """ + import planoai + + pkg_data = os.path.join(os.path.dirname(planoai.__file__), "data") + if os.path.isdir(pkg_data) and os.path.exists( + os.path.join(pkg_data, "plano_config_schema.yaml") + ): + return pkg_data + + repo_root = find_repo_root() + if repo_root: + config_dir = os.path.join(repo_root, "config") + if os.path.isdir(config_dir): + return config_dir + + print( + "Error: Could not find config templates. " + "Make sure you're inside the plano repository or have the planoai package installed." + ) + sys.exit(1) + + @contextlib.contextmanager def _temporary_env(overrides): """Context manager that sets env vars from *overrides* and restores originals on exit.""" @@ -43,17 +69,9 @@ 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() + prompt_gw_path, llm_gw_path = ensure_wasm_plugins() # If --with-tracing, inject tracing config if not already present effective_config_file = os.path.abspath(plano_config_file) @@ -76,7 +94,7 @@ def render_native_config(plano_config_file, env, with_tracing=False): ) # Set environment variables that config_generator.validate_and_render_schema() reads - config_dir = os.path.join(repo_root, "config") + config_dir = _find_config_dir() overrides = { "PLANO_CONFIG_FILE": effective_config_file, "PLANO_CONFIG_SCHEMA_FILE": os.path.join( @@ -150,8 +168,8 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False): print(msg) envoy_path = ensure_envoy_binary() - find_wasm_plugins() # validate they exist - brightstaff_path = find_brightstaff_binary() + ensure_wasm_plugins() + brightstaff_path = ensure_brightstaff_binary() envoy_config_path, plano_config_rendered_path = render_native_config( plano_config_file, env, with_tracing=with_tracing ) @@ -267,7 +285,7 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False): stop_native() else: status_print(f"[dim]Logs: {log_dir}[/dim]") - status_print(f"[dim]Run 'planoai down --native' to stop.[/dim]") + status_print(f"[dim]Run 'planoai down' to stop.[/dim]") def _daemon_exec(args, env, log_path): @@ -379,15 +397,7 @@ def stop_native(): 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") + config_dir = _find_config_dir() # Create temp dir for rendered output (we just want validation) os.makedirs(PLANO_RUN_DIR, exist_ok=True) diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 2446fddb..e53f0bda 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -37,6 +37,10 @@ path = "planoai/__init__.py" [tool.hatch.build.targets.wheel] packages = ["planoai"] +[tool.hatch.build.targets.wheel.force-include] +"../config/plano_config_schema.yaml" = "planoai/data/plano_config_schema.yaml" +"../config/envoy.template.yaml" = "planoai/data/envoy.template.yaml" + [tool.hatch.build.targets.sdist] include = ["planoai/**"] diff --git a/docs/source/resources/deployment.rst b/docs/source/resources/deployment.rst index 13f30ede..8a44327f 100644 --- a/docs/source/resources/deployment.rst +++ b/docs/source/resources/deployment.rst @@ -3,7 +3,47 @@ Deployment ========== -Plano can be deployed in two ways: as a **Docker container** (default) or **natively** on the host without Docker. +Plano can be deployed in two ways: **natively** on the host (default) or inside a **Docker container**. + +Native Deployment (Default) +--------------------------- + +Plano runs natively by default. Pre-compiled binaries (Envoy, WASM plugins, brightstaff) are automatically downloaded on the first run and cached at ``~/.plano/``. + +Supported platforms: Linux (x86_64, aarch64), macOS (Apple Silicon). + +Start Plano +~~~~~~~~~~~~ + +.. code-block:: bash + + planoai up plano_config.yaml + +Options: + +- ``--foreground`` — stay attached and stream logs (Ctrl+C to stop) +- ``--with-tracing`` — start a local OTLP trace collector + +Runtime files (rendered configs, logs, PID file) are stored in ``~/.plano/run/``. + +Stop Plano +~~~~~~~~~~ + +.. code-block:: bash + + planoai down + +Build from Source (Developer) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to build from source instead of using pre-compiled binaries, you need: + +- `Rust `_ with the ``wasm32-wasip1`` target +- OpenSSL dev headers (``libssl-dev`` on Debian/Ubuntu, ``openssl`` on macOS) + +.. code-block:: bash + + planoai build --native Docker Deployment ----------------- @@ -53,47 +93,12 @@ Check container health and logs: docker compose ps docker compose logs -f plano -Native Deployment ------------------ - -Run Plano directly on the host without Docker. This is useful for development, platforms without Docker, or environments where you want to avoid container overhead. - -Prerequisites -~~~~~~~~~~~~~ - -- `Rust `_ with the ``wasm32-wasip1`` target -- OpenSSL dev headers (``libssl-dev`` on Debian/Ubuntu, ``openssl`` on macOS) -- Supported platforms: Linux (x86_64, aarch64), macOS (Apple Silicon) - -Build from Source -~~~~~~~~~~~~~~~~~ - -Compile the WASM plugins and brightstaff binary: +You can also use the CLI with Docker mode: .. code-block:: bash - planoai build --native - -Start Plano -~~~~~~~~~~~~ - -.. code-block:: bash - - planoai up plano_config.yaml --native - -Envoy is automatically downloaded on first run and cached at ``~/.plano/bin/``. Runtime files (rendered configs, logs, PID file) are stored in ``~/.plano/run/``. - -Options: - -- ``--foreground`` — stay attached and stream logs (Ctrl+C to stop) -- ``--with-tracing`` — start a local OTLP trace collector - -Stop Plano -~~~~~~~~~~ - -.. code-block:: bash - - planoai down --native + planoai up plano_config.yaml --docker + planoai down --docker Runtime Tests -------------