diff --git a/README.md b/README.md index f5b0775..d95bc95 100644 --- a/README.md +++ b/README.md @@ -22,25 +22,26 @@ cd && rig deploy | [`url-shortener/`](./url-shortener/) | Python · Django | the **full validated param set** (url/string/number/boolean/select/email/secret/textarea) + SQLite migrations | | [`markdown-notes/`](./markdown-notes/) | Python · Flask | **workspace volume-backed SQLite persistence** + Markdown rendering | -Every example deploys with the same command — `rig deploy`. Most rsync code and -run `install:` on the VM. Several declare a `Dockerfile`, which makes -`rig deploy` **freeze the environment into an image** and boot from it (see -**Docker builds & the hybrid deploy** below). No flag: the Dockerfile is the -signal. +Every example deploys with the same command — `rig deploy`. All of them rsync +code and describe their environment with `install:`. Several also set +`reproducible: true`, which makes `rig deploy` **freeze the result of `install:` +into an image** and boot from it (see **Reproducible deploys & the hybrid model** +below). No Dockerfile anywhere — the flag is the signal. ### Established products, run reproducibly These run real, recognizable self-hosted products on Rigbox via a -`FROM rigbox-base` Dockerfile that installs the product on top of the base. The -image freezes the install; `rig deploy` builds it once, then reuses it. (Upstream -images like `postgres:16` can't be booted directly — they lack the rigbox agent + -init — so each installs the product on the rigbox base instead.) +`reproducible: true` `install:` script that installs the product on top of the +Rigbox base. `rig deploy` runs the script once in a builder VM, freezes the +result as an image, then reuses it. (Upstream Docker images like `postgres:16` +can't be booted — they lack the rigbox agent + init — so each installs the +product on the rigbox base instead.) | Example | Product | What it shows | |---|---|---| -| [`code-server/`](./code-server/) | **code-server** (VS Code) | run an established product via a reproducible Dockerfile; settings/extensions persist under `$DATA_DIR` | +| [`code-server/`](./code-server/) | **code-server** (VS Code) | run an established product via a reproducible `install:`; settings/extensions persist under `$DATA_DIR` | | [`gitea/`](./gitea/) | **Gitea** (Git hosting) | a headless single-binary service (install wizard locked) with SQLite + repos under `$DATA_DIR` | -| [`n8n/`](./n8n/) | **n8n** (workflow automation) | freeze a heavy `npm install` into the image (`sizeMb` bump); workflows persist under `$DATA_DIR` | +| [`n8n/`](./n8n/) | **n8n** (workflow automation) | freeze a heavy `npm install` into the image; workflows persist under `$DATA_DIR` | ## Catalog apps @@ -86,23 +87,36 @@ The point of the suite is to model the *right* primitive for each job: `{ emails: [...] }`) so a redeploy keeps it — only an app's front door is public; siblings reach private apps over loopback via `dependsOn`. -## Docker builds & the hybrid deploy +## Reproducible deploys & the hybrid model -Most examples install their runtime on the VM with `install:`. The established -products — **`code-server`**, **`gitea`**, and **`n8n`** — instead **freeze their -environment into an image** with a `Dockerfile` (`FROM rigbox-base`). The command -is the same — `rig deploy` — and a Dockerfile in `rig.yaml` is all it takes to -switch on the image build (no flag): +Every example installs its runtime with `install:`. By default that script runs +on the workspace VM on each deploy. The established products — +**`code-server`**, **`gitea`**, **`n8n`**, and every [`catalog-apps/`](./catalog-apps/) +example — add one line, `reproducible: true`, which makes the same `install:` +**freeze into an image** instead. The command is the same — `rig deploy`: -- the **first** deploy builds the image from the local Dockerfile (the CLI uploads - the project dir as the build context — no git repo needed), boots from that - frozen image, and rsyncs the code; -- **later** deploys reuse the cached image when the Dockerfile/deps are unchanged - and **only rsync the changed code** — no rebuild, no re-install. +- the **first** deploy boots a throwaway builder VM from the `base` image, runs + `install:` inside it, snapshots the rootfs as a content-addressed image, boots + the workspace from that frozen image, and rsyncs the code; +- **later** deploys reuse the cached image when the build inputs (`install:` + script, base image, lockfiles) are unchanged and **only rsync the changed + code** — no rebuild, no re-install. That's the hybrid: build the slow, stable environment once; ride fast-changing -code over it with rsync. See [`design/CONTRACT.md`](./design/CONTRACT.md) → -*Reproducible builds* for the full rules and when to pick which. +code over it with rsync. `install:` runs as `developer` (with passwordless +`sudo`) in an **empty** deploy dir inside the builder — so it must be +self-contained (inline any config it needs via heredocs) and idempotent, since +the exact same script runs on the workspace VM when `reproducible` is off. +Runtime wrappers (`start.sh`) still rsync in with the code: `start: bash start.sh`. + +> **Builder sizing.** The builder VM boots with 1GB RAM / 1 vCPU and inherits +> the app's `workspace.resources.diskSizeMb` (3GB default, 16GB ceiling), so a +> heavy install just needs that value set high enough to hold it. The heavier +> examples (`n8n`, `firecrawl`, `open-webui`, `hermes-agent`, `excalidraw`) size +> themselves in `rig.yaml` and note their footprint in their README. + +See [`design/CONTRACT.md`](./design/CONTRACT.md) → *Reproducible builds* for the +full rules and when to pick which. ## Layout convention diff --git a/catalog-apps/README.md b/catalog-apps/README.md index 08056a3..9b76f6c 100644 --- a/catalog-apps/README.md +++ b/catalog-apps/README.md @@ -2,7 +2,7 @@ Standalone reproductions of every app in the [Rigbox catalog](https://docs.rigbox.dev/) — same product the catalog installs, but shipped as a regular `rig deploy`-able example you can fork, modify, and run on its own. -These live in their own subtree so they don't crowd the [curated example suite](../README.md) at the repo root (todo-app, quickstart, ai-chat, bluegreen-blog, …). Each catalog app uses the same `FROM rigbox-base` Dockerfile pattern as `gitea/`, `n8n/`, `code-server/`. +These live in their own subtree so they don't crowd the [curated example suite](../README.md) at the repo root (todo-app, quickstart, ai-chat, bluegreen-blog, …). Each catalog app uses the same `reproducible: true` + `install:` pattern as `gitea/`, `n8n/`, `code-server/` — the install script is frozen into an image once and reused by later deploys. ## Service apps — [`service/`](./service/) @@ -17,7 +17,7 @@ Apps that expose a port + health probe, accessed in a browser at the workspace's | [`service/excalidraw/`](./service/excalidraw/) | **Excalidraw** | virtual whiteboard, vite-built static SPA frozen into the image | | [`service/filebrowser/`](./service/filebrowser/) | **File Browser** | pinned single Go binary serving `$DATA_DIR` over a web UI | | [`service/openterminal/`](./service/openterminal/) | **Open Terminal** | sandboxed REST API for shell + filesystem, designed for AI agents; key materialised on boot | -| [`service/firecrawl/`](./service/firecrawl/) | **Firecrawl** | self-hosted web-scraping API — Postgres-17 + Redis + RabbitMQ + Chromium frozen in a 12GB ext4 image | +| [`service/firecrawl/`](./service/firecrawl/) | **Firecrawl** | self-hosted web-scraping API — Postgres-17 + Redis + RabbitMQ + Chromium frozen into one reproducible image | | [`service/hermes-agent/`](./service/hermes-agent/) | **Hermes Agent** | self-improving agent + a separate user-bus messaging gateway bridging Telegram/Discord/Slack/WhatsApp/Signal | | [`service/t3code/`](./service/t3code/) | **T3 Code** | multiple browser-tabbed coding agents (Claude Code, Codex, …) behind one web UI | | [`service/open-webui/`](./service/open-webui/) | **Open WebUI** | full-featured chat UI fronted by the Rigbox managed AI proxy — every chat metered against workspace credits | @@ -33,7 +33,7 @@ AI coding CLIs/TUIs accessed by SSHing into the workspace. Each declares `kind: | [`cli/codex/`](./cli/codex/) | **Codex CLI** | OpenAI's Codex CLI with one-rename `OPENROUTER_* → OPENAI_*` shim (OpenRouter is OpenAI-compatible at `/v1`) | | [`cli/opencode/`](./cli/opencode/) | **OpenCode** | OSS Go-based TUI from sst/opencode with **native** `OPENROUTER_API_KEY` (no translation shim) | | [`cli/junie/`](./cli/junie/) | **Junie** | JetBrains' AI agent with a heavy postinstall archive frozen into the image once | -| [`cli/kilocode/`](./cli/kilocode/) | **Kilo Code** | multi-provider CLI (100+ providers) pinned at image-build time to OpenRouter | +| [`cli/kilocode/`](./cli/kilocode/) | **Kilo Code** | multi-provider CLI (100+ providers) pinned at install time to the managed AI proxy | | [`cli/pi/`](./cli/pi/) | **Pi** | OpenRouter-native CLI coding agent (`@mariozechner/pi-coding-agent`); SSH in and run `pi` | ## Deploy diff --git a/catalog-apps/cli/claude/Dockerfile b/catalog-apps/cli/claude/Dockerfile deleted file mode 100644 index 150a7b7..0000000 --- a/catalog-apps/cli/claude/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM rigbox-base -# Bake Anthropic's Claude Code CLI into the image so deploys don't re-download -# the binary on every boot. The upstream installer drops it at -# ~developer/.local/bin/claude; we symlink to /usr/local/bin so it's on the -# standard SSH PATH (login shells, ForceCommand, scripted execs). -# -# The install + onboarding bypass mirrors the catalog's claude/install.sh so -# this image and the catalog-installed shape stay byte-compatible. - -# 1. Install the CLI as the developer user (no secrets baked in; the installer -# fetches the latest published version at image build time). -RUN su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - curl -fsSL https://claude.ai/install.sh | bash; \ - test -x "$HOME/.local/bin/claude"; \ - mkdir -p "$HOME/.claude"; \ - printf "%s\n" "{\"hasCompletedOnboarding\":true,\"bypassPermissionsModeAccepted\":true}" \ - > "$HOME/.claude.json"; \ - printf "%s\n" "{\"theme\":\"dark\",\"permissions\":{\"defaultMode\":\"bypassPermissions\",\"dangerouslySkipPermissions\":true}}" \ - > "$HOME/.claude/settings.json" \ - ' \ - && ln -sfn /home/developer/.local/bin/claude /usr/local/bin/claude - -# 2. Wire Claude Code to an AI backend at every interactive login shell. The -# routing script points ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN at the -# workspace's managed AI proxy (Anthropic /v1/messages shape) by default, or -# at OpenRouter if the operator brings their own OPENROUTER_API_KEY. SSHing -# in is enough to activate it — no per-session `export` dance. -COPY claude-routing.sh /etc/profile.d/claude-routing.sh -RUN chmod 644 /etc/profile.d/claude-routing.sh diff --git a/catalog-apps/cli/claude/README.md b/catalog-apps/cli/claude/README.md index 8589283..eca3c9d 100644 --- a/catalog-apps/cli/claude/README.md +++ b/catalog-apps/cli/claude/README.md @@ -7,28 +7,29 @@ codebase from a single terminal session, with model routing through Rigbox's **managed AI proxy** — no API key to set, so deploying is never blocked on a local secret. -## The single capability: a long-lived AI-agent workspace, baked into an image +## The single capability: a long-lived AI-agent workspace, frozen into an image The whole point here is running an **AI coding CLI on a persistent VM** instead of locally — your repo, history, and `.claude/` config survive across sessions -and across deploys. The `Dockerfile` is `FROM rigbox-base` (the required base — -the platform asserts the rigbox agent + systemd are present and rejects any -other base at build time) and bakes the upstream installer's binary into the -image once: - -```dockerfile -FROM rigbox-base -RUN su - developer -s /bin/bash -c '...curl -fsSL https://claude.ai/install.sh | bash...' -RUN ln -sfn /home/developer/.local/bin/claude /usr/local/bin/claude -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +and across deploys. `rig.yaml` sets `reproducible: true`, so `rig deploy` runs +the `install:` script once in a builder VM, freezes the result as an image, and +later deploys boot from it instead of re-running the upstream installer: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + curl -fsSL https://claude.ai/install.sh | bash # → ~/.local/bin/claude + sudo ln -sfn "$HOME/.local/bin/claude" /usr/local/bin/claude + sudo tee /etc/profile.d/claude-routing.sh <<'EOF' + … # managed-AI routing, below + EOF ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the system-path steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + ## SSH-in to use it Claude Code is a TUI — there is **no web UI**. The app is declared with @@ -40,7 +41,7 @@ ssh "$(rig workspace ssh-info --workspace --output json | jq -r .ss claude ``` -The onboarding wizard is pre-accepted at image-build time +The onboarding wizard is pre-accepted at install time (`~/.claude.json` + `~/.claude/settings.json`), so `claude` drops you straight into a session — no first-launch prompts. @@ -54,11 +55,12 @@ ai: ``` Claude Code reads `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. The managed -proxy serves the Anthropic `/v1/messages` shape, and the image's -`/etc/profile.d/claude-routing.sh` points Claude at it on every shell start: +proxy serves the Anthropic `/v1/messages` shape, and the +`/etc/profile.d/claude-routing.sh` that `install:` writes points Claude at it on +every shell start: ```sh -# baked into the image, sourced by every login shell +# written by install:, sourced by every login shell . ~/.rigbox/proxy.env # OPENAI_BASE_URL=/v1, OPENAI_API_KEY= export ANTHROPIC_BASE_URL="${OPENAI_BASE_URL%/v1}" # Claude appends /v1/messages itself export ANTHROPIC_AUTH_TOKEN="${OPENAI_API_KEY}" @@ -87,4 +89,4 @@ default). - **No public UI.** `kind: cli` means there's no HTTP front door at all — the workspace is reachable only via SSH on the rigbox gateway. - **Onboarding bypass.** `hasCompletedOnboarding` + `bypassPermissionsModeAccepted` - are written at image-build time so `claude` is non-interactive on first launch. + are written by `install:` so `claude` is non-interactive on first launch. diff --git a/catalog-apps/cli/claude/claude-routing.sh b/catalog-apps/cli/claude/claude-routing.sh deleted file mode 100644 index be96f69..0000000 --- a/catalog-apps/cli/claude/claude-routing.sh +++ /dev/null @@ -1,31 +0,0 @@ -# Managed by rigbox-examples (claude). Wires Claude Code to an AI backend at -# every interactive login shell, so SSHing in is enough to activate routing. -# Claude Code reads ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. -case ":${PATH}:" in - *":${HOME:-/home/developer}/.local/bin:"*) ;; - *) export PATH="${HOME:-/home/developer}/.local/bin:${PATH}" ;; -esac -if [ -n "${OPENROUTER_API_KEY:-}" ]; then - # Bring-your-own-key: route straight at OpenRouter (Anthropic /v1/messages - # shape). ANTHROPIC_BASE_URL must omit the trailing /v1 — Claude Code appends - # /v1/messages itself. - _rb_base="${OPENROUTER_BASE_URL:-https://openrouter.ai/api}" - _rb_base="${_rb_base%/v1}" - export ANTHROPIC_BASE_URL="${_rb_base%/}" - export ANTHROPIC_AUTH_TOKEN="${OPENROUTER_API_KEY}" - unset _rb_base -elif [ -r "${HOME:-/home/developer}/.rigbox/proxy.env" ]; then - # Managed AI: every managed workspace gets ~/.rigbox/proxy.env exporting - # OPENAI_BASE_URL=/v1 and OPENAI_API_KEY=. Strip the /v1 - # to get the proxy root Claude Code's Anthropic client expects. - . "${HOME:-/home/developer}/.rigbox/proxy.env" - export ANTHROPIC_BASE_URL="${OPENAI_BASE_URL%/v1}" - export ANTHROPIC_AUTH_TOKEN="${OPENAI_API_KEY}" -fi -if [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ]; then - # When both AUTH_TOKEN and API_KEY are set Claude Code prefers API_KEY, which - # would silently bypass our routing — clear it explicitly. - export ANTHROPIC_API_KEY="" -fi -export CLAUDE_CODE_SKIP_ONBOARDING=1 -export CLAUDE_CODE_ENABLE_TELEMETRY=0 diff --git a/catalog-apps/cli/claude/rig.yaml b/catalog-apps/cli/claude/rig.yaml index cc3d773..46a6fa9 100644 --- a/catalog-apps/cli/claude/rig.yaml +++ b/catalog-apps/cli/claude/rig.yaml @@ -6,11 +6,67 @@ kind: cli # the agent has comfortable headroom for repo-wide edits and node_modules. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Bake the CLI into an image: `rig deploy` runs `install:` once in a builder VM +# and later deploys boot from the cached image instead of re-running the +# upstream installer. The script runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # 1. Install the CLI as the developer user (no secrets baked in; the installer + # fetches the latest published version). It drops the binary at + # ~/.local/bin/claude; symlink to /usr/local/bin so it's on the standard + # SSH PATH (login shells, ForceCommand, scripted execs). Pre-accept the + # onboarding wizard so the first `claude` is non-interactive — mirrors the + # catalog's claude/install.sh so both shapes stay byte-compatible. + curl -fsSL https://claude.ai/install.sh | bash + test -x "$HOME/.local/bin/claude" + mkdir -p "$HOME/.claude" + printf '%s\n' '{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true}' > "$HOME/.claude.json" + printf '%s\n' '{"theme":"dark","permissions":{"defaultMode":"bypassPermissions","dangerouslySkipPermissions":true}}' > "$HOME/.claude/settings.json" + sudo ln -sfn "$HOME/.local/bin/claude" /usr/local/bin/claude + + # 2. Wire Claude Code to an AI backend at every interactive login shell. The + # routing script points ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN at the + # workspace's managed AI proxy (Anthropic /v1/messages shape) by default, + # or at OpenRouter if the operator brings their own OPENROUTER_API_KEY. + # SSHing in is enough to activate it — no per-session `export` dance. + sudo tee /etc/profile.d/claude-routing.sh >/dev/null <<'EOF' + # Managed by rigbox-examples (claude). Wires Claude Code to an AI backend at + # every interactive login shell, so SSHing in is enough to activate routing. + # Claude Code reads ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. + case ":${PATH}:" in + *":${HOME:-/home/developer}/.local/bin:"*) ;; + *) export PATH="${HOME:-/home/developer}/.local/bin:${PATH}" ;; + esac + if [ -n "${OPENROUTER_API_KEY:-}" ]; then + # Bring-your-own-key: route straight at OpenRouter (Anthropic /v1/messages + # shape). ANTHROPIC_BASE_URL must omit the trailing /v1 — Claude Code appends + # /v1/messages itself. + _rb_base="${OPENROUTER_BASE_URL:-https://openrouter.ai/api}" + _rb_base="${_rb_base%/v1}" + export ANTHROPIC_BASE_URL="${_rb_base%/}" + export ANTHROPIC_AUTH_TOKEN="${OPENROUTER_API_KEY}" + unset _rb_base + elif [ -r "${HOME:-/home/developer}/.rigbox/proxy.env" ]; then + # Managed AI: every managed workspace gets ~/.rigbox/proxy.env exporting + # OPENAI_BASE_URL=/v1 and OPENAI_API_KEY=. Strip the /v1 + # to get the proxy root Claude Code's Anthropic client expects. + . "${HOME:-/home/developer}/.rigbox/proxy.env" + export ANTHROPIC_BASE_URL="${OPENAI_BASE_URL%/v1}" + export ANTHROPIC_AUTH_TOKEN="${OPENAI_API_KEY}" + fi + if [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ]; then + # When both AUTH_TOKEN and API_KEY are set Claude Code prefers API_KEY, which + # would silently bypass our routing — clear it explicitly. + export ANTHROPIC_API_KEY="" + fi + export CLAUDE_CODE_SKIP_ONBOARDING=1 + export CLAUDE_CODE_ENABLE_TELEMETRY=0 + EOF + sudo chmod 644 /etc/profile.d/claude-routing.sh # Opt into the Rigbox managed AI proxy — no OpenRouter key to set, so deploying # this example is never blocked on a local secret. Claude Code reads -# ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN; the image's +# ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN; the baked # /etc/profile.d/claude-routing.sh points those at the workspace's managed # proxy (which serves the Anthropic /v1/messages shape) at every shell start, # so an SSH session sees `claude` wired up. Bring-your-own-key still works: diff --git a/catalog-apps/cli/codex/Dockerfile b/catalog-apps/cli/codex/Dockerfile deleted file mode 100644 index 23e3c6a..0000000 --- a/catalog-apps/cli/codex/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM rigbox-base -# Bake OpenAI's Codex CLI into the image so deploys don't re-run `npm install` -# on every boot. @openai/codex publishes a global CLI; the base image already -# points npm's global prefix at ~/.npm-global, so the install lands at -# ~developer/.npm-global/bin/codex. We symlink to /usr/local/bin so it's on -# the standard SSH PATH (login shells, ForceCommand, scripted execs). -# -# The install mirrors the catalog's codex/install.sh so this image and the -# catalog-installed shape stay byte-compatible. - -# 1. Install the CLI as the developer user. -RUN su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - npm install -g --no-fund --silent @openai/codex; \ - command -v codex >/dev/null 2>&1 \ - ' \ - && ln -sfn /home/developer/.npm-global/bin/codex /usr/local/bin/codex - -# 2. Codex 0.137+ reads its provider from ~/.codex/config.toml (not env), so the -# routing script only makes sure $OPENAI_API_KEY (the proxy placeholder) is -# present for the config's env_key. Sourced by every interactive login shell. -COPY codex-routing.sh /etc/profile.d/codex-routing.sh -RUN chmod 644 /etc/profile.d/codex-routing.sh - -# 3. Bake the managed-AI provider config so `codex` works on first SSH with no -# key to set: a custom provider over the Responses wire pointed at the managed -# AI proxy (plain HTTP — no websocket). Owned by developer so the CLI reads it. -RUN install -d -o developer -g developer -m 755 /home/developer/.codex -COPY --chown=developer:developer config.toml /home/developer/.codex/config.toml diff --git a/catalog-apps/cli/codex/README.md b/catalog-apps/cli/codex/README.md index eea7f6b..38fafa2 100644 --- a/catalog-apps/cli/codex/README.md +++ b/catalog-apps/cli/codex/README.md @@ -7,28 +7,33 @@ small, fast, and natively speaks the OpenAI API, which makes it trivial to point at Rigbox's **managed AI proxy** (an OpenAI-compatible endpoint) — no API key to set, so deploying is never blocked on a local secret. -## The single capability: an OpenAI-shaped AI agent baked into an image +## The single capability: an OpenAI-shaped AI agent frozen into an image The whole point here is **running an OpenAI-API-compatible AI CLI on a persistent VM** — your repo, history, and `~/.codex/` config survive across -sessions and across deploys. The `Dockerfile` is `FROM rigbox-base` (the -required base — the platform asserts the rigbox agent + systemd are present and -rejects any other base at build time) and bakes the npm-published binary into -the image once: - -```dockerfile -FROM rigbox-base -RUN su - developer -s /bin/bash -c 'npm install -g --no-fund --silent @openai/codex' -RUN ln -sfn /home/developer/.npm-global/bin/codex /usr/local/bin/codex -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +sessions and across deploys. `rig.yaml` sets `reproducible: true`, so +`rig deploy` runs the `install:` script once in a builder VM, freezes the +result as an image, and later deploys boot from it instead of re-running +`npm install`: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + npm install -g --no-fund --silent @openai/codex # → ~/.npm-global/bin/codex + sudo ln -sfn "$HOME/.npm-global/bin/codex" /usr/local/bin/codex + sudo tee /etc/profile.d/codex-routing.sh <<'EOF' + … # supplies $OPENAI_API_KEY, below + EOF + cat > "$HOME/.codex/config.toml" <<'EOF' + … # managed-AI provider, below + EOF ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the system-path steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + ## SSH-in to use it Codex CLI is a TUI — there is **no web UI**. The app is declared with @@ -50,7 +55,7 @@ ai: ``` Codex 0.137+ reads its provider from `~/.codex/config.toml`, not env vars, and -only speaks the Responses wire. The image bakes a config that declares the +only speaks the Responses wire. `install:` writes a config that declares the managed proxy as a custom provider (plain HTTP, no websocket): ```toml @@ -63,9 +68,9 @@ env_key = "OPENAI_API_KEY" wire_api = "responses" ``` -The API key comes from `$OPENAI_API_KEY`; `/etc/profile.d/codex-routing.sh` -sources the managed proxy's `~/.rigbox/proxy.env` to supply the placeholder. An -SSH session just works — no key, no `export` dance. +The API key comes from `$OPENAI_API_KEY`; the `/etc/profile.d/codex-routing.sh` +that `install:` writes sources the managed proxy's `~/.rigbox/proxy.env` to +supply the placeholder. An SSH session just works — no key, no `export` dance. ## Deploy diff --git a/catalog-apps/cli/codex/codex-routing.sh b/catalog-apps/cli/codex/codex-routing.sh deleted file mode 100644 index 1d69730..0000000 --- a/catalog-apps/cli/codex/codex-routing.sh +++ /dev/null @@ -1,8 +0,0 @@ -# Managed by rigbox-examples (codex). Codex 0.137+ reads its provider from -# ~/.codex/config.toml (a custom provider pointed at the managed AI proxy over -# the Responses wire — see config.toml) and the API key from $OPENAI_API_KEY. -# Make sure that key is present by sourcing the managed proxy's env file, which -# every managed workspace ships. Sourced by every interactive login shell. -if [ -z "${OPENAI_API_KEY:-}" ] && [ -r "${HOME:-/home/developer}/.rigbox/proxy.env" ]; then - . "${HOME:-/home/developer}/.rigbox/proxy.env" -fi diff --git a/catalog-apps/cli/codex/config.toml b/catalog-apps/cli/codex/config.toml deleted file mode 100644 index 63c2b89..0000000 --- a/catalog-apps/cli/codex/config.toml +++ /dev/null @@ -1,15 +0,0 @@ -# Routes the Codex CLI (v0.137+) through the Rigbox managed AI proxy. Codex no -# longer reads OPENAI_BASE_URL and only speaks the Responses wire, so the proxy -# is declared as a custom provider here. Omitting `supports_websockets` (default -# false) forces plain HTTP `POST /responses` instead of the built-in -# openai provider's wss:// transport. The API key comes from $OPENAI_API_KEY, -# which the managed proxy's ~/.rigbox/proxy.env sets to the placeholder -# (the proxy authenticates by source IP, so the value is a sentinel). -model = "anthropic/claude-sonnet-4.5" -model_provider = "rigbox" - -[model_providers.rigbox] -name = "Rigbox Managed AI" -base_url = "http://172.16.0.1:9090/v1" -env_key = "OPENAI_API_KEY" -wire_api = "responses" diff --git a/catalog-apps/cli/codex/rig.yaml b/catalog-apps/cli/codex/rig.yaml index 3d1f93d..5a6027c 100644 --- a/catalog-apps/cli/codex/rig.yaml +++ b/catalog-apps/cli/codex/rig.yaml @@ -4,12 +4,67 @@ kind: cli # declares that explicitly: no HTTP port, no health probe, no start command. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Freeze the CLI into an image: `rig deploy` runs `install:` once in a builder +# VM and later deploys boot from the cached image instead of re-running +# `npm install`. The script runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # OpenAI's Codex CLI. @openai/codex publishes a global CLI; the base image + # already points npm's global prefix at ~/.npm-global, so the install lands + # at ~/.npm-global/bin/codex. We symlink to /usr/local/bin so it's on the + # standard SSH PATH (login shells, ForceCommand, scripted execs). + # + # The install mirrors the catalog's codex/install.sh so this image and the + # catalog-installed shape stay byte-compatible. + + # 1. Install the CLI as the developer user. + npm install -g --no-fund --silent @openai/codex + command -v codex >/dev/null 2>&1 + sudo ln -sfn "$HOME/.npm-global/bin/codex" /usr/local/bin/codex + + # 2. Codex 0.137+ reads its provider from ~/.codex/config.toml (not env), so the + # routing script only makes sure $OPENAI_API_KEY (the proxy placeholder) is + # present for the config's env_key. Sourced by every interactive login shell. + sudo tee /etc/profile.d/codex-routing.sh >/dev/null <<'EOF' + # Managed by rigbox-examples (codex). Codex 0.137+ reads its provider from + # ~/.codex/config.toml (a custom provider pointed at the managed AI proxy over + # the Responses wire — see step 3 of install:) and the API key from + # $OPENAI_API_KEY. Make sure that key is present by sourcing the managed + # proxy's env file, which every managed workspace ships. Sourced by every + # interactive login shell. + if [ -z "${OPENAI_API_KEY:-}" ] && [ -r "${HOME:-/home/developer}/.rigbox/proxy.env" ]; then + . "${HOME:-/home/developer}/.rigbox/proxy.env" + fi + EOF + sudo chmod 644 /etc/profile.d/codex-routing.sh + + # 3. Write the managed-AI provider config so `codex` works on first SSH with no + # key to set: a custom provider over the Responses wire pointed at the managed + # AI proxy (plain HTTP — no websocket). Owned by developer so the CLI reads it. + mkdir -p "$HOME/.codex" + cat > "$HOME/.codex/config.toml" <<'EOF' + # Routes the Codex CLI (v0.137+) through the Rigbox managed AI proxy. Codex no + # longer reads OPENAI_BASE_URL and only speaks the Responses wire, so the proxy + # is declared as a custom provider here. Omitting `supports_websockets` (default + # false) forces plain HTTP `POST /responses` instead of the built-in + # openai provider's wss:// transport. The API key comes from $OPENAI_API_KEY, + # which the managed proxy's ~/.rigbox/proxy.env sets to the placeholder + # (the proxy authenticates by source IP, so the value is a sentinel). + model = "anthropic/claude-sonnet-4.5" + model_provider = "rigbox" + + [model_providers.rigbox] + name = "Rigbox Managed AI" + base_url = "http://172.16.0.1:9090/v1" + env_key = "OPENAI_API_KEY" + wire_api = "responses" + EOF # Opt into the Rigbox managed AI proxy — no OpenRouter key to set, so deploying # this example is never blocked on a local secret. Codex 0.137+ reads its -# provider from a baked ~/.codex/config.toml (a custom provider over the -# Responses wire pointed at the managed proxy); the routing script just supplies -# the placeholder $OPENAI_API_KEY it expects, so `codex` works on first SSH. +# provider from the ~/.codex/config.toml that `install:` writes (a custom +# provider over the Responses wire pointed at the managed proxy); the routing +# script just supplies the placeholder $OPENAI_API_KEY it expects, so `codex` +# works on first SSH. ai: managed: true diff --git a/catalog-apps/cli/junie/Dockerfile b/catalog-apps/cli/junie/Dockerfile deleted file mode 100644 index a278273..0000000 --- a/catalog-apps/cli/junie/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -FROM rigbox-base -# Bake JetBrains' Junie CLI into the image so deploys don't re-run npm install -# (and its postinstall JetBrains-bundle download) on every boot. The base -# image already points npm's global prefix at ~/.npm-global, so the install -# lands at ~developer/.npm-global/bin/junie. We symlink to /usr/local/bin so -# it's on the standard SSH PATH. -# -# The install mirrors the catalog's junie/install.sh so this image and the -# catalog-installed shape stay byte-compatible. - -# 1. Install the CLI as the developer user. --silent suppresses npm's -# postinstall download progress so the image build log stays readable. -RUN su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - export NPM_CONFIG_PREFIX="$HOME/.npm-global"; \ - mkdir -p "$NPM_CONFIG_PREFIX/bin"; \ - export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"; \ - npm install -g --no-fund --silent @jetbrains/junie-cli; \ - "$NPM_CONFIG_PREFIX/bin/junie" --version >/dev/null \ - ' \ - && ln -sfn /home/developer/.npm-global/bin/junie /usr/local/bin/junie - -# 2. Select the baked managed-AI profile at every shell start -# (JUNIE_MODEL=custom:rigbox) and keep the npm-global bin on PATH. Junie -# ignores the generic OPENAI_* env, so the actual backend is the custom LLM -# profile baked in step 3. -COPY junie-routing.sh /etc/profile.d/junie-routing.sh -RUN chmod 644 /etc/profile.d/junie-routing.sh - -# 3. Bake the managed-AI custom LLM profile so `junie` works on first SSH with -# no key to set: an OpenAI-compatible profile pointed at the managed AI -# proxy. junie-routing.sh selects it via JUNIE_MODEL=custom:rigbox. The -# filename (minus .json) is the profile id. Owned by developer to read. -RUN install -d -o developer -g developer -m 755 /home/developer/.junie/models -COPY --chown=developer:developer rigbox.json /home/developer/.junie/models/rigbox.json diff --git a/catalog-apps/cli/junie/README.md b/catalog-apps/cli/junie/README.md index bbfac16..78705df 100644 --- a/catalog-apps/cli/junie/README.md +++ b/catalog-apps/cli/junie/README.md @@ -3,31 +3,36 @@ Runs [**Junie**](https://www.jetbrains.com/junie/) — JetBrains' AI coding agent — on Rigbox. You SSH in and run `junie`. Junie distinguishes itself with a JetBrains-bundled binary that ships with the npm package, so the install pulls -a chunky postinstall archive — by baking it into a `FROM rigbox-base` image -once, every deploy avoids the slow re-download. +a chunky postinstall archive — by freezing it into a reproducible image once, +every deploy avoids the slow re-download. ## The single capability: a heavy postinstall, frozen into the image The whole point here is **freezing a slow install once** so deploys boot fast. Junie's npm package runs a postinstall step that fetches a JetBrains-bundled -binary archive — easily 30–60 seconds on a fresh VM. The `Dockerfile` is -`FROM rigbox-base` (the required base — the platform asserts the rigbox agent -+ systemd are present and rejects any other base at build time) and bakes the -fully-installed CLI into the image once: - -```dockerfile -FROM rigbox-base -RUN su - developer -s /bin/bash -c 'npm install -g --no-fund --silent @jetbrains/junie-cli' -RUN ln -sfn /home/developer/.npm-global/bin/junie /usr/local/bin/junie -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +binary archive — easily 30–60 seconds on a fresh VM. `rig.yaml` sets +`reproducible: true`, so `rig deploy` runs the `install:` script once in a +builder VM, freezes the fully-installed CLI as an image, and later deploys boot +from it instead of re-running npm install: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + npm install -g --no-fund --silent @jetbrains/junie-cli # → ~/.npm-global/bin/junie + sudo ln -sfn "$NPM_CONFIG_PREFIX/bin/junie" /usr/local/bin/junie + sudo tee /etc/profile.d/junie-routing.sh <<'EOF' + … # selects the profile, below + EOF + cat > "$HOME/.junie/models/rigbox.json" <<'EOF' + … # managed-AI LLM profile, below + EOF ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the system-path steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + ## SSH-in to use it Junie is a TUI — there is **no web UI**. The app is declared with `kind: cli`, @@ -39,10 +44,10 @@ ssh "$(rig workspace ssh-info --workspace --output json | jq -r .ss junie ``` -## Managed AI via a baked custom LLM profile +## Managed AI via a custom LLM profile Junie ignores the generic `OPENAI_*` env, so the AI backend is a **custom LLM -profile**: the image bakes `~/.junie/models/rigbox.json` (OpenAI-compatible, +profile**: `install:` writes `~/.junie/models/rigbox.json` (OpenAI-compatible, pointed at the managed proxy) and selects it at every shell start. ```jsonc @@ -52,7 +57,7 @@ pointed at the managed proxy) and selects it at every shell start. ``` ```sh -# /etc/profile.d/junie-routing.sh, sourced by every login shell +# /etc/profile.d/junie-routing.sh — written by install:, sourced by every login shell export JUNIE_MODEL="custom:rigbox" ``` diff --git a/catalog-apps/cli/junie/junie-routing.sh b/catalog-apps/cli/junie/junie-routing.sh deleted file mode 100644 index 0a8062c..0000000 --- a/catalog-apps/cli/junie/junie-routing.sh +++ /dev/null @@ -1,9 +0,0 @@ -# Managed by rigbox-examples (junie). Junie ignores the generic OPENAI_* env, so -# the AI backend is a baked custom LLM profile at ~/.junie/models/rigbox.json -# (OpenAI-compatible, pointed at the workspace's managed AI proxy). Select it at -# every login and keep the npm-global bin on PATH for non-interactive shells. -case ":${PATH}:" in - *":${HOME:-/home/developer}/.npm-global/bin:"*) ;; - *) export PATH="${HOME:-/home/developer}/.npm-global/bin:${PATH}" ;; -esac -export JUNIE_MODEL="custom:rigbox" diff --git a/catalog-apps/cli/junie/rig.yaml b/catalog-apps/cli/junie/rig.yaml index 64acfc4..cd06828 100644 --- a/catalog-apps/cli/junie/rig.yaml +++ b/catalog-apps/cli/junie/rig.yaml @@ -6,11 +6,62 @@ kind: cli # npm CLIs) — leaves comfortable headroom for project repos on top. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 4096 } -build: - dockerfile: Dockerfile +# Freeze the CLI (and its chunky postinstall JetBrains-bundle download) into an +# image: `rig deploy` runs `install:` once in a builder VM and later deploys +# boot from the cached image instead of re-running npm install. The script +# runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # JetBrains' Junie CLI. The base image already points npm's global prefix at + # ~/.npm-global, so the install lands at ~/.npm-global/bin/junie. We symlink + # to /usr/local/bin so it's on the standard SSH PATH. + # + # The install mirrors the catalog's junie/install.sh so this image and the + # catalog-installed shape stay byte-compatible. + + # 1. Install the CLI as the developer user. --silent suppresses npm's + # postinstall download progress so the install log stays readable. + export NPM_CONFIG_PREFIX="$HOME/.npm-global" + mkdir -p "$NPM_CONFIG_PREFIX/bin" + export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" + npm install -g --no-fund --silent @jetbrains/junie-cli + "$NPM_CONFIG_PREFIX/bin/junie" --version >/dev/null + sudo ln -sfn "$NPM_CONFIG_PREFIX/bin/junie" /usr/local/bin/junie + + # 2. Select the managed-AI profile at every shell start + # (JUNIE_MODEL=custom:rigbox) and keep the npm-global bin on PATH. Junie + # ignores the generic OPENAI_* env, so the actual backend is the custom LLM + # profile written in step 3. + sudo tee /etc/profile.d/junie-routing.sh >/dev/null <<'EOF' + # Managed by rigbox-examples (junie). Junie ignores the generic OPENAI_* env, so + # the AI backend is a custom LLM profile at ~/.junie/models/rigbox.json + # (OpenAI-compatible, pointed at the workspace's managed AI proxy). Select it at + # every login and keep the npm-global bin on PATH for non-interactive shells. + case ":${PATH}:" in + *":${HOME:-/home/developer}/.npm-global/bin:"*) ;; + *) export PATH="${HOME:-/home/developer}/.npm-global/bin:${PATH}" ;; + esac + export JUNIE_MODEL="custom:rigbox" + EOF + sudo chmod 644 /etc/profile.d/junie-routing.sh + + # 3. Write the managed-AI custom LLM profile so `junie` works on first SSH with + # no key to set: an OpenAI-compatible profile pointed at the managed AI + # proxy. junie-routing.sh selects it via JUNIE_MODEL=custom:rigbox. The + # filename (minus .json) is the profile id. Owned by developer to read. + mkdir -p "$HOME/.junie/models" + cat > "$HOME/.junie/models/rigbox.json" <<'EOF' + { + "id": "anthropic/claude-sonnet-4.5", + "baseUrl": "http://172.16.0.1:9090/v1/chat/completions", + "apiType": "OpenAICompletion", + "apiKey": "managed-by-rigbox" + } + EOF # Opt into the Rigbox managed AI proxy — no OpenRouter key to set, so deploying # this example is never blocked on a local secret. Junie ignores the generic -# OPENAI_* env, so the image bakes a custom LLM profile at +# OPENAI_* env, so `install:` writes a custom LLM profile at # ~/.junie/models/rigbox.json (OpenAI-compatible, pointed at the managed proxy) # and /etc/profile.d/junie-routing.sh selects it via JUNIE_MODEL=custom:rigbox. ai: diff --git a/catalog-apps/cli/junie/rigbox.json b/catalog-apps/cli/junie/rigbox.json deleted file mode 100644 index 4c38698..0000000 --- a/catalog-apps/cli/junie/rigbox.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "anthropic/claude-sonnet-4.5", - "baseUrl": "http://172.16.0.1:9090/v1/chat/completions", - "apiType": "OpenAICompletion", - "apiKey": "managed-by-rigbox" -} diff --git a/catalog-apps/cli/kilocode/Dockerfile b/catalog-apps/cli/kilocode/Dockerfile deleted file mode 100644 index a18b2bc..0000000 --- a/catalog-apps/cli/kilocode/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -FROM rigbox-base -# Bake Kilo Code's CLI into the image so deploys don't re-run npm install on -# every boot. @kilocode/cli is a multi-provider AI coding agent — 100+ -# providers behind one binary, OpenRouter included. The base image already -# points npm's global prefix at ~/.npm-global, so the install lands at -# ~developer/.npm-global/bin/kilocode (older releases used `kilo`). We -# symlink whichever exists to /usr/local/bin so it's on the standard SSH PATH. -# -# The install mirrors the catalog's kilocode/install.sh so this image and the -# catalog-installed shape stay byte-compatible. - -# 1. Install the CLI as the developer user. -RUN su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - npm install -g --no-fund --silent @kilocode/cli; \ - command -v kilocode >/dev/null 2>&1 || command -v kilo >/dev/null 2>&1 \ - ' \ - && if [ -x /home/developer/.npm-global/bin/kilocode ]; then \ - ln -sfn /home/developer/.npm-global/bin/kilocode /usr/local/bin/kilocode ; \ - else \ - ln -sfn /home/developer/.npm-global/bin/kilo /usr/local/bin/kilocode ; \ - fi - -# 2. Force the baked provider active at every shell start (KILO_PROVIDER= -# openai-compatible). The provider's base URL, key, and model live in the -# config baked in step 3 — Kilo CLI 1.0 (an OpenCode fork) configures -# providers in a file, not via the pre-1.0 KILO_* env vars. -COPY kilocode-routing.sh /etc/profile.d/kilocode-routing.sh -RUN chmod 644 /etc/profile.d/kilocode-routing.sh - -# 3. Bake the managed-AI provider config (Kilo CLI 1.0 = OpenCode fork): an -# openai-compatible provider pointed at the managed AI proxy, so `kilocode` -# works on first SSH with no key to set. Owned by developer so the CLI can -# read it (and cache beside it). -RUN install -d -o developer -g developer -m 755 /home/developer/.config/kilo -COPY --chown=developer:developer opencode.json /home/developer/.config/kilo/opencode.json diff --git a/catalog-apps/cli/kilocode/README.md b/catalog-apps/cli/kilocode/README.md index 3948a91..57a9d1d 100644 --- a/catalog-apps/cli/kilocode/README.md +++ b/catalog-apps/cli/kilocode/README.md @@ -3,31 +3,36 @@ Runs [**Kilo Code**](https://kilocode.ai) — an all-in-one AI coding CLI that fronts 100+ providers behind one binary — on Rigbox. You SSH in and run `kilocode`. Kilo CLI 1.0 is an [OpenCode](https://opencode.ai) fork, so its -provider, base URL, key, and model live in a config file — the image bakes one -pointed at Rigbox's **managed AI proxy**, with no API key to set. +provider, base URL, key, and model live in a config file — `install:` writes +one pointed at Rigbox's **managed AI proxy**, with no API key to set. ## The single capability: a multi-provider agent, zero-key managed AI The whole point here is **running a provider-agnostic AI CLI** wired up at -image-build time so a fresh SSH session is immediately ready. The `Dockerfile` -is `FROM rigbox-base` (the required base — the platform asserts the rigbox -agent + systemd are present and rejects any other base at build time), bakes -the npm-published CLI into the image, and drops a config file that points Kilo -at the workspace's managed AI proxy: - -```dockerfile -FROM rigbox-base -RUN su - developer -s /bin/bash -c 'npm install -g --no-fund --silent @kilocode/cli' -RUN ln -sfn /home/developer/.npm-global/bin/kilocode /usr/local/bin/kilocode -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +install time so a fresh SSH session is immediately ready. `rig.yaml` sets +`reproducible: true`, so `rig deploy` runs the `install:` script once in a +builder VM — it installs the npm-published CLI and drops a config file that +points Kilo at the workspace's managed AI proxy — freezes the result as an +image, and later deploys boot from it: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + npm install -g --no-fund --silent @kilocode/cli # → ~/.npm-global/bin/kilocode + sudo ln -sfn "$HOME/.npm-global/bin/kilocode" /usr/local/bin/kilocode + sudo tee /etc/profile.d/kilocode-routing.sh <<'EOF' + … # KILO_PROVIDER, below + EOF + cat > "$HOME/.config/kilo/opencode.json" <<'EOF' + … # managed-AI provider, below + EOF ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the system-path steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + ## SSH-in to use it Kilo Code is a TUI — there is **no web UI**. The app is declared with @@ -39,11 +44,11 @@ ssh "$(rig workspace ssh-info --workspace --output json | jq -r .ss kilocode ``` -## Managed AI via a baked provider config +## Managed AI via a provider config Kilo CLI 1.0 (an OpenCode fork) configures providers in a file, not env vars — the pre-1.0 `KILO_PROVIDER_TYPE` / `KILO_OPEN_ROUTER_API_KEY` vars no longer -exist. The image bakes `~/.config/kilo/opencode.json` with an +exist. `install:` writes `~/.config/kilo/opencode.json` with an `openai-compatible` provider pointed at the managed proxy: ```jsonc @@ -57,8 +62,9 @@ exist. The image bakes `~/.config/kilo/opencode.json` with an } ``` -`/etc/profile.d/kilocode-routing.sh` just exports `KILO_PROVIDER=openai-compatible` -so a stray interactive selection can't shadow the baked provider. +The `/etc/profile.d/kilocode-routing.sh` that `install:` writes just exports +`KILO_PROVIDER=openai-compatible` so a stray interactive selection can't shadow +the configured provider. ## Deploy @@ -77,6 +83,6 @@ default). redeploys. - **No public UI.** `kind: cli` means there's no HTTP front door at all — the workspace is reachable only via SSH on the rigbox gateway. -- **Pinned at image-build time.** The baked `opencode.json` selects the managed - proxy and model; edit it (or drop a project-level `opencode.json`) to point - Kilo at a different provider or model. +- **Pinned at install time.** The `opencode.json` that `install:` writes + selects the managed proxy and model; edit it (or drop a project-level + `opencode.json`) to point Kilo at a different provider or model. diff --git a/catalog-apps/cli/kilocode/kilocode-routing.sh b/catalog-apps/cli/kilocode/kilocode-routing.sh deleted file mode 100644 index a5ec196..0000000 --- a/catalog-apps/cli/kilocode/kilocode-routing.sh +++ /dev/null @@ -1,7 +0,0 @@ -# Managed by rigbox-examples (kilocode). Kilo CLI 1.0 is an OpenCode fork: the -# provider, base URL, key, and model all live in the baked -# ~/.config/kilo/opencode.json (an openai-compatible provider pointed at the -# workspace's managed AI proxy). The pre-1.0 KILO_PROVIDER_TYPE / -# KILO_OPEN_ROUTER_API_KEY env vars no longer exist. We force the configured -# provider active so a stray interactive selection can't shadow it. -export KILO_PROVIDER="openai-compatible" diff --git a/catalog-apps/cli/kilocode/opencode.json b/catalog-apps/cli/kilocode/opencode.json deleted file mode 100644 index 4065372..0000000 --- a/catalog-apps/cli/kilocode/opencode.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://app.kilo.ai/config.json", - "model": "openai-compatible/anthropic/claude-sonnet-4.5", - "provider": { - "openai-compatible": { - "options": { - "apiKey": "managed-by-rigbox", - "baseURL": "http://172.16.0.1:9090/v1" - }, - "models": { - "anthropic/claude-sonnet-4.5": { - "name": "Claude Sonnet 4.5 (Rigbox managed AI)", - "tool_call": true, - "limit": { "context": 200000, "output": 16384 } - } - } - } - } -} diff --git a/catalog-apps/cli/kilocode/rig.yaml b/catalog-apps/cli/kilocode/rig.yaml index 11f87a5..ebe0f14 100644 --- a/catalog-apps/cli/kilocode/rig.yaml +++ b/catalog-apps/cli/kilocode/rig.yaml @@ -4,11 +4,74 @@ kind: cli # declares that explicitly: no HTTP port, no health probe, no start command. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Freeze the CLI into an image: `rig deploy` runs `install:` once in a builder +# VM and later deploys boot from the cached image instead of re-running +# npm install. The script runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # Kilo Code's CLI. @kilocode/cli is a multi-provider AI coding agent — 100+ + # providers behind one binary, OpenRouter included. The base image already + # points npm's global prefix at ~/.npm-global, so the install lands at + # ~/.npm-global/bin/kilocode (older releases used `kilo`). We symlink + # whichever exists to /usr/local/bin so it's on the standard SSH PATH. + # + # The install mirrors the catalog's kilocode/install.sh so this image and the + # catalog-installed shape stay byte-compatible. + + # 1. Install the CLI as the developer user. + npm install -g --no-fund --silent @kilocode/cli + command -v kilocode >/dev/null 2>&1 || command -v kilo >/dev/null 2>&1 + if [ -x "$HOME/.npm-global/bin/kilocode" ]; then + sudo ln -sfn "$HOME/.npm-global/bin/kilocode" /usr/local/bin/kilocode + else + sudo ln -sfn "$HOME/.npm-global/bin/kilo" /usr/local/bin/kilocode + fi + + # 2. Force the configured provider active at every shell start (KILO_PROVIDER= + # openai-compatible). The provider's base URL, key, and model live in the + # config written in step 3 — Kilo CLI 1.0 (an OpenCode fork) configures + # providers in a file, not via the pre-1.0 KILO_* env vars. + sudo tee /etc/profile.d/kilocode-routing.sh >/dev/null <<'EOF' + # Managed by rigbox-examples (kilocode). Kilo CLI 1.0 is an OpenCode fork: the + # provider, base URL, key, and model all live in + # ~/.config/kilo/opencode.json (an openai-compatible provider pointed at the + # workspace's managed AI proxy). The pre-1.0 KILO_PROVIDER_TYPE / + # KILO_OPEN_ROUTER_API_KEY env vars no longer exist. We force the configured + # provider active so a stray interactive selection can't shadow it. + export KILO_PROVIDER="openai-compatible" + EOF + sudo chmod 644 /etc/profile.d/kilocode-routing.sh + + # 3. Write the managed-AI provider config (Kilo CLI 1.0 = OpenCode fork): an + # openai-compatible provider pointed at the managed AI proxy, so `kilocode` + # works on first SSH with no key to set. Owned by developer so the CLI can + # read it (and cache beside it). + mkdir -p "$HOME/.config/kilo" + cat > "$HOME/.config/kilo/opencode.json" <<'EOF' + { + "$schema": "https://app.kilo.ai/config.json", + "model": "openai-compatible/anthropic/claude-sonnet-4.5", + "provider": { + "openai-compatible": { + "options": { + "apiKey": "managed-by-rigbox", + "baseURL": "http://172.16.0.1:9090/v1" + }, + "models": { + "anthropic/claude-sonnet-4.5": { + "name": "Claude Sonnet 4.5 (Rigbox managed AI)", + "tool_call": true, + "limit": { "context": 200000, "output": 16384 } + } + } + } + } + } + EOF # Opt into the Rigbox managed AI proxy — no OpenRouter key to set, so deploying # this example is never blocked on a local secret. Kilo CLI 1.0 is an OpenCode -# fork; the image bakes an ~/.config/kilo/opencode.json that registers an +# fork; `install:` writes an ~/.config/kilo/opencode.json that registers an # openai-compatible provider pointed at the managed proxy, so `kilocode` works # on first SSH. ai: diff --git a/catalog-apps/cli/opencode/Dockerfile b/catalog-apps/cli/opencode/Dockerfile deleted file mode 100644 index 4eee060..0000000 --- a/catalog-apps/cli/opencode/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM rigbox-base -# Bake OpenCode (https://opencode.ai) into the image so deploys don't re-run -# the upstream installer on every boot. OpenCode is a Go-based TUI agent — -# the installer drops a single static binary at $HOME/.opencode/bin/opencode -# (earlier releases used $HOME/.local/bin). We accept either, then symlink -# whichever exists to /usr/local/bin so it's on the standard SSH PATH. -# -# The install mirrors the catalog's opencode/install.sh so this image and the -# catalog-installed shape stay byte-compatible. - -# 1. Install the CLI as the developer user. -RUN su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - curl -fsSL https://opencode.ai/install | bash; \ - [ -x "$HOME/.opencode/bin/opencode" ] || [ -x "$HOME/.local/bin/opencode" ] \ - ' \ - && if [ -x /home/developer/.opencode/bin/opencode ]; then \ - ln -sfn /home/developer/.opencode/bin/opencode /usr/local/bin/opencode ; \ - else \ - ln -sfn /home/developer/.local/bin/opencode /usr/local/bin/opencode ; \ - fi - -# 2. Make ~/.opencode/bin and ~/.local/bin reachable from non-interactive -# login shells too (catalog scripts, sshd ForceCommand, ...) so a fresh SSH -# session sees `opencode` on PATH without depending on per-user rc state. -# The AI backend is configured in the baked config (step 3), not via env. -COPY opencode-routing.sh /etc/profile.d/opencode-routing.sh -RUN chmod 644 /etc/profile.d/opencode-routing.sh - -# 3. Bake the managed-AI provider config so `opencode` works on first SSH with -# no key to set: a custom OpenAI-compatible provider pointed at the managed -# AI proxy. Owned by developer so the CLI can read it (and cache beside it). -RUN install -d -o developer -g developer -m 755 /home/developer/.config/opencode -COPY --chown=developer:developer opencode.json /home/developer/.config/opencode/opencode.json diff --git a/catalog-apps/cli/opencode/README.md b/catalog-apps/cli/opencode/README.md index 76f56b4..fe4bcf7 100644 --- a/catalog-apps/cli/opencode/README.md +++ b/catalog-apps/cli/opencode/README.md @@ -2,33 +2,39 @@ Runs [**OpenCode**](https://opencode.ai) — the open-source, terminal-first AI coding agent — on Rigbox. It's a single static Go binary, ships as a TUI, and -routes through Rigbox's **managed AI proxy** via a baked provider config — no -API key to set. You SSH in and run `opencode`. +routes through Rigbox's **managed AI proxy** via a provider config written at +install time — no API key to set. You SSH in and run `opencode`. ## The single capability: a single-binary OSS agent, zero-key managed AI The whole point here is **running the OSS terminal agent on a persistent VM** -with no glue code. The image bakes an `opencode.json` that registers a custom +with no glue code. `install:` writes an `opencode.json` that registers a custom OpenAI-compatible provider pointed at the workspace's managed AI proxy, so `opencode` works on first SSH — nothing to set, no key to forward. -The `Dockerfile` is `FROM rigbox-base` (the required base — the platform asserts -the rigbox agent + systemd are present and rejects any other base at build -time) and bakes the upstream Go binary into the image once: - -```dockerfile -FROM rigbox-base -RUN su - developer -s /bin/bash -c 'curl -fsSL https://opencode.ai/install | bash' -RUN ln -sfn /home/developer/.opencode/bin/opencode /usr/local/bin/opencode -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +`rig.yaml` sets `reproducible: true`, so `rig deploy` runs the `install:` +script once in a builder VM, freezes the upstream Go binary (and that config) +as an image, and later deploys boot from it instead of re-running the +installer: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + curl -fsSL https://opencode.ai/install | bash # → ~/.opencode/bin/opencode + sudo ln -sfn "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode + sudo tee /etc/profile.d/opencode-routing.sh <<'EOF' + … # PATH for login shells, below + EOF + cat > "$HOME/.config/opencode/opencode.json" <<'EOF' + … # managed-AI provider, below + EOF ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the system-path steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + ## SSH-in to use it OpenCode is a TUI — there is **no web UI**. The app is declared with @@ -40,10 +46,10 @@ ssh "$(rig workspace ssh-info --workspace --output json | jq -r .ss opencode ``` -## Managed AI via a baked provider config +## Managed AI via a provider config OpenCode's provider, base URL, key, and model live in a config file, not env -vars. The image bakes `~/.config/opencode/opencode.json` with a custom +vars. `install:` writes `~/.config/opencode/opencode.json` with a custom `@ai-sdk/openai-compatible` provider pointed at the managed proxy: ```jsonc @@ -58,8 +64,9 @@ vars. The image bakes `~/.config/opencode/opencode.json` with a custom } ``` -`/etc/profile.d/opencode-routing.sh` only puts `~/.opencode/bin` on `PATH` for -non-interactive login shells — the AI wiring is entirely in the config file. +The `/etc/profile.d/opencode-routing.sh` that `install:` writes only puts +`~/.opencode/bin` on `PATH` for non-interactive login shells — the AI wiring is +entirely in the config file. ## Deploy diff --git a/catalog-apps/cli/opencode/opencode-routing.sh b/catalog-apps/cli/opencode/opencode-routing.sh deleted file mode 100644 index f3cbafa..0000000 --- a/catalog-apps/cli/opencode/opencode-routing.sh +++ /dev/null @@ -1,17 +0,0 @@ -# Managed by rigbox-examples (opencode). The AI backend is configured in the -# baked ~/.config/opencode/opencode.json (a custom OpenAI-compatible provider -# pointed at the workspace's managed AI proxy), so no env translation is needed -# here. This script only ensures the opencode binary is on PATH for -# non-interactive login shells (catalog scripts, sshd ForceCommand, ...): the -# upstream installer drops it at ~/.opencode/bin (or, on older releases, -# ~/.local/bin) and only patches per-user rc files. -for _rb_bin in "${HOME:-/home/developer}/.opencode/bin" "${HOME:-/home/developer}/.local/bin"; do - if [ -d "$_rb_bin" ]; then - case ":${PATH}:" in - *":$_rb_bin:"*) ;; - *) PATH="$_rb_bin:$PATH" ;; - esac - fi -done -unset _rb_bin -export PATH diff --git a/catalog-apps/cli/opencode/opencode.json b/catalog-apps/cli/opencode/opencode.json deleted file mode 100644 index 19d01dc..0000000 --- a/catalog-apps/cli/opencode/opencode.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "model": "rigbox/anthropic/claude-sonnet-4.5", - "provider": { - "rigbox": { - "npm": "@ai-sdk/openai-compatible", - "name": "Rigbox Managed AI", - "options": { - "baseURL": "http://172.16.0.1:9090/v1", - "apiKey": "managed-by-rigbox" - }, - "models": { - "anthropic/claude-sonnet-4.5": { - "name": "Claude Sonnet 4.5 (Rigbox managed AI)" - } - } - } - } -} diff --git a/catalog-apps/cli/opencode/rig.yaml b/catalog-apps/cli/opencode/rig.yaml index 7e10892..9100ba1 100644 --- a/catalog-apps/cli/opencode/rig.yaml +++ b/catalog-apps/cli/opencode/rig.yaml @@ -4,10 +4,81 @@ kind: cli # declares that explicitly: no HTTP port, no health probe, no start command. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Freeze the CLI into an image: `rig deploy` runs `install:` once in a builder +# VM and later deploys boot from the cached image instead of re-running the +# upstream installer. The script runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # OpenCode (https://opencode.ai) is a Go-based TUI agent — the installer + # drops a single static binary at $HOME/.opencode/bin/opencode (earlier + # releases used $HOME/.local/bin). We accept either, then symlink whichever + # exists to /usr/local/bin so it's on the standard SSH PATH. + # + # The install mirrors the catalog's opencode/install.sh so this image and the + # catalog-installed shape stay byte-compatible. + + # 1. Install the CLI as the developer user. + curl -fsSL https://opencode.ai/install | bash + [ -x "$HOME/.opencode/bin/opencode" ] || [ -x "$HOME/.local/bin/opencode" ] + if [ -x "$HOME/.opencode/bin/opencode" ]; then + sudo ln -sfn "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode + else + sudo ln -sfn "$HOME/.local/bin/opencode" /usr/local/bin/opencode + fi + + # 2. Make ~/.opencode/bin and ~/.local/bin reachable from non-interactive + # login shells too (catalog scripts, sshd ForceCommand, ...) so a fresh SSH + # session sees `opencode` on PATH without depending on per-user rc state. + # The AI backend is configured in the config written in step 3, not via env. + sudo tee /etc/profile.d/opencode-routing.sh >/dev/null <<'EOF' + # Managed by rigbox-examples (opencode). The AI backend is configured in + # ~/.config/opencode/opencode.json (a custom OpenAI-compatible provider + # pointed at the workspace's managed AI proxy), so no env translation is needed + # here. This script only ensures the opencode binary is on PATH for + # non-interactive login shells (catalog scripts, sshd ForceCommand, ...): the + # upstream installer drops it at ~/.opencode/bin (or, on older releases, + # ~/.local/bin) and only patches per-user rc files. + for _rb_bin in "${HOME:-/home/developer}/.opencode/bin" "${HOME:-/home/developer}/.local/bin"; do + if [ -d "$_rb_bin" ]; then + case ":${PATH}:" in + *":$_rb_bin:"*) ;; + *) PATH="$_rb_bin:$PATH" ;; + esac + fi + done + unset _rb_bin + export PATH + EOF + sudo chmod 644 /etc/profile.d/opencode-routing.sh + + # 3. Write the managed-AI provider config so `opencode` works on first SSH with + # no key to set: a custom OpenAI-compatible provider pointed at the managed + # AI proxy. Owned by developer so the CLI can read it (and cache beside it). + mkdir -p "$HOME/.config/opencode" + cat > "$HOME/.config/opencode/opencode.json" <<'EOF' + { + "$schema": "https://opencode.ai/config.json", + "model": "rigbox/anthropic/claude-sonnet-4.5", + "provider": { + "rigbox": { + "npm": "@ai-sdk/openai-compatible", + "name": "Rigbox Managed AI", + "options": { + "baseURL": "http://172.16.0.1:9090/v1", + "apiKey": "managed-by-rigbox" + }, + "models": { + "anthropic/claude-sonnet-4.5": { + "name": "Claude Sonnet 4.5 (Rigbox managed AI)" + } + } + } + } + } + EOF # Opt into the Rigbox managed AI proxy — no OpenRouter key to set, so deploying -# this example is never blocked on a local secret. The image bakes an +# this example is never blocked on a local secret. `install:` writes an # ~/.config/opencode/opencode.json that registers a custom OpenAI-compatible # provider pointed at the managed proxy, so `opencode` works on first SSH. ai: diff --git a/catalog-apps/cli/pi/Dockerfile b/catalog-apps/cli/pi/Dockerfile deleted file mode 100644 index 7914c63..0000000 --- a/catalog-apps/cli/pi/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM rigbox-base -# Pi (https://www.npmjs.com/package/@mariozechner/pi-coding-agent) — an -# OpenRouter-native coding agent that runs interactively in your terminal. -# Pi has no web UI; the catalog item is CLI-shaped. We install the binary -# globally so it's on $PATH for both interactive SSH sessions and the -# `pi` wrapper symlink at /usr/local/bin/pi. -# -# Pi reads OPENROUTER_API_KEY directly — no profile.d env translation needed. -# The workspace env layer (managed proxy injection OR a real key set via -# `rig app secret set OPENROUTER_API_KEY=…`) carries it. - -RUN set -eu; \ - su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - export NPM_CONFIG_PREFIX="$HOME/.npm-global"; \ - mkdir -p "$NPM_CONFIG_PREFIX/bin"; \ - export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"; \ - npm install -g --no-fund --silent @mariozechner/pi-coding-agent; \ - pi --version >/dev/null 2>&1 || [ -x "$NPM_CONFIG_PREFIX/bin/pi" ]; \ - # Persist PATH for interactive shells. - for RC in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do \ - if [ -f "$RC" ] && ! grep -qF NPM_CONFIG_PREFIX "$RC"; then \ - printf "\n# Added by rigbox example (pi)\nexport NPM_CONFIG_PREFIX=\"\$HOME/.npm-global\"\nexport PATH=\"\$NPM_CONFIG_PREFIX/bin:\$PATH\"\n" >> "$RC"; \ - fi; \ - done; \ - '; \ - install -d -m 755 /usr/local/bin; \ - ln -sfn /home/developer/.npm-global/bin/pi /usr/local/bin/pi diff --git a/catalog-apps/cli/pi/README.md b/catalog-apps/cli/pi/README.md index 39b813b..dd7d2d0 100644 --- a/catalog-apps/cli/pi/README.md +++ b/catalog-apps/cli/pi/README.md @@ -33,15 +33,24 @@ $ pi > refactor the auth handler to use early returns ``` -## Docker build + the hybrid deploy +## Reproducible deploy + the hybrid model -```dockerfile -FROM rigbox-base -RUN npm install -g @mariozechner/pi-coding-agent +```yaml +reproducible: true +install: | + set -euo pipefail + npm install -g --no-fund --silent @mariozechner/pi-coding-agent + sudo ln -sfn "$NPM_CONFIG_PREFIX/bin/pi" /usr/local/bin/pi ``` -- **First `rig deploy`**: builds the image (one npm install), boots from it. -- **Later `rig deploy`**: cached image reused, no re-install. +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (one npm install), snapshots the rootfs as a + content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, the cached image is reused — no re-install. ## Deploy @@ -53,7 +62,7 @@ rig workspace ssh # then run `pi` interactively ## Notes - **CLI app, declaratively.** `kind: cli` tells the platform there's no HTTP - port to probe; the deploy just bakes `pi` onto the SSH PATH and stops there. + port to probe; the deploy just puts `pi` on the SSH PATH and stops there. That matches the catalog's internal `AppKind::Cli` shape exactly. - **Persistence**: Pi keeps no state of its own; conversations are ephemeral per terminal session. diff --git a/catalog-apps/cli/pi/rig.yaml b/catalog-apps/cli/pi/rig.yaml index 004de04..f0d7d0f 100644 --- a/catalog-apps/cli/pi/rig.yaml +++ b/catalog-apps/cli/pi/rig.yaml @@ -5,8 +5,35 @@ kind: cli # 512MB RAM is plenty; bumping vCPU to 1 matches the catalog minimum. workspace: resources: { ramMb: 512, vcpuCount: 1, diskSizeMb: 1024 } -build: - dockerfile: Dockerfile +# Freeze the CLI into an image: `rig deploy` runs `install:` once in a builder +# VM and later deploys boot from the cached image instead of re-running +# npm install. The script runs as `developer` with passwordless sudo. +reproducible: true +install: | + set -euo pipefail + # Pi (https://www.npmjs.com/package/@mariozechner/pi-coding-agent) — an + # OpenRouter-native coding agent that runs interactively in your terminal. + # Pi has no web UI; the catalog item is CLI-shaped. We install the binary + # globally so it's on $PATH for both interactive SSH sessions and the + # `pi` wrapper symlink at /usr/local/bin/pi. + # + # Pi reads OPENROUTER_API_KEY directly — no profile.d env translation needed. + # The workspace env layer (managed proxy injection OR a real key set via + # `rig app secret set OPENROUTER_API_KEY=…`) carries it. + export NPM_CONFIG_PREFIX="$HOME/.npm-global" + mkdir -p "$NPM_CONFIG_PREFIX/bin" + export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" + npm install -g --no-fund --silent @mariozechner/pi-coding-agent + pi --version >/dev/null 2>&1 || [ -x "$NPM_CONFIG_PREFIX/bin/pi" ] + # Persist PATH for interactive shells (grep-guarded so re-runs don't + # duplicate the block). + for RC in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$RC" ] && ! grep -qF NPM_CONFIG_PREFIX "$RC"; then + printf '\n# Added by rigbox example (pi)\nexport NPM_CONFIG_PREFIX="$HOME/.npm-global"\nexport PATH="$NPM_CONFIG_PREFIX/bin:$PATH"\n' >> "$RC" + fi + done + sudo install -d -m 755 /usr/local/bin + sudo ln -sfn "$NPM_CONFIG_PREFIX/bin/pi" /usr/local/bin/pi # Opt into the Rigbox managed AI proxy. Pi reads OPENROUTER_API_KEY natively, # so the proxy-injected placeholder is enough — no code wiring, no .env. ai: diff --git a/catalog-apps/service/excalidraw/Dockerfile b/catalog-apps/service/excalidraw/Dockerfile deleted file mode 100644 index f74df69..0000000 --- a/catalog-apps/service/excalidraw/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM rigbox-base -# Excalidraw is a React app — the upstream repo is a monorepo, but the -# published `@excalidraw/excalidraw` npm package can be embedded into a -# tiny vite project and built to a static SPA. The catalog does this at -# install time on a live workspace; here we do it at image-build time so -# every deploy boots a frozen, prebuilt bundle. -ARG EXCALIDRAW_VERSION=0.17.6 - -WORKDIR /opt/excalidraw - -# Minimal project scaffold (same shape as the catalog's install.sh). -RUN mkdir -p src \ - && printf '%s\n' \ - '{' \ - ' "name": "rigbox-excalidraw",' \ - ' "private": true,' \ - ' "type": "module",' \ - ' "scripts": { "build": "vite build" }' \ - '}' > package.json \ - && printf '%s\n' \ - 'import { defineConfig } from "vite";' \ - 'import react from "@vitejs/plugin-react";' \ - 'export default defineConfig({' \ - ' plugins: [react()],' \ - ' define: { "process.env.IS_PREACT": JSON.stringify("false") },' \ - ' build: { outDir: "build" }' \ - '});' > vite.config.js \ - && printf '%s\n' \ - '' \ - '' \ - '' \ - ' ' \ - ' ' \ - ' Excalidraw - Rigbox' \ - ' ' \ - '' \ - '' \ - '
' \ - ' ' \ - '' \ - '' > index.html \ - && printf '%s\n' \ - 'import React from "react";' \ - 'import { createRoot } from "react-dom/client";' \ - 'import { Excalidraw } from "@excalidraw/excalidraw";' \ - 'import "@excalidraw/excalidraw/index.css";' \ - 'const root = createRoot(document.getElementById("root"));' \ - 'root.render();' > src/main.jsx - -# Install deps + build the static SPA. `serve` stays in node_modules so the -# start command can find it at /opt/excalidraw/node_modules/.bin/serve. -RUN npm install --no-audit --no-fund \ - react react-dom \ - "@excalidraw/excalidraw@${EXCALIDRAW_VERSION}" \ - vite @vitejs/plugin-react \ - serve \ - && npx vite build \ - && npm cache clean --force diff --git a/catalog-apps/service/excalidraw/README.md b/catalog-apps/service/excalidraw/README.md index 8f507dd..a1b8188 100644 --- a/catalog-apps/service/excalidraw/README.md +++ b/catalog-apps/service/excalidraw/README.md @@ -6,38 +6,43 @@ with that distinctive hand-drawn feel, all in a React SPA. This example runs it on Rigbox unchanged, embedded as a tiny vite project around the published `@excalidraw/excalidraw` package. -## The single capability: run Excalidraw reproducibly on Rigbox via a Docker build +## The single capability: run Excalidraw reproducibly on Rigbox This isn't a toy app we wrote — it's a real off-the-shelf product running on the -platform. The one thing it demonstrates is the **reproducible `FROM rigbox-base` -Docker build**: the `Dockerfile` scaffolds a minimal vite project, `npm install`s -a **pinned** `@excalidraw/excalidraw@0.17.6`, runs `vite build`, and freezes the -resulting static `build/` directory into the image. Every deploy boots from that -frozen image and just runs `serve`. - -```dockerfile -FROM rigbox-base -ARG EXCALIDRAW_VERSION=0.17.6 -# scaffold + npm install + vite build, baked once at image-build time -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag — and -bumps the frozen image's ext4 because vite's `node_modules` plus the bundle -overflows the default rootfs: +platform. The one thing it demonstrates is the **reproducible deploy**: the +`install:` script scaffolds a minimal vite project under `/opt/excalidraw`, +`npm install`s a **pinned** `@excalidraw/excalidraw@0.17.6`, runs `vite build`, +and the resulting static `build/` directory is frozen into the image. Every +deploy boots from that frozen image and just runs `serve`. ```yaml -build: - dockerfile: Dockerfile - sizeMb: 4096 +reproducible: true +install: | + set -euo pipefail + EXCALIDRAW_VERSION=0.17.6 + sudo mkdir -p /opt/excalidraw && sudo chown developer:developer /opt/excalidraw + cd /opt/excalidraw + … # scaffold package.json / vite.config.js / index.html / src/main.jsx + npm install --no-audit --no-fund react react-dom "@excalidraw/excalidraw@${EXCALIDRAW_VERSION}" vite @vitejs/plugin-react serve + npx vite build ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (`vite build` runs once — heavy, expect a few + minutes), snapshots the rootfs as a content-addressed image, boots the + workspace from it, then starts `serve -s build`. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no rebuild, fast. -- **First `rig deploy`**: builds the image from the local `Dockerfile` - (`vite build` runs once — heavy, expect a few minutes), boots the workspace - from that frozen image, then starts `serve -s build`. -- **Later `rig deploy`**: if the build inputs are unchanged, it **reuses the - cached image** — no rebuild, fast. +> Excalidraw's install footprint (vite + `node_modules` + the built bundle) is +> larger than the 3GB default. The builder takes its disk from +> `workspace.resources.diskSizeMb`, so keep that value above the footprint — see +> the repo README. ## No persistence diff --git a/catalog-apps/service/excalidraw/rig.yaml b/catalog-apps/service/excalidraw/rig.yaml index d2e685e..1b8b099 100644 --- a/catalog-apps/service/excalidraw/rig.yaml +++ b/catalog-apps/service/excalidraw/rig.yaml @@ -2,14 +2,86 @@ name: excalidraw port: 5000 # Excalidraw itself is a static React SPA — it serves fine on 256MB at runtime, # but the vite bundle is large and the catalog ships 1024MB to leave margin. -# We bake the build at image-build time so the VM only runs `serve`. +# We build the bundle at install time (frozen into the reproducible image) so +# the VM only runs `serve`. workspace: - resources: { ramMb: 512, vcpuCount: 1, diskSizeMb: 1024 } -build: - dockerfile: Dockerfile - # vite + react + @excalidraw + node_modules + the built bundle don't fit in - # the default rootfs at image-export time. Bump the frozen image's ext4. - sizeMb: 4096 + # diskSizeMb holds the frozen install (vite + node_modules + bundle) and + # sizes the builder VM that produces it. + resources: { ramMb: 512, vcpuCount: 1, diskSizeMb: 4096 } +# Freeze the scaffold + `npm install` + `vite build` into an image: `rig deploy` +# runs `install:` once in a builder VM, snapshots the rootfs, and later deploys +# with unchanged inputs boot from the cached image. Bump EXCALIDRAW_VERSION +# here to upgrade. +# Footprint note: the builder VM boots with the platform default 3GB disk; vite + +# react + @excalidraw + node_modules + the built bundle are a large install. +reproducible: true +install: | + set -euo pipefail + # Excalidraw is a React app — the upstream repo is a monorepo, but the + # published `@excalidraw/excalidraw` npm package can be embedded into a + # tiny vite project and built to a static SPA. The catalog does this at + # install time on a live workspace; here the same build is frozen into the + # reproducible image so every deploy boots a prebuilt bundle. + EXCALIDRAW_VERSION=0.17.6 + + # Project lives at /opt/excalidraw (outside the rsync zone), owned by + # developer so npm/vite run unprivileged. + sudo mkdir -p /opt/excalidraw + sudo chown developer:developer /opt/excalidraw + cd /opt/excalidraw + + # Minimal project scaffold (same shape as the catalog's install.sh). + mkdir -p src + cat > package.json <<'EOF' + { + "name": "rigbox-excalidraw", + "private": true, + "type": "module", + "scripts": { "build": "vite build" } + } + EOF + cat > vite.config.js <<'EOF' + import { defineConfig } from "vite"; + import react from "@vitejs/plugin-react"; + export default defineConfig({ + plugins: [react()], + define: { "process.env.IS_PREACT": JSON.stringify("false") }, + build: { outDir: "build" } + }); + EOF + cat > index.html <<'EOF' + + + + + + Excalidraw - Rigbox + + + +
+ + + + EOF + cat > src/main.jsx <<'EOF' + import React from "react"; + import { createRoot } from "react-dom/client"; + import { Excalidraw } from "@excalidraw/excalidraw"; + import "@excalidraw/excalidraw/index.css"; + const root = createRoot(document.getElementById("root")); + root.render(); + EOF + + # Install deps + build the static SPA. `serve` stays in node_modules so the + # start command can find it at /opt/excalidraw/node_modules/.bin/serve. + npm install --no-audit --no-fund \ + react react-dom \ + "@excalidraw/excalidraw@${EXCALIDRAW_VERSION}" \ + vite @vitejs/plugin-react \ + serve + npx vite build + npm cache clean --force # Serve the prebuilt SPA. --no-clipboard matches the catalog (the Rigbox proxy # isn't an HTTPS origin from the browser's POV for clipboard APIs). start: >- diff --git a/catalog-apps/service/filebrowser/Dockerfile b/catalog-apps/service/filebrowser/Dockerfile deleted file mode 100644 index 9efa349..0000000 --- a/catalog-apps/service/filebrowser/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM rigbox-base -# Freeze the File Browser binary into the image so deploys don't re-download it. -# File Browser ships as a single static Go binary — no toolchain needed. -ARG FB_VERSION=v2.32.0 -RUN ARCH=$(uname -m) \ - && case "$ARCH" in \ - x86_64|amd64) FB_ARCH="linux-amd64" ;; \ - aarch64|arm64) FB_ARCH="linux-arm64" ;; \ - *) echo "Unsupported arch: $ARCH" >&2 ; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/filebrowser/filebrowser/releases/download/${FB_VERSION}/${FB_ARCH}-filebrowser.tar.gz" \ - | tar -xz -C /usr/local/bin/ filebrowser \ - && chmod +x /usr/local/bin/filebrowser \ - && /usr/local/bin/filebrowser version diff --git a/catalog-apps/service/filebrowser/README.md b/catalog-apps/service/filebrowser/README.md index fdc24d4..58f01c1 100644 --- a/catalog-apps/service/filebrowser/README.md +++ b/catalog-apps/service/filebrowser/README.md @@ -5,28 +5,27 @@ upload, download, preview, edit, and share the files in a directory through a clean web UI. It ships as a single static Go binary. This example runs it on Rigbox unchanged, pointed at the workspace's persistent data dir. -## The single capability: run File Browser reproducibly on Rigbox via a Docker build +## The single capability: run File Browser reproducibly on Rigbox This isn't a toy app we wrote — it's a real off-the-shelf product running on the -platform. The one thing it demonstrates is the **reproducible `FROM rigbox-base` -Docker build**: the `Dockerfile` downloads a **pinned** File Browser binary -(`v2.32.0`) onto the Rigbox base image once, and every deploy boots from that -frozen image instead of re-downloading. - -```dockerfile -FROM rigbox-base -ARG FB_VERSION=v2.32.0 -RUN curl -fsSL ".../${FB_ARCH}-filebrowser.tar.gz" \ - | tar -xz -C /usr/local/bin/ filebrowser -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: +platform. The one thing it demonstrates is the **reproducible deploy**: the +`install:` script downloads a **pinned** File Browser binary (`v2.32.0`) onto +the Rigbox base once, and every deploy boots from that frozen image instead of +re-downloading. ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + FB_VERSION=v2.32.0 + … # arch switch + curl -fsSL ".../${FB_ARCH}-filebrowser.tar.gz" \ + | sudo tar -xz -C /usr/local/bin/ filebrowser ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + ## Persistence (survives redeploys) File Browser serves `--root /home/developer/data` and keeps its SQLite database diff --git a/catalog-apps/service/filebrowser/rig.yaml b/catalog-apps/service/filebrowser/rig.yaml index fd64275..d4a9158 100644 --- a/catalog-apps/service/filebrowser/rig.yaml +++ b/catalog-apps/service/filebrowser/rig.yaml @@ -4,8 +4,25 @@ port: 8080 # but the VM floors push to a usable minimum and leave headroom for uploads. workspace: resources: { ramMb: 256, vcpuCount: 1, diskSizeMb: 1024 } -build: - dockerfile: Dockerfile +# Freeze the pinned File Browser binary into an image: `rig deploy` runs +# `install:` once in a builder VM and later deploys reuse the cached image +# instead of re-downloading. Bump FB_VERSION here to upgrade. +reproducible: true +install: | + set -euo pipefail + # File Browser ships as a single static Go binary — no toolchain needed. + # Frozen into the reproducible image so deploys don't re-download it. + FB_VERSION=v2.32.0 + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) FB_ARCH="linux-amd64" ;; + aarch64|arm64) FB_ARCH="linux-arm64" ;; + *) echo "Unsupported arch: $ARCH" >&2 ; exit 1 ;; + esac + curl -fsSL "https://github.com/filebrowser/filebrowser/releases/download/${FB_VERSION}/${FB_ARCH}-filebrowser.tar.gz" \ + | sudo tar -xz -C /usr/local/bin/ filebrowser + sudo chmod +x /usr/local/bin/filebrowser + /usr/local/bin/filebrowser version # Serve /home/developer/data so uploads and edits persist across redeploys. # The catalog item uses --noauth (gateway-fronted); the example also lets # filebrowser's default admin/admin login take effect so you can see its diff --git a/catalog-apps/service/firecrawl/Dockerfile b/catalog-apps/service/firecrawl/Dockerfile deleted file mode 100644 index ea61e25..0000000 --- a/catalog-apps/service/firecrawl/Dockerfile +++ /dev/null @@ -1,156 +0,0 @@ -FROM rigbox-base -# Firecrawl v2.9.0 stack, frozen into the image. This Dockerfile installs: -# - PostgreSQL 17 + pg_cron, Redis, RabbitMQ (system services) -# - Node toolchain + corepack-managed pnpm@9 (v2.9.0 builds on pnpm 9, not 10) -# - The firecrawl repo at v2.9.0, with apps/api + apps/playwright-service-ts built -# - Playwright chromium under /opt/pw-browsers -# - A sidecar firecrawl-playwright.service systemd unit on :3000 -# -# The postgres DB itself (the `firecrawl` role + database + nuq schema) is NOT -# created here — postgres data lives at /var/lib/postgresql on the VM, not in -# the image. start.sh creates the role/db on first boot. - -ARG FIRECRAWL_VERSION=v2.9.0 -ARG PNPM_VERSION=9.15.4 -ARG PG_PORT=5433 -ARG GO_VERSION=1.24.4 - -ENV DEBIAN_FRONTEND=noninteractive -ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers - -# --------------------------------------------------------------------------- -# 1. apt: postgres-17 + pg_cron + redis + rabbitmq + build deps -# --------------------------------------------------------------------------- -RUN apt-get update \ - && apt-get install -y --no-install-recommends gnupg ca-certificates curl \ - && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ - > /etc/apt/sources.list.d/pgdg.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - postgresql-17 \ - postgresql-17-cron \ - postgresql-client-17 \ - libpq-dev \ - redis-server \ - rabbitmq-server \ - git \ - build-essential \ - unzip \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# --------------------------------------------------------------------------- -# 2. postgres config: pg_cron preload, trust local auth, listen on 5433 -# (postgres runtime data dir is /var/lib/postgresql/17/main, untouched here) -# --------------------------------------------------------------------------- -RUN PG_CONF=/etc/postgresql/17/main/postgresql.conf \ - && sed -i "s/^#\?shared_preload_libraries.*/shared_preload_libraries = 'pg_cron'/" "$PG_CONF" \ - && printf "\n# pg_cron settings\ncron.database_name = 'firecrawl'\n" >> "$PG_CONF" \ - && sed -i "s/^port = .*/port = ${PG_PORT}/" "$PG_CONF" \ - && PG_HBA=/etc/postgresql/17/main/pg_hba.conf \ - && sed -i 's/^local\s\+all\s\+all\s\+peer/local all all trust/' "$PG_HBA" \ - && printf 'host all all 127.0.0.1/32 md5\n' >> "$PG_HBA" - -# Bind redis to loopback — same as the catalog. -RUN sed -i 's/^bind .*/bind 127.0.0.1 ::1/' /etc/redis/redis.conf - -# --------------------------------------------------------------------------- -# 3. pnpm@9.15.4 via corepack. v2.9.0 silently skips esbuild install scripts -# under pnpm 10/11, so we pin 9. -# --------------------------------------------------------------------------- -RUN corepack enable \ - && corepack prepare "pnpm@${PNPM_VERSION}" --activate \ - && echo 'resolution-mode=highest' >> /etc/npmrc - -# --------------------------------------------------------------------------- -# 4. Go + Rust toolchains (needed to build the html-to-markdown .so and a few -# firecrawl native modules). The base image doesn't ship them. -# --------------------------------------------------------------------------- -ENV RUSTUP_HOME=/usr/local/rustup \ - CARGO_HOME=/usr/local/cargo \ - PATH=/usr/local/cargo/bin:/usr/local/go/bin:${PATH} -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain stable --profile minimal \ - && chmod -R a+rw /usr/local/cargo /usr/local/rustup \ - && curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ - | tar -xz -C /usr/local \ - && ln -sf /usr/local/go/bin/go /usr/local/bin/go \ - && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt - -# --------------------------------------------------------------------------- -# 5. Clone firecrawl @ v2.9.0 and pin pnpm in every package.json. -# --------------------------------------------------------------------------- -RUN git clone --depth 1 --branch "${FIRECRAWL_VERSION}" \ - https://github.com/firecrawl/firecrawl.git /home/developer/firecrawl \ - && chown -R developer:developer /home/developer/firecrawl \ - && find /home/developer/firecrawl -maxdepth 4 -name package.json -not -path '*/node_modules/*' \ - -exec node -e 'const fs=require("fs"); const p=process.argv[1]; const j=JSON.parse(fs.readFileSync(p,"utf8")); j.packageManager = "pnpm@'"${PNPM_VERSION}"'"; fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");' {} \; - -# --------------------------------------------------------------------------- -# 6. Build the Go html-to-markdown shared library. -# --------------------------------------------------------------------------- -RUN cd /home/developer/firecrawl/apps/api/sharedLibs/go-html-to-md \ - && go build -buildmode=c-shared -o html-to-markdown.so html-to-markdown.go \ - && chown -R developer:developer /home/developer/firecrawl/apps/api/sharedLibs - -# --------------------------------------------------------------------------- -# 7. pnpm install + build api + playwright-service-ts. -# --no-frozen-lockfile because v2.9.0's lockfile has an overrides mismatch. -# --------------------------------------------------------------------------- -USER developer -RUN cd /home/developer/firecrawl/apps/api \ - && pnpm install --no-frozen-lockfile \ - && pnpm run build \ - && pnpm prune --prod --ignore-scripts || true -RUN cd /home/developer/firecrawl/apps/playwright-service-ts \ - && pnpm install --no-frozen-lockfile \ - && pnpm run build - -# --------------------------------------------------------------------------- -# 8. Playwright chromium under /opt/pw-browsers (world-readable so the dev -# user can launch it whether the service runs as root or developer). -# --------------------------------------------------------------------------- -USER root -RUN mkdir -p /opt/pw-browsers \ - && cd /home/developer/firecrawl/apps/playwright-service-ts \ - && npx --yes playwright install --with-deps chromium \ - && chmod -R a+rX /opt/pw-browsers - -# --------------------------------------------------------------------------- -# 9. Sidecar systemd unit for the playwright service on :3000. -# --------------------------------------------------------------------------- -RUN cat > /etc/systemd/system/firecrawl-playwright.service <<'EOF' -[Unit] -Description=Firecrawl Playwright service -After=network.target - -[Service] -Type=simple -User=developer -Group=developer -WorkingDirectory=/home/developer/firecrawl/apps/playwright-service-ts -Environment=PORT=3000 -Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers -ExecStart=/usr/local/bin/node /home/developer/firecrawl/apps/playwright-service-ts/dist/api.js -Restart=always -RestartSec=2 - -[Install] -WantedBy=multi-user.target -EOF -RUN systemctl enable firecrawl-playwright.service - -# --------------------------------------------------------------------------- -# 10. Prune build caches + the toolchains we installed (only used at build). -# --------------------------------------------------------------------------- -RUN rm -rf /home/developer/.cache/go-build /home/developer/.npm/_cacache /home/developer/.npm/_logs \ - && su - developer -c 'pnpm store prune || true' \ - && rm -rf /usr/local/cargo /usr/local/rustup /usr/local/go /usr/local/bin/go /usr/local/bin/gofmt - -# --------------------------------------------------------------------------- -# 11. Boot wrapper at an absolute path (systemd requires it). -# --------------------------------------------------------------------------- -COPY start.sh /usr/local/bin/firecrawl-start.sh -RUN chmod +x /usr/local/bin/firecrawl-start.sh diff --git a/catalog-apps/service/firecrawl/README.md b/catalog-apps/service/firecrawl/README.md index 4c9c447..d71ddfe 100644 --- a/catalog-apps/service/firecrawl/README.md +++ b/catalog-apps/service/firecrawl/README.md @@ -9,24 +9,51 @@ v2.9.0 — on Rigbox in a single VM. Firecrawl isn't one process — it's an API on Node, a Playwright/Chromium worker, PostgreSQL 17 (with `pg_cron`), Redis, and RabbitMQ. What this example -demonstrates is **freezing the entire stack into a `FROM rigbox-base` image -once**, so every later deploy boots from the frozen image instead of re-cloning, -re-`pnpm install`ing, and re-downloading Chromium (a ~6GB build that takes -10–20 minutes cold). - -The Dockerfile installs the apt packages, clones `firecrawl@v2.9.0`, -`pnpm install`s, builds `apps/api` + `apps/playwright-service-ts`, downloads -Playwright Chromium to `/opt/pw-browsers`, and writes the playwright sidecar -systemd unit. The image carries everything except the live Postgres data dir. - -`rig.yaml` bumps the frozen image's ext4 because the build is huge: +demonstrates is **freezing that entire stack into one image**, so every later +deploy boots from the frozen image instead of re-cloning, re-`pnpm install`ing, +and re-downloading Chromium (a build that takes 10–20 minutes cold). + +`install:` does all of it — apt-installs Postgres 17 + pg_cron + Redis + +RabbitMQ, points Postgres at `:5433` with `pg_cron` preloaded, pins +`pnpm@9.15.4` via corepack, clones `firecrawl@v2.9.0`, builds the Go +html-to-markdown shared library, `pnpm install && pnpm run build`s both +`apps/api` and `apps/playwright-service-ts`, downloads Playwright Chromium to +`/opt/pw-browsers`, writes the playwright sidecar systemd unit, and finally +drops the build-only Go/Rust toolchains again: ```yaml -build: - dockerfile: Dockerfile - sizeMb: 12288 +reproducible: true +install: | + set -euo pipefail + sudo apt-get install -y postgresql-17 postgresql-17-cron redis-server rabbitmq-server … + sudo corepack prepare "pnpm@9.15.4" --activate + git clone --depth 1 --branch v2.9.0 https://github.com/firecrawl/firecrawl.git ~/firecrawl + (cd ~/firecrawl/apps/api && pnpm install --no-frozen-lockfile && pnpm run build) + sudo PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers npx --yes playwright install --with-deps chromium + sudo tee /etc/systemd/system/firecrawl-playwright.service … ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for the apt/`/opt`/`/etc` steps); +`reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model + +The deploy is **hybrid** — the image carries the whole stack, rsync carries +`start.sh`: + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (15+ minutes — apt, clone, two pnpm builds, a + Chromium download), snapshots the rootfs as a content-addressed image, boots + the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no rebuild, fast. Editing + `start.sh` only rsyncs. + +> This install lands around **6GB** (node_modules for two apps + Chromium + the +> apt stack). The builder takes its disk from `workspace.resources.diskSizeMb`, +> which is `8192` here to leave headroom for it; see the repo README. + ## Credentials + first-boot database init Three credentials are **generated on first deploy** by Rigbox and persist @@ -39,7 +66,8 @@ credentials: bull_auth_key: { generate: true } # → CRED_BULL_AUTH_KEY ``` -On every boot, `start.sh`: +The frozen image carries the *software*, not the live database, so `start.sh` +rsyncs in with the app and on every boot: 1. Brings up `postgresql@17-main`, `redis-server`, `rabbitmq-server`, and the `firecrawl-playwright` sidecar. @@ -49,8 +77,9 @@ On every boot, `start.sh`: `ai_proxy_mode` / `proxy_*` params. 4. Exec's the api harness: `node apps/api/dist/src/harness.js --start-built`. -The Postgres data dir at `/var/lib/postgresql/17/main` lives on the VM (not in -the image), so your scraped data persists across redeploys. +Everything Postgres writes at runtime lives on the workspace disk at +`/var/lib/postgresql/17/main`, so your scraped data survives redeploys — a +cached-image redeploy boots the existing disk and only rsyncs code. ## Using it @@ -71,5 +100,5 @@ the listener binds. cd firecrawl && rig deploy ``` -The first deploy is slow — building Firecrawl + Chromium can take 15+ minutes -and the image runs ~6GB. Later deploys reuse the cached image and are fast. +The first deploy is slow — the builder runs the full Firecrawl + Chromium build. +Later deploys reuse the cached image and are fast. diff --git a/catalog-apps/service/firecrawl/rig.yaml b/catalog-apps/service/firecrawl/rig.yaml index c1a689f..fe595ea 100644 --- a/catalog-apps/service/firecrawl/rig.yaml +++ b/catalog-apps/service/firecrawl/rig.yaml @@ -4,16 +4,179 @@ port: 3002 # headless chromium in one VM. The dropped template hinted 4GB; we give it # 4.5GB + 8GB disk to leave headroom for crawls and the playwright cache. workspace: - resources: { ramMb: 4608, vcpuCount: 2, diskSizeMb: 8192 } -build: - dockerfile: Dockerfile - # The frozen image carries firecrawl@v2.9.0 + its node_modules + chromium - # under /opt/pw-browsers — roughly 6GB on disk. The default rootfs is way - # too small to receive the docker→ext4 export; size generously. - sizeMb: 12288 -# start.sh ensures postgres is up, creates the firecrawl role + DB if missing, -# loads the nuq schema once, then exec's the firecrawl api harness. -start: /usr/local/bin/firecrawl-start.sh + # diskSizeMb holds the frozen install (~6GB: two node_modules trees, + # Chromium, the apt stack) and sizes the builder VM that produces it. + resources: { ramMb: 4608, vcpuCount: 2, diskSizeMb: 12288 } +# Freeze the whole stack into an image: `rig deploy` runs `install:` once in a +# builder VM (apt services, firecrawl@v2.9.0 + node_modules, chromium under +# /opt/pw-browsers, the playwright sidecar unit), snapshots the rootfs, and +# later deploys with unchanged inputs boot from the cached image. +# Footprint note: the builder VM boots with the platform default 3GB disk and +# this install lands around 6GB — see the README. +reproducible: true +install: | + set -euo pipefail + # Firecrawl v2.9.0 stack, frozen into the reproducible image. This script + # installs: + # - PostgreSQL 17 + pg_cron, Redis, RabbitMQ (system services) + # - Node toolchain + corepack-managed pnpm@9 (v2.9.0 builds on pnpm 9, not 10) + # - The firecrawl repo at v2.9.0, with apps/api + apps/playwright-service-ts built + # - Playwright chromium under /opt/pw-browsers + # - A sidecar firecrawl-playwright.service systemd unit on :3000 + # + # The postgres DB itself (the `firecrawl` role + database + nuq schema) is NOT + # created here — postgres data lives at /var/lib/postgresql on the VM, not in + # the image. start.sh creates the role/db on first boot. + # + # Runs as `developer` (passwordless sudo); root-only steps are prefixed with + # sudo, the pnpm build steps run as the developer user like the unit does. + + FIRECRAWL_VERSION=v2.9.0 + PNPM_VERSION=9.15.4 + PG_PORT=5433 + GO_VERSION=1.24.4 + + # Build-only env. sudo resets the environment, so DEBIAN_FRONTEND is also + # passed inline on every apt line below. + export DEBIAN_FRONTEND=noninteractive + export PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers + + # --------------------------------------------------------------------------- + # 1. apt: postgres-17 + pg_cron + redis + rabbitmq + build deps + # --------------------------------------------------------------------------- + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends gnupg ca-certificates curl + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | sudo gpg --dearmor --yes -o /usr/share/keyrings/pgdg.gpg + echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ + | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + postgresql-17 \ + postgresql-17-cron \ + postgresql-client-17 \ + libpq-dev \ + redis-server \ + rabbitmq-server \ + git \ + build-essential \ + unzip + sudo apt-get clean + sudo rm -rf /var/lib/apt/lists/* + + # --------------------------------------------------------------------------- + # 2. postgres config: pg_cron preload, trust local auth, listen on 5433 + # (postgres runtime data dir is /var/lib/postgresql/17/main, untouched here) + # --------------------------------------------------------------------------- + PG_CONF=/etc/postgresql/17/main/postgresql.conf + sudo sed -i "s/^#\?shared_preload_libraries.*/shared_preload_libraries = 'pg_cron'/" "$PG_CONF" + sudo grep -q '^cron.database_name' "$PG_CONF" \ + || printf "\n# pg_cron settings\ncron.database_name = 'firecrawl'\n" | sudo tee -a "$PG_CONF" >/dev/null + sudo sed -i "s/^port = .*/port = ${PG_PORT}/" "$PG_CONF" + PG_HBA=/etc/postgresql/17/main/pg_hba.conf + sudo sed -i 's/^local\s\+all\s\+all\s\+peer/local all all trust/' "$PG_HBA" + sudo grep -q '^host all all 127.0.0.1/32 md5' "$PG_HBA" \ + || printf 'host all all 127.0.0.1/32 md5\n' | sudo tee -a "$PG_HBA" >/dev/null + + # Bind redis to loopback — same as the catalog. + sudo sed -i 's/^bind .*/bind 127.0.0.1 ::1/' /etc/redis/redis.conf + + # --------------------------------------------------------------------------- + # 3. pnpm@9.15.4 via corepack. v2.9.0 silently skips esbuild install scripts + # under pnpm 10/11, so we pin 9. + # --------------------------------------------------------------------------- + sudo corepack enable + sudo corepack prepare "pnpm@${PNPM_VERSION}" --activate + sudo grep -qx 'resolution-mode=highest' /etc/npmrc 2>/dev/null \ + || echo 'resolution-mode=highest' | sudo tee -a /etc/npmrc >/dev/null + + # --------------------------------------------------------------------------- + # 4. Go + Rust toolchains (needed to build the html-to-markdown .so and a few + # firecrawl native modules). The base image doesn't ship them; both are + # removed again in step 10. + # --------------------------------------------------------------------------- + export RUSTUP_HOME=/usr/local/rustup + export CARGO_HOME=/usr/local/cargo + export PATH=/usr/local/cargo/bin:/usr/local/go/bin:${PATH} + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sudo RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" sh -s -- -y --default-toolchain stable --profile minimal + sudo chmod -R a+rw /usr/local/cargo /usr/local/rustup + sudo rm -rf /usr/local/go + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -xz -C /usr/local + sudo ln -sf /usr/local/go/bin/go /usr/local/bin/go + sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + + # --------------------------------------------------------------------------- + # 5. Clone firecrawl @ v2.9.0 and pin pnpm in every package.json. + # --------------------------------------------------------------------------- + [ -d /home/developer/firecrawl ] || git clone --depth 1 --branch "${FIRECRAWL_VERSION}" \ + https://github.com/firecrawl/firecrawl.git /home/developer/firecrawl + find /home/developer/firecrawl -maxdepth 4 -name package.json -not -path '*/node_modules/*' \ + -exec node -e 'const fs=require("fs"); const p=process.argv[1]; const j=JSON.parse(fs.readFileSync(p,"utf8")); j.packageManager = "pnpm@'"${PNPM_VERSION}"'"; fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\n");' {} \; + + # --------------------------------------------------------------------------- + # 6. Build the Go html-to-markdown shared library. + # --------------------------------------------------------------------------- + (cd /home/developer/firecrawl/apps/api/sharedLibs/go-html-to-md \ + && go build -buildmode=c-shared -o html-to-markdown.so html-to-markdown.go) + + # --------------------------------------------------------------------------- + # 7. pnpm install + build api + playwright-service-ts (as developer). + # --no-frozen-lockfile because v2.9.0's lockfile has an overrides mismatch. + # --------------------------------------------------------------------------- + (cd /home/developer/firecrawl/apps/api \ + && pnpm install --no-frozen-lockfile \ + && pnpm run build \ + && { pnpm prune --prod --ignore-scripts || true; }) + (cd /home/developer/firecrawl/apps/playwright-service-ts \ + && pnpm install --no-frozen-lockfile \ + && pnpm run build) + + # --------------------------------------------------------------------------- + # 8. Playwright chromium under /opt/pw-browsers (world-readable so the dev + # user can launch it whether the service runs as root or developer). + # --------------------------------------------------------------------------- + sudo mkdir -p /opt/pw-browsers + (cd /home/developer/firecrawl/apps/playwright-service-ts \ + && sudo PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers DEBIAN_FRONTEND=noninteractive \ + npx --yes playwright install --with-deps chromium /dev/null <<'EOF' + [Unit] + Description=Firecrawl Playwright service + After=network.target + + [Service] + Type=simple + User=developer + Group=developer + WorkingDirectory=/home/developer/firecrawl/apps/playwright-service-ts + Environment=PORT=3000 + Environment=PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers + ExecStart=/usr/local/bin/node /home/developer/firecrawl/apps/playwright-service-ts/dist/api.js + Restart=always + RestartSec=2 + + [Install] + WantedBy=multi-user.target + EOF + sudo systemctl enable firecrawl-playwright.service + + # --------------------------------------------------------------------------- + # 10. Prune build caches + the toolchains we installed (only used at build). + # --------------------------------------------------------------------------- + rm -rf "$HOME/.cache/go-build" "$HOME/go" "$HOME/.npm/_cacache" "$HOME/.npm/_logs" + pnpm store prune || true + sudo rm -rf /root/.npm /usr/local/cargo /usr/local/rustup /usr/local/go /usr/local/bin/go /usr/local/bin/gofmt +# start.sh rsyncs in with the app: it ensures postgres is up, creates the +# firecrawl role + DB if missing, loads the nuq schema once, then exec's the +# firecrawl api harness. +start: bash start.sh health: # v2.9.0 doesn't expose /v2/health; the harness returns 200 JSON at `/` once # the api is up. Generous timeout because postgres + nuq init runs on first diff --git a/catalog-apps/service/firecrawl/start.sh b/catalog-apps/service/firecrawl/start.sh index db163b9..ca4f205 100644 --- a/catalog-apps/service/firecrawl/start.sh +++ b/catalog-apps/service/firecrawl/start.sh @@ -4,9 +4,10 @@ # .firecrawl/.env from the rigbox-generated credentials + user params, then # exec the firecrawl api harness. # -# The Dockerfile bakes the firecrawl source + node_modules + chromium into -# the image; postgres data lives at /var/lib/postgresql/17/main on the VM -# (not in the image) so the database itself is initialized here. +# rig.yaml's `install:` bakes the firecrawl source + node_modules + chromium +# into the reproducible image, but not the live database: the postgres data dir +# at /var/lib/postgresql/17/main is workspace state, so the role/DB/schema are +# initialized here on first boot. set -euo pipefail PG_PORT="5433" @@ -15,8 +16,8 @@ ENV_DIR="/home/developer/.firecrawl" ENV_FILE="$ENV_DIR/.env" # --------------------------------------------------------------------------- -# Ensure system services are up. The Dockerfile installed but didn't enable -# them across reboots; do it idempotently here. +# Ensure system services are up. `install:` installed but didn't enable them +# across reboots; do it idempotently here. # --------------------------------------------------------------------------- systemctl enable --now postgresql@17-main systemctl enable --now redis-server diff --git a/catalog-apps/service/hermes-agent/Dockerfile b/catalog-apps/service/hermes-agent/Dockerfile deleted file mode 100644 index 6150050..0000000 --- a/catalog-apps/service/hermes-agent/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM rigbox-base -# Hermes Agent (NousResearch/hermes-agent) — self-improving AI agent with a web -# dashboard and a separate multi-platform messaging gateway (Telegram, Discord, -# Slack, WhatsApp, Signal). The upstream installer pulls uv, Python 3.11, -# Node 22, Playwright + Chromium, and a fairly large npm tree to pre-build the -# Vite SPA bundle; freeze it all into the image once so deploys don't re-run it. -# -# `--skip-setup` keeps the installer non-interactive. Curl|bash is non-tty so -# the upstream script also auto-detects non-interactive mode. -ARG HERMES_HOME=/home/developer/.hermes - -RUN set -eu; \ - su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - export HERMES_HOME='"$HERMES_HOME"'; \ - mkdir -p "$HERMES_HOME"; \ - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \ - | bash -s -- --skip-setup; \ - # Pre-build the dashboard SPA — `hermes dashboard` would otherwise run - # `npm ci && npm run build` lazily on the first request, blowing the - # readiness probe. - WEB_DIR="$HERMES_HOME/hermes-agent/web"; \ - DIST_DIR="$HERMES_HOME/hermes-agent/hermes_cli/web_dist"; \ - if [ -d "$WEB_DIR" ] && [ ! -f "$DIST_DIR/index.html" ]; then \ - cd "$WEB_DIR"; \ - npm ci --silent || npm install --silent; \ - npm run build --silent; \ - rm -rf "$WEB_DIR/node_modules" "$HOME/.npm/_cacache"; \ - fi; \ - test -f "$DIST_DIR/index.html"; \ - # Trim install-only caches that the upstream installer leaves behind. - rm -rf "$HOME/.cache/ms-playwright" "$HOME/.cache/camoufox" \ - "$HOME/.cache/huggingface" "$HOME/.cache/pip" "$HOME/.cache/uv" \ - "$HOME/.npm/_cacache" /tmp/camoufox-* /tmp/playwright-* 2>/dev/null || true; \ - # Ensure ~/.local/bin/hermes is the canonical launcher (the upstream - # installer occasionally truncates it after dirty-exiting on slow disks). - VENV_HERMES="$HERMES_HOME/hermes-agent/venv/bin/hermes"; \ - if [ ! -s "$HOME/.local/bin/hermes" ] && [ -x "$VENV_HERMES" ]; then \ - mkdir -p "$HOME/.local/bin"; \ - printf "%s\n" "#!/usr/bin/env bash" "unset PYTHONPATH" "unset PYTHONHOME" \ - "exec $VENV_HERMES \"\$@\"" > "$HOME/.local/bin/hermes"; \ - chmod +x "$HOME/.local/bin/hermes"; \ - fi; \ - touch "$HERMES_HOME/.env"; \ - chmod 600 "$HERMES_HOME/.env"; \ - ' diff --git a/catalog-apps/service/hermes-agent/README.md b/catalog-apps/service/hermes-agent/README.md index bf3c3dc..4817201 100644 --- a/catalog-apps/service/hermes-agent/README.md +++ b/catalog-apps/service/hermes-agent/README.md @@ -25,19 +25,44 @@ managed AI proxy** (no key required, billed to workspace credits) via `ai: { managed: true }` in `rig.yaml`. Hermes reads the proxy-injected `OPENROUTER_API_KEY` / `OPENAI_API_KEY` natively, so no code wiring is needed. -## Docker build + the hybrid deploy +## The upstream installer, frozen once -The upstream installer is heavy — uv, Python 3.11, Node 22, Playwright + a -Chromium download, and a Vite SPA build — so it's frozen into the image once: +The upstream installer is heavy — uv, Python 3.11, Node 22, Playwright, and a +Vite SPA build — so `install:` runs it once and the result is frozen into the +image. The SPA bundle is pre-built here on purpose: `hermes dashboard` would +otherwise run `npm ci && npm run build` lazily on the first request and blow +the readiness probe. -```dockerfile -FROM rigbox-base -RUN curl -fsSL https://.../scripts/install.sh | bash -s -- --skip-setup -# … then `npm ci && npm run build` to pre-bake hermes_cli/web_dist/ … +```yaml +reproducible: true +install: | + set -euo pipefail + export HERMES_HOME=/home/developer/.hermes + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \ + | bash -s -- --skip-setup + … # npm ci && npm run build → hermes_cli/web_dist/ + … # trim install-only caches, ensure ~/.local/bin/hermes ``` -- **First `rig deploy`**: builds the image (multi-minute), boots from it. -- **Later `rig deploy`**: cached image reused, no re-install. +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, exactly the user the upstream installer expects); +`reproducible: true` is what makes `rig deploy` freeze its result. It's +idempotent: the SPA build is skipped when `web_dist/index.html` is already +there, and the launcher shim is only rewritten when the installer truncated it. + +## Reproducible deploy + the hybrid model + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (multi-minute — uv, Node 22, Playwright, the Vite + build), snapshots the rootfs as a content-addressed image, boots the + workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no re-install, fast. + +> This install (uv-managed CPython, a Node 22 toolchain, and the SPA build tree) +> outgrows the 3GB default even after the cache trim. The builder takes its disk +> from `workspace.resources.diskSizeMb`, which is `4096` here; see the repo +> README. ## Deploy @@ -60,3 +85,7 @@ SSH in once and run `hermes gateway enable telegram && systemctl --user enable boundary. Don't drop it without auth-gating differently. - **Health probe**: `GET /api/status` is the only endpoint that doesn't require a session token, so it's what the readiness probe hits. +- **Browser automation.** `install:` trims `~/.cache/ms-playwright` along with + the other installer caches (it's a build cache, not a runtime dependency of + the dashboard); Hermes re-fetches a browser on demand if you use a tool that + needs one. diff --git a/catalog-apps/service/hermes-agent/rig.yaml b/catalog-apps/service/hermes-agent/rig.yaml index a077a95..a92cabe 100644 --- a/catalog-apps/service/hermes-agent/rig.yaml +++ b/catalog-apps/service/hermes-agent/rig.yaml @@ -2,14 +2,67 @@ name: hermes-agent # Hermes' dashboard binds 0.0.0.0:9119 and refuses non-loopback without # `--insecure`. The trust boundary is the rigbox subdomain layer. port: 9119 -# Playwright + Chromium baseline plus uv-managed Python 3.11 and a pre-built -# Vite SPA bundle — the install footprint is heavier than the catalog minimum, -# so bump the ext4 image so the docker→ext4 export fits. +# Playwright plus uv-managed Python 3.11, Node 22, and a pre-built Vite SPA +# bundle — the install footprint is heavier than the catalog minimum, so bump +# the workspace disk. workspace: - resources: { ramMb: 2048, vcpuCount: 2, diskSizeMb: 4096 } -build: - dockerfile: Dockerfile - sizeMb: 6144 + # diskSizeMb holds the frozen install (uv CPython, Node 22, SPA build) and + # sizes the builder VM that produces it. + resources: { ramMb: 2048, vcpuCount: 2, diskSizeMb: 6144 } +# Freeze the whole upstream install into an image: `rig deploy` runs `install:` +# once in a builder VM, snapshots the rootfs, and later deploys with unchanged +# inputs boot from the cached image instead of re-running the installer. +# Footprint note: the builder VM boots with the platform default 3GB disk and +# this install is close to it — see the README. +reproducible: true +install: | + set -euo pipefail + # Hermes Agent (NousResearch/hermes-agent) — a self-improving AI agent with a + # web dashboard and a separate multi-platform messaging gateway (Telegram, + # Discord, Slack, WhatsApp, Signal). The upstream installer pulls uv, Python + # 3.11, Node 22, Playwright, and a fairly large npm tree to pre-build the Vite + # SPA bundle; this script freezes it all into the image once so deploys don't + # re-run it. + # + # `--skip-setup` keeps the installer non-interactive; the curl|bash pipe is + # non-tty so the upstream script also auto-detects non-interactive mode. The + # script runs as `developer`, which is what the upstream installer expects. + export HERMES_HOME=/home/developer/.hermes + mkdir -p "$HERMES_HOME" + curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh \ + | bash -s -- --skip-setup + + # Pre-build the dashboard SPA — `hermes dashboard` would otherwise run + # `npm ci && npm run build` lazily on the first request, blowing the + # readiness probe. Skipped when the bundle is already there (idempotent). + WEB_DIR="$HERMES_HOME/hermes-agent/web" + DIST_DIR="$HERMES_HOME/hermes-agent/hermes_cli/web_dist" + if [ -d "$WEB_DIR" ] && [ ! -f "$DIST_DIR/index.html" ]; then + (cd "$WEB_DIR" \ + && { npm ci --silent || npm install --silent; } \ + && npm run build --silent) + rm -rf "$WEB_DIR/node_modules" "$HOME/.npm/_cacache" + fi + test -f "$DIST_DIR/index.html" + + # Trim install-only caches that the upstream installer leaves behind. + rm -rf "$HOME/.cache/ms-playwright" "$HOME/.cache/camoufox" \ + "$HOME/.cache/huggingface" "$HOME/.cache/pip" "$HOME/.cache/uv" \ + "$HOME/.npm/_cacache" /tmp/camoufox-* /tmp/playwright-* 2>/dev/null || true + + # Ensure ~/.local/bin/hermes is the canonical launcher (the upstream installer + # occasionally truncates it after dirty-exiting on slow disks). + VENV_HERMES="$HERMES_HOME/hermes-agent/venv/bin/hermes" + if [ ! -s "$HOME/.local/bin/hermes" ] && [ -x "$VENV_HERMES" ]; then + mkdir -p "$HOME/.local/bin" + printf "%s\n" "#!/usr/bin/env bash" "unset PYTHONPATH" "unset PYTHONHOME" \ + "exec $VENV_HERMES \"\$@\"" > "$HOME/.local/bin/hermes" + chmod +x "$HOME/.local/bin/hermes" + fi + test -x "$HOME/.local/bin/hermes" + + touch "$HERMES_HOME/.env" + chmod 600 "$HERMES_HOME/.env" start: >- /home/developer/.local/bin/hermes dashboard --host 0.0.0.0 --port 9119 --no-open --insecure diff --git a/catalog-apps/service/jupyter/Dockerfile b/catalog-apps/service/jupyter/Dockerfile deleted file mode 100644 index 546b204..0000000 --- a/catalog-apps/service/jupyter/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM rigbox-base -# Install JupyterLab on top of the rigbox base. The base ships Debian's -# python3 (3.11 at /usr/bin/python3); PEP 668 requires --break-system-packages -# to install into the system site-packages. The heavy pip install is frozen -# into the image so deploys don't re-run it. -RUN if [ -x /usr/local/bin/uv ]; then \ - /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages jupyterlab ; \ - else \ - /usr/bin/pip3 install --break-system-packages jupyterlab ; \ - fi \ - && jupyter --version diff --git a/catalog-apps/service/jupyter/README.md b/catalog-apps/service/jupyter/README.md index fd7b8fc..3a840c4 100644 --- a/catalog-apps/service/jupyter/README.md +++ b/catalog-apps/service/jupyter/README.md @@ -7,31 +7,31 @@ markdown. This example runs the real, unmodified JupyterLab on Rigbox. ## The single capability: a reproducible interactive notebook server The heavy `pip install jupyterlab` happens **once**, frozen into the image, and -every later deploy reuses it. The `Dockerfile` is `FROM rigbox-base` and bakes -JupyterLab into the system site-packages: - -```dockerfile -FROM rigbox-base -RUN /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages jupyterlab -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: +every later deploy reuses it. `rig.yaml`'s `install:` script puts JupyterLab +into the system site-packages on the Rigbox base, and `reproducible: true` +freezes the result: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages jupyterlab ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model The deploy is **hybrid** — the image carries the environment, rsync carries notebooks you put next to `rig.yaml`: -- **First `rig deploy`**: builds the image from the local `Dockerfile` - (JupyterLab installed once — this build is slow), boots the workspace from it. -- **Later `rig deploy`**: if the build inputs are unchanged, it **reuses the - cached image** — no pip re-run, fast. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (JupyterLab installed once — this build is slow), + snapshots the rootfs as a content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no pip re-run, fast. ## Persistence (survives redeploys) @@ -58,4 +58,5 @@ No required env — everything is set in `rig.yaml`. - **`--IdentityProvider.token=''` is intentional.** The app is private by default and the Rigbox gateway auth-gates anonymous traffic, so Jupyter's own token gate is redundant. Don't set this app `public` without re-adding a token. -- Stack: JupyterLab on Debian's `python3` (3.11), baked into the image. +- Stack: JupyterLab on Debian's `python3` (3.11), frozen into the reproducible + image. diff --git a/catalog-apps/service/jupyter/rig.yaml b/catalog-apps/service/jupyter/rig.yaml index bc671ea..ba10755 100644 --- a/catalog-apps/service/jupyter/rig.yaml +++ b/catalog-apps/service/jupyter/rig.yaml @@ -4,8 +4,24 @@ port: 8888 # bit of headroom; 1GB / 1vCPU is comfortable for an interactive notebook. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 4096 } -build: - dockerfile: Dockerfile +# Freeze the heavy `pip install jupyterlab` into an image: `rig deploy` runs +# `install:` once in a builder VM, snapshots the rootfs, and later deploys with +# unchanged inputs boot from the cached image and only rsync code. +reproducible: true +install: | + set -euo pipefail + # Install JupyterLab on top of the rigbox base. The base ships Debian's + # python3 (3.11 at /usr/bin/python3); PEP 668 requires --break-system-packages + # to install into the system site-packages (root-owned, hence sudo). The + # heavy pip install is frozen into the reproducible image so deploys don't + # re-run it. + if [ -x /usr/local/bin/uv ]; then + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages jupyterlab + else + sudo /usr/bin/pip3 install --break-system-packages jupyterlab + fi + jupyter --version # Token-less auth: the app is private by default and the Rigbox gateway # auth-gates anonymous traffic, so Jupyter's own token gate is redundant — # the gateway is the front door. Don't set this app `public` without re-adding diff --git a/catalog-apps/service/marimo/Dockerfile b/catalog-apps/service/marimo/Dockerfile deleted file mode 100644 index 85b2a88..0000000 --- a/catalog-apps/service/marimo/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM rigbox-base -# Install Marimo (reactive Python notebook) on top of the rigbox base. The -# base ships Debian's python3 (3.11 at /usr/bin/python3); PEP 668 requires -# --break-system-packages to install into the system site-packages. The -# install is frozen into the image so deploys don't re-run pip. -RUN if [ -x /usr/local/bin/uv ]; then \ - /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages marimo ; \ - else \ - /usr/bin/pip3 install --break-system-packages marimo ; \ - fi \ - && marimo --version diff --git a/catalog-apps/service/marimo/README.md b/catalog-apps/service/marimo/README.md index 848e9f3..76383e8 100644 --- a/catalog-apps/service/marimo/README.md +++ b/catalog-apps/service/marimo/README.md @@ -8,28 +8,28 @@ example runs the real, unmodified Marimo on Rigbox. ## The single capability: a reactive notebook server frozen into the image `pip install marimo` happens **once**, frozen into the image, and every later -deploy reuses it. The `Dockerfile` is `FROM rigbox-base` and bakes Marimo into -the system site-packages: - -```dockerfile -FROM rigbox-base -RUN /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages marimo -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: +deploy reuses it. `rig.yaml`'s `install:` script puts Marimo into the system +site-packages on the Rigbox base, and `reproducible: true` freezes the result: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages marimo ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model -- **First `rig deploy`**: builds the image (Marimo installed once), boots the - workspace from it, then starts `marimo edit` on `0.0.0.0:2718`. -- **Later `rig deploy`**: if the build inputs are unchanged, it **reuses the - cached image** — no pip re-run, fast. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (Marimo installed once), snapshots the rootfs as a + content-addressed image, boots the workspace from it, then starts + `marimo edit` on `0.0.0.0:2718`. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no pip re-run, fast. ## Persistence (survives redeploys) @@ -56,4 +56,5 @@ No required env — everything is set in `rig.yaml`. - **`--no-token` is intentional.** The app is private by default and the Rigbox gateway auth-gates anonymous traffic, so Marimo's own token gate is redundant. Don't set this app `public` without dropping `--no-token`. -- Stack: Marimo on Debian's `python3` (3.11), baked into the image. +- Stack: Marimo on Debian's `python3` (3.11), frozen into the reproducible + image. diff --git a/catalog-apps/service/marimo/rig.yaml b/catalog-apps/service/marimo/rig.yaml index 4b0f21c..c3d1f1d 100644 --- a/catalog-apps/service/marimo/rig.yaml +++ b/catalog-apps/service/marimo/rig.yaml @@ -3,8 +3,24 @@ port: 2718 # Marimo is lighter than Jupyter — pure Python + a small web bundle. workspace: resources: { ramMb: 512, vcpuCount: 1, diskSizeMb: 2048 } -build: - dockerfile: Dockerfile +# Freeze `pip install marimo` into an image: `rig deploy` runs `install:` once +# in a builder VM, snapshots the rootfs, and later deploys with unchanged +# inputs boot from the cached image and only rsync code. +reproducible: true +install: | + set -euo pipefail + # Install Marimo (reactive Python notebook) on top of the rigbox base. The + # base ships Debian's python3 (3.11 at /usr/bin/python3); PEP 668 requires + # --break-system-packages to install into the system site-packages + # (root-owned, hence sudo). The install is frozen into the reproducible + # image so deploys don't re-run pip. + if [ -x /usr/local/bin/uv ]; then + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages marimo + else + sudo /usr/bin/pip3 install --break-system-packages marimo + fi + marimo --version # Token-less auth: the app is private by default and the Rigbox gateway # auth-gates anonymous traffic, so Marimo's own token gate is redundant — the # gateway is the front door. Don't set this app `public` without dropping diff --git a/catalog-apps/service/open-webui/Dockerfile b/catalog-apps/service/open-webui/Dockerfile deleted file mode 100644 index 8611b75..0000000 --- a/catalog-apps/service/open-webui/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -FROM rigbox-base -# Open WebUI (https://docs.openwebui.com/) — a full-featured chat UI for -# OpenAI-compatible model endpoints. Pinned to a version that's known to boot -# clean on the Rigbox base; bump APP_VERSION and rebuild to upgrade. -# -# Open WebUI pins its torch wheel to cp312, so we use uv to materialize a -# Python 3.12 venv (the base image ships 3.11) and install the CPU torch -# wheel + open-webui from PyPI into it. Everything frozen into the image — -# deploys never re-run pip. -ARG APP_HOME=/home/developer/.open-webui -ARG APP_VERSION=0.9.4 -ARG TORCH_CPU_WHEEL_URL=https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl - -RUN set -eu; \ - mkdir -p "$APP_HOME" "$APP_HOME/data" "$APP_HOME/tmp"; \ - chown -R developer:developer "$APP_HOME"; \ - su - developer -s /bin/sh -c "\ - set -eu; \ - export UV_CACHE_DIR='$APP_HOME/.uv-cache'; \ - export UV_PYTHON_INSTALL_DIR='/home/developer/.local/share/uv/python'; \ - export TMPDIR='$APP_HOME/tmp'; \ - /usr/local/bin/uv python install 3.12; \ - /usr/local/bin/uv venv --clear --python 3.12 '$APP_HOME/venv'; \ - /usr/local/bin/uv pip install --no-cache --python '$APP_HOME/venv/bin/python' \ - '$TORCH_CPU_WHEEL_URL' 'open-webui==$APP_VERSION'; \ - "; \ - rm -rf "$APP_HOME/.uv-cache" "$APP_HOME/tmp" /root/.cache/uv \ - /home/developer/.cache/uv; \ - mkdir -p "$APP_HOME/tmp"; \ - chown -R developer:developer "$APP_HOME" - -# A tiny entrypoint so we exec the open-webui binary out of the venv — no shell -# rc files, no PATH munging at boot. -RUN set -eu; \ - cat > /home/developer/.open-webui/start.sh <<'EOF' -#!/usr/bin/env sh -set -eu -exec /home/developer/.open-webui/venv/bin/open-webui serve --host 0.0.0.0 --port 8080 -EOF - chmod +x /home/developer/.open-webui/start.sh && \ - chown developer:developer /home/developer/.open-webui/start.sh diff --git a/catalog-apps/service/open-webui/README.md b/catalog-apps/service/open-webui/README.md index a240e7c..f4df7bb 100644 --- a/catalog-apps/service/open-webui/README.md +++ b/catalog-apps/service/open-webui/README.md @@ -26,22 +26,42 @@ OpenRouter models (`rigbox/free`, Qwen Coder, Llama 3.3 70B), and `DEFAULT_MODEL_PARAMS` caps `max_tokens` at 4096 so a runaway chat can't burn the budget. -## Docker build + the hybrid deploy +## The heavy install, frozen once -Open WebUI's install is heavy — it pins a `cp312` torch wheel, so we use **uv** -to materialize a Python 3.12 venv in the image (the base ships 3.11), then -install `torch-cpu` + `open-webui` from PyPI into it. Everything frozen once: +Open WebUI pins a `cp312` torch wheel and the Rigbox base ships Debian's Python +3.11, so `install:` uses **uv** to materialize a 3.12 venv and installs the CPU +torch wheel + `open-webui` into it. That whole tree is frozen into the image: -```dockerfile -FROM rigbox-base -RUN uv python install 3.12 \ - && uv venv --python 3.12 ~/.open-webui/venv \ - && uv pip install open-webui==0.9.4 +```yaml +reproducible: true +install: | + set -euo pipefail + APP_HOME=/home/developer/.open-webui + APP_VERSION=0.9.4 + uv python install 3.12 + uv venv --clear --python 3.12 "$APP_HOME/venv" + uv pip install --no-cache --python "$APP_HOME/venv/bin/python" \ + "$TORCH_CPU_WHEEL_URL" "open-webui==${APP_VERSION}" ``` -- **First `rig deploy`**: builds the image (multi-minute), boots from it. -- **Later `rig deploy`**: cached image reused, no re-install. Bump - `APP_VERSION` in the Dockerfile to upgrade. +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, who owns `$APP_HOME`, so no `sudo` is needed here); +`reproducible: true` is what makes `rig deploy` freeze its result. `start:` +execs the binary straight out of the frozen venv — no wrapper, no PATH munging. + +## Reproducible deploy + the hybrid model + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (torch + open-webui — multi-minute), snapshots the + rootfs as a content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no re-install, fast. Bump + `APP_VERSION` in `install:` to upgrade. + +> This install is the largest in the catalog subtree — the CPU torch wheel alone +> is most of a gigabyte on top of a full CPython 3.12. The builder takes its disk +> from `workspace.resources.diskSizeMb`, which is `8192` here for exactly that +> reason; see the repo README. ## Deploy @@ -59,5 +79,6 @@ admin because `ENABLE_SIGNUP: False` flips off after the first user), and chat. - **`ENABLE_SIGNUP=False`** keeps this single-tenant. Drop it for multi-user. - **Health probe**: `GET /health` once the SvelteKit bundle is built; the `timeoutSeconds: 600` covers the first-boot DB migration on a cold start. -- **Stack**: Open WebUI (SvelteKit + FastAPI + SQLite) pointed at the Rigbox - managed proxy. +- **Stack**: Open WebUI (SvelteKit + FastAPI + SQLite) on a uv-managed Python + 3.12 venv, frozen into the reproducible image, pointed at the Rigbox managed + proxy. diff --git a/catalog-apps/service/open-webui/rig.yaml b/catalog-apps/service/open-webui/rig.yaml index 5225017..3399b78 100644 --- a/catalog-apps/service/open-webui/rig.yaml +++ b/catalog-apps/service/open-webui/rig.yaml @@ -1,13 +1,54 @@ name: open-webui port: 8080 -# Open WebUI + uv-installed Python 3.12 + CPU torch wheel — the install footprint -# is sizeable, so bump the workspace disk and the frozen image accordingly. +# Open WebUI + a uv-managed Python 3.12 + the CPU torch wheel — the install +# footprint is sizeable, so the workspace disk is bumped well past the catalog +# minimum. workspace: resources: { ramMb: 2048, vcpuCount: 2, diskSizeMb: 8192 } -build: - dockerfile: Dockerfile - sizeMb: 8192 -start: /home/developer/.open-webui/start.sh +# Freeze the whole Python 3.12 venv (torch-cpu + open-webui) into an image: +# `rig deploy` runs `install:` once in a builder VM, snapshots the rootfs, and +# later deploys with unchanged inputs boot from the cached image instead of +# re-running pip. Bump APP_VERSION below to upgrade. +# Footprint note: the builder VM boots with the platform default 3GB disk; the +# torch wheel alone is most of a GB — see the README. +reproducible: true +install: | + set -euo pipefail + # Open WebUI (https://docs.openwebui.com/) — a full-featured chat UI for + # OpenAI-compatible model endpoints. Pinned to a version that's known to boot + # clean on the Rigbox base; bump APP_VERSION and redeploy to upgrade. + # + # Open WebUI pins its torch wheel to cp312, so we use uv to materialize a + # Python 3.12 venv (the base ships Debian's 3.11) and install the CPU torch + # wheel + open-webui from PyPI into it. The script runs as `developer`, which + # owns $APP_HOME, so nothing here needs sudo. + APP_HOME=/home/developer/.open-webui + APP_VERSION=0.9.4 + TORCH_CPU_WHEEL_URL=https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl + + mkdir -p "$APP_HOME/data" "$APP_HOME/tmp" + + # Keep uv's caches inside $APP_HOME so the cleanup below reclaims all of them + # — the builder VM's disk is the tight resource here. + export UV_CACHE_DIR="$APP_HOME/.uv-cache" + export UV_PYTHON_INSTALL_DIR="$HOME/.local/share/uv/python" + export TMPDIR="$APP_HOME/tmp" + + /usr/local/bin/uv python install 3.12 + # --clear makes the venv step idempotent: a re-run rebuilds it from scratch + # rather than layering onto a half-finished one. + /usr/local/bin/uv venv --clear --python 3.12 "$APP_HOME/venv" + /usr/local/bin/uv pip install --no-cache --python "$APP_HOME/venv/bin/python" \ + "$TORCH_CPU_WHEEL_URL" "open-webui==${APP_VERSION}" + test -x "$APP_HOME/venv/bin/open-webui" + + rm -rf "$APP_HOME/.uv-cache" "$APP_HOME/tmp" "$HOME/.cache/uv" + mkdir -p "$APP_HOME/tmp" +# Exec the open-webui binary straight out of the frozen venv — no wrapper, no +# shell rc files, no PATH munging at boot. +start: >- + /home/developer/.open-webui/venv/bin/open-webui serve + --host 0.0.0.0 --port 8080 health: path: /health timeoutSeconds: 600 diff --git a/catalog-apps/service/openclaw-gateway/Dockerfile b/catalog-apps/service/openclaw-gateway/Dockerfile deleted file mode 100644 index d343d04..0000000 --- a/catalog-apps/service/openclaw-gateway/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM rigbox-base -# OpenClaw Gateway — the websocket gateway that powers the Clawd browser UI. -# Pinned to a known-good upstream release; bump and rebuild to upgrade. -ARG OPENCLAW_VERSION=2026.4.29 - -# Install openclaw globally into a user-writable npm prefix so root is never -# required at runtime. Frozen into the image so deploys don't re-install. -RUN set -eu; \ - su - developer -s /bin/bash -c "\ - set -euo pipefail; \ - mkdir -p \$HOME/.npm-global/bin \$HOME/.cache/nodejs \$HOME/.openclaw/bin \$HOME/.openclaw/agents/main/agent; \ - touch \$HOME/.openclaw/.env; \ - npm config set prefix \$HOME/.npm-global; \ - export PATH=\$HOME/.npm-global/bin:\$PATH; \ - export NODE_ENV=production; \ - npm install -g --no-fund --no-audit --omit=dev openclaw@${OPENCLAW_VERSION}; \ - openclaw --version; \ - " - -# Wrapper that the rigbox subdomain layer routes to. It synthesizes -# ~/.openclaw/openclaw.json + ~/.openclaw/agents/main/agent/auth-profiles.json -# from environment, then exec's the openclaw binary on :18789. -COPY gateway.js /home/developer/.openclaw/bin/gateway.js -COPY rigbox-openclaw-gateway /home/developer/.openclaw/bin/rigbox-openclaw-gateway -RUN chmod +x /home/developer/.openclaw/bin/rigbox-openclaw-gateway \ - && chown -R developer:developer /home/developer/.openclaw /home/developer/.npm-global /home/developer/.cache/nodejs diff --git a/catalog-apps/service/openclaw-gateway/README.md b/catalog-apps/service/openclaw-gateway/README.md index aca8371..ac03084 100644 --- a/catalog-apps/service/openclaw-gateway/README.md +++ b/catalog-apps/service/openclaw-gateway/README.md @@ -9,8 +9,8 @@ in order to drive a remote agent. The distinctive thing here is the **runtime config synthesis** in `gateway.js`. OpenClaw normally expects a hand-written `openclaw.json` and an -`auth-profiles.json` on disk; for ephemeral VMs that's painful. This wrapper -generates both at every boot: +`auth-profiles.json` on disk; for ephemeral VMs that's painful. `start.sh` runs +`gateway.js` first and generates both at every boot: - **Managed mode** (default): `AI_PROXY_URL` is injected by `ai: { managed: true }`. The wrapper writes a `rigbox-openrouter` provider entry pointed at @@ -24,18 +24,40 @@ The `gateway_token` credential (`generate: true`) is minted once per deploy — the dashboard reads it via `rig app credentials get gateway_token` and uses it to authenticate the `wss://` handshake. -## Docker build + the hybrid deploy +## The install, frozen once -```dockerfile -FROM rigbox-base -RUN npm install -g openclaw@2026.4.29 -COPY gateway.js rigbox-openclaw-gateway /home/developer/.openclaw/bin/ +Only one thing is heavy here — the global npm install of OpenClaw — so that's +what gets frozen: + +```yaml +reproducible: true +install: | + set -euo pipefail + OPENCLAW_VERSION=2026.4.29 + npm config set prefix "$HOME/.npm-global" + export PATH="$HOME/.npm-global/bin:$PATH" + export NODE_ENV=production + npm install -g --no-fund --no-audit --omit=dev "openclaw@${OPENCLAW_VERSION}" + openclaw --version >/dev/null ``` -- **First `rig deploy`**: builds the image (npm install OpenClaw), boots from - it. -- **Later `rig deploy`**: cached image reused. Bump `OPENCLAW_VERSION` in the - Dockerfile to upgrade. +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, into a user-writable npm prefix so root is never required); +`reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model + +The deploy is **hybrid**, and the split matters for this app: the image carries +the OpenClaw runtime, while `start.sh` and `gateway.js` — the per-boot config +synthesis — **rsync in with the app**, so you can iterate on the wrapper without +rebuilding anything. + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (`npm install -g openclaw`), snapshots the rootfs + as a content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no re-install, fast. Bump + `OPENCLAW_VERSION` in `install:` to upgrade. ## Deploy diff --git a/catalog-apps/service/openclaw-gateway/rig.yaml b/catalog-apps/service/openclaw-gateway/rig.yaml index e532d0d..2dd23de 100644 --- a/catalog-apps/service/openclaw-gateway/rig.yaml +++ b/catalog-apps/service/openclaw-gateway/rig.yaml @@ -4,11 +4,32 @@ name: openclaw-gateway port: 18789 workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile -# The wrapper synthesizes openclaw.json + auth-profiles.json from env at every -# boot, then exec's `openclaw gateway --bind lan --port 18789`. -start: /home/developer/.openclaw/bin/rigbox-openclaw-gateway +# Freeze `npm install -g openclaw` into an image: `rig deploy` runs `install:` +# once in a builder VM, snapshots the rootfs, and later deploys with unchanged +# inputs boot from the cached image instead of re-installing. Bump +# OPENCLAW_VERSION below to upgrade. +reproducible: true +install: | + set -euo pipefail + # OpenClaw Gateway — the WebSocket gateway that powers the Clawd browser UI. + # Pinned to a known-good upstream release. + OPENCLAW_VERSION=2026.4.29 + + # Install openclaw globally into the base image's user-writable npm prefix so + # root is never required at runtime. The script runs as `developer`, the same + # user the systemd unit runs as, so there's no `su -` dance here. + mkdir -p "$HOME/.npm-global/bin" "$HOME/.cache/nodejs" \ + "$HOME/.openclaw/agents/main/agent" + touch "$HOME/.openclaw/.env" + npm config set prefix "$HOME/.npm-global" + export PATH="$HOME/.npm-global/bin:$PATH" + export NODE_ENV=production + npm install -g --no-fund --no-audit --omit=dev "openclaw@${OPENCLAW_VERSION}" + openclaw --version >/dev/null +# start.sh + gateway.js rsync in with the app: the wrapper synthesizes +# openclaw.json + auth-profiles.json from env at every boot, then exec's +# `openclaw gateway --bind lan --port 18789`. +start: bash start.sh # Opt into the Rigbox managed AI proxy. The wrapper detects AI_PROXY_URL and # wires OpenClaw's custom provider entry at the proxy's /v1 endpoint. ai: @@ -16,7 +37,7 @@ ai: env: NODE_OPTIONS: --max-old-space-size=2048 NODE_COMPILE_CACHE: /home/developer/.cache/nodejs - PATH: /home/developer/.npm-global/bin:/home/developer/.openclaw/bin:/home/developer/.local/bin:/usr/local/bin:/usr/bin:/bin + PATH: /home/developer/.npm-global/bin:/home/developer/.local/bin:/usr/local/bin:/usr/bin:/bin OPENCLAW_PROVIDER: anthropic OPENCLAW_DEFAULT_MODEL: anthropic/claude-sonnet-4.6 OPENCLAW_SYSTEM_PROMPT: "You are a helpful AI assistant." diff --git a/catalog-apps/service/openclaw-gateway/rigbox-openclaw-gateway b/catalog-apps/service/openclaw-gateway/start.sh similarity index 64% rename from catalog-apps/service/openclaw-gateway/rigbox-openclaw-gateway rename to catalog-apps/service/openclaw-gateway/start.sh index 4716deb..a44b19f 100644 --- a/catalog-apps/service/openclaw-gateway/rigbox-openclaw-gateway +++ b/catalog-apps/service/openclaw-gateway/start.sh @@ -1,8 +1,12 @@ #!/usr/bin/env bash # Boot wrapper for the OpenClaw gateway. Synthesizes per-VM config from env, # then exec's the openclaw binary on the Rigbox-routed port. +# +# This file rsyncs in with the app, so gateway.js sits next to it — resolve it +# relative to this script rather than by absolute path. set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" mkdir -p /home/developer/.openclaw/agents/main/agent /home/developer/.cache/nodejs -node /home/developer/.openclaw/bin/gateway.js +node "$here/gateway.js" exec /home/developer/.npm-global/bin/openclaw gateway \ --bind lan --port 18789 --allow-unconfigured diff --git a/catalog-apps/service/openterminal/Dockerfile b/catalog-apps/service/openterminal/Dockerfile deleted file mode 100644 index d369441..0000000 --- a/catalog-apps/service/openterminal/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM rigbox-base -# Open Terminal is published as a Python package; the catalog installs it via -# pipx into an isolated venv. We do the same here at image-build time so the -# package is frozen into the image and every deploy boots from it. -# -# Debian python3 enforces PEP 668 (externally-managed-environment), so we -# install into a dedicated venv at /opt/openterminal rather than fight pip. -RUN python3 -m venv /opt/openterminal \ - && /opt/openterminal/bin/pip install --no-cache-dir --upgrade pip \ - && /opt/openterminal/bin/pip install --no-cache-dir open-terminal \ - && ln -sf /opt/openterminal/bin/open-terminal /usr/local/bin/open-terminal \ - && /usr/local/bin/open-terminal --help >/dev/null - -# Bake the start wrapper at an absolute path. systemd's ExecStart needs an -# absolute path (a relative `./start.sh` is rejected), so rig.yaml's `start` -# points here rather than at the rsynced copy. -COPY start.sh /usr/local/bin/openterminal-start.sh -RUN chmod +x /usr/local/bin/openterminal-start.sh diff --git a/catalog-apps/service/openterminal/README.md b/catalog-apps/service/openterminal/README.md index e1bf0ae..5efc9d7 100644 --- a/catalog-apps/service/openterminal/README.md +++ b/catalog-apps/service/openterminal/README.md @@ -8,17 +8,28 @@ raw SSH. This example runs the upstream Python package on Rigbox unchanged. ## The single capability: run Open Terminal reproducibly with a generated API key This isn't a toy app we wrote — it's a real off-the-shelf product running on -the platform. What it demonstrates is the **reproducible Docker build plus -Rigbox-managed credentials**: the `Dockerfile` pip-installs `open-terminal` into -a venv at `/opt/openterminal` once, frozen into the image, and `rig.yaml` asks -Rigbox to generate an API key on first deploy: +the platform. What it demonstrates is the **reproducible deploy plus +Rigbox-managed credentials**: `install:` pip-installs `open-terminal` into a +venv at `/opt/openterminal` once, frozen into the reproducible image, and +`rig.yaml` asks Rigbox to generate an API key on first deploy: ```yaml +reproducible: true +install: | + set -euo pipefail + sudo python3 -m venv /opt/openterminal + sudo /opt/openterminal/bin/pip install --no-cache-dir open-terminal + sudo ln -sf /opt/openterminal/bin/open-terminal /usr/local/bin/open-terminal credentials: api_key: generate: true # → injected as CRED_API_KEY ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. Later +deploys with an unchanged `install:` script + base image reuse the cached +image and only rsync `start.sh`. + On boot, `start.sh` writes `$CRED_API_KEY` into `~/.config/open-terminal/config.toml` and exec's `open-terminal run`. The key is stable across redeploys and shown once in the deploy output — save it; you'll need it on every `Authorization: Bearer …` diff --git a/catalog-apps/service/openterminal/rig.yaml b/catalog-apps/service/openterminal/rig.yaml index 9c614ab..c1382b8 100644 --- a/catalog-apps/service/openterminal/rig.yaml +++ b/catalog-apps/service/openterminal/rig.yaml @@ -4,11 +4,27 @@ port: 8000 # minimum, but a touch more disk lets pip cache the install on first boot. workspace: resources: { ramMb: 512, vcpuCount: 1, diskSizeMb: 1024 } -build: - dockerfile: Dockerfile -# start.sh writes the api-key config from $CRED_API_KEY on first boot, then -# exec's `open-terminal run`. The catalog uses a wrapper at the same path. -start: /usr/local/bin/openterminal-start.sh +# Freeze the pip install into an image: `rig deploy` runs `install:` once in a +# builder VM, snapshots the rootfs, and later deploys with unchanged inputs +# boot from the cached image instead of re-running pip. +reproducible: true +install: | + set -euo pipefail + # Open Terminal is published as a Python package; the catalog installs it via + # pipx into an isolated venv. We do the same here at install time so the + # package is frozen into the reproducible image and every deploy boots from it. + # + # Debian python3 enforces PEP 668 (externally-managed-environment), so we + # install into a dedicated venv at /opt/openterminal rather than fight pip. + sudo python3 -m venv /opt/openterminal + sudo /opt/openterminal/bin/pip install --no-cache-dir --upgrade pip + sudo /opt/openterminal/bin/pip install --no-cache-dir open-terminal + sudo ln -sf /opt/openterminal/bin/open-terminal /usr/local/bin/open-terminal + /usr/local/bin/open-terminal --help >/dev/null +# start.sh rsyncs in with the app, writes the api-key config from $CRED_API_KEY +# on first boot, then exec's `open-terminal run`. The catalog's wrapper has the +# same body. +start: bash start.sh health: path: /health timeoutSeconds: 60 diff --git a/catalog-apps/service/pgweb/Dockerfile b/catalog-apps/service/pgweb/Dockerfile deleted file mode 100644 index 75058de..0000000 --- a/catalog-apps/service/pgweb/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM rigbox-base -# Freeze a pinned pgweb binary into the image so deploys don't re-download it. -# pgweb ships as a single static Linux binary inside a release .zip. -ARG PGWEB_VERSION=0.16.2 -RUN ARCH=$(uname -m) \ - && case "$ARCH" in \ - x86_64|amd64) PGWEB_ARCH=linux_amd64 ;; \ - aarch64|arm64) PGWEB_ARCH=linux_arm64 ;; \ - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; \ - esac \ - && curl -fsSL "https://github.com/sosedoff/pgweb/releases/download/v${PGWEB_VERSION}/pgweb_${PGWEB_ARCH}.zip" \ - -o /tmp/pgweb.zip \ - && unzip -o /tmp/pgweb.zip -d /tmp \ - && mv "/tmp/pgweb_${PGWEB_ARCH}" /usr/local/bin/pgweb \ - && chmod +x /usr/local/bin/pgweb \ - && rm -f /tmp/pgweb.zip \ - && /usr/local/bin/pgweb --version diff --git a/catalog-apps/service/pgweb/README.md b/catalog-apps/service/pgweb/README.md index 397db1a..d328e31 100644 --- a/catalog-apps/service/pgweb/README.md +++ b/catalog-apps/service/pgweb/README.md @@ -7,27 +7,26 @@ Rigbox, pointed at any Postgres you give it. ## The single capability: a browser-based Postgres admin, frozen into the image -A pinned pgweb release (`0.16.2`, downloaded from GitHub releases) is baked +A pinned pgweb release (`0.16.2`, downloaded from GitHub releases) is frozen into the image once; every deploy boots from that frozen image. The connection string is a **secret param** — set it once with `rig app param set`, and pgweb picks it up via `DATABASE_URL` on boot. -```dockerfile -FROM rigbox-base -ARG PGWEB_VERSION=0.16.2 -RUN curl -fsSL "https://github.com/sosedoff/pgweb/releases/download/v${PGWEB_VERSION}/pgweb_linux_amd64.zip" \ - -o /tmp/pgweb.zip \ - && unzip -o /tmp/pgweb.zip -d /tmp \ - && mv /tmp/pgweb_linux_amd64 /usr/local/bin/pgweb -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: - ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + PGWEB_VERSION=0.16.2 + … # arch switch + curl -fsSL "https://github.com/sosedoff/pgweb/releases/download/v${PGWEB_VERSION}/pgweb_${PGWEB_ARCH}.zip" \ + -o /tmp/pgweb.zip + unzip -o /tmp/pgweb.zip -d /tmp + sudo install -m 755 "/tmp/pgweb_${PGWEB_ARCH}" /usr/local/bin/pgweb ``` +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + ## External Postgres (the design choice) This example **does not bundle Postgres**. pgweb is the admin, not the database @@ -47,12 +46,14 @@ params: into the connection — no config file. Leave the param blank to start without a connection and connect via the UI's `--sessions` mode instead. -## Docker build + the hybrid deploy +## Reproducible deploy + the hybrid model -- **First `rig deploy`**: builds the image (pgweb binary pinned + downloaded - once), boots from it. -- **Later `rig deploy`**: image cache reused — no re-download, fast. Bump - `PGWEB_VERSION` in the Dockerfile to upgrade. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (pgweb binary pinned + downloaded once), snapshots + the rootfs as a content-addressed image, boots from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, the image cache is reused — no re-download, fast. Bump + `PGWEB_VERSION` in `rig.yaml`'s `install:` to upgrade. ## Deploy @@ -73,4 +74,4 @@ boots — the UI lets you punch in a connection manually.) connection — pgweb has no dedicated `/health` endpoint). - **`--sessions`** lets users open additional Postgres connections through the UI instead of being pinned to the `DATABASE_URL` connection only. -- Stack: pgweb single Go binary, pinned + frozen in the image. +- Stack: pgweb single Go binary, pinned + frozen in the reproducible image. diff --git a/catalog-apps/service/pgweb/rig.yaml b/catalog-apps/service/pgweb/rig.yaml index d95473c..607d9b7 100644 --- a/catalog-apps/service/pgweb/rig.yaml +++ b/catalog-apps/service/pgweb/rig.yaml @@ -3,8 +3,28 @@ port: 8081 # pgweb is a tiny single Go binary. 256MB / 0.5GB disk is plenty. workspace: resources: { ramMb: 256, vcpuCount: 1, diskSizeMb: 1024 } -build: - dockerfile: Dockerfile +# Freeze the pinned pgweb binary into an image: `rig deploy` runs `install:` +# once in a builder VM and later deploys reuse the cached image instead of +# re-downloading. Bump PGWEB_VERSION here to upgrade. +reproducible: true +install: | + set -euo pipefail + # pgweb ships as a single static Linux binary inside a release .zip (the + # rigbox base ships `unzip`). Frozen into the reproducible image so deploys + # don't re-download it. + PGWEB_VERSION=0.16.2 + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) PGWEB_ARCH=linux_amd64 ;; + aarch64|arm64) PGWEB_ARCH=linux_arm64 ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac + curl -fsSL "https://github.com/sosedoff/pgweb/releases/download/v${PGWEB_VERSION}/pgweb_${PGWEB_ARCH}.zip" \ + -o /tmp/pgweb.zip + unzip -o /tmp/pgweb.zip -d /tmp + sudo install -m 755 "/tmp/pgweb_${PGWEB_ARCH}" /usr/local/bin/pgweb + rm -f /tmp/pgweb.zip "/tmp/pgweb_${PGWEB_ARCH}" + /usr/local/bin/pgweb --version # pgweb auto-reads DATABASE_URL on startup if --url is not passed, so all the # connection config lives in the param below. --sessions lets a user open new # connections through the UI instead of being pinned to DATABASE_URL. diff --git a/catalog-apps/service/streamlit/Dockerfile b/catalog-apps/service/streamlit/Dockerfile deleted file mode 100644 index 5f5d8b5..0000000 --- a/catalog-apps/service/streamlit/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM rigbox-base -# Install Streamlit on top of the rigbox base. The base ships Debian's -# python3 (3.11 at /usr/bin/python3); PEP 668 requires --break-system-packages -# to install into the system site-packages. The install is frozen into the -# image so deploys don't re-run pip. -RUN if [ -x /usr/local/bin/uv ]; then \ - /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages streamlit ; \ - else \ - /usr/bin/pip3 install --break-system-packages streamlit ; \ - fi \ - && streamlit --version diff --git a/catalog-apps/service/streamlit/README.md b/catalog-apps/service/streamlit/README.md index e9d9433..fe37198 100644 --- a/catalog-apps/service/streamlit/README.md +++ b/catalog-apps/service/streamlit/README.md @@ -11,25 +11,26 @@ The framework install (`pip install streamlit`, ~80MB with deps) happens **once**, frozen into the image. Your `app.py` rsyncs in on every deploy — so iterating on the app is fast, but you never re-install Streamlit: -```dockerfile -FROM rigbox-base -RUN /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ - --break-system-packages streamlit -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: - ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages streamlit ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model -- **First `rig deploy`**: builds the image (Streamlit installed once), boots - the workspace from it, rsyncs `app.py` on top, runs `streamlit run`. -- **Later `rig deploy`**: image is cached; only the changed `app.py` rsyncs. - Edit a widget, redeploy, the page refreshes. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (Streamlit installed once), snapshots the rootfs as + a content-addressed image, boots the workspace from it, rsyncs `app.py` on + top, runs `streamlit run`. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, the image is cached; only the changed `app.py` rsyncs. Edit a + widget, redeploy, the page refreshes. ## Persistence @@ -54,4 +55,5 @@ No required env — everything is set in `rig.yaml`. probe). The process binds `0.0.0.0:8501`. - **`--server.headless true`** skips the "open browser" prompt and Streamlit's first-run "send anonymous stats" question, which would hang the boot. -- Stack: Streamlit on Debian's `python3` (3.11), baked into the image. +- Stack: Streamlit on Debian's `python3` (3.11), frozen into the reproducible + image. diff --git a/catalog-apps/service/streamlit/rig.yaml b/catalog-apps/service/streamlit/rig.yaml index ac5a366..9290fa5 100644 --- a/catalog-apps/service/streamlit/rig.yaml +++ b/catalog-apps/service/streamlit/rig.yaml @@ -3,8 +3,23 @@ port: 8501 # Streamlit + pandas/altair pulled by typical apps want some headroom. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Freeze `pip install streamlit` into an image: `rig deploy` runs `install:` +# once in a builder VM, snapshots the rootfs, and later deploys with unchanged +# inputs boot from the cached image and only rsync app.py. +reproducible: true +install: | + set -euo pipefail + # Install Streamlit on top of the rigbox base. The base ships Debian's + # python3 (3.11 at /usr/bin/python3); PEP 668 requires --break-system-packages + # to install into the system site-packages (root-owned, hence sudo). The + # install is frozen into the reproducible image so deploys don't re-run pip. + if [ -x /usr/local/bin/uv ]; then + sudo /usr/local/bin/uv pip install --system --python /usr/bin/python3 \ + --break-system-packages streamlit + else + sudo /usr/bin/pip3 install --break-system-packages streamlit + fi + streamlit --version # Streamlit runs the rsynced app.py (CWD is the synced app dir, like other # single-app examples). --server.headless skips the "open browser" prompt and # the "send anonymous stats" first-run question; we're behind the gateway. diff --git a/catalog-apps/service/t3code/Dockerfile b/catalog-apps/service/t3code/Dockerfile deleted file mode 100644 index f74aefc..0000000 --- a/catalog-apps/service/t3code/Dockerfile +++ /dev/null @@ -1,74 +0,0 @@ -FROM rigbox-base -# T3 Code — browser-based AI workspace that hosts Claude Code, Codex, and other -# coding agents behind a single web UI (https://github.com/OpenRouterTeam/spawn). -# Installed via `npm install -g t3` into a user-writable npm prefix so root is -# never required. The heavy install is frozen into the image so deploys don't -# re-run it. -# -# t3@0.0.23 ships a Node shebang but gates execution behind `import.meta.main`, -# which is a Bun-only thing — unset under Node. Patch that single line so the -# CLI actually starts the web server when systemd execs it. - -RUN set -eu; \ - su - developer -s /bin/bash -c '\ - set -euo pipefail; \ - export NPM_CONFIG_PREFIX="$HOME/.npm-global"; \ - mkdir -p "$NPM_CONFIG_PREFIX/bin"; \ - export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"; \ - npm install -g --no-fund --silent t3; \ - T3_DIST="$NPM_CONFIG_PREFIX/lib/node_modules/t3/dist/bin.mjs"; \ - python3 - "$T3_DIST" </dev/null; \ - ' - -# Systemd does not run a login shell, so source the workspace env file -# explicitly and translate provider envs into the shapes T3 reads -# (ANTHROPIC_*/OPENAI_*) before exec'ing the absolute binary. -RUN set -eu; \ - mkdir -p /home/developer/.local/bin; \ - cat > /home/developer/.local/bin/rigbox-t3code-start <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -export HOME="${HOME:-/home/developer}" -export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/developer/.npm-global}" -export PATH="${NPM_CONFIG_PREFIX}/bin:/home/developer/.local/bin:/usr/local/bin:/usr/bin:/bin" - -set -a -[ -f /home/developer/.rigbox/.env ] && . /home/developer/.rigbox/.env -set +a - -# Managed Rigbox proxy injection → OpenAI/OpenRouter env shapes. -proxy_base="${AI_PROXY_URL:-${RIGBOX_AI_PROXY_URL:-}}" -if [ -n "${proxy_base}" ]; then - proxy_base="${proxy_base%/}" - [ -z "${OPENAI_BASE_URL:-}" ] && export OPENAI_BASE_URL="${proxy_base}/v1" - [ -z "${OPENAI_API_KEY:-}" ] && export OPENAI_API_KEY="managed-by-rigbox" - [ -z "${OPENROUTER_BASE_URL:-}" ] && export OPENROUTER_BASE_URL="${proxy_base}/v1" - [ -z "${OPENROUTER_API_KEY:-}" ] && export OPENROUTER_API_KEY="${OPENAI_API_KEY}" -fi - -# OpenRouter key → ANTHROPIC_*/OPENAI_* shapes T3 also reads. -if [ -n "${OPENROUTER_API_KEY:-}" ]; then - [ -z "${OPENAI_API_KEY:-}" ] && export OPENAI_API_KEY="${OPENROUTER_API_KEY}" - [ -z "${OPENAI_BASE_URL:-}" ] && export OPENAI_BASE_URL="${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}" - openrouter_base="${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}" - if [[ "${openrouter_base}" == https://openrouter.ai/* ]]; then - [ -z "${ANTHROPIC_API_KEY:-}" ] && export ANTHROPIC_API_KEY="${OPENROUTER_API_KEY}" - [ -z "${ANTHROPIC_BASE_URL:-}" ] && export ANTHROPIC_BASE_URL="https://openrouter.ai/api" - fi -fi - -exec /home/developer/.npm-global/bin/t3 --port 3773 --host 0.0.0.0 --mode web --no-browser -EOF - chmod 755 /home/developer/.local/bin/rigbox-t3code-start && \ - chown developer:developer /home/developer/.local/bin/rigbox-t3code-start diff --git a/catalog-apps/service/t3code/README.md b/catalog-apps/service/t3code/README.md index 0135f6b..31ab5d5 100644 --- a/catalog-apps/service/t3code/README.md +++ b/catalog-apps/service/t3code/README.md @@ -10,10 +10,10 @@ conversation to the right backend, and you never leave the browser tab. Most CLI coding agents (Claude Code, Codex, OpenCode, Pi, …) want their own terminal + their own provider env shape. T3 collapses that into a single web UI: each agent gets a tab, T3 owns the LLM connection, and a small **provider -shim** in the start wrapper translates whatever the workspace gives it into -the env vars each agent reads natively. +shim** in `start.sh` translates whatever the workspace gives it into the env +vars each agent reads natively. -The shim runs at boot in `rigbox-t3code-start`: +The shim runs at boot: - Sources `~/.rigbox/.env` so the managed-proxy injection (or any user secret) is in scope. @@ -24,24 +24,40 @@ The shim runs at boot in `rigbox-t3code-start`: Then it execs the absolute `t3` binary on `0.0.0.0:3773`. -## Why a Dockerfile patch +## Why `install:` patches the entrypoint `t3@0.0.23` ships a Node shebang but guards its entrypoint behind `if (import.meta.main)` — a Bun convention that is **unset under Node**. Left unpatched, `systemd` execs `t3`, the CLI exits 0 silently with no output, and -nothing ever listens on `:3773`. The Dockerfile rewrites that single line to -`if (true)` so the server actually starts. +nothing ever listens on `:3773`. The install script rewrites that single line to +`if (true)` so the server actually starts: + +```yaml +reproducible: true +install: | + set -euo pipefail + export NPM_CONFIG_PREFIX="$HOME/.npm-global" + npm install -g --no-fund --silent t3 + T3_DIST="$NPM_CONFIG_PREFIX/lib/node_modules/t3/dist/bin.mjs" + node -e '…' "$T3_DIST" # if (import.meta.main) → if (true) + t3 --version >/dev/null +``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM (as `developer`, with passwordless `sudo` for any system-path step); +`reproducible: true` is what makes `rig deploy` freeze its result. -```dockerfile -FROM rigbox-base -RUN npm install -g t3 && -COPY /home/developer/.local/bin/rigbox-t3code-start -``` +## Reproducible deploy + the hybrid model + +The deploy is **hybrid** — the image carries the environment, rsync carries the +boot wrapper next to `rig.yaml`: -- **First `rig deploy`**: builds the image (npm install + patch), boots from it. -- **Later `rig deploy`**: cached image reused, no re-install. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (`npm install -g t3` + the patch), snapshots the + rootfs as a content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no re-install, fast. Editing + `start.sh` only rsyncs; it doesn't rebuild. ## Deploy diff --git a/catalog-apps/service/t3code/rig.yaml b/catalog-apps/service/t3code/rig.yaml index 783c53b..22d9cb2 100644 --- a/catalog-apps/service/t3code/rig.yaml +++ b/catalog-apps/service/t3code/rig.yaml @@ -4,12 +4,46 @@ port: 3773 # the per-agent UIs stay responsive. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile -# rigbox-t3code-start sources $HOME/.rigbox/.env, translates managed-proxy / -# OPENROUTER_API_KEY into the ANTHROPIC_*/OPENAI_* shapes T3 reads, then exec's -# the absolute t3 binary on 0.0.0.0:3773. -start: /home/developer/.local/bin/rigbox-t3code-start +# Freeze `npm install -g t3` + the entrypoint patch into an image: `rig deploy` +# runs `install:` once in a builder VM, snapshots the rootfs, and later deploys +# with unchanged inputs boot from the cached image instead of re-installing. +reproducible: true +install: | + set -euo pipefail + # T3 Code — a browser-based AI workspace that hosts Claude Code, Codex, and + # other coding agents behind a single web UI + # (https://github.com/OpenRouterTeam/spawn). Installed with `npm install -g t3` + # into the base image's user-writable npm prefix, so root is never required. + # + # The script runs as `developer` — the same user the systemd unit runs as — + # so there's no `su -` dance here. + export NPM_CONFIG_PREFIX="$HOME/.npm-global" + mkdir -p "$NPM_CONFIG_PREFIX/bin" + export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" + npm install -g --no-fund --silent t3 + command -v t3 >/dev/null + + # t3@0.0.23 ships a Node shebang but gates its entrypoint behind + # `import.meta.main` — a Bun-only convention that is *unset* under Node. Left + # alone, systemd execs `t3`, the CLI exits 0 silently, and nothing ever listens + # on :3773. Rewrite that one line. Idempotent: a re-run finds nothing to match. + T3_DIST="$NPM_CONFIG_PREFIX/lib/node_modules/t3/dist/bin.mjs" + test -f "$T3_DIST" + node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const body = fs.readFileSync(file, "utf8"); + const guarded = "if (import.meta.main) Command.run(cli, { version })"; + if (body.includes(guarded)) { + fs.writeFileSync(file, body.replace(guarded, "if (true) Command.run(cli, { version })")); + } + ' "$T3_DIST" + t3 --version >/dev/null +# start.sh rsyncs in with the app. systemd does not run a login shell, so the +# wrapper sources the workspace env file, translates the managed-proxy / +# OPENROUTER_API_KEY injection into the ANTHROPIC_*/OPENAI_* shapes T3 reads, +# then exec's the absolute t3 binary on 0.0.0.0:3773. +start: bash start.sh health: # T3 doesn't expose a dedicated probe; the app root returns the SPA shell # once the Node server is listening. diff --git a/catalog-apps/service/t3code/start.sh b/catalog-apps/service/t3code/start.sh new file mode 100644 index 0000000..60e2380 --- /dev/null +++ b/catalog-apps/service/t3code/start.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Boot wrapper for T3 Code. systemd does not run a login shell, so source the +# workspace env file explicitly and translate the provider envs the workspace +# injects into the shapes T3 reads (ANTHROPIC_*/OPENAI_*) before exec'ing the +# absolute binary. +set -euo pipefail + +export HOME="${HOME:-/home/developer}" +export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-/home/developer/.npm-global}" +export PATH="${NPM_CONFIG_PREFIX}/bin:/home/developer/.local/bin:/usr/local/bin:/usr/bin:/bin" + +set -a +[ -f /home/developer/.rigbox/.env ] && . /home/developer/.rigbox/.env +set +a + +# Managed Rigbox proxy injection → OpenAI/OpenRouter env shapes. +proxy_base="${AI_PROXY_URL:-${RIGBOX_AI_PROXY_URL:-}}" +if [ -n "${proxy_base}" ]; then + proxy_base="${proxy_base%/}" + [ -z "${OPENAI_BASE_URL:-}" ] && export OPENAI_BASE_URL="${proxy_base}/v1" + [ -z "${OPENAI_API_KEY:-}" ] && export OPENAI_API_KEY="managed-by-rigbox" + [ -z "${OPENROUTER_BASE_URL:-}" ] && export OPENROUTER_BASE_URL="${proxy_base}/v1" + [ -z "${OPENROUTER_API_KEY:-}" ] && export OPENROUTER_API_KEY="${OPENAI_API_KEY}" +fi + +# OpenRouter key → ANTHROPIC_*/OPENAI_* shapes T3 also reads. +if [ -n "${OPENROUTER_API_KEY:-}" ]; then + [ -z "${OPENAI_API_KEY:-}" ] && export OPENAI_API_KEY="${OPENROUTER_API_KEY}" + [ -z "${OPENAI_BASE_URL:-}" ] && export OPENAI_BASE_URL="${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}" + openrouter_base="${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}" + if [[ "${openrouter_base}" == https://openrouter.ai/* ]]; then + [ -z "${ANTHROPIC_API_KEY:-}" ] && export ANTHROPIC_API_KEY="${OPENROUTER_API_KEY}" + [ -z "${ANTHROPIC_BASE_URL:-}" ] && export ANTHROPIC_BASE_URL="https://openrouter.ai/api" + fi +fi + +exec /home/developer/.npm-global/bin/t3 --port 3773 --host 0.0.0.0 --mode web --no-browser diff --git a/code-server/Dockerfile b/code-server/Dockerfile deleted file mode 100644 index 392d5cf..0000000 --- a/code-server/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM rigbox-base -# Install code-server (VS Code in the browser) on top of the rigbox base. -RUN curl -fsSL https://code-server.dev/install.sh | sh diff --git a/code-server/README.md b/code-server/README.md index 168edc6..c5a3429 100644 --- a/code-server/README.md +++ b/code-server/README.md @@ -5,36 +5,34 @@ on Rigbox. Open the app and you get the full VS Code UI: file tree, editor, integrated terminal, extensions. This is an established off-the-shelf product, not hand-written app code. -## The single capability: an established product via a reproducible Docker build +## The single capability: an established product, installed reproducibly The whole point here is running a real, third-party product **as-is** through a -reproducible Dockerfile build. The `Dockerfile` is `FROM rigbox-base` (the -required base — the platform asserts the rigbox agent + systemd are present and -rejects any other base at build time) and installs code-server on top with its -official installer: - -```dockerfile -FROM rigbox-base -# Install code-server (VS Code in the browser) on top of the rigbox base. -RUN curl -fsSL https://code-server.dev/install.sh | sh -``` - -`rig.yaml` points at it with a `build:` block (no `install:`): +reproducible deploy. `rig.yaml`'s `install:` script puts code-server onto the +Rigbox base with its official installer, and `reproducible: true` freezes the +result so it only ever runs once: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + # Install code-server (VS Code in the browser) on top of the rigbox base. + curl -fsSL https://code-server.dev/install.sh | sudo sh ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model The deploy is **hybrid** — the image carries the environment, rsync carries the code: -- **First `rig deploy`**: builds the image from the local `Dockerfile` - (code-server installed once), boots the workspace from that frozen image. -- **Later `rig deploy`**: if the build inputs (Dockerfile, base image) are - unchanged, it **reuses the cached image** — no re-install, fast. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (code-server installed once), snapshots the rootfs + as a content-addressed image, boots the workspace from it. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** — no re-install, fast. ## Deploy diff --git a/code-server/rig.yaml b/code-server/rig.yaml index a84950f..66b9d92 100644 --- a/code-server/rig.yaml +++ b/code-server/rig.yaml @@ -3,8 +3,17 @@ port: 8080 # A browser IDE needs headroom for language servers, extensions, and terminals. workspace: resources: { ramMb: 2048, vcpuCount: 2, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile +# Freeze the code-server install into an image: `rig deploy` runs `install:` +# once in a builder VM, snapshots the rootfs, and later deploys with unchanged +# inputs boot from the cached image instead of re-running the installer. +reproducible: true +install: | + set -euo pipefail + # Install code-server (VS Code in the browser) on top of the rigbox base. The + # upstream installer lays down the .deb at the system path (/usr/bin), so it + # runs as root and `code-server` resolves on the unit's PATH. + curl -fsSL https://code-server.dev/install.sh | sudo sh + code-server --version start: >- code-server --bind-addr 0.0.0.0:8080 --auth none --user-data-dir /home/developer/data/code-server diff --git a/design/CONTRACT.md b/design/CONTRACT.md index cfa0f5d..1eb218c 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -93,40 +93,35 @@ apps: app name) into the dependent's env, pointing at the sibling over loopback. Boot order follows the dependency graph. -## Reproducible builds — Dockerfile + the hybrid deploy +## Reproducible builds — `reproducible: true` + the hybrid deploy -Most examples install their runtime with `install:`, which runs on the booted VM -on every deploy. An example can instead **freeze its environment into an image** -with a Dockerfile and deploy reproducibly: +Every example installs its runtime with `install:`. By default that script runs +on the booted workspace VM on every deploy. An example can instead **freeze the +result of `install:` into an image** by adding one app-level flag: ```yaml name: my-app port: 8080 -build: - dockerfile: Dockerfile # local path; no source: block needed +reproducible: true # freeze install: into an image; no Dockerfile +install: | + set -euo pipefail + pip install --break-system-packages --no-cache-dir flask gunicorn start: health: { path: /healthz, timeoutSeconds: 30 } ``` -```dockerfile -# Dockerfile — MUST be FROM rigbox-base. The platform asserts the rigbox agent -# + systemd are present and rejects any other base at build time, so you keep -# the platform's opinions while adding your own dependencies on top. -FROM rigbox-base -RUN pip install --break-system-packages --no-cache-dir flask gunicorn -``` - -Declaring a `build.dockerfile` (or `build.image`) is the **only** signal needed — -`rig deploy` then takes the reproducible path automatically (no flag). Plain -`rig deploy`: +`reproducible: true` (bool, default `false`) is the **only** signal needed — the +same `install:` script, the same `rig deploy`. There is no Dockerfile and no +`build: { dockerfile | image }` map; `rig` rejects those with a hint to set the +flag and move `RUN` steps into `install:`. -- **First deploy** builds the image from the local `Dockerfile` — the CLI uploads - the project directory as the build context, so **no git repo is required** — - boots the workspace from that frozen image, then rsyncs the app code. -- **Later deploys** reuse the cached image when the build inputs (the Dockerfile, - declared deps/lockfiles, base image) are unchanged and **only rsync the changed - code**: no rebuild, no re-install. Edit the Dockerfile or a lockfile and the - next deploy rebuilds the image. +- **First deploy** boots a throwaway builder VM from the `base` image, runs + `install:` inside it, snapshots the rootfs as a content-addressed image, boots + the workspace from that frozen image, then rsyncs the app code. +- **Later deploys** reuse the cached image when the build inputs (the `install:` + script, declared deps/lockfiles, base image) are unchanged and **only rsync + the changed code**: no rebuild, no re-install. Edit `install:` or a lockfile + and the next deploy rebuilds the image. That's the **hybrid model**: the slow, stable environment is built once and frozen; fast-changing app code rides over it via rsync. It fits interpreted @@ -134,14 +129,30 @@ runtimes — deps install to **system paths** (e.g. pip `--break-system-packages so the rsynced code finds them. Keep your app's own source **out** of the image; it arrives by rsync. +Rules for a reproducible `install:`: + +- It runs as **`developer`** (login shell, passwordless `sudo`) with the deploy + dir as CWD. In the builder that dir is **empty** — none of your project files + are there — so anything the script needs must be inline (config files and + `/etc/profile.d` snippets via quoted heredocs) or fetched from the network. + `sudo` only the steps that need a system path (`/usr/local`, `/etc`, `/opt`, apt). +- It must be **idempotent**: with `reproducible` off the identical script runs on + the workspace VM after rsync, and re-runs whenever it changes. +- Runtime wrappers stay in the repo and rsync in with the code — + `start: bash start.sh` (a relative `./start.sh` is rejected by systemd; a bare + command resolves via PATH). +- The builder VM boots with 1GB RAM / 1 vCPU and takes its disk from the app's + `workspace.resources.diskSizeMb` (3GB default, 16GB ceiling): the frozen image + boots into that workspace, so it is sized to hold what `install:` wrote. + When to use which: -- `install:` (no Dockerfile) — simple apps with fast installs. The default. -- `build: { dockerfile }` — heavier or slower environments you want frozen and +- `install:` alone — simple apps with fast installs. The default. +- `reproducible: true` — heavier or slower environments you want frozen and byte-identical across deploys; `rig deploy` builds + mounts the image for you. -Examples on the Dockerfile path: **`ai-chat`** and **`markdown-notes`**. The rest -use `install:`. +Examples on the reproducible path: **`code-server`**, **`gitea`**, **`n8n`**, and +every example under **`catalog-apps/`**. The rest use plain `install:`. ## Resources diff --git a/gitea/Dockerfile b/gitea/Dockerfile deleted file mode 100644 index 7c2df92..0000000 --- a/gitea/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM rigbox-base -# Freeze a pinned Gitea binary into the image so deploys don't re-download it. -# Gitea ships as a single static linux-amd64 binary — no Go toolchain needed. -ARG GITEA_VERSION=1.22.6 -ARG GITEA_SHA256=fd77f1a0273c85a0950207c1cfa6753a9fa57604e4ab1382484b191cc919ce15 -RUN curl -fsSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" \ - -o /usr/local/bin/gitea \ - && echo "${GITEA_SHA256} /usr/local/bin/gitea" | sha256sum -c - \ - && chmod +x /usr/local/bin/gitea \ - && gitea --version - -# Bake the headless-boot wrapper at an absolute path. systemd's ExecStart needs -# an absolute path (a relative `./start.sh` is rejected), so the rig.yaml `start` -# points here rather than at the rsynced copy. -COPY start.sh /usr/local/bin/gitea-start.sh -RUN chmod +x /usr/local/bin/gitea-start.sh diff --git a/gitea/README.md b/gitea/README.md index d92460a..c611d88 100644 --- a/gitea/README.md +++ b/gitea/README.md @@ -4,51 +4,56 @@ a lightweight GitHub-in-a-box with repos, issues, pull requests, and a web UI, shipped as a single static Go binary. This example runs it on Rigbox unchanged. -## The single capability: run Gitea reproducibly on Rigbox via a Docker build +## The single capability: run Gitea reproducibly on Rigbox This isn't a toy app we wrote — it's a real, off-the-shelf product running on the -platform. The one thing it demonstrates is the **reproducible `FROM rigbox-base` -Docker build**: the `Dockerfile` downloads a **pinned** Gitea binary -(`1.22.6`, checksum-verified) onto the Rigbox base image once, and every deploy -boots from that frozen image instead of re-downloading. - -```dockerfile -FROM rigbox-base -ARG GITEA_VERSION=1.22.6 -RUN curl -fsSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" \ - -o /usr/local/bin/gitea \ - && echo " /usr/local/bin/gitea" | sha256sum -c - \ - && chmod +x /usr/local/bin/gitea -``` - -`rig.yaml` points at it with a `build:` block — no `install:`, no flag: +platform. The one thing it demonstrates is the **reproducible deploy**: the +`install:` script downloads a **pinned** Gitea binary (`1.22.6`, +checksum-verified) onto the Rigbox base once, and every deploy boots from that +frozen image instead of re-downloading. ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + GITEA_VERSION=1.22.6 + GITEA_SHA256=fd77f1a0… + curl -fsSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" -o /tmp/gitea + echo "${GITEA_SHA256} /tmp/gitea" | sha256sum -c - + sudo install -m 755 /tmp/gitea /usr/local/bin/gitea ``` -## Docker build + the hybrid deploy +No Dockerfile — `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. + +## Reproducible deploy + the hybrid model The deploy is **hybrid** — the image carries the Gitea binary, rsync carries the app config: -- **First `rig deploy`**: builds the image from the local `Dockerfile` (Gitea - binary frozen once), boots the workspace from it, then rsyncs `start.sh` on top. -- **Later `rig deploy`**: if the build inputs (Dockerfile/version) are unchanged, - it **reuses the cached image** and only rsyncs the changed code — no - re-download, fast. Bump `GITEA_VERSION` in the Dockerfile to upgrade. +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (Gitea binary frozen once), snapshots the rootfs as + a content-addressed image, boots the workspace from it, then rsyncs `start.sh` + on top. +- **Later `rig deploy`**: if the build inputs (`install:` script, base image) + are unchanged, it **reuses the cached image** and only rsyncs the changed + code — no re-download, fast. Bump `GITEA_VERSION` (and the sha256) in + `rig.yaml` to upgrade. + +`start: bash start.sh` runs the rsynced wrapper from the synced app dir — the +wrapper isn't part of the image, so editing it is a code-only redeploy. ## No setup wizard (the crux) Gitea normally greets a fresh install with an interactive web **install wizard** — which would hang the health check forever. This example boots **headless**: -- Everything is configured up front via `GITEA__
__` environment - variables in `rig.yaml` (Gitea reads these at startup) — HTTP address/port, - SQLite database, repository root, data/log/session paths. -- **`GITEA__security__INSTALL_LOCK=true`** is the bypass: it makes Gitea boot - straight to the app and refuse to serve `/install`. The wizard never appears. +- `start.sh` generates `app.ini` under `$DATA_DIR` on first boot — HTTP + address/port, SQLite database, repository root, data/log/session paths. (The + raw gitea binary ignores `GITEA__section__KEY` env vars; only the upstream + Docker entrypoint translates those, so the config is generated as a file.) +- **`INSTALL_LOCK = true`** is the bypass: it makes Gitea boot straight to the + app and refuse to serve `/install`. The wizard never appears. So Gitea comes up clean, binds `0.0.0.0:8080`, and `GET /api/healthz` goes green without any human in the loop. @@ -91,5 +96,6 @@ No required env — everything is set in `rig.yaml`. - **Persistence: yes.** SQLite + repos + work dir under `$DATA_DIR`, durable across redeploys. - Health: `GET /api/healthz` → 2xx; the process binds `0.0.0.0:8080`. -- Wizard: bypassed via `GITEA__security__INSTALL_LOCK=true` + full env config. -- Stack: Gitea (single static Go binary), pinned + checksum-verified in the image. +- Wizard: bypassed via `INSTALL_LOCK = true` in the generated `app.ini`. +- Stack: Gitea (single static Go binary), pinned + checksum-verified in the + reproducible image. diff --git a/gitea/rig.yaml b/gitea/rig.yaml index 5d6c3a7..5f71b82 100644 --- a/gitea/rig.yaml +++ b/gitea/rig.yaml @@ -3,13 +3,24 @@ port: 8080 # Gitea is a single lightweight Go binary — 1GB is comfortable. workspace: resources: { ramMb: 1024, vcpuCount: 1, diskSizeMb: 3072 } -build: - dockerfile: Dockerfile -# start.sh generates app.ini (headless, INSTALL_LOCK on) under $DATA_DIR, then -# exec's `gitea web --config`. The raw gitea binary ignores GITEA__* env vars -# (only the upstream Docker entrypoint translates them), so configuration lives -# in the generated app.ini instead. -start: /usr/local/bin/gitea-start.sh +# Freeze the pinned, checksum-verified Gitea binary into an image: `rig deploy` +# runs `install:` once in a builder VM and later deploys reuse the cached image +# instead of re-downloading. Bump GITEA_VERSION (+ sha256) here to upgrade. +reproducible: true +install: | + set -euo pipefail + GITEA_VERSION=1.22.6 + GITEA_SHA256=fd77f1a0273c85a0950207c1cfa6753a9fa57604e4ab1382484b191cc919ce15 + curl -fsSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64" -o /tmp/gitea + echo "${GITEA_SHA256} /tmp/gitea" | sha256sum -c - + sudo install -m 755 /tmp/gitea /usr/local/bin/gitea + rm -f /tmp/gitea + gitea --version +# start.sh rsyncs in with the app and generates app.ini (headless, INSTALL_LOCK +# on) under $DATA_DIR, then exec's `gitea web --config`. The raw gitea binary +# ignores GITEA__* env vars (only the upstream Docker entrypoint translates +# them), so configuration lives in the generated app.ini instead. +start: bash start.sh health: path: /api/healthz timeoutSeconds: 90 diff --git a/n8n/Dockerfile b/n8n/Dockerfile deleted file mode 100644 index 4f38e4e..0000000 --- a/n8n/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM rigbox-base -# Install n8n (workflow automation) on top of the rigbox base. The heavy npm -# install is frozen into the image so deploys don't re-run it. -RUN npm install -g n8n diff --git a/n8n/README.md b/n8n/README.md index 80797d5..d9f3158 100644 --- a/n8n/README.md +++ b/n8n/README.md @@ -10,38 +10,38 @@ shared example design language. n8n is a big Node app, and `npm install -g n8n` is heavy. The point of this example is that the heavy install happens **once**, frozen into an image, and -every later deploy reuses it. The `Dockerfile` is `FROM rigbox-base` (the -Debian-12 base with node22 + the rigbox agent/systemd) and bakes n8n into the -image's global npm packages: - -```dockerfile -FROM rigbox-base -RUN npm install -g n8n -``` - -`rig.yaml` points at it with a `build:` block (no `install:`), which makes -`rig deploy` reproducible automatically — no flag: +every later deploy reuses it. `rig.yaml` declares the install as a plain +`install:` script and flips on `reproducible: true`: ```yaml -build: - dockerfile: Dockerfile +reproducible: true +install: | + set -euo pipefail + sudo npm install -g n8n ``` -## Docker build + the hybrid deploy +No Dockerfile. `install:` is the same script a plain deploy would run on the +VM; `reproducible: true` is what makes `rig deploy` freeze its result. -The deploy is **hybrid** — the image carries the environment, rsync carries the -code: +## Reproducible deploy + the hybrid model -- **First `rig deploy`**: builds the image from the local `Dockerfile` - (`npm install -g n8n` runs once — this build is **slow**, expect a few - minutes), boots the workspace from that frozen image, then starts - `n8n start`. -- **Later `rig deploy`**: if the build inputs (the Dockerfile, base image) are - unchanged, it **reuses the cached image** — no npm re-run, fast. +The deploy is **hybrid** — the frozen image carries the environment, rsync +carries the code: + +- **First `rig deploy`**: boots a throwaway builder VM from the `base` image, + runs `install:` inside it (`npm install -g n8n` runs once — this build is + **slow**, expect a few minutes), snapshots the rootfs as a content-addressed + image, boots the workspace from that image, then starts `n8n start`. +- **Later `rig deploy`**: if the build inputs (the `install:` script, base + image) are unchanged, it **reuses the cached image** — no npm re-run, fast. Because n8n lives in the image at a system path (global npm), `n8n start` finds it at runtime. +> n8n's `node_modules` is large. The builder takes its disk from +> `workspace.resources.diskSizeMb`, so keep that value comfortably above the +> installed footprint — see the repo README. + ## Persistence (survives redeploys) n8n keeps its SQLite database, its **encryption key**, and all your saved @@ -87,4 +87,4 @@ No required env — everything is set in `rig.yaml`. The first deploy is slow - `N8N_SECURE_COOKIE=false` because the app is served behind the Rigbox gateway over the workspace subdomain; n8n would otherwise refuse to load the editor over the proxied connection. -- Stack: n8n on Node 22, baked into the image. +- Stack: n8n on Node 22, frozen into the reproducible image. diff --git a/n8n/rig.yaml b/n8n/rig.yaml index b987eca..d44fcdf 100644 --- a/n8n/rig.yaml +++ b/n8n/rig.yaml @@ -7,11 +7,17 @@ workspace: resources: ramMb: 2048 vcpuCount: 2 -build: - dockerfile: Dockerfile - # n8n's node_modules is large; the base + install overflow the default rootfs. - # Size the frozen image's ext4 generously so the docker→ext4 export fits. - sizeMb: 8192 + # Holds the frozen `npm install -g n8n` tree and sizes the builder VM + # that produces it. + diskSizeMb: 8192 +# Freeze the heavy `npm install -g n8n` into an image: `rig deploy` runs +# `install:` once in a builder VM, snapshots the rootfs, and later deploys with +# unchanged inputs boot from the cached image and only rsync code. +reproducible: true +install: | + set -euo pipefail + # Global install to a system path so `n8n start` resolves on the unit's PATH. + sudo npm install -g n8n start: n8n start health: path: /healthz