From f38cd6810bc6c394a7bdc8fca95c08b527343041 Mon Sep 17 00:00:00 2001 From: ISMAIL MASSERAN Date: Tue, 30 Jun 2026 10:28:48 +0800 Subject: [PATCH] DONE: add cicd pipeline separate fe and be, test first on gitea --- .gitea/gitea-compose.yml | 40 ++++++ .gitea/workflows/build.yml | 96 +++++++++++++ .gitea/workflows/deploy-prod.yml | 132 ++++++++++++++++++ be/.env.production | 46 +++--- be/.env.staging | 2 +- be/.env.training | 101 -------------- be/config/cors.php | 4 +- be/docker/common/unified/Dockerfile | 41 +----- .../production/docker-compose.production.yml | 45 +++--- be/docker/production/nginx/nginx.conf | 40 +----- be/docker/staging/docker-compose.staging.yml | 4 +- .../training/docker-compose.training.yml | 2 +- fe/docker/Dockerfile | 17 +++ fe/docker/nginx.conf | 23 +++ 14 files changed, 374 insertions(+), 219 deletions(-) create mode 100644 .gitea/gitea-compose.yml create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/deploy-prod.yml delete mode 100644 be/.env.training create mode 100644 fe/docker/Dockerfile create mode 100644 fe/docker/nginx.conf diff --git a/.gitea/gitea-compose.yml b/.gitea/gitea-compose.yml new file mode 100644 index 0000000..d769ad4 --- /dev/null +++ b/.gitea/gitea-compose.yml @@ -0,0 +1,40 @@ +networks: + gitea: + external: false + +services: + server: + image: docker.gitea.com/gitea:1.26.4 + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=db:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=giteauser + - GITEA__database__PASSWD=kopkb@gitea_2026 + restart: always + networks: + - gitea + volumes: + - ./gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "222:22" + depends_on: + - db + + db: + image: docker.io/library/postgres:17 + restart: always + environment: + - POSTGRES_USER=giteauser + - POSTGRES_PASSWORD=kopkb@gitea_2026 + - POSTGRES_DB=gitea + networks: + - gitea + volumes: + - ./postgres:/var/lib/postgresql diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..4a193cf --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,96 @@ +name: Build Docker Image + +# Builds images on every push to main or tag, but does NOT auto-deploy +on: + push: + branches: + - main + tags: + - "v*" # v1.0.0, v1.0.1, etc. + +jobs: + # test-backend: + # runs-on: docker + + # steps: + # - name: Checkout repository + # uses: actions/checkout@v4 + + # - name: Run backend tests + # run: | + # docker buildx build \ + # --target backend-test \ + # --platform linux/amd64 \ + # -f be/docker/common/unified/Dockerfile \ + # . + + build-backend: + runs-on: docker + # needs: [test-backend] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Docker registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login git.koppkb.com \ + -u "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin + + - name: Build & push backend image + run: | + set -e + + IMAGE="git.koppkb.com/KoPKB/My-KOPKB-be" + TAGS="-t ${IMAGE}:${{ gitea.sha }}" + + if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then + TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}" + fi + + echo "Building and pushing backend: ${TAGS}" + docker buildx build \ + --platform linux/amd64 \ + --cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \ + --cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \ + -f be/docker/common/unified/Dockerfile \ + ${TAGS} \ + --push . + + build-frontend: + runs-on: docker + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Docker registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login git.koppkb.com \ + -u "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin + + - name: Build & push frontend image + run: | + set -e + + IMAGE="git.koppkb.com/KoPKB/My-KOPKB-fe" + TAGS="-t ${IMAGE}:${{ gitea.sha }}" + + if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then + TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}" + fi + + echo "Building and pushing frontend: ${TAGS}" + docker buildx build \ + --platform linux/amd64 \ + --cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \ + --cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \ + -f fe/docker/Dockerfile \ + --build-arg VITE_API_BASE_URL=https://api.koppkb.com \ + --build-arg VITE_APP_URL=https://anggota.koppkb.com \ + ${TAGS} \ + --push . diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..a741344 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,132 @@ +name: Deploy to Production + +# Manual production deploy — pulls pre-built images from the registry +on: + workflow_dispatch: + inputs: + image_tag: + description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)" + required: true + default: "latest" + type: string + +env: + BACKEND_IMAGE: git.koppkb.com/KoPKB/My-KOPKB-be + FRONTEND_IMAGE: git.koppkb.com/KoPKB/My-KOPKB-fe + DEPLOY_DIR: /home/arrahn/Project + +jobs: + deploy: + runs-on: host + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.image_tag }} + + - name: Login to Docker registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | \ + docker login git.koppkb.com \ + -u "${{ secrets.REGISTRY_USERNAME }}" \ + --password-stdin + + - name: Verify images exist in registry + run: | + set -e + IMAGE_TAG="${{ inputs.image_tag }}" + + for IMAGE in "${BACKEND_IMAGE}:${IMAGE_TAG}" "${FRONTEND_IMAGE}:${IMAGE_TAG}"; do + echo "Checking if image exists: ${IMAGE}" + if ! docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then + echo "ERROR: Image ${IMAGE} does not exist in registry!" + exit 1 + fi + echo "✓ ${IMAGE} found" + done + + - name: Pull Docker images + run: | + set -e + IMAGE_TAG="${{ inputs.image_tag }}" + docker pull "${BACKEND_IMAGE}:${IMAGE_TAG}" + docker pull "${FRONTEND_IMAGE}:${IMAGE_TAG}" + echo "✓ Images pulled successfully" + + - name: Sync compose file and deploy + run: | + set -e + IMAGE_TAG="${{ inputs.image_tag }}" + + mkdir -p "${DEPLOY_DIR}" + cp be/docker/production/docker-compose.production.yml "${DEPLOY_DIR}/docker-compose.yml" + + # nginx config is mounted from repo checkout path on each deploy + NGINX_CONF="$(pwd)/be/docker/production/nginx/nginx.conf" + if [ ! -f "${NGINX_CONF}" ]; then + echo "ERROR: nginx config not found at ${NGINX_CONF}" + exit 1 + fi + + cd "${DEPLOY_DIR}" + + if [ ! -f .env.production ]; then + echo "ERROR: ${DEPLOY_DIR}/.env.production is missing on the server" + exit 1 + fi + + # Optional compose-level env (REDIS_PASSWORD, API_PORT, FE_PORT, etc.) + if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a + fi + + export IMAGE_TAG + export NGINX_CONF + + docker compose -f docker-compose.yml pull backend frontend + docker compose -f docker-compose.yml up -d --remove-orphans + + echo "✓ Deployed IMAGE_TAG=${IMAGE_TAG}" + + - name: Verify deployment + run: | + set -e + echo "Waiting for services to start..." + sleep 15 + + cd "${DEPLOY_DIR}" + + for SERVICE in backend frontend; do + STATUS=$(docker compose -f docker-compose.yml ps --status running --format '{{.Name}}' "$SERVICE" 2>/dev/null || true) + if [ -z "$STATUS" ]; then + echo "ERROR: ${SERVICE} is not running" + docker compose -f docker-compose.yml ps + docker compose -f docker-compose.yml logs --tail=50 "$SERVICE" || true + exit 1 + fi + echo "✓ ${SERVICE} is running (${STATUS})" + done + + BE_CONTAINER=$(docker compose -f docker-compose.yml ps -q backend) + if docker exec "$BE_CONTAINER" test -f /var/www/.env; then + echo "✓ Backend .env file exists" + else + echo "WARNING: Backend .env file not found" + fi + + if docker exec "$BE_CONTAINER" curl -sf http://localhost/health > /dev/null; then + echo "✓ Backend health check passed" + else + echo "WARNING: Backend health check failed" + fi + + FE_CONTAINER=$(docker compose -f docker-compose.yml ps -q frontend) + if docker exec "$FE_CONTAINER" wget -q --spider http://localhost/; then + echo "✓ Frontend health check passed" + else + echo "WARNING: Frontend health check failed" + fi diff --git a/be/.env.production b/be/.env.production index 87617b6..2919b12 100644 --- a/be/.env.production +++ b/be/.env.production @@ -1,9 +1,9 @@ -APP_NAME="SUTERA 3.0" +APP_NAME="MyKOPKB 1.0" APP_ENV=production APP_KEY=base64:XdMAy5IbQMHsTrp1N7Tn/LIJC4SKenN8CfhYLt1s3tk= APP_DEBUG=false APP_TIMEZONE=Asia/Kuala_Lumpur -APP_URL=http://20.11.32.50 +APP_URL=https://anggota.koppkb.com APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -22,11 +22,11 @@ LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=error DB_CONNECTION=pgsql -DB_HOST=20.11.32.56 -DB_PORT=5432 -DB_DATABASE=sutera -DB_USERNAME=suterauser -DB_PASSWORD=Aretus@Production_2025 +DB_HOST=172.16.5.12 +DB_PORT=5433 +DB_DATABASE=mykopkb +DB_USERNAME=mykopkbuser +DB_PASSWORD=MyKOPKB@Production_2026 SESSION_DRIVER=redis SESSION_LIFETIME=60 @@ -42,7 +42,7 @@ HORIZON_WORKER_MEMORY=512 HORIZON_WORKER_TIMEOUT=3600 CACHE_STORE=redis -CACHE_PREFIX=sutera_cache +CACHE_PREFIX=mykopkb_cache CACHE_DRIVER=redis MEMCACHED_HOST=memcached @@ -50,7 +50,7 @@ MEMCACHED_HOST=memcached REDIS_CLIENT=phpredis REDIS_HOST=redis REDIS_PORT=6379 -REDIS_PASSWORD=sutera_redis@2025 +REDIS_PASSWORD=mykopkb_redis@2026 REDIS_DB=0 # only change this to smtp if is deployed to public server @@ -76,22 +76,22 @@ VITE_APP_NAME="${APP_NAME}" # VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" # VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -EXTERNAL_API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibWJzcC1yZXNvdXJjZUlkIl0sInRpbWVvdXRQZXJpb2QiOjkwMCwidXNlcl9uYW1lIjoic3BhLXVzZXIiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiZXhwIjoxNzI4NTgzNjEyLCJqdGkiOiJjMmU2OTkxOS0yZDU3LTQ5YmYtYTVmZS1lZjVmMzIyNDZkZDIiLCJjbGllbnRfaWQiOiJzcGEtY2xpZW50In0.LE2byaRzsYkchzI4ox9Gz2u6jNmTqZQa-_ttR2PY8e8" -EXTERNAL_API_BASE_URL=http://20.11.32.134/rest/td/gasset/asset +EXTERNAL_API_TOKEN="" +EXTERNAL_API_BASE_URL= -SSO_SECRET="Y6g#Rb@Km=Nz]o^X+fEC~ [], diff --git a/be/docker/common/unified/Dockerfile b/be/docker/common/unified/Dockerfile index 8a506eb..7bd634a 100644 --- a/be/docker/common/unified/Dockerfile +++ b/be/docker/common/unified/Dockerfile @@ -1,33 +1,3 @@ -# Stage 0: Build Vue frontend assets with Node.js -FROM node:22 AS frontend-builder - -WORKDIR /app/frontend - -# Copy frontend package files -COPY fe/package*.json ./ -COPY fe/pnpm-lock.yaml* ./ - -# Install pnpm and frontend dependencies (including dev dependencies for build) -RUN npm install -g pnpm -RUN pnpm install --frozen-lockfile - -# Copy frontend source code -COPY fe/ ./ - -# Build TWO frontend bundles (path-based): -# - Production: served at / -# - Training: served at /training/ -# -# This avoids runtime JS injection and allows training/prod UI differences -# while keeping a single backend image. -RUN pnpm run typecheck \ - && pnpm exec vite build --mode=production --base=/ --outDir dist-prod \ - && pnpm exec vite build --mode=training --base=/training/ --outDir dist-training - -# Clean up dev dependencies to reduce image size -ENV CI=true -RUN pnpm prune --prod - # Stage 1: Build environment and Composer dependencies FROM php:8.4-fpm AS builder @@ -150,12 +120,6 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \ # Copy the application code and dependencies from the build stage first COPY --from=builder /var/www /var/www -# Copy the built Vue frontends from frontend-builder stage -# - Production frontend at / -COPY --from=frontend-builder /app/frontend/dist-prod /var/www/public/ -# - Training frontend at /training/ -COPY --from=frontend-builder /app/frontend/dist-training /var/www/public/training/ - # Set working directory WORKDIR /var/www @@ -173,3 +137,8 @@ EXPOSE 80 9000 # Start supervisor which manages both Nginx and PHP-FPM CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"] + +# CI: install dev dependencies and run PHPUnit (build fails if tests fail) +FROM builder AS backend-test +RUN composer install --no-interaction --prefer-dist --no-scripts \ + && ./vendor/bin/phpunit --no-coverage diff --git a/be/docker/production/docker-compose.production.yml b/be/docker/production/docker-compose.production.yml index 242719d..9174a8c 100644 --- a/be/docker/production/docker-compose.production.yml +++ b/be/docker/production/docker-compose.production.yml @@ -1,16 +1,10 @@ services: - app: - build: - context: . - dockerfile: ./be/docker/common/unified/Dockerfile - platforms: - - linux/amd64 - - linux/arm64 - image: git.ismailmasseran.com/topaz/sutera:v3.0.1b - container_name: sutera-app-production + backend: + image: git.koppkb.com/KoPKB/My-KOPKB-be:${IMAGE_TAG:-latest} + container_name: mykopkb-be-production restart: unless-stopped ports: - - "${APP_PORT:-80}:80" + - "${API_PORT:-8080}:80" environment: - APP_ENV=production - APP_DEBUG=true @@ -23,9 +17,9 @@ services: - app-storage-production:/var/www/storage - app-logs-production:/var/www/storage/logs - ./.env.production:/var/www/.env:ro - - ./be/docker/production/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ${NGINX_CONF:-./be/docker/production/nginx/nginx.conf}:/etc/nginx/nginx.conf:ro networks: - - sutera-production-network + - mykopkb-production-network depends_on: redis: condition: service_healthy @@ -41,17 +35,36 @@ services: max-size: "10m" max-file: "3" - # Redis for caching and sessions + frontend: + image: git.koppkb.com/KoPKB/My-KOPKB-fe:${IMAGE_TAG:-latest} + container_name: mykopkb-fe-production + restart: unless-stopped + ports: + - "${FE_PORT:-8081}:80" + networks: + - mykopkb-production-network + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + redis: image: redis:8 - container_name: sutera-redis-production + container_name: mykopkb-redis-production restart: unless-stopped volumes: - redis-data-production:/data - ./redis-config/redis.conf:/usr/local/etc/redis/redis.conf:ro command: redis-server /usr/local/etc/redis/redis.conf networks: - - sutera-production-network + - mykopkb-production-network healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s @@ -65,7 +78,7 @@ services: max-file: "3" networks: - sutera-production-network: + mykopkb-production-network: driver: bridge volumes: diff --git a/be/docker/production/nginx/nginx.conf b/be/docker/production/nginx/nginx.conf index ac3c643..0e5aa46 100644 --- a/be/docker/production/nginx/nginx.conf +++ b/be/docker/production/nginx/nginx.conf @@ -9,74 +9,40 @@ http { sendfile on; keepalive_timeout 65; - # Logging access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; - # Production HTTP server + # API-only server (api.koppkb.com via host reverse proxy) server { listen 80; server_name _; root /var/www/public; - index index.php index.html; + index index.php; - # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-XSS-Protection "1; mode=block" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; - # API routes - pass directly to Laravel with original REQUEST_URI location / { - fastcgi_pass 127.0.0.1:9000; - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME $document_root/index.php; - include fastcgi_params; - fastcgi_param HTTP_PROXY ""; - fastcgi_param HTTPS $https if_not_empty; - fastcgi_read_timeout 300; - fastcgi_send_timeout 300; + try_files $uri $uri/ /index.php?$query_string; } - # PRODUCTION: /training/ path is disabled - return 404 - location ^~ /training/ { - return 404; - } - - # Serve frontend assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; - try_files $uri =404; - } - - # Handle all other routes - serve Vue app or Laravel - location / { - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; - try_files $uri $uri/ /index.html /index.php?$query_string; - } - - # Handle PHP files - connect to localhost PHP-FPM location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; - - # Additional FastCGI parameters fastcgi_param HTTP_PROXY ""; fastcgi_param HTTPS $https if_not_empty; fastcgi_read_timeout 300; fastcgi_send_timeout 300; } - # Deny access to hidden files location ~ /\. { deny all; } - # Health check endpoint for Docker location = /health { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; diff --git a/be/docker/staging/docker-compose.staging.yml b/be/docker/staging/docker-compose.staging.yml index 3217647..4ae6487 100644 --- a/be/docker/staging/docker-compose.staging.yml +++ b/be/docker/staging/docker-compose.staging.yml @@ -30,8 +30,8 @@ services: volumes: - app-storage-staging:/var/www/storage - app-logs-staging:/var/www/storage/logs - - /etc/letsencrypt/live/sutera.ismailmasseran.com/fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro - - /etc/letsencrypt/live/sutera.ismailmasseran.com/privkey.pem:/etc/nginx/ssl/privkey.pem:ro + - /etc/letsencrypt/live/sutera.koppkb.com/fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro + - /etc/letsencrypt/live/sutera.koppkb.com/privkey.pem:/etc/nginx/ssl/privkey.pem:ro networks: - sutera-staging-network depends_on: diff --git a/be/docker/training/docker-compose.training.yml b/be/docker/training/docker-compose.training.yml index 030aae7..7333049 100644 --- a/be/docker/training/docker-compose.training.yml +++ b/be/docker/training/docker-compose.training.yml @@ -6,7 +6,7 @@ services: platforms: - linux/amd64 - linux/arm64 - image: git.ismailmasseran.com/topaz/sutera:33a53e08a641152f255f5c3fa27765200add4f45 + image: git.koppkb.com/topaz/sutera:33a53e08a641152f255f5c3fa27765200add4f45 container_name: sutera-app-training restart: unless-stopped ports: diff --git a/fe/docker/Dockerfile b/fe/docker/Dockerfile new file mode 100644 index 0000000..5062238 --- /dev/null +++ b/fe/docker/Dockerfile @@ -0,0 +1,17 @@ +# Stage 1: Build Vue SPA +FROM node:22 AS builder +WORKDIR /app +COPY fe/package*.json fe/pnpm-lock.yaml ./ +RUN npm install -g pnpm && pnpm install --frozen-lockfile +COPY fe/ ./ +ARG VITE_API_BASE_URL=https://api.koppkb.com +ARG VITE_APP_URL=https://anggota.koppkb.com +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_APP_URL=$VITE_APP_URL +RUN pnpm run typecheck && pnpm exec vite build --mode=production + +# Stage 2: Serve static files +FROM nginx:1.27-alpine +COPY fe/docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/fe/docker/nginx.conf b/fe/docker/nginx.conf new file mode 100644 index 0000000..e65b452 --- /dev/null +++ b/fe/docker/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location ~ /\. { + deny all; + } +}