diff --git a/app/config.py b/app/config.py index dbeff09..b324ae4 100644 --- a/app/config.py +++ b/app/config.py @@ -22,8 +22,14 @@ def read_env_value(key: str, default: str = "") -> str: line = line.strip() if line.startswith(f"{key}="): return line.split("=", 1)[1].strip().strip('"') - except Exception: + except FileNotFoundError: + # appfactory.env nemusí existovat (např. lokální vývoj) – očekávaný stav, vrátíme default. pass + except Exception as exc: + # Neočekávaná chyba čtení configu – nesmí být tichá. + from app.logging_config import get_logger + + get_logger(__name__).warning("Čtení %s (klíč %s) selhalo: %s", APPFACTORY_ENV, key, exc) return default diff --git a/app/gitea_provisioning.py b/app/gitea_provisioning.py index 3aacf43..2c388ea 100644 --- a/app/gitea_provisioning.py +++ b/app/gitea_provisioning.py @@ -8,6 +8,9 @@ from urllib.request import urlopen from app.config import get_gitea_admin_token, get_gitea_server_url from app.db.users import update_gitea_sync_state +from app.logging_config import get_logger + +logger = get_logger(__name__) GITEA_ACCOUNT_ROLES = {"admin", "developer"} @@ -22,41 +25,84 @@ def desired_gitea_username(user: dict) -> str: def apply_gitea_credentials(user: dict, new_username: str = "", new_password: str = "") -> dict: """Admin nastaví Gitea uživatelské jméno a/nebo heslo přes admin API. - Vrací {"status": "synced"/"error", "applied": [...], "gitea_username", "gitea_user_id"}. + + Heslo a přejmenování se aplikují NEZÁVISLE – když jedno selže, druhé se i tak provede a do + výsledku se vrátí přesný důvod selhání (chybí endpoint na staré Gitea, jméno obsazené apod.). + Vrací: {"status": synced|partial|error, "applied": [...], "errors": [...], + "gitea_username", "gitea_user_id", "renamed": bool}. Heslo se nikam neukládá – jen se předá do Gitea.""" new_username = (new_username or "").strip() new_password = new_password or "" if not new_username and not new_password: - return {"status": "error", "error": "Nebyla zadána žádná změna."} + return {"status": "error", "error": "Nebyla zadána žádná změna.", "errors": [], "applied": [], "renamed": False} if not _requires_gitea_account(user): return { "status": "error", "error": "Uživatel nemá mít Gitea účet (potřebuje roli admin/developer a aktivní účet).", + "errors": [], + "applied": [], + "renamed": False, } + user_id = int(user["id"]) try: current_username = desired_gitea_username(user) gitea_user = _ensure_gitea_user(user, current_username) - current_login = _gitea_login(gitea_user) or current_username - - applied: list[str] = [] - if new_username and new_username != current_login: - _rename_gitea_user(current_login, new_username) - current_login = new_username - applied.append("username") - if new_password: - _set_gitea_password(current_login, user, new_password, gitea_user=gitea_user) - applied.append("password") - - return { - "status": "synced", - "applied": applied, - "gitea_username": current_login, - "gitea_user_id": int(gitea_user["id"]), - } except Exception as exc: - return {"status": "error", "error": str(exc)[:500]} + message = f"Gitea účet se nepodařilo načíst: {str(exc)[:400]}" + logger.error("apply_gitea_credentials: %s (user id=%s)", message, user_id) + return { + "status": "error", + "error": message, + "errors": [], + "applied": [], + "renamed": False, + } + + current_login = _gitea_login(gitea_user) or current_username + final_username = current_login + applied: list[str] = [] + errors: list[str] = [] + + # 1) Heslo – nezávisle na přejmenování (vždy se zkusí, i kdyby rename neexistoval). + if new_password: + try: + _set_gitea_password(current_login, user, new_password, gitea_user=gitea_user) + applied.append("heslo") + logger.info("Gitea heslo nastaveno pro %s (user id=%s)", current_login, user_id) + except Exception as exc: + detail = str(exc)[:300] + errors.append(f"heslo: {detail}") + logger.error("Gitea nastavení hesla selhalo pro %s (user id=%s): %s", current_login, user_id, detail) + + # 2) Přejmenování (Gitea >= 1.21). Na starší Gitea vrátí HTTP 404 → zobrazí se srozumitelně. + if new_username and new_username != current_login: + try: + _rename_gitea_user(current_login, new_username) + final_username = new_username + applied.append("jméno") + logger.info("Gitea účet přejmenován %s -> %s (user id=%s)", current_login, new_username, user_id) + except Exception as exc: + detail = str(exc)[:300] + errors.append(f"jméno: {detail}") + logger.error("Gitea přejmenování %s -> %s selhalo (user id=%s): %s", current_login, new_username, user_id, detail) + + if applied and not errors: + status = "synced" + elif applied: + status = "partial" + else: + status = "error" + + return { + "status": status, + "applied": applied, + "errors": errors, + "gitea_username": final_username, + "gitea_user_id": int(gitea_user["id"]), + "renamed": "jméno" in applied, + } def _rename_gitea_user(current_username: str, new_username: str) -> dict: @@ -121,6 +167,7 @@ def sync_gitea_user(user: dict) -> dict: return {"status": "not_required", "action": "none"} except Exception as exc: error = str(exc)[:500] + logger.error("Gitea sync selhal pro uživatele id=%s (%s): %s", user_id, username, error) update_gitea_sync_state(user_id, "error", error=error) return {"status": "error", "error": error} diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..2651c79 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,31 @@ +import logging +import os + +_CONFIGURED = False + + +def configure_logging() -> None: + """Jednotné logování portálu do stdout (zachytí uvicorn / docker logs). + + Pravidlo projektu: žádné tiché selhání – chyby a neočekávané stavy patří do logu. + Úroveň lze řídit přes PORTAL_LOG_LEVEL (výchozí INFO).""" + global _CONFIGURED + if _CONFIGURED: + return + + level_name = (os.environ.get("PORTAL_LOG_LEVEL") or "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + root = logging.getLogger() + if not root.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) + root.addHandler(handler) + root.setLevel(level) + + _CONFIGURED = True + + +def get_logger(name: str) -> logging.Logger: + configure_logging() + return logging.getLogger(name) diff --git a/app/main.py b/app/main.py index f20feb1..a4e24f7 100644 --- a/app/main.py +++ b/app/main.py @@ -5,10 +5,12 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_bool, read_env_value +from .logging_config import configure_logging from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, logs, operations, runtime, scheduled_scripts, users, workers def create_app() -> FastAPI: + configure_logging() app = FastAPI(title="CSBot Services Portal") session_secret = read_env_value("PORTAL_SESSION_SECRET", "") or "dev-only-appfactory-session-secret" diff --git a/app/routes/developers.py b/app/routes/developers.py index 72556bd..325c53b 100644 --- a/app/routes/developers.py +++ b/app/routes/developers.py @@ -104,6 +104,14 @@ def developers_page(user=Depends(require_user)):

