diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..4bc5f6c79 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +* +!docker/entrypoint.sh +!docker/settings.toml +!docker-context/** diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f4d75a457..e4954108f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Mint GitHub App token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ vars.FABRO_RELEASES_APP_ID }} private-key: ${{ secrets.FABRO_RELEASES_APP_PRIVATE_KEY }} @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # full history + tags for nightly probing - token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - name: Skip if HEAD is already a nightly tag id: skip @@ -43,6 +43,8 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: steps.skip.outputs.skip != 'true' + with: + no-cache: true - name: Install bun deps (for SPA verify) if: steps.skip.outputs.skip != 'true' @@ -65,4 +67,6 @@ jobs: run: | git config user.name "fabro-releases[bot]" git config user.email "fabro-releases[bot]@users.noreply.github.com" + git remote set-url origin \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" bin/dev/release.sh nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4873d35c6..3a0a3ce90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,8 @@ jobs: with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + no-cache: true - run: bun install - run: scripts/refresh-fabro-spa.sh - run: git diff --exit-code -- lib/crates/fabro-spa/assets @@ -101,6 +103,64 @@ jobs: fi gh release create "$TAG_NAME" target/distrib/* --generate-notes $PRERELEASE_FLAG + docker: + name: Docker image + needs: compile + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: target/distrib + pattern: artifacts-fabro-*-linux-gnu + merge-multiple: true + + - name: Stage binaries for Docker build context + run: | + for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do + case "$target" in + x86_64-*) arch=amd64 ;; + aarch64-*) arch=arm64 ;; + esac + mkdir -p "docker-context/$arch" + tar -xzf "target/distrib/fabro-${target}.tar.gz" -C target/distrib + cp "target/distrib/fabro-${target}/fabro" "docker-context/$arch/fabro" + done + + - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.10.0 + + - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ghcr.io/fabro-sh/fabro + tags: | + type=match,pattern=v(.*),group=1 + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + type=raw,value=nightly,enable=${{ contains(github.ref_name, '-nightly.') }} + + - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + update-homebrew: name: Update Homebrew Formula needs: release diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ad6ea78b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.9 +# +# Runtime image for the Fabro server. +# +# Binaries are supplied pre-built via the release workflow: +# docker-context/amd64/fabro (x86_64-unknown-linux-gnu) +# docker-context/arm64/fabro (aarch64-unknown-linux-gnu) +# +# The image serves the HTTP API (with embedded web UI) on port 80, +# persists state to /storage, and runs as the unprivileged `fabro` user. + +FROM debian:trixie-slim + +ARG TARGETARCH + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libcap2-bin \ + tini \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 1000 fabro \ + && useradd --system --uid 1000 --gid fabro \ + --home-dir /var/fabro --shell /usr/sbin/nologin fabro \ + && install -d -o fabro -g fabro -m 0755 /var/fabro /storage \ + && install -d -m 0755 /etc/fabro + +COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro +RUN setcap cap_net_bind_service=+ep /usr/local/bin/fabro + +COPY docker/settings.toml /etc/fabro/settings.toml +COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint + +ENV FABRO_HOME=/var/fabro \ + FABRO_CONFIG=/etc/fabro/settings.toml + +VOLUME ["/storage"] +EXPOSE 80 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/fabro-entrypoint"] +CMD ["fabro", "server", "start", "--foreground", "--bind", "0.0.0.0:80"] diff --git a/bin/dev/docker-build.sh b/bin/dev/docker-build.sh new file mode 100755 index 000000000..9ee8dc785 --- /dev/null +++ b/bin/dev/docker-build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build a local fabro Docker image from the current working tree. +# +# The release workflow uses prebuilt release tarballs, but for local development +# we cargo build first and stage the binary where the Dockerfile expects it. + +set -euo pipefail + +cd "$(dirname "$0")/../.." + +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) + echo "unsupported host arch: $(uname -m)" >&2 + exit 1 + ;; +esac + +echo "Building fabro (release, host arch: $arch)..." +cargo build --release -p fabro-cli + +mkdir -p "docker-context/$arch" +cp target/release/fabro "docker-context/$arch/fabro" + +echo "Building Docker image..." +docker build -t fabro . diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 32a57261b..000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM rust:1-bookworm@sha256:4ec71e955e6c08aeb238885083222ddff79d82eb87654a96c76e38e94da1a53b AS rust-builder -WORKDIR /app -COPY Cargo.toml Cargo.lock ./ -COPY lib/crates lib/crates -COPY openapi openapi -RUN cargo build --release --bin fabro - -FROM oven/bun:1@sha256:c9aa897b6028d54e510c4babd41bcdd481ad4a4b5030ad31135a9cabfe2c24df -RUN apt-get update && apt-get install -y --no-install-recommends \ - python3 make g++ ca-certificates nodejs npm && \ - rm -rf /var/lib/apt/lists/* -RUN npm i -g mintlify@4.2.507 && \ - sed -i 's/const version = __VERSION__/const version = "0.0.0"/' \ - /usr/local/lib/node_modules/mintlify/node_modules/katex/dist/katex.mjs - -WORKDIR /app - -COPY --from=rust-builder /app/target/release/fabro /usr/local/bin/fabro - -COPY package.json bun.lock ./ -COPY apps/fabro-web/package.json apps/fabro-web/ -COPY lib/packages/fabro-api-client/package.json lib/packages/fabro-api-client/ -RUN bun install --frozen-lockfile - -COPY apps/fabro-web apps/fabro-web -COPY lib/packages/fabro-api-client lib/packages/fabro-api-client -COPY docs docs -COPY docker/entrypoint.ts . - -ENTRYPOINT ["bun", "run", "entrypoint.ts"] diff --git a/docker/demo-settings.toml b/docker/demo-settings.toml deleted file mode 100644 index 53a144640..000000000 --- a/docker/demo-settings.toml +++ /dev/null @@ -1,19 +0,0 @@ -[api] -base_url = "http://api:3000" - -[features] -session_sandboxes = false - -[checkpoint] -exclude_globs = [ - "**/node_modules/**", - "**/.pnpm-store/**", - "**/.npm/**", - "**/.cache/**", - "**/playwright-report/**", - "**/test-results/**", - "**/.cargo-target*/**", - "**/.cargo_target*/**", - "**/.wasm-pack/**", - "**/.tmpbuild/**", -] diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 8ec7f8ca1..30c9479fc 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,35 +1,13 @@ services: - api: + fabro: image: fabro build: context: .. - dockerfile: docker/Dockerfile - platforms: - - linux/arm64 - pull_policy: never - command: ["api"] + dockerfile: Dockerfile ports: - - "3000:3000" - - web: - image: fabro - pull_policy: never - command: ["web"] - ports: - - "5173:5173" - environment: - - FABRO_DEMO=1 + - "80:80" volumes: - - ./demo-settings.toml:/root/.fabro/settings.toml:ro - - ../apps/fabro-web/public:/app/apps/fabro-web/public - depends_on: - - api + - fabro-storage:/storage - docs: - image: fabro - pull_policy: never - command: ["docs"] - ports: - - "3333:3333" - volumes: - - ../docs:/app/docs:ro +volumes: + fabro-storage: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..09675fb73 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +# When started as root (the default), ensure the storage volume is writable +# by the unprivileged fabro user, then drop privileges. File capabilities on +# /usr/local/bin/fabro grant CAP_NET_BIND_SERVICE so it can still bind port 80. +if [ "$(id -u)" = 0 ]; then + chown fabro:fabro /storage + exec runuser -u fabro -- "$@" +fi + +exec "$@" diff --git a/docker/entrypoint.ts b/docker/entrypoint.ts deleted file mode 100644 index 6824a8ad7..000000000 --- a/docker/entrypoint.ts +++ /dev/null @@ -1,44 +0,0 @@ -const service = process.argv[2]; - -type ServiceOptions = { - command: string[]; - cwd?: string; -}; - -const services: Record = { - api: { - command: ["fabro", "serve", "--host", "0.0.0.0"], - }, - web: { - command: ["bun", "run", "dev", "--host", "0.0.0.0"], - cwd: "apps/fabro-web", - }, - docs: { - command: ["mintlify", "dev", "--port", "3333", "--host", "0.0.0.0"], - cwd: "docs", - }, -}; - -const validServices = Object.keys(services).join("|"); - -if (!service) { - console.error(`Usage: docker run fabro <${validServices}>`); - process.exit(1); -} - -const config = services[service]; -if (!config) { - console.error( - `Unknown service: ${service}. Use one of: ${Object.keys(services).join(", ")}`, - ); - process.exit(1); -} - -const child = Bun.spawn(config.command, { - stdout: "inherit", - stderr: "inherit", - stdin: "inherit", - cwd: config.cwd, -}); - -process.exit(await child.exited); diff --git a/docker/settings.toml b/docker/settings.toml new file mode 100644 index 000000000..ad625e87e --- /dev/null +++ b/docker/settings.toml @@ -0,0 +1,2 @@ +[server.storage] +root = "/storage" diff --git a/docs-internal/updating-web-screenshots.md b/docs-internal/updating-web-screenshots.md index 18f3e8775..de8d03d2a 100644 --- a/docs-internal/updating-web-screenshots.md +++ b/docs-internal/updating-web-screenshots.md @@ -2,53 +2,37 @@ Screenshots of the Fabro web UI are embedded in the public docs. This guide covers how to retake them when the UI changes. +> **Known gaps** after the server absorbed the web UI (April 2026): +> - **Demo mode** is now per-request via the `X-Fabro-Demo: 1` header, not a container env var. The browser won't send it by default — you'll need a browser extension (e.g. ModHeader) or a sidecar proxy to inject the header. Needs a follow-up to land a server-level toggle. +> - **HMR trick is gone.** Web assets are baked into the Rust binary (`fabro-spa/assets`), so the `sed` technique for hiding nav items no longer works. Either commit a temporary nav change to a local branch and rebuild, or script visibility changes in the browser devtools. + ## Prerequisites - Docker installed - Chrome running with DevTools MCP or similar screenshot tool -- The `fabro` Docker image already built (`docker compose -f docker/docker-compose.yaml build`) +- Header-injection tool (browser extension or proxy) to send `X-Fabro-Demo: 1` +- The `fabro` Docker image built locally: `bin/dev/docker-build.sh` ## Boot the demo environment ```bash -docker compose -f docker/docker-compose.yaml up api web -d +docker compose -f docker/docker-compose.yaml up -d ``` -Wait ~10 seconds for both services to be ready, then verify: +Wait ~5 seconds for the server to be ready, then verify (sending the demo header): ```bash -curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/runs +curl -s -H "X-Fabro-Demo: 1" -o /dev/null -w "%{http_code}" http://localhost/runs # Should return 200 ``` -The web container runs in demo mode (`FABRO_DEMO=1`), which returns synthetic data from the API. - ## Applying temporary UI changes for screenshots -The Docker image bakes in the fabro-web source at build time. To make temporary changes (like hiding nav items), edit files inside the running container using `sed`: - -```bash -# Example: hide Start and Settings nav items -docker exec docker-web-1 sed -i '/{.*"Start".*}/d; /{.*"Settings".*}/d' \ - /app/apps/fabro-web/app/layouts/app-shell.tsx -``` - -**Do not use `docker cp`** to replace source files — it breaks Vite's module resolution and causes 500 errors. Use `sed -i` inside the container instead, which preserves the file inode and lets HMR work correctly. - -`docker cp` works fine for static assets in `public/` (logos, images), just not for source files that Vite processes. - -After `sed` edits, wait a few seconds for HMR to rebuild, then verify pages still return 200 before taking screenshots. +With the SPA baked into the binary, there's no in-container edit path. Commit the nav change on a local branch, rerun `bin/dev/docker-build.sh`, then `docker compose up -d --force-recreate`. ## Updating logos -The logos are static files in `apps/fabro-web/public/`. The Docker compose file mounts this directory as a volume: - -```yaml -volumes: - - ../apps/fabro-web/public:/app/apps/fabro-web/public -``` - -So changes to `apps/fabro-web/public/logotype.svg` (dark theme) and `apps/fabro-web/public/logotype-light.svg` (light theme) are picked up immediately by the running container. The source-of-truth logos are in `docs/logo/dark.svg` and `docs/logo/light.svg`. +Logos live at `apps/fabro-web/public/logotype.svg` (dark) and `apps/fabro-web/public/logotype-light.svg` (light). These are bundled into the SPA at build time — rebuilding the image picks up the changes. The source-of-truth logos are in `docs/logo/dark.svg` and `docs/logo/light.svg`. ## Browser setup @@ -61,7 +45,7 @@ If the nav bar is too crowded at this width, hide low-priority items (Start, Set ## Taking screenshots -Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL: +Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL (served from `http://localhost/` with the `X-Fabro-Demo: 1` header): | File | URL | |---|---| @@ -91,7 +75,6 @@ Screenshots live in `docs/images/web/`. Each screenshot maps to a specific URL: ### Pages that need extra wait time - **Run overview** (`/runs/run-1`) — the workflow graph diagram takes 2-3 seconds to render after the page loads. Wait before screenshotting. -- **After `sed` edits** — HMR needs a few seconds to rebuild. The first navigation after an edit may hit a transient error; retry once. ## Where screenshots are used in docs