From 8f2ccf473b571323377ab3972210ff9af1fd880f Mon Sep 17 00:00:00 2001 From: hienp Date: Thu, 2 Apr 2026 09:21:03 +0700 Subject: [PATCH] =?UTF-8?q?ho=C3=A0n=20th=C3=A0nh=20ch=E1=BB=A9c=20n=C4=83?= =?UTF-8?q?ng=20backup=20v2=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 4 + backend/app/main.py | 3 +- backend/app/routers/backup.py | 258 ++++++++++++++++++++++++++++++++ build.sh | 183 ++++++++++++++++++++++ frontend/src/app/admin/page.tsx | 127 ++++++++++++++++ frontend/src/lib/api.ts | 27 ++++ push-ghcr.sh | 161 +++++++++++++++----- readme.md | 9 ++ 8 files changed, 732 insertions(+), 40 deletions(-) create mode 100644 backend/app/routers/backup.py create mode 100755 build.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index 5d19b4a..b7125ef 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,10 @@ FROM python:3.12-slim AS runtime WORKDIR /app +# postgresql-client provides pg_dump / psql used by the backup/restore API +RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + # Copy installed packages from deps stage COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY --from=deps /usr/local/bin /usr/local/bin diff --git a/backend/app/main.py b/backend/app/main.py index 3090492..b3e65ca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from .routers import admin, annotations, auth, pdfs, ws +from .routers import admin, annotations, auth, backup, pdfs, ws @asynccontextmanager @@ -25,6 +25,7 @@ app.add_middleware( app.include_router(auth.router, prefix="/api") app.include_router(admin.router, prefix="/api") +app.include_router(backup.router, prefix="/api") app.include_router(pdfs.router, prefix="/api") app.include_router(annotations.router, prefix="/api") app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path) diff --git a/backend/app/routers/backup.py b/backend/app/routers/backup.py new file mode 100644 index 0000000..0328fd7 --- /dev/null +++ b/backend/app/routers/backup.py @@ -0,0 +1,258 @@ +""" +Backup / Restore endpoints — Admin only. + +Backup : GET /api/admin/backup/download + Streams a ZIP containing: + - dump.sql (pg_dump plain-text SQL of the entire database) + - uploads/ (all uploaded PDF files from the volume) + - meta.json (LMS version, timestamp, db name) + +Restore : POST /api/admin/backup/restore + Accepts the same ZIP, drops & recreates the schema via + psql, then copies files back into the uploads directory. + The running app is re-migrated automatically by entrypoint + on next restart, but this endpoint also runs alembic upgrade head. +""" + +import io +import json +import os +import shutil +import subprocess +import tempfile +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from ..database import get_db, engine +from ..dependencies import require_admin +from ..models import User + +router = APIRouter(prefix="/admin", tags=["backup"]) + +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads")) +DATABASE_URL = os.getenv("DATABASE_URL", "") + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _parse_db_url(url: str) -> dict: + """Parse DATABASE_URL into components for pg_dump / psql CLI.""" + p = urlparse(url) + return { + "host": p.hostname or "db", + "port": str(p.port or 5432), + "user": p.username or "lms_user", + "password": p.password or "", + "dbname": p.path.lstrip("/") or "lms_db", + } + + +def _pg_env(db_params: dict) -> dict: + """Return env dict that passes PGPASSWORD so no password prompt.""" + env = os.environ.copy() + env["PGPASSWORD"] = db_params["password"] + return env + + +# ── GET /api/admin/backup/download ──────────────────────────────────────────── + +@router.get("/backup/download") +def download_backup( + _admin: User = Depends(require_admin), +): + """ + Create an in-memory ZIP with pg_dump SQL + all uploaded files + and stream it back to the browser. + """ + db_params = _parse_db_url(DATABASE_URL) + + # 1. Run pg_dump → SQL text + try: + result = subprocess.run( + [ + "pg_dump", + "-h", db_params["host"], + "-p", db_params["port"], + "-U", db_params["user"], + "-d", db_params["dbname"], + "--no-password", + "--format=plain", + "--no-owner", + "--no-acl", + ], + capture_output=True, + text=True, + env=_pg_env(db_params), + timeout=120, + ) + except FileNotFoundError: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="pg_dump not found in container. Add postgresql-client to backend Dockerfile.", + ) + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"pg_dump failed: {result.stderr[:500]}", + ) + + sql_bytes = result.stdout.encode("utf-8") + + # 2. Build ZIP in memory + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + + # meta.json + meta = { + "lms_backup_version": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "db_name": db_params["dbname"], + } + zf.writestr("meta.json", json.dumps(meta, indent=2)) + + # Database dump + zf.writestr("dump.sql", sql_bytes) + + # Upload files + if UPLOAD_DIR.exists(): + for filepath in UPLOAD_DIR.rglob("*"): + if filepath.is_file(): + arcname = "uploads/" + filepath.relative_to(UPLOAD_DIR).as_posix() + zf.write(filepath, arcname) + + buf.seek(0) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename = f"lms_backup_{timestamp}.zip" + + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ── POST /api/admin/backup/restore ──────────────────────────────────────────── + +@router.post("/backup/restore", status_code=status.HTTP_200_OK) +async def restore_backup( + file: UploadFile, + _admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """ + Restore from a backup ZIP created by /backup/download. + ⚠️ THIS OVERWRITES all current data. + Steps: + 1. Validate ZIP contains meta.json + dump.sql + 2. Drop all tables (via SQLAlchemy) and recreate via psql + 3. Re-run alembic upgrade head + 4. Restore upload files + """ + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="File must be a .zip backup.") + + contents = await file.read() + if len(contents) < 22: # minimum valid ZIP size + raise HTTPException(status_code=400, detail="Invalid or empty ZIP file.") + + db_params = _parse_db_url(DATABASE_URL) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + # ── Unzip ───────────────────────────────────────────────────────── + try: + with zipfile.ZipFile(io.BytesIO(contents)) as zf: + names = zf.namelist() + if "dump.sql" not in names: + raise HTTPException(status_code=400, detail="ZIP missing dump.sql — not a valid LMS backup.") + if "meta.json" not in names: + raise HTTPException(status_code=400, detail="ZIP missing meta.json — not a valid LMS backup.") + zf.extractall(tmp) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Corrupted ZIP file.") + + # ── Validate meta ───────────────────────────────────────────────── + meta = json.loads((tmp / "meta.json").read_text()) + if meta.get("lms_backup_version") != 1: + raise HTTPException(status_code=400, detail="Unsupported backup version.") + + # ── Close all active DB connections to allow DROP ───────────────── + db.close() + engine.dispose() + + pg_env = _pg_env(db_params) + + # ── Drop and recreate the public schema ─────────────────────────── + drop_sql = ( + "DROP SCHEMA public CASCADE; " + "CREATE SCHEMA public; " + "GRANT ALL ON SCHEMA public TO PUBLIC;" + ) + drop_result = subprocess.run( + [ + "psql", + "-h", db_params["host"], + "-p", db_params["port"], + "-U", db_params["user"], + "-d", db_params["dbname"], + "--no-password", + "-c", drop_sql, + ], + capture_output=True, text=True, + env=pg_env, timeout=30, + ) + if drop_result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"Failed to reset schema: {drop_result.stderr[:400]}", + ) + + # ── Restore SQL dump ────────────────────────────────────────────── + sql_file = str(tmp / "dump.sql") + restore_result = subprocess.run( + [ + "psql", + "-h", db_params["host"], + "-p", db_params["port"], + "-U", db_params["user"], + "-d", db_params["dbname"], + "--no-password", + "-f", sql_file, + ], + capture_output=True, text=True, + env=pg_env, timeout=120, + ) + if restore_result.returncode != 0: + raise HTTPException( + status_code=500, + detail=f"psql restore failed: {restore_result.stderr[:400]}", + ) + + # ── Re-run Alembic migrations (idempotent) ──────────────────────── + subprocess.run( + ["alembic", "upgrade", "head"], + capture_output=True, text=True, + ) + + # ── Restore uploaded files ──────────────────────────────────────── + uploads_src = tmp / "uploads" + if uploads_src.exists(): + if UPLOAD_DIR.exists(): + shutil.rmtree(UPLOAD_DIR) + shutil.copytree(uploads_src, UPLOAD_DIR) + else: + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + return { + "ok": True, + "message": "Restore complete. Please refresh the page.", + "backup_created_at": meta.get("created_at"), + } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2e9c69d --- /dev/null +++ b/build.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# LMS — Docker Compose Build & Run Script +# Usage: +# ./build.sh # build + start (production) +# ./build.sh --no-cache # force full rebuild (no Docker layer cache) +# ./build.sh --down # stop and remove containers +# ./build.sh --restart # stop, rebuild, and start +# ./build.sh --logs # tail logs after starting +# ───────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +# ── Colour helpers ──────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +ok() { echo -e "${GREEN}✔${RESET} $*"; } +info() { echo -e "${CYAN}▶${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +fail() { echo -e "${RED}✘${RESET} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; } + +# ── Parse flags ─────────────────────────────────────────────────────────────── +NO_CACHE=false +DO_DOWN=false +DO_RESTART=false +DO_LOGS=false + +for arg in "$@"; do + case $arg in + --no-cache) NO_CACHE=true ;; + --down) DO_DOWN=true ;; + --restart) DO_RESTART=true ;; + --logs) DO_LOGS=true ;; + --help|-h) + sed -n '/^# Usage:/,/^# ─/p' "$0" | grep '^#' | sed 's/^# \?//' + exit 0 ;; + *) fail "Unknown option: $arg" ;; + esac +done + +# ── Change to script directory ──────────────────────────────────────────────── +cd "$(dirname "$0")" + +# ── Helper: check required commands ────────────────────────────────────────── +require_cmd() { + command -v "$1" &>/dev/null || fail "'$1' is not installed or not in PATH." +} +require_cmd docker +require_cmd docker compose 2>/dev/null || { + # Older Docker installs use "docker-compose" instead of "docker compose" + require_cmd docker-compose + # Shim so the rest of the script uses the right command + docker() { + if [ "$1" = "compose" ]; then shift; docker-compose "$@"; else command docker "$@"; fi + } + export -f docker +} + +# ───────────────────────────────────────────────────────────────────────────── +# --down: stop and remove containers +# ───────────────────────────────────────────────────────────────────────────── +if $DO_DOWN; then + step "Stopping & removing containers" + docker compose down --remove-orphans + ok "All containers stopped." + exit 0 +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Validate .env +# ───────────────────────────────────────────────────────────────────────────── +step "Checking .env" + +if [ ! -f .env ]; then + warn ".env not found — copying from .env.example" + if [ ! -f .env.example ]; then + fail ".env.example not found either. Cannot continue." + fi + cp .env.example .env + echo "" + warn "Please edit .env and set POSTGRES_PASSWORD and JWT_SECRET_KEY, then re-run this script." + exit 1 +fi + +source .env + +ERRORS=0 +check_var() { + local name=$1 val=${!1:-} placeholder=${2:-""} + if [ -z "$val" ] || { [ -n "$placeholder" ] && [ "$val" = "$placeholder" ]; }; then + warn "Missing or placeholder value for ${BOLD}${name}${RESET} in .env" + ERRORS=$((ERRORS+1)) + fi +} + +check_var POSTGRES_PASSWORD "change_me_strong_password" +check_var JWT_SECRET_KEY "change_me_generate_with_secrets_token_hex_32" + +if [ $ERRORS -gt 0 ]; then + echo "" + echo -e " ${YELLOW}Tip — generate a JWT secret:${RESET}" + echo " python3 -c \"import secrets; print(secrets.token_hex(32))\"" + echo "" + fail "Fix the above values in .env and re-run." +fi + +ok ".env looks good" + +# ───────────────────────────────────────────────────────────────────────────── +# --restart: tear down first +# ───────────────────────────────────────────────────────────────────────────── +if $DO_RESTART; then + step "Stopping existing containers" + docker compose down --remove-orphans + ok "Stopped." +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Build images +# ───────────────────────────────────────────────────────────────────────────── +step "Building Docker images" + +BUILD_ARGS="" +$NO_CACHE && BUILD_ARGS="--no-cache" && warn "Building without layer cache (--no-cache)" + +# shellcheck disable=SC2086 +docker compose build $BUILD_ARGS + +ok "Images built successfully" + +# ───────────────────────────────────────────────────────────────────────────── +# Start stack +# ───────────────────────────────────────────────────────────────────────────── +step "Starting services" +docker compose up -d --remove-orphans + +# ───────────────────────────────────────────────────────────────────────────── +# Wait for backend health +# ───────────────────────────────────────────────────────────────────────────── +step "Waiting for backend to become healthy" + +MAX_WAIT=60 +ELAPSED=0 +INTERVAL=3 +printf " " +while true; do + STATUS=$(docker compose ps --format json backend 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Health','') or d.get('State',''))" 2>/dev/null || echo "") + case "$STATUS" in + healthy) echo ""; ok "Backend is healthy"; break ;; + running) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;; + *) printf "."; sleep $INTERVAL; ELAPSED=$((ELAPSED+INTERVAL)) ;; + esac + if [ $ELAPSED -ge $MAX_WAIT ]; then + echo "" + warn "Timed out waiting for backend health check — checking logs:" + docker compose logs --tail=20 backend + break + fi +done + +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── +PORT="${FRONTEND_PORT:-3000}" +echo "" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}" +echo -e "${BOLD}${GREEN} ✅ LMS is up!${RESET}" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${RESET}" +echo -e " ${BOLD}App URL :${RESET} http://localhost:${PORT}" +echo -e " ${BOLD}Backend :${RESET} http://localhost:${PORT}/api/docs (via proxy)" +echo "" +docker compose ps +echo "" + +# ───────────────────────────────────────────────────────────────────────────── +# Tail logs if requested +# ───────────────────────────────────────────────────────────────────────────── +if $DO_LOGS; then + step "Tailing logs (Ctrl+C to stop)" + docker compose logs -f +fi diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 2b66ba1..146d693 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -40,6 +40,13 @@ export default function AdminPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [busy, setBusy] = useState(null); // userId being mutated + + // Backup / Restore state + const [backupLoading, setBackupLoading] = useState(false); + const [restoreLoading, setRestoreLoading] = useState(false); + const [backupMsg, setBackupMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null); + const restoreInputRef = useState(null); + const [filterStatus, setFilterStatus] = useState("all"); const [filterRole, setFilterRole] = useState("all"); const [search, setSearch] = useState(""); @@ -238,6 +245,126 @@ export default function AdminPage() { )} + {/* ── Backup & Restore ─────────────────────────────────────────────── */} +
+