+
+

Replikace serveru

+

+ Checklist a kopírovatelné příkazy pro čistou instalaci nebo obnovu AppFactory na nový Linux server. +

+

Replikace serveru / požadavky na nový server

+
+

Jak publikovat

Postup od založení nové služby po nasazení změn.

@@ -181,3 +189,370 @@ def developers_page(user=Depends(require_user)): """, user=user, ) + + +# --- Replikace serveru / požadavky na nový server ------------------------------------------------- +# Čistě dokumentační stránka (checklist + kopírovatelné příkazy). Žádná Docker logika v portálu. +# Bash/text bloky jsou běžné (ne f-string) řetězce, aby složené závorky ({}, {{.Names}}) zůstaly +# literálně a copy tlačítka kopírovala přesný obsah. + +def _code_block(code: str) -> str: + """Kopírovatelný blok příkazu (tmavý
 + tlačítko Kopírovat)."""
+    escaped = html.escape(code.strip("\n"))
+    return (
+        '
' + '' + f'
{escaped}
' + '
' + ) + + +def _text_block(text: str) -> str: + """Blok prostého výpisu (např. chybová hláška) bez kopírování.""" + return f'
{html.escape(text.strip(chr(10)))}
' + + +APT_INSTALL = r"""apt update + +apt install -y \ + git \ + curl \ + wget \ + jq \ + sqlite3 \ + unzip \ + zip \ + tar \ + gzip \ + ca-certificates \ + gnupg \ + lsb-release \ + bash \ + sudo \ + openssh-client \ + openssh-server \ + nano \ + vim \ + rsync \ + cron \ + procps \ + net-tools \ + dnsutils \ + iputils-ping \ + software-properties-common""" + +DOCKER_INSTALL = r"""curl -fsSL https://get.docker.com | sh + +apt install -y docker-compose-plugin""" + +DOCKER_VERIFY = r"""docker --version +docker compose version""" + +DOCKER_GROUP = r"""groupadd docker || true +usermod -aG docker root""" + +DOCKER_GROUP_ADMIN = r"""usermod -aG docker jiri""" + +UTILITIES = """git +curl +wget +jq +sqlite3 +tar +zip +unzip +bash +cron +rsync +ssh +docker +docker compose""" + +DNS_LIST = """services.csbot.cz +git.csbot.cz +registry.csbot.cz""" + +DNS_CHECK = r"""nslookup services.csbot.cz +nslookup git.csbot.cz +nslookup registry.csbot.cz""" + +FIREWALL = """22 SSH +80 HTTP +443 HTTPS""" + +STRUCTURE = """/opt/appfactory +/opt/appfactory/config +/opt/appfactory/data +/opt/appfactory/workspace +/opt/appfactory/backups +/opt/appfactory/logs +/opt/appfactory/tools""" + +GIT_CONFIG_HOST = r"""git config --system --add safe.directory "*" """ + +SERVER_VERIFY = r"""git --version +docker --version +docker compose version +jq --version +sqlite3 --version +curl --version""" + +FIX_PERMISSIONS = r"""cat > /opt/appfactory/tools/fix-appfactory-server-permissions.sh <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +APPFACTORY_ROOT="/opt/appfactory" + +echo "[1/9] Create appfactory group..." +getent group appfactory >/dev/null || groupadd appfactory + +echo "[2/9] Add known users to appfactory group..." +for u in root jiri git caddy www-data; do + if id "$u" >/dev/null 2>&1; then + usermod -aG appfactory "$u" || true + fi +done + +echo "[3/9] Ensure base directories exist..." +mkdir -p "$APPFACTORY_ROOT/config" +mkdir -p "$APPFACTORY_ROOT/data" +mkdir -p "$APPFACTORY_ROOT/workspace" +mkdir -p "$APPFACTORY_ROOT/backups" +mkdir -p "$APPFACTORY_ROOT/logs" +mkdir -p "$APPFACTORY_ROOT/tools" + +echo "[4/9] Set ownership under /opt/appfactory..." +chown -R root:appfactory "$APPFACTORY_ROOT" + +echo "[5/9] Set directory permissions..." +find "$APPFACTORY_ROOT" -type d -exec chmod 2775 {} \; + +echo "[6/9] Set file permissions..." +find "$APPFACTORY_ROOT" -type f -exec chmod 664 {} \; + +echo "[7/9] Make shell scripts executable..." +find "$APPFACTORY_ROOT" -type f -name "*.sh" -exec chmod 775 {} \; + +echo "[8/9] Fix Git safe.directory on host..." +git config --system --add safe.directory "*" || true + +echo "[9/9] Fix Gitea sessions directory if present..." +if [ -d "$APPFACTORY_ROOT/data/gitea/gitea" ]; then + rm -rf "$APPFACTORY_ROOT/data/gitea/gitea/sessions" + mkdir -p "$APPFACTORY_ROOT/data/gitea/gitea/sessions" + chmod 777 "$APPFACTORY_ROOT/data/gitea/gitea/sessions" +fi + +echo "Done." +EOF + +bash /opt/appfactory/tools/fix-appfactory-server-permissions.sh""" + +CONTAINER_GIT = r"""for c in appfactory-portal appfactory-worker appfactory-gitea; do + if docker ps -a --format '{{.Names}}' | grep -qx "$c"; then + docker exec "$c" sh -lc 'git config --system --add safe.directory "*" || git config --global --add safe.directory "*" || true' || true + fi +done""" + +GITEA_PANIC = """PANIC: session(start): chtimes /data/gitea/sessions/... operation not permitted""" + +GITEA_SESSIONS_FIX = r"""docker stop appfactory-gitea + +rm -rf /opt/appfactory/data/gitea/gitea/sessions +mkdir -p /opt/appfactory/data/gitea/gitea/sessions +chmod 777 /opt/appfactory/data/gitea/gitea/sessions + +docker start appfactory-gitea""" + +GITEA_HOOKS_BROKEN = """Git hooks of this repository seem to be broken""" + +GITEA_HOOKS_FIX = r"""docker exec -u git -it appfactory-gitea sh -lc ' +gitea admin regenerate hooks --config /data/gitea/conf/app.ini +' +docker restart appfactory-gitea""" + +COMPOSE_ENV_ERRORS = """APPFACTORY_UID variable is not set +APPFACTORY_GID variable is not set +APPFACTORY_DOCKER_GID variable is not set +Unable to find group""" + +COMPOSE_ENV_EXPORT = r"""export APPFACTORY_UID="$(id -u)" +export APPFACTORY_GID="$(id -g)" +export APPFACTORY_DOCKER_GID="$(getent group docker | cut -d: -f3)" """ + +MOUNT_YAML = """volumes: + - /opt/appfactory/config:/opt/appfactory/config:rw""" + +READONLY_PATH = """/opt/appfactory/config/appfactory.env""" + +READONLY_ERROR = """Read-only file system""" + + +@router.get("/developers/server-replication", response_class=HTMLResponse) +def server_replication_page(user=Depends(require_user)): + body = "".join([ + """ +
+

