mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 11:03:12 +07:00
hoàn thành chức năng backup v2 restore
This commit is contained in:
@@ -10,6 +10,10 @@ FROM python:3.12-slim AS runtime
|
|||||||
|
|
||||||
WORKDIR /app
|
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 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/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
COPY --from=deps /usr/local/bin /usr/local/bin
|
COPY --from=deps /usr/local/bin /usr/local/bin
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .routers import admin, annotations, auth, pdfs, ws
|
from .routers import admin, annotations, auth, backup, pdfs, ws
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -25,6 +25,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth.router, prefix="/api")
|
||||||
app.include_router(admin.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(pdfs.router, prefix="/api")
|
||||||
app.include_router(annotations.router, prefix="/api")
|
app.include_router(annotations.router, prefix="/api")
|
||||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -40,6 +40,13 @@ export default function AdminPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
|
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 [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
|
||||||
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
|
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -238,6 +245,126 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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ộ 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.
|
||||||
|
</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 ────────────────────────────────────────────────────────── */}
|
{/* ── Table ────────────────────────────────────────────────────────── */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
|
|||||||
@@ -74,4 +74,31 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
adminDeleteUser: (userId: number) =>
|
adminDeleteUser: (userId: number) =>
|
||||||
request<void>(`/api/admin/users/${userId}`, { method: "DELETE" }),
|
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
@@ -1,62 +1,145 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Push LMS images to GitHub Container Registry (GHCR)
|
# 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
|
# Reads GITHUB_USER and GITHUB_TOKEN from .env
|
||||||
|
# Version is stored in ./VERSION
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Load .env
|
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||||
if [ -f .env ]; then
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||||
export $(grep -v '^#' .env | grep -E 'GITHUB_USER|GITHUB_TOKEN' | xargs)
|
ok() { echo -e "${GREEN}✔${RESET} $*"; }
|
||||||
fi
|
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_USER="${GITHUB_USER:?GITHUB_USER not set in .env}"
|
||||||
GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}"
|
GITHUB_TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN not set in .env}"
|
||||||
|
|
||||||
BACKEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-backend:${TAG}"
|
# ── Read current version ──────────────────────────────────────────────────────
|
||||||
FRONTEND_IMAGE="ghcr.io/${GITHUB_USER}/lms-frontend:${TAG}"
|
VERSION_FILE="VERSION"
|
||||||
|
[ -f "$VERSION_FILE" ] || echo "1.0.0" > "$VERSION_FILE"
|
||||||
|
|
||||||
echo "========================================"
|
CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||||
echo " LMS → GHCR Push"
|
if ! [[ $CURRENT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
echo " Backend : $BACKEND_IMAGE"
|
fail "VERSION file contains invalid semver: '$CURRENT'. Expected format: X.Y.Z"
|
||||||
echo " Frontend: $FRONTEND_IMAGE"
|
fi
|
||||||
echo "========================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── 1. Login to GHCR ──────────────────────────────────────────────────────────
|
IFS='.' read -r V_MAJOR V_MINOR V_PATCH <<< "$CURRENT"
|
||||||
echo "🔐 Logging in to ghcr.io ..."
|
|
||||||
|
# ── 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
|
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_USER" --password-stdin
|
||||||
|
ok "Logged in"
|
||||||
|
|
||||||
# ── 2. Build images ───────────────────────────────────────────────────────────
|
# ── 2. Build images ───────────────────────────────────────────────────────────
|
||||||
echo ""
|
step "Building images"
|
||||||
echo "🔨 Building images..."
|
[ -n "$NO_CACHE" ] && warn "Building without layer cache"
|
||||||
cd "$(dirname "$0")"
|
# shellcheck disable=SC2086
|
||||||
docker compose build
|
docker compose build $NO_CACHE
|
||||||
|
ok "Build complete"
|
||||||
|
|
||||||
# ── 3. Tag for GHCR ───────────────────────────────────────────────────────────
|
# ── 3. Tag versioned + latest ─────────────────────────────────────────────────
|
||||||
echo ""
|
step "Tagging images"
|
||||||
echo "🏷 Tagging images..."
|
docker tag lms-backend:latest "$BACKEND_VERSIONED"
|
||||||
docker tag lms-backend:latest "$BACKEND_IMAGE"
|
docker tag lms-frontend:latest "$FRONTEND_VERSIONED"
|
||||||
docker tag lms-frontend:latest "$FRONTEND_IMAGE"
|
docker tag lms-backend:latest "$BACKEND_LATEST"
|
||||||
|
docker tag lms-frontend:latest "$FRONTEND_LATEST"
|
||||||
|
ok "Tagged ${NEW_VERSION} + latest"
|
||||||
|
|
||||||
# ── 4. Push ───────────────────────────────────────────────────────────────────
|
# ── 4. Push versioned + latest ────────────────────────────────────────────────
|
||||||
echo ""
|
step "Pushing to GHCR"
|
||||||
echo "⬆ Pushing to GHCR..."
|
docker push "$BACKEND_VERSIONED"
|
||||||
docker push "$BACKEND_IMAGE"
|
docker push "$FRONTEND_VERSIONED"
|
||||||
docker push "$FRONTEND_IMAGE"
|
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 ""
|
||||||
echo "✅ Done! Images pushed:"
|
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════════${RESET}"
|
||||||
echo " $BACKEND_IMAGE"
|
echo -e "${BOLD}${GREEN} ✅ Images pushed successfully — v${NEW_VERSION}${RESET}"
|
||||||
echo " $FRONTEND_IMAGE"
|
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 ""
|
||||||
echo "📋 Add these to .env on the target machine:"
|
echo -e " ${BOLD}📋 Add to .env on target machine:${RESET}"
|
||||||
echo " BACKEND_IMAGE=$BACKEND_IMAGE"
|
echo " BACKEND_IMAGE=${BACKEND_VERSIONED}"
|
||||||
echo " FRONTEND_IMAGE=$FRONTEND_IMAGE"
|
echo " FRONTEND_IMAGE=${FRONTEND_VERSIONED}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "🚀 On the target machine:"
|
echo -e " ${BOLD}🚀 Deploy on target machine:${RESET}"
|
||||||
echo " docker compose pull && docker compose up -d"
|
echo " docker compose pull && docker compose up -d"
|
||||||
|
|||||||
@@ -141,3 +141,12 @@ username admin
|
|||||||
password Admin@12345
|
password Admin@12345
|
||||||
role admin
|
role admin
|
||||||
status approved
|
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
|
||||||
Reference in New Issue
Block a user