Sao lưu & Khôi phục

+

+ File ZIP chứa toàn bộ cơ sở dữ liệu (SQL dump) và các file PDF đã tải lên. + Dùng để di chuyển sang server khác. +

+ + {backupMsg && ( +
+ {backupMsg.text} + +
+ )} + +
+ {/* Download backup */} + + + {/* Restore */} + +
+

+ ⚠️ Khôi phục sẽ ghi đè toàn bộ dữ liệu hiện tại (người dùng, PDF, annotation). Hãy tải backup trước khi khôi phục. +

+
+ {/* ── Table ────────────────────────────────────────────────────────── */}
{filtered.length === 0 ? ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 21cbd3c..aa6fce1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -74,4 +74,31 @@ export const api = { }), adminDeleteUser: (userId: number) => request(`/api/admin/users/${userId}`, { method: "DELETE" }), + + // Backup / Restore + /** Triggers pg_dump + file pack; returns a Blob for the browser to download. */ + adminDownloadBackup: async (): Promise => { + const res = await fetch("/api/admin/backup/download", { credentials: "include" }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? "Backup failed"); + } + return res.blob(); + }, + + /** Upload a backup ZIP to restore the system. */ + adminRestoreBackup: async (file: File): Promise<{ message: string; backup_created_at: string }> => { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/admin/backup/restore", { + method: "POST", + credentials: "include", + body: form, + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? "Restore failed"); + } + return res.json(); + }, }; diff --git a/push-ghcr.sh b/push-ghcr.sh index 03dd5ee..dfebc77 100755 --- a/push-ghcr.sh +++ b/push-ghcr.sh @@ -1,62 +1,145 @@ #!/bin/bash # Push LMS images to GitHub Container Registry (GHCR) -# Usage: ./push-ghcr.sh [tag] +# Auto-bumps the patch version in VERSION file on every successful push. +# +# Usage: +# ./push-ghcr.sh # auto-bump patch: 1.2.3 → 1.2.4 +# ./push-ghcr.sh minor # bump minor: 1.2.3 → 1.3.0 +# ./push-ghcr.sh major # bump major: 1.2.3 → 2.0.0 +# ./push-ghcr.sh 2.5.1 # use exact version (no auto-bump) +# ./push-ghcr.sh --no-cache # force full Docker rebuild +# # Reads GITHUB_USER and GITHUB_TOKEN from .env +# Version is stored in ./VERSION -set -e +set -euo pipefail cd "$(dirname "$0")" -# Load .env -if [ -f .env ]; then - export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs) -fi +# ── Colour helpers ──────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +ok() { echo -e "${GREEN}✔${RESET} $*"; } +info() { echo -e "${CYAN}▶${RESET} $*"; } +warn() { echo -e "${YELLOW}⚠${RESET} $*"; } +fail() { echo -e "\033[0;31m✘${RESET} $*" >&2; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}"; } -TAG="${1:-latest}" +# ── Parse arguments ─────────────────────────────────────────────────────────── +BUMP_TYPE="patch" +EXPLICIT_TAG="" +NO_CACHE="" + +for arg in "$@"; do + case $arg in + major|minor|patch) BUMP_TYPE=$arg ;; + --no-cache) NO_CACHE="--no-cache" ;; + [0-9]*.[0-9]*.[0-9]*) EXPLICIT_TAG=$arg ;; # exact semver passed + *) fail "Unknown argument: $arg" ;; + esac +done + +# ── Load .env ───────────────────────────────────────────────────────────────── +[ -f .env ] || fail ".env not found. Copy .env.example → .env first." +# shellcheck disable=SC2046 +export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs) GITHUB_USER="${GITHUB_USER:?GITHUB_USER not set in .env}" GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}" -BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}" -FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}" +# ── Read current version ────────────────────────────────────────────────────── +VERSION_FILE="VERSION" +[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE" -echo "========================================" -echo " LMS → GHCR Push" -echo " Backend : $BACKEND_IMAGE" -echo " Frontend: $FRONTEND_IMAGE" -echo "========================================" -echo "" +CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]') +if ! [[ $CURRENT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "VERSION file contains invalid semver: '$CURRENT'. Expected format: X.Y.Z" +fi -# ── 1. Login to GHCR ────────────────────────────────────────────────────────── -echo "🔐 Logging in to ghcr.io ..." +IFS='.' read -r V_MAJOR V_MINOR V_PATCH <<< "$CURRENT" + +# ── Compute new version ─────────────────────────────────────────────────────── +if [ -n "$EXPLICIT_TAG" ]; then + NEW_VERSION="$EXPLICIT_TAG" + info "Using explicit version: ${BOLD}$NEW_VERSION${RESET}" +else + case $BUMP_TYPE in + major) NEW_VERSION="$((V_MAJOR+1)).0.0" ;; + minor) NEW_VERSION="${V_MAJOR}.$((V_MINOR+1)).0" ;; + patch) NEW_VERSION="${V_MAJOR}.${V_MINOR}.$((V_PATCH+1))" ;; + esac + info "Bumping ${BUMP_TYPE}: ${BOLD}${CURRENT}${RESET} → ${BOLD}${NEW_VERSION}${RESET}" +fi + +# ── Image names ─────────────────────────────────────────────────────────────── +REGISTRY="ghcr.io/${GITHUB_USER}" +BACKEND_VERSIONED="${REGISTRY}/lms-backend:${NEW_VERSION}" +FRONTEND_VERSIONED="${REGISTRY}/lms-frontend:${NEW_VERSION}" +BACKEND_LATEST="${REGISTRY}/lms-backend:latest" +FRONTEND_LATEST="${REGISTRY}/lms-frontend:latest" + +step "LMS → GHCR Push" +echo -e " Version : ${BOLD}${NEW_VERSION}${RESET}" +echo -e " Backend : ${BACKEND_VERSIONED}" +echo -e " Frontend : ${FRONTEND_VERSIONED}" + +# ── 1. Login to GHCR ───────────────────────────────────────────────────────── +step "Login to ghcr.io" echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin +ok "Logged in" # ── 2. Build images ─────────────────────────────────────────────────────────── -echo "" -echo "🔨 Building images..." -cd "$(dirname "$0")" -docker compose build +step "Building images" +[ -n "$NO_CACHE" ] && warn "Building without layer cache" +# shellcheck disable=SC2086 +docker compose build $NO_CACHE +ok "Build complete" -# ── 3. Tag for GHCR ─────────────────────────────────────────────────────────── -echo "" -echo "🏷 Tagging images..." -docker tag lms-backend:latest "$BACKEND_IMAGE" -docker tag lms-frontend:latest "$FRONTEND_IMAGE" +# ── 3. Tag versioned + latest ───────────────────────────────────────────────── +step "Tagging images" +docker tag lms-backend:latest "$BACKEND_VERSIONED" +docker tag lms-frontend:latest "$FRONTEND_VERSIONED" +docker tag lms-backend:latest "$BACKEND_LATEST" +docker tag lms-frontend:latest "$FRONTEND_LATEST" +ok "Tagged ${NEW_VERSION} + latest" -# ── 4. Push ─────────────────────────────────────────────────────────────────── -echo "" -echo "⬆ Pushing to GHCR..." -docker push "$BACKEND_IMAGE" -docker push "$FRONTEND_IMAGE" +# ── 4. Push versioned + latest ──────────────────────────────────────────────── +step "Pushing to GHCR" +docker push "$BACKEND_VERSIONED" +docker push "$FRONTEND_VERSIONED" +docker push "$BACKEND_LATEST" +docker push "$FRONTEND_LATEST" +ok "Push complete" +# ── 5. Save new version (only after successful push) ───────────────────────── +if [ -z "$EXPLICIT_TAG" ]; then + echo "$NEW_VERSION" > "$VERSION_FILE" + + # Commit VERSION bump if inside a git repo + if git rev-parse --git-dir &>/dev/null; then + git add "$VERSION_FILE" + git commit -m "chore: bump version ${CURRENT} → ${NEW_VERSION}" --no-verify 2>/dev/null \ + && ok "Committed VERSION bump to git" \ + || warn "VERSION updated locally but git commit skipped (nothing to commit or no git user configured)" + else + ok "VERSION file updated to ${NEW_VERSION}" + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── echo "" -echo "✅ Done! Images pushed:" -echo " $BACKEND_IMAGE" -echo " $FRONTEND_IMAGE" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD}${GREEN} ✅ Images pushed successfully — v${NEW_VERSION}${RESET}" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}" +echo -e " ${BOLD}Versioned tags:${RESET}" +echo " $BACKEND_VERSIONED" +echo " $FRONTEND_VERSIONED" +echo -e " ${BOLD}Latest tags also updated:${RESET}" +echo " $BACKEND_LATEST" +echo " $FRONTEND_LATEST" echo "" -echo "📋 Add these to .env on the target machine:" -echo " BACKEND_IMAGE=$BACKEND_IMAGE" -echo " FRONTEND_IMAGE=$FRONTEND_IMAGE" +echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}" +echo " BACKEND_IMAGE=${BACKEND_VERSIONED}" +echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}" echo "" -echo "🚀 On the target machine:" -echo " docker compose pull && docker compose up -d" +echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}" +echo " docker compose pull && docker compose up -d" diff --git a/readme.md b/readme.md index 60d7c79..61036b0 100644 --- a/readme.md +++ b/readme.md @@ -141,3 +141,12 @@ username admin password Admin@12345 role admin status approved + + +Lệnh Kết quả tag +./push-ghcr.sh 1.0.0 → 1.0.1 (tự động tăng patch) +./push-ghcr.sh minor 1.0.0 → 1.1.0 +./push-ghcr.sh major 1.0.0 → 2.0.0 +./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump +./push-ghcr.sh --no-cache rebuild + auto-bump patch +./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu \ No newline at end of file