Replikace serveru / požadavky na nový server

+

+ Checklist pro čistou obnovu nebo instalaci AppFactory na nový Linux server. Jde o dokumentaci pro + administrátora – příkazy se spouštějí na serveru, ne z portálu. +

+

← Zpět na Pro vývojáře

+
+ +
+

1. Požadavky na nový server

+

Nový server musí mít:

+
    +
  • Linux server, ideálně Ubuntu/Debian
  • +
  • Docker
  • +
  • Docker Compose plugin
  • +
  • Git
  • +
  • curl
  • +
  • bash
  • +
  • sqlite3
  • +
  • openssh-client
  • +
  • přístup k repozitářům v Gitea
  • +
  • funkční DNS pro: +
      +
    • services.csbot.cz
    • +
    • git.csbot.cz
    • +
    • registry.csbot.cz
    • +
    +
  • +
  • adresář /opt/appfactory
  • +
  • Docker síť používanou AppFactory
  • +
  • volumes/adresáře pro: +
      +
    • /opt/appfactory/config
    • +
    • /opt/appfactory/data
    • +
    • /opt/appfactory/workspace
    • +
    • /opt/appfactory/backups
    • +
    • /opt/appfactory/logs
    • +
    +
  • +
+
+ +
+

2. Základní instalace nového serveru

