diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38a57d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell scripts + systemd units must stay LF — they run on the Linux VM and +# would break (bad interpreter / `\r` in commands) with CRLF line endings, +# regardless of a contributor's local core.autocrlf setting. +*.sh text eol=lf +*.service text eol=lf +deploy/** text eol=lf diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml new file mode 100644 index 0000000..7302914 --- /dev/null +++ b/.github/workflows/ci-full.yml @@ -0,0 +1,29 @@ +name: CI (full, real Gemini) + +# The complete suite INCLUDING the integration tests that call the real Gemini +# API (per CLAUDE.md: "tests run against the real LLM using keys from .env"). +# This burns tokens, so it is NOT wired to every push — it runs nightly and +# on-demand. Requires the AGENT_GEMINI_API_KEY repo secret. + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * *" # 03:00 UTC nightly + +jobs: + full-suite: + name: Full pytest suite (real Gemini) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System libs for OpenCASCADE / matplotlib + run: sudo apt-get update && sudo apt-get install -y libgl1 libglu1-mesa + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install deps (uv sync) + run: uv sync --dev + - name: Full suite + run: uv run pytest -q + env: + AGENT_GEMINI_API_KEY: ${{ vars.AGENT_GEMINI_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..30ab06a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +# Fast, dependency-free gate: frontend build + deterministic backend tests. +# The real-Gemini integration suite is intentionally NOT run here (it costs +# tokens on every push). It runs in ci-full.yml (nightly + manual). LLM tests +# auto-skip when AGENT_GEMINI_API_KEY is unset (tests/conftest.py::_require_llm_key +# and tests/integration/conftest.py::require_gemini), so simply not providing the +# key gives a clean deterministic gate. + +on: + push: + branches: [main, "cicd/**"] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + frontend: + name: Frontend build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm build + + backend: + name: Backend tests (deterministic, no Gemini key) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: System libs for OpenCASCADE / matplotlib + run: sudo apt-get update && sudo apt-get install -y libgl1 libglu1-mesa + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install deps (uv sync) + run: uv sync --dev + - name: Deterministic unit tests + # No AGENT_GEMINI_API_KEY in env -> every real-LLM test auto-skips. + run: uv run pytest tests/unit -q + - name: Migration smoke (alembic upgrade head) + run: | + mkdir -p data + uv run alembic upgrade head + env: + AGENT_DATABASE_URL: sqlite:///./data/ci.db diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4682a05 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,70 @@ +name: Deploy to VM + +# On merge to main: build the frontend, bundle the app, ship it to the GCP VM, +# write the VM's .env from secrets, install deps + run migrations, restart the +# systemd service. The VM must be bootstrapped once first (see deploy/README.md). + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-vm + cancel-in-progress: false # never interrupt an in-flight deploy + +jobs: + deploy: + name: Build + deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # ---- build the static frontend (never build on the 2 GB VM) ---- + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Build frontend + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + # ---- bundle backend + built frontend ---- + - name: Create deploy bundle + run: tar czf bundle.tar.gz src alembic alembic.ini pyproject.toml uv.lock frontend/out + + - name: Write .env for the VM + run: | + umask 077 + cat > deploy.env < ⚠️ `key.json` is a credential — delete it after uploading (`rm key.json`). Never commit it. + +### 3. Open the app firewall port (once) + +```bash +gcloud compute firewall-rules create allow-ir-civil-agent-8001 \ + --allow tcp:8001 --project ai-agent-boilerplate0 \ + --description "IR agent HTTP" +``` + +### 4. Bootstrap the VM (once) + +```bash +gcloud compute scp deploy/bootstrap.sh ir-civil-agent:~ \ + --zone us-central1-b --project ai-agent-boilerplate0 +gcloud compute ssh ir-civil-agent \ + --zone us-central1-b --project ai-agent-boilerplate0 \ + --command 'sudo bash bootstrap.sh' +``` + +This adds 2 GB swap, installs system libs + `uv`, creates the `iragent` service +user and `/opt/ir-agent`, and installs (enables, does not start) the +`ir-agent` systemd unit. + +--- + +## Deploy + +Any push to `main` (typically merging your PR) runs `deploy.yml`. To deploy +by hand: Actions → **Deploy to VM** → Run workflow. + +Once green, the app is live at **`http://:8001/app/`** +(`gcloud compute instances list` for the IP). + +## Operate (on the VM) + +```bash +sudo systemctl status ir-agent # health +sudo journalctl -u ir-agent -f # live logs +sudo systemctl restart ir-agent # manual restart +``` + +## Notes / gotchas + +- **HTTP only** — the app is served on `:8001` over plain HTTP against the VM's + IP (fine for a same-origin demo). No TLS/domain wired. Don't put the Gemini + key anywhere client-facing. +- **First deploy is slow** — `uv sync` pulls OpenCASCADE (`build123d`) wheels + (~1 GB). Later deploys reuse the cached venv. +- **SSH auth** — if `gcloud compute ssh` from the runner can't reach port 22, + add `--tunnel-through-iap` to the scp/ssh steps in `deploy.yml` and grant the + SA `roles/iap.tunnelResourceAccessor`. +- **Teardown after the demo** — `gcloud compute instances delete ir-civil-agent + --zone us-central1-b` to stop billing. diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100644 index 0000000..3913f30 --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# One-time VM setup for the IR Civil Engineer Agent. Run ONCE on a fresh VM as a +# sudo-capable user: +# +# gcloud compute scp deploy/bootstrap.sh ir-civil-agent:~ \ +# --zone us-central1-b --project ai-agent-boilerplate0 +# gcloud compute ssh ir-civil-agent \ +# --zone us-central1-b --project ai-agent-boilerplate0 \ +# --command 'sudo bash bootstrap.sh' +# +# Idempotent: safe to re-run. It does NOT start the service — the first +# successful "Deploy to VM" GitHub Action ships the code and starts it. +set -euo pipefail + +APP_DIR=/opt/ir-agent +SERVICE=ir-agent +SERVICE_USER=iragent + +echo "==> 1/5 Swap (2 GB, idempotent)" +if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab +fi + +echo "==> 2/5 System packages (git, OpenCASCADE/matplotlib libs)" +# Python itself is NOT installed here — uv fetches a standalone CPython (>=3.11) +# per pyproject. This keeps us independent of the base image's Python version. +apt-get update +apt-get install -y git libgl1 libglu1-mesa ca-certificates curl + +echo "==> 3/5 uv (system-wide at /usr/local/bin)" +if ! command -v /usr/local/bin/uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh \ + | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh +fi + +echo "==> 4/5 Service user + app dir" +id -u "${SERVICE_USER}" >/dev/null 2>&1 \ + || useradd --system --create-home --shell /usr/sbin/nologin "${SERVICE_USER}" +mkdir -p "${APP_DIR}/data/artifacts" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" + +echo "==> 5/5 systemd unit" +cat > "/etc/systemd/system/${SERVICE}.service" <<'UNIT' +[Unit] +Description=IR Civil Engineer Agent (FastAPI + LangGraph) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=iragent +Group=iragent +WorkingDirectory=/opt/ir-agent +ExecStart=/usr/local/bin/uv run python -m src +Restart=on-failure +RestartSec=5 +Environment=PATH=/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=multi-user.target +UNIT +systemctl daemon-reload +systemctl enable "${SERVICE}" + +echo "" +echo "==> Bootstrap complete." +echo " The service is enabled but NOT started (no code yet)." +echo " Push to main (or run the 'Deploy to VM' workflow) to ship + start it." +echo " App will serve at: http://:8001/app/" diff --git a/deploy/deploy-remote.sh b/deploy/deploy-remote.sh new file mode 100644 index 0000000..ef78465 --- /dev/null +++ b/deploy/deploy-remote.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Runs ON THE VM (as root, via `sudo bash /tmp/deploy-remote.sh`), invoked by +# .github/workflows/deploy.yml. Expects /tmp/bundle.tar.gz, /tmp/deploy.env, and +# a bootstrapped host (see bootstrap.sh: iragent user, /opt/ir-agent, uv, unit). +set -euo pipefail + +APP_DIR=/opt/ir-agent +SERVICE=ir-agent +SERVICE_USER=iragent + +echo "==> Unpacking bundle into ${APP_DIR}" +mkdir -p "${APP_DIR}" +tar xzf /tmp/bundle.tar.gz -C "${APP_DIR}" + +echo "==> Installing .env (0600, owned by ${SERVICE_USER})" +install -o "${SERVICE_USER}" -g "${SERVICE_USER}" -m 600 /tmp/deploy.env "${APP_DIR}/.env" + +echo "==> Fixing ownership" +mkdir -p "${APP_DIR}/data/artifacts" +chown -R "${SERVICE_USER}:${SERVICE_USER}" "${APP_DIR}" + +echo "==> uv sync (prod deps) + alembic upgrade" +sudo -u "${SERVICE_USER}" --set-home env PATH=/usr/local/bin:/usr/bin:/bin \ + bash -lc "cd ${APP_DIR} && uv sync --no-dev && uv run alembic upgrade head" + +echo "==> Restarting ${SERVICE}" +systemctl restart "${SERVICE}" +sleep 2 +systemctl --no-pager --lines=10 status "${SERVICE}" || true + +echo "==> Cleaning up /tmp" +rm -f /tmp/bundle.tar.gz /tmp/deploy.env /tmp/deploy-remote.sh + +echo "==> Deploy complete." diff --git a/deploy/ir-agent.service b/deploy/ir-agent.service new file mode 100644 index 0000000..821e7b2 --- /dev/null +++ b/deploy/ir-agent.service @@ -0,0 +1,18 @@ +[Unit] +Description=IR Civil Engineer Agent (FastAPI + LangGraph) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=iragent +Group=iragent +WorkingDirectory=/opt/ir-agent +# pydantic-settings reads /opt/ir-agent/.env from the working directory. +ExecStart=/usr/local/bin/uv run python -m src +Restart=on-failure +RestartSec=5 +Environment=PATH=/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=multi-user.target