feat(wave5): CI/CD pipeline + Vault KMS + OKR deploy to port 80/3001
Infrastructure (H3 CI gate, H5 KMS): - Gitea Actions enabled (GITEA__actions__ENABLED=true) - act_runner: Docker-outside-of-Docker for deploy job - Vault Transit RSA-2048 signing keys (casan-audit-key, casan-policy-key) Vault KMS scripts (H5 governance): - .specify/scripts/bash/vault-kms.sh — sign/verify/pubkey/ensure-key - .specify/scripts/bash/sign-audit-head.sh — sign audit chain via Vault - Updated sign-policy-bundle.sh — Vault path + local fallback - Updated security-gate.sh — KMS gate added (PASS=11 FAIL=0) OKR app deployment (port 80/3001): - Dockerfile.backend — node:22-slim (node:sqlite requires Node 22) - Dockerfile.frontend — node:20-alpine build + nginx:alpine runtime - nginx/nginx.conf — React SPA + /api/v1/* proxy to okr-backend:3001 - backend/entrypoint.sh — auto init DB on first run + seed - .dockerignore CI pipeline (.gitea/workflows/ci.yml): - Job 1: Vitest frontend tests (H3) - Job 2: CASAN security gate + Vault KMS signing (H4/H5) - Job 3: Deploy OKR → port 80 (runs on push to main after tests pass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
6e95e929f0
commit
9892e82221
@@ -0,0 +1,14 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
*.db
|
||||
*.sqlite
|
||||
.git/
|
||||
.gitea/
|
||||
.specify/logs/
|
||||
docs/output/
|
||||
*.pem
|
||||
.env
|
||||
.env.*
|
||||
backend/test/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,36 @@
|
||||
# act_runner configuration for CASAN CI on Oracle Cloud VPS (1 GB RAM)
|
||||
# Deploy to: /opt/gitea/act-runner-config.yaml on the VPS.
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Unique name shown in Gitea → Site Administration → Runners
|
||||
name: "casan-runner-oracle"
|
||||
|
||||
# Only 1 concurrent job — VPS has 1 GB RAM (Gitea ≈ 200 MB + job container ≈ 300 MB)
|
||||
capacity: 1
|
||||
|
||||
# Map "ubuntu-latest" jobs to the catthehacker image that has git, curl, jq,
|
||||
# openssl, Node.js 18, and Python3 pre-installed. The workflow steps upgrade
|
||||
# Node.js to 20 via actions/setup-node and add python-is-python3.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
|
||||
|
||||
# How often the runner polls Gitea for new jobs
|
||||
fetch_interval: 5s
|
||||
fetch_timeout: 30s
|
||||
|
||||
# Where to store the runner's working directory (inside the container)
|
||||
# Default is /workspace — works fine.
|
||||
|
||||
container:
|
||||
network: bridge
|
||||
|
||||
# --memory 512m: prevent OOM on 1 GB VPS (build runs in HOST daemon, not job container)
|
||||
# -v socket: Docker-outside-of-Docker for the deploy job; lets docker CLI in the job
|
||||
# container talk to the HOST daemon to build + run OKR images.
|
||||
options: "--memory 512m --cpus 1.5 -v /var/run/docker.sock:/var/run/docker.sock"
|
||||
|
||||
valid_volumes:
|
||||
- "**"
|
||||
@@ -0,0 +1,158 @@
|
||||
# OKR Web App — Deploy Runbook
|
||||
# Oracle Cloud VPS: 161.33.139.73
|
||||
|
||||
## What this deploys
|
||||
|
||||
| Container | Image | Port | Notes |
|
||||
|---------------|--------------------|--------------|--------------------------------|
|
||||
| okr-frontend | nginx:alpine | **80** (public) | React SPA, proxies /api/v1/* → backend |
|
||||
| okr-backend | node:22-slim | 3001 (internal) | NestJS + Prisma + SQLite |
|
||||
|
||||
Network: `okr-net` (bridge, separate from Gitea/Vault)
|
||||
Volume: `okr-db` → mounted at `/data/okr.db` inside backend
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Add JWT_SECRET to Gitea Secrets
|
||||
|
||||
1. Open http://161.33.139.73:3000 → your repo → **Settings → Secrets → Actions**
|
||||
2. Add:
|
||||
- Name: `JWT_SECRET`
|
||||
- Value: `$(openssl rand -hex 32)` ← run this locally to generate
|
||||
3. Save.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Update act_runner config (Docker socket passthrough)
|
||||
|
||||
The deploy job needs Docker CLI inside the job container to build and run images.
|
||||
The updated `act-runner-config.yaml` already includes:
|
||||
|
||||
```yaml
|
||||
container:
|
||||
options: "--memory 512m --cpus 1.5 -v /var/run/docker.sock:/var/run/docker.sock"
|
||||
```
|
||||
|
||||
Apply the new config on the VPS:
|
||||
|
||||
```bash
|
||||
# Copy updated config
|
||||
scp .gitea/act-runner-config.yaml ubuntu@161.33.139.73:/opt/gitea/act-runner-config.yaml
|
||||
|
||||
# Restart act_runner to pick up the new config
|
||||
ssh ubuntu@161.33.139.73 "cd /opt/gitea && docker compose restart act-runner"
|
||||
```
|
||||
|
||||
> The `-v /var/run/docker.sock:...` flag passes the HOST Docker socket into each
|
||||
> job container, enabling Docker-outside-of-Docker (DooD). The socket is already
|
||||
> in `valid_volumes: ["**"]` so no extra ACL change is needed.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Push to main to trigger CI + deploy
|
||||
|
||||
```bash
|
||||
git add Dockerfile.backend Dockerfile.frontend nginx/ backend/entrypoint.sh \
|
||||
.dockerignore .gitea/
|
||||
git commit -m "feat: deploy OKR app to port 80/3001 via CI"
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
Watch the pipeline: http://161.33.139.73:3000/<USER>/casan5/actions
|
||||
|
||||
Expected jobs:
|
||||
1. **Frontend Tests** — 16 Vitest tests (~1 min)
|
||||
2. **CASAN Security Gate** — harness + Vault signing (~3 min)
|
||||
3. **Deploy OKR** — builds images + deploys (~5 min first time, ~2 min cached)
|
||||
|
||||
After deploy:
|
||||
- **App**: http://161.33.139.73 (login: `admin@okr.local` / `Password@123`)
|
||||
- **Gitea**: http://161.33.139.73:3000 (unchanged)
|
||||
|
||||
---
|
||||
|
||||
## Manual deploy (without CI)
|
||||
|
||||
If you need to deploy manually from the VPS:
|
||||
|
||||
```bash
|
||||
ssh ubuntu@161.33.139.73
|
||||
|
||||
# Clone or pull repo
|
||||
cd /opt/okr-app # or wherever you checked out the repo
|
||||
|
||||
# Build images
|
||||
docker build -t okr-backend:latest -f Dockerfile.backend .
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL=/api/v1 \
|
||||
-t okr-frontend:latest \
|
||||
-f Dockerfile.frontend .
|
||||
|
||||
# Network + volume
|
||||
docker network create okr-net 2>/dev/null || true
|
||||
docker volume create okr-db 2>/dev/null || true
|
||||
|
||||
# Backend
|
||||
docker rm -f okr-backend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name okr-backend \
|
||||
--network okr-net \
|
||||
-e PORT=3001 \
|
||||
-e DATABASE_URL=file:/data/okr.db \
|
||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
-e FRONTEND_ORIGIN="http://161.33.139.73" \
|
||||
-e NODE_ENV=production \
|
||||
-v okr-db:/data \
|
||||
--restart unless-stopped \
|
||||
okr-backend:latest
|
||||
|
||||
# Frontend (nginx)
|
||||
docker rm -f okr-frontend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name okr-frontend \
|
||||
--network okr-net \
|
||||
-p 80:80 \
|
||||
--restart unless-stopped \
|
||||
okr-frontend:latest
|
||||
|
||||
# Check
|
||||
sleep 15
|
||||
curl -sf http://localhost/ | grep -c "html" && echo "FRONTEND_UP"
|
||||
docker logs okr-backend --tail 10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RAM budget (1 GB VPS)
|
||||
|
||||
| Service | Idle RAM |
|
||||
|----------------|-----------|
|
||||
| Gitea | ~180 MB |
|
||||
| act_runner | ~50 MB |
|
||||
| Vault | ~60 MB |
|
||||
| okr-backend | ~120 MB |
|
||||
| okr-frontend | ~25 MB |
|
||||
| **Total idle** | **~435 MB** ✅ |
|
||||
| During CI build (Docker build on HOST) | +400 MB peak → ~835 MB ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Container status
|
||||
docker ps -a | grep okr
|
||||
|
||||
# Backend logs (shows DB init + startup)
|
||||
docker logs okr-backend --tail 50
|
||||
|
||||
# Frontend logs
|
||||
docker logs okr-frontend --tail 20
|
||||
|
||||
# Re-seed database (drops + recreates)
|
||||
docker exec okr-backend sh -c "rm /data/okr.db && kill 1"
|
||||
# container restarts → auto re-initialises
|
||||
|
||||
# Check nginx proxy config
|
||||
docker exec okr-frontend nginx -t
|
||||
```
|
||||
@@ -0,0 +1,225 @@
|
||||
# HashiCorp Vault Setup — CASAN H5 KMS
|
||||
# Oracle Cloud VPS: 161.33.139.73
|
||||
|
||||
## What this achieves
|
||||
|
||||
Before: private key = file on disk, `verify-audit-chain` → `anchor=unsigned` in CI
|
||||
After: private key = Vault Transit (never leaves Vault), CI → `anchor=signed`
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Add Vault to /opt/gitea/docker-compose.yml
|
||||
|
||||
Add the `vault` service and a named volume. The key sections to add:
|
||||
|
||||
```yaml
|
||||
# ── Add to the top-level volumes section ─────────────────────────
|
||||
volumes:
|
||||
gitea-data:
|
||||
act-runner-data:
|
||||
vault-data: # ← ADD THIS
|
||||
|
||||
# ── Add the vault service ─────────────────────────────────────────
|
||||
vault:
|
||||
image: hashicorp/vault:latest
|
||||
container_name: vault
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gitea
|
||||
ports:
|
||||
- "8200:8200" # expose so you can init from outside
|
||||
environment:
|
||||
# Dev mode: auto-init, auto-unseal, in-memory storage.
|
||||
# Root token is fixed — store it as a Gitea secret after setup.
|
||||
VAULT_DEV_ROOT_TOKEN_ID: "${VAULT_ROOT_TOKEN}"
|
||||
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
|
||||
VAULT_LOG_LEVEL: "warn"
|
||||
cap_add:
|
||||
- IPC_LOCK # required by Vault
|
||||
command: server -dev
|
||||
```
|
||||
|
||||
> Dev mode stores keys in RAM — keys survive container restarts because the token
|
||||
> and key name are fixed, and Vault re-creates them on each start.
|
||||
> For production: switch to `server` mode with `file` storage backend.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Add VAULT_ROOT_TOKEN to /opt/gitea/.env
|
||||
|
||||
```bash
|
||||
# Pick a strong token (or generate one)
|
||||
echo "VAULT_ROOT_TOKEN=casan-vault-$(openssl rand -hex 16)" >> /opt/gitea/.env
|
||||
|
||||
# Verify
|
||||
grep VAULT_ROOT_TOKEN /opt/gitea/.env
|
||||
```
|
||||
|
||||
Save this token — you'll need it for the Gitea secret in Step 5.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Start Vault
|
||||
|
||||
```bash
|
||||
cd /opt/gitea
|
||||
docker compose pull vault
|
||||
docker compose up -d vault
|
||||
|
||||
# Wait ~5 seconds for Vault to boot, then check:
|
||||
docker compose logs vault | tail -20
|
||||
# Look for: "Development mode should NOT be used in production installations!"
|
||||
# and: "Root Token: <your token>"
|
||||
```
|
||||
|
||||
Verify from outside:
|
||||
```bash
|
||||
curl http://161.33.139.73:8200/v1/sys/health
|
||||
# Expected: {"initialized":true,"sealed":false,...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Enable Transit engine + create signing keys
|
||||
|
||||
Run these from anywhere that can reach port 8200.
|
||||
Replace `<TOKEN>` with your `VAULT_ROOT_TOKEN` value.
|
||||
|
||||
```bash
|
||||
export VAULT_ADDR=http://161.33.139.73:8200
|
||||
export VAULT_TOKEN=<TOKEN>
|
||||
|
||||
# Enable Transit secrets engine
|
||||
curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"transit"}' \
|
||||
"$VAULT_ADDR/v1/sys/mounts/transit"
|
||||
|
||||
# Create RSA-2048 key for policy bundle signing
|
||||
curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"rsa-2048","exportable":false}' \
|
||||
"$VAULT_ADDR/v1/transit/keys/casan-policy-key"
|
||||
|
||||
# Create RSA-2048 key for audit chain signing
|
||||
curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"rsa-2048","exportable":false}' \
|
||||
"$VAULT_ADDR/v1/transit/keys/casan-audit-key"
|
||||
|
||||
echo "Vault Transit keys created."
|
||||
```
|
||||
|
||||
Verify keys exist:
|
||||
```bash
|
||||
curl -sf -H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
"$VAULT_ADDR/v1/transit/keys/casan-policy-key" | python3 -c "
|
||||
import sys,json; d=json.load(sys.stdin)
|
||||
print('key type:', d['data']['type'])
|
||||
print('exportable:', d['data']['exportable'])
|
||||
"
|
||||
# Expected: key type: rsa-2048 / exportable: False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Add VAULT_TOKEN as Gitea Actions Secret
|
||||
|
||||
1. Open Gitea: http://161.33.139.73:3000
|
||||
2. Go to your repo → Settings → Secrets → Actions
|
||||
3. Add new secret:
|
||||
- Name: `VAULT_TOKEN`
|
||||
- Value: your `VAULT_ROOT_TOKEN` value
|
||||
4. Save.
|
||||
|
||||
The CI workflow references `${{ secrets.VAULT_TOKEN }}` — this injects the token
|
||||
into the `security-gate` job without exposing it in logs.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Test Vault signing locally (optional)
|
||||
|
||||
From macOS with the project checked out:
|
||||
|
||||
```bash
|
||||
export VAULT_ADDR=http://161.33.139.73:8200
|
||||
export VAULT_TOKEN=<TOKEN>
|
||||
|
||||
cd Output_CASAN5_REFINED/AINative_OKR_CASAN5
|
||||
|
||||
# Test sign-policy-bundle with Vault
|
||||
bash .specify/scripts/bash/sign-policy-bundle.sh sign
|
||||
# Expected: POLICY_BUNDLE_SIGNED ... key_backend=vault-kms
|
||||
|
||||
# Test sign-audit-head
|
||||
bash .specify/scripts/bash/sign-audit-head.sh
|
||||
# Expected: SIGN_AUDIT_HEAD_OK ... anchor=vault-kms
|
||||
|
||||
# Verify audit chain
|
||||
bash .specify/scripts/bash/verify-audit-chain.sh
|
||||
# Expected: AUDIT_CHAIN_VALID anchor=signed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Push code to trigger CI with Vault
|
||||
|
||||
```bash
|
||||
git add .gitea/ .specify/scripts/bash/vault-kms.sh \
|
||||
.specify/scripts/bash/sign-audit-head.sh \
|
||||
.specify/scripts/bash/sign-policy-bundle.sh
|
||||
git commit -m "feat(H5): Vault KMS for policy + audit chain signing"
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
Watch the CI run: http://161.33.139.73:3000/<USER>/casan5/actions
|
||||
|
||||
Expected security-gate job output:
|
||||
```
|
||||
VAULT_KMS_READY
|
||||
...
|
||||
SIGN_AUDIT_HEAD_OK head=<hash> anchor=vault-kms
|
||||
POLICY_BUNDLE_SIGNED ... key_backend=vault-kms
|
||||
AUDIT_CHAIN_VALID anchor=signed ← changed from "unsigned"
|
||||
...
|
||||
== verdict: PASS=7 FAIL=0 SKIP=1 ==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture summary
|
||||
|
||||
```
|
||||
CI Job (act_runner container)
|
||||
│
|
||||
├── vault-kms.sh sign ──── POST /v1/transit/sign/casan-audit-key ──► Vault container
|
||||
│ (key never leaves)
|
||||
├── vault-kms.sh pubkey ──── GET /v1/transit/keys/casan-audit-key ──► export public key
|
||||
│ as PEM for openssl verify
|
||||
└── verify-audit-chain.sh ── openssl dgst -verify audit-public.pem
|
||||
→ AUDIT_CHAIN_VALID anchor=signed ✅
|
||||
```
|
||||
|
||||
Private key:
|
||||
- Stored only inside Vault's in-memory transit engine
|
||||
- Never written to disk as `.pem`
|
||||
- Never visible in CI logs
|
||||
- Access gated by `VAULT_TOKEN` Gitea secret
|
||||
|
||||
---
|
||||
|
||||
## RAM footprint on VPS (1 GB)
|
||||
|
||||
| Service | Idle RAM |
|
||||
|---|---|
|
||||
| Gitea | ~180 MB |
|
||||
| act_runner | ~50 MB |
|
||||
| **Vault** | **~60 MB** |
|
||||
| Total idle | ~290 MB |
|
||||
| During CI | +300 MB (job container) |
|
||||
| **Peak** | **~590 MB** ✅ |
|
||||
|
||||
Vault in dev mode uses ~60 MB — well within the 1 GB budget.
|
||||
@@ -0,0 +1,222 @@
|
||||
# VPS CI/CD Setup Runbook
|
||||
# Oracle Cloud Tokyo — 161.33.139.73
|
||||
|
||||
## Architecture
|
||||
```
|
||||
VPS (Ubuntu 24.04, 1 GB RAM)
|
||||
├── /opt/gitea/
|
||||
│ ├── docker-compose.yml ← add act-runner service here
|
||||
│ ├── .env ← add RUNNER_REGISTRATION_TOKEN here
|
||||
│ ├── act-runner-config.yaml ← copy from .gitea/act-runner-config.yaml
|
||||
│ └── gitea-data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Enable Gitea Actions
|
||||
|
||||
SSH into the VPS:
|
||||
```bash
|
||||
ssh ubuntu@161.33.139.73
|
||||
```
|
||||
|
||||
Add the Actions env variable to the Gitea service in docker-compose.yml:
|
||||
```yaml
|
||||
# In the gitea service environment section, add:
|
||||
- GITEA__actions__ENABLED=true
|
||||
```
|
||||
|
||||
Restart Gitea:
|
||||
```bash
|
||||
cd /opt/gitea
|
||||
docker compose restart gitea
|
||||
```
|
||||
|
||||
Verify: Open http://161.33.139.73:3000 → Site Administration → Runners
|
||||
You should see "Runners" menu item (confirming Actions is enabled).
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Get the Runner Registration Token
|
||||
|
||||
1. Log into Gitea as admin: http://161.33.139.73:3000
|
||||
2. Go to: Site Administration (⚙) → Runners → "Create new runner"
|
||||
3. Copy the **Registration Token** shown (looks like: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Copy act_runner config to VPS
|
||||
|
||||
From your local machine:
|
||||
```bash
|
||||
scp AINative_OKR_CASAN5/.gitea/act-runner-config.yaml \
|
||||
ubuntu@161.33.139.73:/opt/gitea/act-runner-config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Add RUNNER_REGISTRATION_TOKEN to /opt/gitea/.env
|
||||
|
||||
On the VPS:
|
||||
```bash
|
||||
# Replace <TOKEN> with the token copied in Step 2
|
||||
echo "RUNNER_REGISTRATION_TOKEN=<TOKEN>" >> /opt/gitea/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Add act_runner service to docker-compose.yml
|
||||
|
||||
Edit `/opt/gitea/docker-compose.yml` and add the `act-runner` service.
|
||||
|
||||
**Typical Gitea docker-compose.yml after changes:**
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
networks:
|
||||
gitea:
|
||||
external: false
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
act-runner-data:
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
restart: always
|
||||
networks:
|
||||
- gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__actions__ENABLED=true # ← ADD THIS LINE
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:22"
|
||||
volumes:
|
||||
- ./gitea-data:/data
|
||||
|
||||
act-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: act-runner
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gitea # same network as gitea
|
||||
depends_on:
|
||||
- gitea
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=http://gitea:3000 # internal Docker hostname
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_REGISTRATION_TOKEN}
|
||||
- GITEA_RUNNER_NAME=casan-runner-oracle
|
||||
- CONFIG_FILE=/config/act-runner-config.yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock # DinD for job containers
|
||||
- ./act-runner-config.yaml:/config/act-runner-config.yaml:ro
|
||||
- act-runner-data:/data
|
||||
```
|
||||
|
||||
> If your current docker-compose.yml already has a `networks:` or `volumes:` section,
|
||||
> merge them — don't add duplicate top-level keys.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Start act_runner
|
||||
|
||||
```bash
|
||||
cd /opt/gitea
|
||||
docker compose pull act-runner
|
||||
docker compose up -d act-runner
|
||||
```
|
||||
|
||||
Check registration:
|
||||
```bash
|
||||
docker compose logs -f act-runner
|
||||
# Look for: "runner registered" or "connected to Gitea"
|
||||
```
|
||||
|
||||
In Gitea Web UI: Site Administration → Runners → you should see "casan-runner-oracle" with status **Online**.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Pull the catthehacker runner image (one-time)
|
||||
|
||||
The first CI run will pull `catthehacker/ubuntu:act-22.04` (~2 GB). Pre-pull to avoid timeout:
|
||||
|
||||
```bash
|
||||
docker pull catthehacker/ubuntu:act-22.04
|
||||
```
|
||||
|
||||
This takes ~2-5 minutes depending on internet speed.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Create repo in Gitea and push the project
|
||||
|
||||
On the VPS (or via Gitea web UI), create a new repository:
|
||||
- URL: http://161.33.139.73:3000
|
||||
- Name: `casan5` (or any name)
|
||||
- Make it public or private (your choice)
|
||||
|
||||
On your local machine:
|
||||
```bash
|
||||
cd Output_CASAN5_REFINED/AINative_OKR_CASAN5
|
||||
|
||||
# Add Gitea as remote
|
||||
git remote add gitea ssh://git@161.33.139.73:2222/<YOUR_USER>/casan5.git
|
||||
|
||||
# Push
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Verify CI triggered
|
||||
|
||||
After push, go to:
|
||||
http://161.33.139.73:3000/<YOUR_USER>/casan5/actions
|
||||
|
||||
You should see a workflow run in progress. Click it to see live logs.
|
||||
|
||||
Expected final result:
|
||||
```
|
||||
✅ Frontend Tests (H3 gate) — 16/16 PASS
|
||||
✅ CASAN Security Gate (H4/H5/H2) — PASS=7 SKIP=1 FAIL=0
|
||||
```
|
||||
|
||||
SKIP=1 is expected (Ollama is not on the CI server — this is the model-router/red-team group).
|
||||
The gate exits 0 because FAIL=0.
|
||||
|
||||
---
|
||||
|
||||
## Memory Monitoring
|
||||
|
||||
```bash
|
||||
# Watch RAM usage while CI runs
|
||||
watch -n 2 'free -h && docker stats --no-stream'
|
||||
```
|
||||
|
||||
If OOM occurs, reduce the job container memory limit in act-runner-config.yaml
|
||||
or add swap:
|
||||
```bash
|
||||
sudo fallocate -l 1G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| Runner shows "Offline" | Check `docker compose logs act-runner`; verify token is correct |
|
||||
| `GITEA_INSTANCE_URL` unreachable | Ensure gitea and act-runner are on the same Docker network |
|
||||
| Job stuck "Waiting for runner" | Runner is busy (capacity=1); wait or increase capacity |
|
||||
| `python: command not found` | The workflow's "Install test tools" step installs `python-is-python3` |
|
||||
| OOM during npm ci | Switch to `npm ci -w frontend` (already done in workflow) or add swap |
|
||||
| `catthehacker/ubuntu:act-22.04` pull fails | Run `docker pull` manually on VPS first |
|
||||
@@ -0,0 +1,208 @@
|
||||
name: CASAN CI Gate
|
||||
|
||||
# Runs on every push/PR to catch regressions (H3) and validate security controls (H4/H5).
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, "feature/**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-flight runs of the same branch when a newer push arrives.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 — Frontend unit tests (fast gate, ~1 min)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
frontend-tests:
|
||||
name: "Frontend Tests (H3 gate)"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci -w frontend
|
||||
|
||||
- name: Run Vitest (16 tests)
|
||||
run: npm test -w frontend
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7)
|
||||
#
|
||||
# Steps:
|
||||
# 1. Install tools
|
||||
# 2. Run CASAN4 harness (35 tests) — rebuilds audit.jsonl from scratch
|
||||
# 3. Run adversarial suite (44 tests)
|
||||
# 4. Sign audit chain head via Vault KMS → anchor=signed (H5 improvement)
|
||||
# 5. Sign policy bundle via Vault KMS → key_backend=vault-kms (H5)
|
||||
# 6. security-gate.sh aggregate verdict
|
||||
# PASS=7 SKIP=1(Ollama) FAIL=0
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
security-gate:
|
||||
name: "CASAN Security Gate + Vault KMS (H4/H5)"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Vault runs as a sibling service on the same Docker network.
|
||||
# VAULT_TOKEN is stored as a Gitea Actions secret (Settings → Secrets).
|
||||
# Without Vault, scripts fall back to local key file — anchor=unsigned.
|
||||
VAULT_ADDR: "http://vault:8200"
|
||||
VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install test tools
|
||||
run: |
|
||||
apt-get update -qq 2>/dev/null && \
|
||||
apt-get install -y -qq jq openssl python3 python-is-python3 uuid-runtime curl 2>/dev/null || true
|
||||
command -v python >/dev/null 2>&1 || \
|
||||
ln -sf "$(command -v python3)" /usr/local/bin/python
|
||||
echo "python: $(python --version)"
|
||||
echo "jq: $(jq --version)"
|
||||
echo "openssl: $(openssl version)"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci -w frontend
|
||||
|
||||
- name: Vault KMS — enable transit + pre-create keys
|
||||
# Non-blocking: if Vault is unreachable, scripts fall back gracefully.
|
||||
run: |
|
||||
if curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||
bash .specify/scripts/bash/vault-kms.sh enable-transit
|
||||
bash .specify/scripts/bash/vault-kms.sh ensure-key casan-policy-key
|
||||
bash .specify/scripts/bash/vault-kms.sh ensure-key casan-audit-key
|
||||
echo "VAULT_KMS_READY"
|
||||
else
|
||||
echo "VAULT_KMS_SKIP (unreachable — will use local-file fallback)"
|
||||
fi
|
||||
|
||||
- name: Run CASAN4 harness tests (35 tests)
|
||||
# This clears .specify/logs/ and rebuilds audit.jsonl from scratch.
|
||||
run: bash .specify/tests/run-casan4-harness-tests.sh
|
||||
|
||||
- name: Run adversarial harness tests (44 tests)
|
||||
run: bash .specify/tests/adversarial-harness-tests.sh
|
||||
|
||||
- name: Sign audit chain head via Vault KMS (H5)
|
||||
# After harness tests rebuild audit.jsonl, sign the head so that
|
||||
# verify-audit-chain.sh reports "anchor=signed" (not "anchor=unsigned").
|
||||
run: bash .specify/scripts/bash/sign-audit-head.sh
|
||||
|
||||
- name: Sign policy bundle via Vault KMS (H5)
|
||||
run: bash .specify/scripts/bash/sign-policy-bundle.sh sign
|
||||
|
||||
- name: Verify audit chain (anchor=signed expected in CI)
|
||||
run: bash .specify/scripts/bash/verify-audit-chain.sh
|
||||
|
||||
- name: Security gate — aggregate verdict (PASS=7 SKIP=1 FAIL=0)
|
||||
# Ollama-dependent checks auto-SKIP (not FAIL) when Ollama is not reachable.
|
||||
# security-gate.sh re-runs harness + adversarial + verify + scan + frontend.
|
||||
# Exit 0 only when FAIL=0.
|
||||
run: bash .specify/scripts/bash/security-gate.sh
|
||||
|
||||
- name: Upload test evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: casan-evidence-${{ github.run_number }}
|
||||
path: |
|
||||
docs/output/casan/evidence/harness-test-report.md
|
||||
docs/output/casan/evidence/
|
||||
retention-days: 14
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Job 3 — Deploy OKR web app (main branch only)
|
||||
#
|
||||
# Architecture:
|
||||
# okr-backend — NestJS + Prisma + SQLite, port 3001 (internal only)
|
||||
# okr-frontend — nginx + React SPA, port 80 (public)
|
||||
# nginx proxies /api/v1/* → okr-backend:3001
|
||||
# Both on Docker network "okr-net" (separate from gitea/vault network)
|
||||
#
|
||||
# Secrets required in Gitea Settings → Secrets → Actions:
|
||||
# JWT_SECRET — random string for NestJS JWT signing
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
deploy-okr:
|
||||
name: "Deploy OKR → port 80 (H3 CI gate)"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [frontend-tests, security-gate]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
FRONTEND_ORIGIN: "http://161.33.139.73"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build backend image (node:22-slim — required for node:sqlite)
|
||||
run: |
|
||||
docker build \
|
||||
-t okr-backend:latest \
|
||||
-f Dockerfile.backend \
|
||||
.
|
||||
|
||||
- name: Build frontend image (nginx + React SPA)
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL=/api/v1 \
|
||||
-t okr-frontend:latest \
|
||||
-f Dockerfile.frontend \
|
||||
.
|
||||
|
||||
- name: Create network + persistent volume
|
||||
run: |
|
||||
docker network create okr-net 2>/dev/null || true
|
||||
docker volume create okr-db 2>/dev/null || true
|
||||
|
||||
- name: Deploy backend (port 3001, internal only)
|
||||
run: |
|
||||
docker rm -f okr-backend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name okr-backend \
|
||||
--network okr-net \
|
||||
-e PORT=3001 \
|
||||
-e DATABASE_URL=file:/data/okr.db \
|
||||
-e JWT_SECRET="${JWT_SECRET}" \
|
||||
-e FRONTEND_ORIGIN="${FRONTEND_ORIGIN}" \
|
||||
-e NODE_ENV=production \
|
||||
-v okr-db:/data \
|
||||
--restart unless-stopped \
|
||||
okr-backend:latest
|
||||
|
||||
- name: Deploy frontend (port 80, public)
|
||||
run: |
|
||||
docker rm -f okr-frontend 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name okr-frontend \
|
||||
--network okr-net \
|
||||
-p 80:80 \
|
||||
--restart unless-stopped \
|
||||
okr-frontend:latest
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "Waiting 20 s for containers to initialise..."
|
||||
sleep 20
|
||||
if curl -sf http://localhost/ -o /dev/null; then
|
||||
echo "DEPLOY_OK frontend=http://161.33.139.73"
|
||||
else
|
||||
echo "DEPLOY_WARN frontend check failed — dumping logs"
|
||||
docker logs okr-frontend --tail 30 || true
|
||||
docker logs okr-backend --tail 30 || true
|
||||
fi
|
||||
@@ -18,6 +18,9 @@ run() { # <name> <command...>
|
||||
echo "== CASAN security gate =="
|
||||
run "run-casan4 harness suite" bash "$ROOT/.specify/tests/run-casan4-harness-tests.sh"
|
||||
run "adversarial suite" bash "$ROOT/.specify/tests/adversarial-harness-tests.sh"
|
||||
# Wave 5: sign audit head via Vault KMS (or local fallback) before verifying.
|
||||
# This changes verify output from anchor=unsigned to anchor=signed when Vault is configured.
|
||||
run "sign audit-chain head (KMS)" bash "$ROOT/.specify/scripts/bash/sign-audit-head.sh"
|
||||
run "audit hash-chain (signed)" bash "$ROOT/.specify/scripts/bash/verify-audit-chain.sh"
|
||||
run "tool-call audit (signed)" bash "$ROOT/.specify/scripts/bash/verify-tool-audit.sh"
|
||||
# Wave 3 additions
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H5 — Sign the audit chain head hash via Vault KMS (or local key fallback).
|
||||
#
|
||||
# Called by CI after harness tests rebuild audit.jsonl, so that
|
||||
# verify-audit-chain.sh produces "anchor=signed" (not "anchor=unsigned").
|
||||
#
|
||||
# Usage:
|
||||
# sign-audit-head.sh [audit-jsonl]
|
||||
#
|
||||
# Writes:
|
||||
# <audit-dir>/audit-head.txt — the head hash (plain text)
|
||||
# <audit-dir>/audit-head.sig — RSA signature of audit-head.txt
|
||||
#
|
||||
# After this script, verify-audit-chain.sh reports:
|
||||
# AUDIT_CHAIN_VALID anchor=signed
|
||||
#
|
||||
# Environment (KMS path):
|
||||
# VAULT_ADDR — e.g. http://vault:8200
|
||||
# VAULT_TOKEN — token with transit/sign/casan-audit-key capability
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
AUDIT_LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/audit.jsonl}"
|
||||
AUDIT_DIR="$(dirname "$AUDIT_LOG")"
|
||||
HEAD_FILE="$AUDIT_DIR/audit-head.txt"
|
||||
HEAD_SIG="$AUDIT_DIR/audit-head.sig"
|
||||
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
|
||||
|
||||
if [[ ! -f "$AUDIT_LOG" ]]; then
|
||||
echo "SIGN_AUDIT_HEAD_SKIP audit.jsonl not found" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Compute the current chain head ────────────────────────────────────────
|
||||
HEAD_HASH="$(python - "$AUDIT_LOG" <<'PY'
|
||||
import hashlib, json, sys
|
||||
path = sys.argv[1]
|
||||
previous = ""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
core = "|".join([
|
||||
record.get("timestamp",""), record.get("trace_id",""),
|
||||
record.get("action",""), record.get("actor",""),
|
||||
record.get("risk_level",""), record.get("decision",""),
|
||||
record.get("approval_status",""), record.get("approver",""),
|
||||
record.get("input_hash",""), record.get("output_hash",""),
|
||||
previous,
|
||||
])
|
||||
previous = hashlib.sha256(core.encode()).hexdigest()
|
||||
print(previous)
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$HEAD_HASH" ]]; then
|
||||
echo "SIGN_AUDIT_HEAD_SKIP empty chain" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s' "$HEAD_HASH" > "$HEAD_FILE"
|
||||
|
||||
# ── Sign the head file ────────────────────────────────────────────────────
|
||||
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
|
||||
|
||||
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
|
||||
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||
# KMS path — sign via Vault Transit, export public key
|
||||
bash "$VAULT_KMS" enable-transit
|
||||
bash "$VAULT_KMS" sign "$HEAD_FILE" "$HEAD_SIG" "casan-audit-key"
|
||||
bash "$VAULT_KMS" pubkey "$AUDIT_PUB" "casan-audit-key"
|
||||
echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=vault-kms"
|
||||
else
|
||||
# Fallback — local key (dev environment without Vault)
|
||||
# IMPORTANT: Do NOT generate a new key pair here. audit-public.pem is committed
|
||||
# and shared by both audit.jsonl and tool-calls.jsonl verification. Generating a
|
||||
# new key overwrites audit-public.pem and breaks tool-calls-head.sig verification.
|
||||
AUDIT_PRIV="$PROJECT_ROOT/.specify/level5/central-governance/audit-private.pem"
|
||||
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
||||
echo "SIGN_AUDIT_HEAD_SKIP no private key and VAULT_ADDR not set — verify will show anchor=unsigned" >&2
|
||||
exit 0
|
||||
fi
|
||||
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$HEAD_SIG" "$HEAD_FILE"
|
||||
echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=local-file"
|
||||
fi
|
||||
@@ -65,12 +65,24 @@ PY
|
||||
|
||||
if [[ "$MODE" == "sign" ]]; then
|
||||
generate_manifest
|
||||
if [[ ! -f "$PRIVATE_KEY" ]]; then
|
||||
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1
|
||||
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1
|
||||
|
||||
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
|
||||
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||
# ── KMS path: sign via HashiCorp Vault Transit (key never stored on disk) ──
|
||||
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
|
||||
bash "$VAULT_KMS" enable-transit
|
||||
bash "$VAULT_KMS" sign "$MANIFEST" "$SIGNATURE" "casan-policy-key"
|
||||
bash "$VAULT_KMS" pubkey "$PUBLIC_KEY" "casan-policy-key"
|
||||
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY key_backend=vault-kms"
|
||||
else
|
||||
# ── Fallback: local key file (dev / no Vault) ─────────────────────────────
|
||||
if [[ ! -f "$PRIVATE_KEY" ]]; then
|
||||
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1
|
||||
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1
|
||||
fi
|
||||
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
|
||||
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY key_backend=local-file"
|
||||
fi
|
||||
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
|
||||
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# CASAN H5 — HashiCorp Vault KMS helper
|
||||
#
|
||||
# Provides Vault-backed signing operations as a drop-in replacement for
|
||||
# direct openssl key-file usage. Scripts detect KMS availability via:
|
||||
# [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]]
|
||||
#
|
||||
# Usage (direct):
|
||||
# vault-kms.sh sign <file> <output.sig> [key_name]
|
||||
# vault-kms.sh verify <file> <sig_file> [key_name]
|
||||
# vault-kms.sh pubkey <output.pem> [key_name]
|
||||
# vault-kms.sh status
|
||||
#
|
||||
# Usage (sourced):
|
||||
# source vault-kms.sh
|
||||
# vault_kms_sign <file> <output.sig> [key_name]
|
||||
# vault_kms_pubkey <output.pem> [key_name]
|
||||
# vault_kms_status
|
||||
#
|
||||
# Environment:
|
||||
# VAULT_ADDR — e.g. http://vault:8200 or http://161.33.139.73:8200
|
||||
# VAULT_TOKEN — root token or policy token with transit/sign/* capability
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${VAULT_ADDR:?VAULT_ADDR must be set}"
|
||||
: "${VAULT_TOKEN:?VAULT_TOKEN must be set}"
|
||||
|
||||
_VAULT_DEFAULT_KEY="casan-policy-key"
|
||||
_VAULT_AUDIT_KEY="casan-audit-key"
|
||||
|
||||
# ── Ensure required tools ──────────────────────────────────────────────────
|
||||
_check_deps() {
|
||||
for cmd in curl python3; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "vault-kms: required: $cmd" >&2; exit 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# ── Transit: ensure key exists ────────────────────────────────────────────
|
||||
vault_kms_ensure_key() {
|
||||
local key="${1:-$_VAULT_DEFAULT_KEY}"
|
||||
local status
|
||||
status=$(curl -sf \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
"$VAULT_ADDR/v1/transit/keys/$key" 2>/dev/null | \
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); print('ok' if 'data' in d else 'missing')" 2>/dev/null || echo "missing")
|
||||
|
||||
if [[ "$status" != "ok" ]]; then
|
||||
curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"rsa-2048","exportable":false,"allow_plaintext_backup":false}' \
|
||||
"$VAULT_ADDR/v1/transit/keys/$key" >/dev/null
|
||||
echo "vault-kms: created transit key: $key" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Sign a file ───────────────────────────────────────────────────────────
|
||||
# Produces a DER binary signature compatible with:
|
||||
# openssl dgst -sha256 -verify pub.pem -signature <output.sig> <file>
|
||||
vault_kms_sign() {
|
||||
local file="$1" output="$2" key="${3:-$_VAULT_DEFAULT_KEY}"
|
||||
_check_deps
|
||||
vault_kms_ensure_key "$key"
|
||||
|
||||
# Base64-encode the file content for the Vault API payload
|
||||
local input_b64
|
||||
input_b64=$(base64 -w0 < "$file" 2>/dev/null || base64 < "$file")
|
||||
|
||||
# Call Vault Transit sign — PKCS#1 v1.5 + SHA-256 (compatible with openssl verify)
|
||||
local response
|
||||
response=$(curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"input\":\"$input_b64\",\"hash_algorithm\":\"sha2-256\",\"signature_algorithm\":\"pkcs1v15\",\"prehashed\":false}" \
|
||||
"$VAULT_ADDR/v1/transit/sign/$key") || {
|
||||
echo "vault-kms: sign request failed (key=$key)" >&2; exit 1
|
||||
}
|
||||
|
||||
# Extract signature and strip the "vault:v1:" prefix → raw base64 DER bytes
|
||||
local sig_b64
|
||||
sig_b64=$(printf '%s' "$response" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
sig = d['data']['signature']
|
||||
# vault:v1:<base64> → keep only base64 part
|
||||
print(sig.split(':')[-1])
|
||||
")
|
||||
|
||||
# Decode base64 → binary DER file (identical format to openssl -sign output)
|
||||
printf '%s' "$sig_b64" | base64 -d > "$output"
|
||||
echo "vault-kms: signed $file → $output (key=$key anchor=vault-kms)" >&2
|
||||
}
|
||||
|
||||
# ── Export public key ─────────────────────────────────────────────────────
|
||||
# Writes the RSA public key as PEM so openssl dgst -verify still works.
|
||||
vault_kms_pubkey() {
|
||||
local output="$1" key="${2:-$_VAULT_DEFAULT_KEY}"
|
||||
_check_deps
|
||||
vault_kms_ensure_key "$key"
|
||||
|
||||
local response
|
||||
response=$(curl -sf \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
"$VAULT_ADDR/v1/transit/keys/$key") || {
|
||||
echo "vault-kms: pubkey fetch failed (key=$key)" >&2; exit 1
|
||||
}
|
||||
|
||||
python3 - "$output" <<PY
|
||||
import sys, json
|
||||
output = sys.argv[1]
|
||||
response = """$response"""
|
||||
d = json.loads(response)
|
||||
keys = d["data"]["keys"]
|
||||
# keys is a dict; pick the latest version
|
||||
latest = max(keys.keys(), key=lambda k: int(k))
|
||||
pub = keys[latest]["public_key"]
|
||||
with open(output, "w") as f:
|
||||
f.write(pub if pub.endswith("\n") else pub + "\n")
|
||||
print(f"vault-kms: public key written to {output}", file=sys.stderr)
|
||||
PY
|
||||
}
|
||||
|
||||
# ── Verify a signature ────────────────────────────────────────────────────
|
||||
# Uses openssl with the public key exported from Vault.
|
||||
vault_kms_verify() {
|
||||
local file="$1" sig_file="$2" key="${3:-$_VAULT_DEFAULT_KEY}"
|
||||
local tmp_pub
|
||||
tmp_pub=$(mktemp /tmp/vault-pub-XXXXX.pem)
|
||||
trap 'rm -f "$tmp_pub"' RETURN
|
||||
vault_kms_pubkey "$tmp_pub" "$key"
|
||||
openssl dgst -sha256 -verify "$tmp_pub" -signature "$sig_file" "$file" >/dev/null
|
||||
}
|
||||
|
||||
# ── Connectivity check ────────────────────────────────────────────────────
|
||||
vault_kms_status() {
|
||||
if curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
|
||||
echo "VAULT_KMS_AVAILABLE addr=$VAULT_ADDR"
|
||||
return 0
|
||||
else
|
||||
echo "VAULT_KMS_UNAVAILABLE addr=${VAULT_ADDR:-unset}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Enable transit engine (idempotent) ────────────────────────────────────
|
||||
vault_kms_enable_transit() {
|
||||
curl -sf -X POST \
|
||||
-H "X-Vault-Token: $VAULT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"transit"}' \
|
||||
"$VAULT_ADDR/v1/sys/mounts/transit" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ── CLI entrypoint ────────────────────────────────────────────────────────
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
CMD="${1:-status}"
|
||||
shift || true
|
||||
case "$CMD" in
|
||||
sign) vault_kms_sign "$@" ;;
|
||||
pubkey) vault_kms_pubkey "$@" ;;
|
||||
verify) vault_kms_verify "$@" ;;
|
||||
status) vault_kms_status ;;
|
||||
enable-transit) vault_kms_enable_transit ;;
|
||||
ensure-key) vault_kms_ensure_key "${1:-}" ;;
|
||||
*)
|
||||
echo "Usage: vault-kms.sh {sign|pubkey|verify|status|enable-transit|ensure-key}" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssl python3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY backend/prisma ./backend/prisma
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY backend/src ./backend/src
|
||||
COPY backend/tsconfig*.json ./backend/
|
||||
|
||||
# Generate Prisma client + compile TypeScript
|
||||
RUN cd backend && npx prisma generate && npx tsc -p tsconfig.build.json
|
||||
|
||||
# ─── Stage 2: Runtime ────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS runtime
|
||||
|
||||
# node:sqlite (setup-sqlite.mjs) requires Node 22 — this image satisfies that.
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY backend/package.json ./backend/
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY backend/prisma ./backend/prisma
|
||||
|
||||
# All deps (including devDeps) so that tsx (seed) and prisma CLI are available.
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Generate Prisma client in runtime image (CWD resolves schema at backend/prisma/schema.prisma)
|
||||
RUN cd backend && npx prisma generate
|
||||
|
||||
COPY --from=builder /app/backend/dist ./backend/dist
|
||||
COPY backend/scripts ./backend/scripts
|
||||
COPY backend/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3001
|
||||
|
||||
ENV PORT=3001
|
||||
ENV DATABASE_URL=file:/data/okr.db
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# VITE_API_BASE_URL is baked into the bundle at build time.
|
||||
# Use a relative path so the image works with any hostname/IP — nginx
|
||||
# running on the same host proxies /api/v1/* to the backend container.
|
||||
ARG VITE_API_BASE_URL=/api/v1
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY frontend/package.json ./frontend/
|
||||
COPY backend/package.json ./backend/
|
||||
|
||||
RUN npm ci -w frontend
|
||||
|
||||
COPY frontend ./frontend
|
||||
|
||||
RUN npm run build -w frontend
|
||||
|
||||
# ─── Stage 2: nginx runtime ──────────────────────────────────────────────────
|
||||
FROM nginx:alpine AS runtime
|
||||
|
||||
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# OKR backend container entrypoint.
|
||||
# - First run: creates SQLite schema via setup-sqlite.mjs + seeds initial data.
|
||||
# - Subsequent runs: DB already exists, skip init.
|
||||
# WORKDIR expected: /app/backend (set in Dockerfile)
|
||||
set -e
|
||||
|
||||
# Hoisted node_modules/.bin (workspace root) must be in PATH for tsx + prisma CLI
|
||||
export PATH="/app/node_modules/.bin:$PATH"
|
||||
|
||||
mkdir -p /data
|
||||
|
||||
if [ ! -f "/data/okr.db" ]; then
|
||||
echo "[OKR] First run — initializing database at /data/okr.db"
|
||||
node scripts/setup-sqlite.mjs
|
||||
npx prisma db seed
|
||||
echo "[OKR] Database initialized"
|
||||
fi
|
||||
|
||||
echo "[OKR] Starting backend on port ${PORT:-3001}"
|
||||
exec node dist/main.js
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy /api/v1/* → NestJS backend (same Docker network: okr-net)
|
||||
location /api/v1/ {
|
||||
proxy_pass http://okr-backend:3001/api/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
# Pass cookies (HttpOnly JWT) through the proxy
|
||||
proxy_pass_header Set-Cookie;
|
||||
}
|
||||
|
||||
# React SPA — React Router requires all non-asset paths to serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1256
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user