+

Před spuštěním bootstrapu musí být na serveru nainstalováno:

+ """, + _code_block(APT_INSTALL), + """ +

Docker a Docker Compose plugin

+ """, + _code_block(DOCKER_INSTALL), + "

Ověření:

", + _code_block(DOCKER_VERIFY), + """ +

Docker skupina

+ """, + _code_block(DOCKER_GROUP), + "

Případně i pro administrátora:

", + _code_block(DOCKER_GROUP_ADMIN), + """ +

Povinné utility používané AppFactory

+

Používají je skripty AppFactory:

+ """, + _text_block(UTILITIES), + """ +

DNS požadavky

+

Musí fungovat:

+ """, + _text_block(DNS_LIST), + "

Kontrola:

", + _code_block(DNS_CHECK), + """ +

Firewall

+

Musí být otevřené:

+ """, + _text_block(FIREWALL), + """ +

Struktura AppFactory

+

Musí existovat:

+ """, + _text_block(STRUCTURE), + """ +

Git konfigurace (host)

+ """, + _code_block(GIT_CONFIG_HOST), + """ +

Ověření serveru

+

Před bootstrapem musí projít:

+ """, + _code_block(SERVER_VERIFY), + "

Pokud některý příkaz selže, server není připraven pro AppFactory.

", + "
", + """ +
+

