hoàn thành chức năng backup v2 restore

This commit is contained in:
2026-04-02 09:21:03 +07:00
parent 2aa7d68e52
commit 8f2ccf473b
8 changed files with 732 additions and 40 deletions
+4
View File
@@ -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
+2 -1
View File
@@ -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)
+258
View File
@@ -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"),
}
Executable
+183
View File
@@ -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
+127
View File
@@ -40,6 +40,13 @@ export default function AdminPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState<number | null>(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<HTMLInputElement | null>(null);
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
const [search, setSearch] = useState("");
@@ -238,6 +245,126 @@ export default function AdminPage() {
)}
</div>
{/* ── Backup & Restore ─────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 shadow-sm">
<h2 className="text-sm font-bold text-gray-800 mb-1">Sao lưu & Khôi phục</h2>
<p className="text-xs text-gray-400 mb-4">
File ZIP chứa toàn bộ sở dữ liệu (SQL dump) các file PDF đã tải lên.
Dùng đ di chuyển sang server khác.
</p>
{backupMsg && (
<div className={`mb-4 text-xs px-3 py-2 rounded-lg flex items-center justify-between ${
backupMsg.type === "ok"
? "bg-green-50 border border-green-200 text-green-700"
: "bg-red-50 border border-red-200 text-red-700"
}`}>
{backupMsg.text}
<button onClick={() => setBackupMsg(null)} className="ml-3 opacity-60 hover:opacity-100"></button>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3">
{/* Download backup */}
<button
disabled={backupLoading}
onClick={async () => {
setBackupLoading(true);
setBackupMsg(null);
try {
const blob = await api.adminDownloadBackup();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `lms_backup_${ts}.zip`;
a.click();
URL.revokeObjectURL(url);
setBackupMsg({ type: "ok", text: "Tải backup thành công." });
} catch (e: unknown) {
setBackupMsg({ type: "err", text: e instanceof Error ? e.message : "Backup thất bại." });
} finally {
setBackupLoading(false);
}
}}
className="flex items-center justify-center gap-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white px-4 py-2 rounded-lg transition"
>
{backupLoading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
)}
{backupLoading ? "Đang tạo backup…" : "Tải backup (.zip)"}
</button>
{/* Restore */}
<label
className={`flex items-center justify-center gap-2 text-sm font-medium border-2 border-dashed px-4 py-2 rounded-lg transition cursor-pointer ${
restoreLoading
? "opacity-60 cursor-not-allowed border-gray-300 text-gray-400"
: "border-orange-300 text-orange-600 hover:bg-orange-50"
}`}
>
{restoreLoading ? (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12" />
</svg>
)}
{restoreLoading ? "Đang khôi phục…" : "Khôi phục từ backup (.zip)"}
<input
type="file"
accept=".zip"
className="hidden"
disabled={restoreLoading}
ref={el => { restoreInputRef[1](el); }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!confirm(
`⚠️ Khôi phục từ "${file.name}" sẽ XOÁ TOÀN BỘ dữ liệu hiện tại và thay bằng backup.\n\nBạn có chắc chắn không?`
)) {
e.target.value = "";
return;
}
setRestoreLoading(true);
setBackupMsg(null);
try {
const result = await api.adminRestoreBackup(file);
const ts = result.backup_created_at
? new Date(result.backup_created_at).toLocaleString("vi-VN")
: "";
setBackupMsg({
type: "ok",
text: `Khôi phục thành công${ts ? " (backup ngày " + ts + ")" : ""}. Trang sẽ tải lại sau 3 giây.`,
});
setTimeout(() => window.location.reload(), 3000);
} catch (err: unknown) {
setBackupMsg({ type: "err", text: err instanceof Error ? err.message : "Khôi phục thất bại." });
} finally {
setRestoreLoading(false);
e.target.value = "";
}
}}
/>
</label>
</div>
<p className="text-[10px] text-gray-300 mt-3">
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.
</p>
</div>
{/* ── Table ────────────────────────────────────────────────────────── */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{filtered.length === 0 ? (
+27
View File
@@ -74,4 +74,31 @@ export const api = {
}),
adminDeleteUser: (userId: number) =>
request<void>(`/api/admin/users/${userId}`, { method: "DELETE" }),
// Backup / Restore
/** Triggers pg_dump + file pack; returns a Blob for the browser to download. */
adminDownloadBackup: async (): Promise<Blob> => {
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();
},
};
+121 -38
View File
@@ -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 -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
echo " docker compose pull && docker compose up -d"
+9
View File
@@ -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