Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions .github/workflows/ci-full.yml
Original file line number Diff line number Diff line change
@@ -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 }}
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
AGENT_GEMINI_API_KEY=${{ vars.AGENT_GEMINI_API_KEY }}
AGENT_DATABASE_URL=sqlite:///./data/agent.db
AGENT_LLM_MODEL=gemini-2.5-pro
AGENT_PORT=8001
AGENT_ARTIFACTS_DIR=data/artifacts
EOF

# ---- authenticate to GCP as the service account ----
- uses: google-github-actions/auth@v2
with:
credentials_json: ${{ vars.GCP_SA_KEY }}
- uses: google-github-actions/setup-gcloud@v2

# ---- ship + run the remote deploy ----
- name: Copy files to VM
run: |
gcloud compute scp bundle.tar.gz deploy.env deploy/deploy-remote.sh \
${{ vars.GCP_INSTANCE }}:/tmp/ \
--zone "${{ vars.GCP_ZONE }}" --project "${{ vars.GCP_PROJECT }}" --quiet

- name: Run remote deploy
run: |
gcloud compute ssh ${{ vars.GCP_INSTANCE }} \
--zone "${{ vars.GCP_ZONE }}" --project "${{ vars.GCP_PROJECT }}" --quiet \
--command 'sudo bash /tmp/deploy-remote.sh'
121 changes: 121 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# CI/CD — deploy to the GCP VM

This directory + `.github/workflows/` wire up:

| Workflow | Trigger | What it does |
|---|---|---|
| `.github/workflows/ci.yml` | push to `main`/`cicd/**`, PRs to `main` | Frontend `pnpm build` + deterministic backend `pytest tests/unit` (no Gemini key → real-LLM tests auto-skip) + `alembic upgrade` migration smoke |
| `.github/workflows/ci-full.yml` | nightly 03:00 UTC + manual | The **full** suite including real-Gemini integration tests |
| `.github/workflows/deploy.yml` | push to `main` + manual | Build frontend → bundle → ship to VM → write `.env` → `uv sync` + migrate → restart `ir-agent` service |

Target VM: **`ir-civil-agent`**, zone `us-central1-b`, project `ai-agent-boilerplate0` (e2-small, 2 GB / 20 GB).

---

## One-time setup

### 1. Create a deploy service account + key (on your machine, `gcloud` authed)

```bash
PROJECT=ai-agent-boilerplate0
gcloud iam service-accounts create ir-agent-deployer \
--display-name="IR agent CI/CD deployer" --project "$PROJECT"

SA="ir-agent-deployer@${PROJECT}.iam.gserviceaccount.com"

# Roles: SSH/SCP + manage the instance, and act as the VM's own service account.
gcloud projects add-iam-policy-binding "$PROJECT" \
--member="serviceAccount:${SA}" --role="roles/compute.instanceAdmin.v1"
gcloud projects add-iam-policy-binding "$PROJECT" \
--member="serviceAccount:${SA}" --role="roles/iam.serviceAccountUser"
# If the VM has OS Login enabled, also grant sudo-capable OS Login:
gcloud projects add-iam-policy-binding "$PROJECT" \
--member="serviceAccount:${SA}" --role="roles/compute.osAdminLogin"

# Download the JSON key (paste its CONTENTS into the GCP_SA_KEY secret below).
gcloud iam service-accounts keys create key.json --iam-account "$SA"
```

### 2. Add GitHub secrets + variables

Repo → Settings → Secrets and variables → Actions.

**Secrets** (`gh secret set NAME`):

| Secret | Value |
|---|---|
| `GCP_SA_KEY` | full contents of `key.json` from step 1 |
| `AGENT_GEMINI_API_KEY` | your Google AI Studio key (used to write the VM `.env` and to run `ci-full`) |

**Variables** (`gh variable set NAME`):

| Variable | Value |
|---|---|
| `GCP_PROJECT` | `ai-agent-boilerplate0` |
| `GCP_ZONE` | `us-central1-b` |
| `GCP_INSTANCE` | `ir-civil-agent` |

CLI shortcut:

```bash
gh secret set GCP_SA_KEY < key.json
gh secret set AGENT_GEMINI_API_KEY --body "YOUR_GEMINI_KEY"
gh variable set GCP_PROJECT --body "ai-agent-boilerplate0"
gh variable set GCP_ZONE --body "us-central1-b"
gh variable set GCP_INSTANCE --body "ir-civil-agent"
```

> ⚠️ `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://<EXTERNAL_IP>: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.
73 changes: 73 additions & 0 deletions deploy/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -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://<EXTERNAL_IP>:8001/app/"
34 changes: 34 additions & 0 deletions deploy/deploy-remote.sh
Original file line number Diff line number Diff line change
@@ -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."
Loading
Loading