3. Povinné nastavení práv

+

+ V AppFactory nechceme řešit opakované chyby typu: +

+
    +
  • Permission denied
  • +
  • Read-only file system
  • +
  • detected dubious ownership
  • +
  • operation not permitted
  • +
  • Git safe.directory problém
  • +
+

+ Proto má být pro AppFactory část serveru sjednocené právo zápisu pro relevantní procesy. +

+

+ Důležité: Neaplikovat chmod 777 na celý Linux server. + Aplikovat pouze na /opt/appfactory a na konkrétní problematické runtime adresáře. +

+ +

Serverový příkaz (oprava práv)

+ """, + _code_block(FIX_PERMISSIONS), + """ +

Oprava Git safe.directory v kontejnerech

+ """, + _code_block(CONTAINER_GIT), + "
", + """ +
+

4. Gitea – sessions

+

Pokud Gitea spadne na chybu:

+ """, + _text_block(GITEA_PANIC), + "

Použij:

", + _code_block(GITEA_SESSIONS_FIX), + "
", + """ +
+

5. Gitea – hooks

+

Pokud Gitea ukazuje:

+ """, + _text_block(GITEA_HOOKS_BROKEN), + "

Oprava:

", + _code_block(GITEA_HOOKS_FIX), + "
", + """ +
+

6. docker compose – env proměnné

+

Pokud docker compose hlásí:

+ """, + _text_block(COMPOSE_ENV_ERRORS), + "

Použij:

", + _code_block(COMPOSE_ENV_EXPORT), + "
", + """ +
+

7. Mounty

+

Portál musí mít RW přístup ke konfiguraci:

