diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c45254e..91f697c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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= 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 + ' diff --git a/AINative_OKR_CASAN5/Dockerfile.backend b/AINative_OKR_CASAN5/Dockerfile.backend index 62855ff..f743517 100644 --- a/AINative_OKR_CASAN5/Dockerfile.backend +++ b/AINative_OKR_CASAN5/Dockerfile.backend @@ -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"] diff --git a/AINative_OKR_CASAN5/backend/entrypoint.sh b/AINative_OKR_CASAN5/backend/entrypoint.sh index e9d20c4..d00834b 100644 --- a/AINative_OKR_CASAN5/backend/entrypoint.sh +++ b/AINative_OKR_CASAN5/backend/entrypoint.sh @@ -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 diff --git a/AINative_OKR_CASAN5/backend/prisma/migrations/202606280001_init/migration.sql b/AINative_OKR_CASAN5/backend/prisma/migrations/202606280001_init/migration.sql index a86d739..0fc00e9 100644 --- a/AINative_OKR_CASAN5/backend/prisma/migrations/202606280001_init/migration.sql +++ b/AINative_OKR_CASAN5/backend/prisma/migrations/202606280001_init/migration.sql @@ -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; diff --git a/AINative_OKR_CASAN5/backend/prisma/migrations/migration_lock.toml b/AINative_OKR_CASAN5/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..592fc0b --- /dev/null +++ b/AINative_OKR_CASAN5/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/AINative_OKR_CASAN5/backend/prisma/schema.prisma b/AINative_OKR_CASAN5/backend/prisma/schema.prisma index 52efdc9..8285d85 100644 --- a/AINative_OKR_CASAN5/backend/prisma/schema.prisma +++ b/AINative_OKR_CASAN5/backend/prisma/schema.prisma @@ -3,7 +3,7 @@ generator client { } datasource db { - provider = "sqlite" + provider = "mysql" url = env("DATABASE_URL") } diff --git a/AINative_OKR_CASAN5/docker-compose.prod.yml b/AINative_OKR_CASAN5/docker-compose.prod.yml new file mode 100644 index 0000000..58dea00 --- /dev/null +++ b/AINative_OKR_CASAN5/docker-compose.prod.yml @@ -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 diff --git a/AINative_OKR_CASAN5/scripts/setup-ci-runner.sh b/AINative_OKR_CASAN5/scripts/setup-ci-runner.sh new file mode 100644 index 0000000..469758c --- /dev/null +++ b/AINative_OKR_CASAN5/scripts/setup-ci-runner.sh @@ -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= 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"