feat(deploy): switch to Oracle MySQL, separate CI runner, docker-compose deploy

- Prisma schema: sqlite → mysql provider
- Migration SQL rewritten as MySQL DDL (utf8mb4, DATETIME(3), AUTO_INCREMENT)
- Add migration_lock.toml for mysql provider
- Dockerfile.backend: drop node:22/sqlite deps, use node:20-slim
- entrypoint.sh: replace SQLite first-run logic with prisma migrate deploy + db seed
- docker-compose.prod.yml: production compose for /opt/webapps/okr on web VPS
  - reads DB creds from /opt/webapps/webapp-mysql.env
  - reads app secrets from /opt/webapps/okr/.env.app (written by CI)
  - port 80 (frontend), no conflict with Gitea 3000/Vault 8200
- ci.yml deploy-okr: moves from ubuntu-latest (web VPS) to ci-runner (161.33.149.243)
  - builds images on CI runner VPS (no heavy build on web/Gitea VPS)
  - transfers images via docker save | gzip | ssh | docker load
  - deploys via SSH + docker compose up on web VPS
- scripts/setup-ci-runner.sh: one-time setup script for CI runner VPS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-01 16:37:56 +09:00
co-authored by Claude Sonnet 4.6
parent 719f1147d1
commit 9229fee656
8 changed files with 284 additions and 122 deletions
+75 -50
View File
@@ -1,13 +1,11 @@
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
@@ -19,6 +17,7 @@ concurrency:
jobs:
# ──────────────────────────────────────────────────────────────────────────
# Job 1 — Frontend unit tests (fast gate, ~1 min)
# Runs on: web/Gitea VPS runner (161.33.139.73) — light job, no Docker build
# ──────────────────────────────────────────────────────────────────────────
frontend-tests:
name: "Frontend Tests (H3 gate)"
@@ -45,6 +44,8 @@ jobs:
# ──────────────────────────────────────────────────────────────────────────
# Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7)
# Runs on: web/Gitea VPS runner (161.33.139.73) — after frontend-tests to
# prevent OOM (1 GB RAM VPS, sequential jobs via capacity: 1)
# ──────────────────────────────────────────────────────────────────────────
security-gate:
name: "CASAN Security Gate + Vault KMS (H4/H5)"
@@ -116,15 +117,33 @@ jobs:
retention-days: 14
# ──────────────────────────────────────────────────────────────────────────
# Job 3 — Deploy OKR web app (main branch only)
# Job 3 — Build Docker images + deploy to /opt/webapps/okr (main only)
#
# okr-backend — NestJS + Prisma + SQLite, port 3001 (internal)
# okr-frontend — nginx + React SPA, port 80 (public)
# nginx proxies /api/v1/* → okr-backend:3001
# Runs on: DEDICATED CI RUNNER (161.33.149.243, label: ci-runner)
# Heavy Docker builds do NOT run on the web/Gitea VPS.
#
# Architecture:
# CI runner (161.33.149.243) Web VPS (161.33.139.73)
# ───────────────────────── ────────────────────────────────
# docker build okr-backend /opt/webapps/okr/
# docker build okr-frontend ──► docker-compose.yml
# docker save | gzip | ssh ──► docker load
# docker compose up -d
#
# DB: Oracle MySQL HeatWave (10.0.1.254:3306, webapp_db)
# Credentials live on web VPS: /opt/webapps/webapp-mysql.env
#
# Required Gitea secrets:
# DEPLOY_SSH_KEY — ed25519 private key for ubuntu@161.33.139.73
# JWT_SECRET — app JWT signing secret
#
# One-time CI runner setup:
# ssh ubuntu@161.33.149.243
# RUNNER_TOKEN=<from Gitea admin> bash AINative_OKR_CASAN5/scripts/setup-ci-runner.sh
# ──────────────────────────────────────────────────────────────────────────
deploy-okr:
name: "Deploy OKR → port 80 (H3 CI gate)"
runs-on: ubuntu-latest
name: "Build & Deploy OKR → /opt/webapps/okr"
runs-on: [ci-runner]
needs: [frontend-tests, security-gate]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
defaults:
@@ -132,20 +151,25 @@ jobs:
working-directory: AINative_OKR_CASAN5
env:
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FRONTEND_ORIGIN: "http://161.33.139.73"
WEB_VPS: "ubuntu@161.33.139.73"
APP_DIR: "/opt/webapps/okr"
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Build backend image (node:22-slim — required for node:sqlite)
- name: Setup SSH for web VPS deploy
run: |
docker build \
-t okr-backend:latest \
-f Dockerfile.backend \
.
mkdir -p ~/.ssh
printf '%s' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H 161.33.139.73 >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH ready"
- name: Build frontend image (nginx + React SPA)
- name: Build backend image (node:20-slim + Prisma MySQL)
run: docker build -t okr-backend:latest -f Dockerfile.backend .
- name: Build frontend image (nginx + React SPA, VITE_API_BASE_URL=/api/v1)
run: |
docker build \
--build-arg VITE_API_BASE_URL=/api/v1 \
@@ -153,46 +177,47 @@ jobs:
-f Dockerfile.frontend \
.
- name: Create network + persistent volume
- name: Transfer images to web VPS
run: |
docker network create okr-net 2>/dev/null || true
docker volume create okr-db 2>/dev/null || true
echo "Streaming images to web VPS (this may take ~1 min)..."
docker save okr-backend:latest okr-frontend:latest | \
gzip | \
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
'docker load'
- name: Deploy backend (port 3001, internal only)
- name: Prepare app directory + docker-compose on web VPS
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
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
"mkdir -p $APP_DIR"
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
docker-compose.prod.yml $WEB_VPS:$APP_DIR/docker-compose.yml
- name: Deploy frontend (port 80, public)
- name: Write app secrets on web VPS
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
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
"printf 'JWT_SECRET=%s\nFRONTEND_ORIGIN=http://161.33.139.73\n' '${JWT_SECRET}' \
> $APP_DIR/.env.app && chmod 600 $APP_DIR/.env.app"
- name: Deploy containers on web VPS
run: |
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
"cd $APP_DIR && docker compose up -d --remove-orphans --pull never"
- name: Health check
run: |
echo "Waiting 20 s for containers to initialise..."
sleep 20
echo "=== Running containers ==="
docker ps --filter "name=okr" --format "{{.Names}}\t{{.Status}}\t{{.Ports}}"
if docker ps --filter "name=okr-frontend" --filter "status=running" | grep -q okr-frontend; then
echo "DEPLOY_OK frontend=http://161.33.139.73"
else
echo "DEPLOY_WARN containers not fully running — dumping logs"
docker logs okr-frontend --tail 30 || true
docker logs okr-backend --tail 30 || true
fi
echo "Waiting 30s for containers to initialise (includes Prisma migrate + seed)..."
sleep 30
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS '
echo "=== Containers ===" &&
docker compose -f /opt/webapps/okr/docker-compose.yml ps &&
echo "" &&
echo "=== HTTP check ===" &&
if curl -fsS -o /dev/null -w "HTTP %{http_code}" http://localhost; then
echo ""
echo "DEPLOY_OK http://161.33.139.73"
else
echo "DEPLOY_WARN — backend logs:"
docker logs okr-backend --tail 30 || true
docker logs okr-frontend --tail 20 || true
fi
'
+2 -10
View File
@@ -1,5 +1,5 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:22-slim AS builder
FROM node:20-slim AS builder
WORKDIR /app
@@ -15,13 +15,10 @@ 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.
FROM node:20-slim AS runtime
WORKDIR /app
@@ -32,24 +29,19 @@ 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"]
+7 -12
View File
@@ -1,21 +1,16 @@
#!/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)
# OKR backend container entrypoint for MySQL.
# - Applies pending Prisma migrations (idempotent).
# - Seeds initial data via upsert (safe to run on every start).
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
echo "[OKR] Applying database migrations..."
npx prisma migrate deploy
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] Seeding database..."
npx prisma db seed
echo "[OKR] Starting backend on port ${PORT:-3001}"
exec node dist/main.js
@@ -1,53 +1,65 @@
-- Initial OKR SQLite schema generated from prisma/schema.prisma via prisma migrate diff.
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- MySQL initial schema for OKR application
CREATE TABLE `User` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`passwordHash` VARCHAR(191) NOT NULL,
`role` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `User_username_key`(`username`),
UNIQUE INDEX `User_email_key`(`email`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "Objective" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"description" TEXT,
"ownerId" INTEGER NOT NULL,
"quarter" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Objective_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE `Objective` (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`ownerId` INT NOT NULL,
`quarter` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'NOT_STARTED',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Objective_ownerId_idx`(`ownerId`),
INDEX `Objective_quarter_idx`(`quarter`),
INDEX `Objective_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "KeyResult" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"objectiveId" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"startValue" INTEGER NOT NULL,
"targetValue" INTEGER NOT NULL,
"deadline" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "KeyResult_objectiveId_fkey" FOREIGN KEY ("objectiveId") REFERENCES "Objective" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE `KeyResult` (
`id` INT NOT NULL AUTO_INCREMENT,
`objectiveId` INT NOT NULL,
`title` VARCHAR(191) NOT NULL,
`progress` INT NOT NULL DEFAULT 0,
`startValue` INT NOT NULL,
`targetValue` INT NOT NULL,
`deadline` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `KeyResult_objectiveId_idx`(`objectiveId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "ProgressUpdate" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"keyResultId" INTEGER NOT NULL,
"progress" INTEGER NOT NULL,
"comment" TEXT,
"createdById" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProgressUpdate_keyResultId_fkey" FOREIGN KEY ("keyResultId") REFERENCES "KeyResult" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE `ProgressUpdate` (
`id` INT NOT NULL AUTO_INCREMENT,
`keyResultId` INT NOT NULL,
`progress` INT NOT NULL,
`comment` VARCHAR(191) NULL,
`createdById` INT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "Objective_ownerId_idx" ON "Objective"("ownerId");
CREATE INDEX "Objective_quarter_idx" ON "Objective"("quarter");
CREATE INDEX "Objective_status_idx" ON "Objective"("status");
CREATE INDEX "KeyResult_objectiveId_idx" ON "KeyResult"("objectiveId");
ALTER TABLE `Objective` ADD CONSTRAINT `Objective_ownerId_fkey`
FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`)
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `KeyResult` ADD CONSTRAINT `KeyResult_objectiveId_fkey`
FOREIGN KEY (`objectiveId`) REFERENCES `Objective`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `ProgressUpdate` ADD CONSTRAINT `ProgressUpdate_keyResultId_fkey`
FOREIGN KEY (`keyResultId`) REFERENCES `KeyResult`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
@@ -3,7 +3,7 @@ generator client {
}
datasource db {
provider = "sqlite"
provider = "mysql"
url = env("DATABASE_URL")
}
@@ -0,0 +1,42 @@
version: '3.8'
# Production deployment for VPS: /opt/webapps/okr/
# DB credentials come from /opt/webapps/webapp-mysql.env (managed on VPS, not in repo).
# App secrets (JWT_SECRET) come from /opt/webapps/okr/.env.app (written by CI deploy step).
# Ports in use on this VPS — DO NOT conflict:
# 3000 = Gitea HTTP, 2222 = Gitea SSH, 8200 = Vault
services:
okr-backend:
image: okr-backend:latest
container_name: okr-backend
restart: unless-stopped
env_file:
- /opt/webapps/webapp-mysql.env # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DATABASE_URL
- /opt/webapps/okr/.env.app # JWT_SECRET, FRONTEND_ORIGIN
environment:
PORT: "3001"
NODE_ENV: production
mem_limit: 256m
cpus: "0.5"
networks:
- okr-net
expose:
- "3001"
okr-frontend:
image: okr-frontend:latest
container_name: okr-frontend
restart: unless-stopped
ports:
- "80:80"
mem_limit: 64m
cpus: "0.25"
networks:
- okr-net
depends_on:
- okr-backend
networks:
okr-net:
driver: bridge
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setup act_runner on the CI runner VPS (161.33.149.243).
#
# Prerequisites (run on CI runner VPS as ubuntu):
# 1. Get a runner registration token from Gitea:
# http://161.33.139.73:3000 → Site Administration → Runners → "Create Runner"
# 2. Generate a deploy SSH key for accessing the web VPS:
# ssh-keygen -t ed25519 -f /tmp/deploy_key -N ""
# ssh-copy-id -i /tmp/deploy_key.pub ubuntu@161.33.139.73
# Add /tmp/deploy_key (private) as Gitea secret: DEPLOY_SSH_KEY
# rm /tmp/deploy_key
#
# Usage:
# RUNNER_TOKEN=<token-from-gitea> bash setup-ci-runner.sh
#
set -euo pipefail
GITEA_URL="http://161.33.139.73:3000"
RUNNER_NAME="casan-ci-runner"
RUNNER_VERSION="v0.2.12"
INSTALL_DIR="/opt/act-runner"
if [[ -z "${RUNNER_TOKEN:-}" ]]; then
echo "ERROR: RUNNER_TOKEN env var is required."
echo " Get it from: $GITEA_URL → Site Administration → Runners → Create Runner"
exit 1
fi
echo "=== Installing act_runner $RUNNER_VERSION ==="
sudo mkdir -p "$INSTALL_DIR"
sudo curl -fsSL \
"https://gitea.com/gitea/act_runner/releases/download/${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64" \
-o "$INSTALL_DIR/act_runner"
sudo chmod +x "$INSTALL_DIR/act_runner"
echo "=== Writing runner config ==="
sudo tee "$INSTALL_DIR/config.yaml" > /dev/null <<'CONFIG'
log:
level: info
runner:
name: "casan-ci-runner"
capacity: 1
labels:
- "ci-runner:docker://catthehacker/ubuntu:act-22.04"
fetch_interval: 5s
fetch_timeout: 60s
container:
# host network so the job container can reach Gitea at 161.33.139.73:3000
network: host
# 2 GB RAM available on CI runner — builds need up to 1.5 GB
options: "--memory 1536m --cpus 1.5"
valid_volumes:
- "**"
CONFIG
echo "=== Registering runner with Gitea ==="
cd "$INSTALL_DIR"
sudo ./act_runner register \
--instance "$GITEA_URL" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--no-interactive
echo "=== Installing systemd service ==="
sudo tee /etc/systemd/system/act-runner.service > /dev/null <<'SERVICE'
[Unit]
Description=Gitea act_runner (CI builds)
After=docker.service
Requires=docker.service
[Service]
User=ubuntu
Group=docker
WorkingDirectory=/opt/act-runner
ExecStart=/opt/act-runner/act_runner daemon --config /opt/act-runner/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable act-runner
sudo systemctl start act-runner
echo ""
echo "=== CI runner setup complete ==="
echo "Check status: sudo systemctl status act-runner"
echo "View logs: sudo journalctl -u act-runner -f"
echo "Verify in Gitea: $GITEA_URL/-/admin/runners"