+ """, + _code_block(MOUNT_YAML), + "

Nepoužívat read-only mount pro:

", + _text_block(READONLY_PATH), + "

protože editor appfactory.env pak končí chybou:

", + _text_block(READONLY_ERROR), + "
", + ]) + + return page("Replikace serveru", body, user=user) diff --git a/app/routes/scheduled_scripts.py b/app/routes/scheduled_scripts.py index aed7819..02f699d 100644 --- a/app/routes/scheduled_scripts.py +++ b/app/routes/scheduled_scripts.py @@ -8,6 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import require_user from app.db.audit import log_audit_event from app.db.jobs import create_job +from app.logging_config import get_logger from app.db.scheduled_scripts import ( create_scheduled_script, delete_scheduled_script, @@ -143,11 +144,11 @@ def save_script_file(script_name: str, content: str) -> None: reason = exc.strerror or str(exc) raise HTTPException(status_code=500, detail=f"Soubor se nepodařilo uložit: {reason}") # Spustitelná práva nastavujeme best-effort – soubor už je uložený, takže selhání chmod - # (jiný vlastník, FS bez podpory práv) nesmí hlásit chybu uložení. + # (jiný vlastník, FS bez podpory práv) nesmí hlásit chybu uložení, ale nesmí být ani tiché. try: os.chmod(path, 0o755) - except OSError: - pass + except OSError as exc: + get_logger(__name__).warning("Nepodařilo se nastavit spustitelná práva na %s: %s", path, exc) def validate_schedule_type(value: str) -> str: diff --git a/app/routes/users.py b/app/routes/users.py index 88ee673..4c48391 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -18,9 +18,11 @@ from app.db.users import ( update_user_role, ) from app.gitea_provisioning import apply_gitea_credentials, sync_gitea_user +from app.logging_config import get_logger from app.templates.layout import page router = APIRouter() +logger = get_logger(__name__) # Gitea handle: začíná alfanumerickým znakem, dál povolen . _ -; max 40 znaků. GITEA_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$") @@ -383,14 +385,10 @@ def update_gitea_credentials_action( ) result = apply_gitea_credentials(target, new_username, new_password) - if result.get("status") == "error": - return RedirectResponse( - url="/portal/admin/users?error=" + quote(f"Gitea: {result.get('error')}"), - status_code=303, - ) - applied = result.get("applied") or [] - if "username" in applied: + errors = result.get("errors") or [] + + if result.get("renamed"): set_gitea_username(user_id, result.get("gitea_username") or new_username) log_audit_event( @@ -401,12 +399,25 @@ def update_gitea_credentials_action( metadata={ "username": target.get("username"), "gitea_username": result.get("gitea_username"), - "applied": applied, # heslo se nikdy nezaznamenává + "applied": applied, + "errors": errors, # heslo se nikdy nezaznamenává }, ) - changed = ", ".join(applied) if applied else "beze změny" + + status = result.get("status") + if status == "error": + detail = result.get("error") or "; ".join(errors) or "neznámá chyba" + logger.error("Změna Gitea údajů selhala pro uživatele id=%s: %s", user_id, detail) + return RedirectResponse(url="/portal/admin/users?error=" + quote(f"Gitea: {detail}"), status_code=303) + + if errors: + msg = f"Gitea částečně aktualizováno ({', '.join(applied)}). Nepovedlo se: {'; '.join(errors)}" + logger.warning("Gitea údaje částečně pro uživatele id=%s: applied=%s errors=%s", user_id, applied, errors) + return RedirectResponse(url="/portal/admin/users?error=" + quote(msg), status_code=303) + + logger.info("Gitea údaje aktualizovány pro uživatele id=%s: %s", user_id, applied) return RedirectResponse( - url="/portal/admin/users?message=" + quote(f"Gitea přihlašovací údaje aktualizovány ({changed})."), + url="/portal/admin/users?message=" + quote(f"Gitea aktualizováno: {', '.join(applied) or 'beze změny'}."), status_code=303, ) diff --git a/app/static/styles.css b/app/static/styles.css index 0deef16..9569e25 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -1021,6 +1021,49 @@ pre { line-height: 1.45; } +/* Dokumentační stránka: kopírovatelné bloky příkazů + výpisy. */ +.code-block { + position: relative; + margin: 10px 0 18px; +} + +.code-block .code-copy { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + width: auto; + padding: 6px 12px; +} + +.code-block pre { + margin: 0; + padding-top: 44px; +} + +.text-block { + margin: 8px 0 16px; + padding: 12px 14px; + background: #f1f5f9; + color: #334155; + border: 1px solid var(--border); + border-radius: 10px; + white-space: pre-wrap; + word-break: break-word; + overflow: auto; +} + +.req-list { + margin: 8px 0 0; + padding-left: 22px; + line-height: 1.7; +} + +.req-list ul { + margin: 4px 0 4px; + padding-left: 20px; +} + /* Kompaktní log v řádku tabulky (globální prohlížeč logů). */ .log-inline { margin: 0;