feat: add update management system with version check and one-click update
- Bake version info (commit, branch, date) into /app/version.json at build time
via Docker ARG GIT_COMMIT/GIT_BRANCH/GIT_COMMIT_DATE
- Mount source directory as /app-source for in-container git operations
- Add git config safe.directory for /app-source (ownership mismatch fix)
- Add SystemConfig fields: git_repo_url, git_branch, git_token_encrypted
- Add DB migrations for the three new columns
- Add git_token encryption in update_settings() handler
- New endpoints:
GET /api/settings/version — current version + latest from Gitea API
POST /api/settings/update — DB backup + git pull + docker compose rebuild
- New service: app/services/update_service.py
get_current_version() — reads /app/version.json
check_for_updates() — queries Gitea API for latest commit on branch
backup_database() — timestamped SQLite copy to /app/backups/
trigger_update() — git pull + fire-and-forget compose rebuild
- New script: update.sh — SSH-based manual update with health check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,8 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import SystemConfig, User
|
||||
from app.services import dns_service, ldap_service, npm_service
|
||||
from app.utils.config import get_system_config
|
||||
from app.services import dns_service, ldap_service, npm_service, update_service
|
||||
from app.utils.config import DATABASE_PATH, get_system_config
|
||||
from app.utils.security import encrypt_value
|
||||
from app.utils.validators import SystemConfigUpdate
|
||||
|
||||
@@ -94,6 +94,10 @@ async def update_settings(
|
||||
if "ldap_bind_password" in update_data:
|
||||
row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password"))
|
||||
|
||||
# Handle git token encryption
|
||||
if "git_token" in update_data:
|
||||
row.git_token_encrypted = encrypt_value(update_data.pop("git_token"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
if hasattr(row, field):
|
||||
setattr(row, field, value)
|
||||
@@ -310,3 +314,61 @@ async def delete_logo(
|
||||
db.commit()
|
||||
|
||||
return {"branding_logo_path": None}
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def get_version(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return current installed version and latest available from the git remote.
|
||||
|
||||
Returns:
|
||||
Dict with current version, latest version, and needs_update flag.
|
||||
"""
|
||||
config = get_system_config(db)
|
||||
current = update_service.get_current_version()
|
||||
if not config or not config.git_repo_url:
|
||||
return {"current": current, "latest": None, "needs_update": False}
|
||||
result = await update_service.check_for_updates(config)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
async def trigger_update(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Backup the database, git pull the latest code, and rebuild the container.
|
||||
|
||||
The rebuild is fire-and-forget — the app will restart in ~60 seconds.
|
||||
Only admin users may trigger an update.
|
||||
|
||||
Returns:
|
||||
Dict with ok, message, and backup path.
|
||||
"""
|
||||
if getattr(current_user, "role", "admin") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admin users can trigger an update.",
|
||||
)
|
||||
config = get_system_config(db)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="System configuration not initialized.",
|
||||
)
|
||||
if not config.git_repo_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="git_repo_url is not configured in settings.",
|
||||
)
|
||||
|
||||
result = update_service.trigger_update(config, DATABASE_PATH)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.get("message", "Update failed."),
|
||||
)
|
||||
logger.info("Update triggered by %s.", current_user.username)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user