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)):
++ Checklist a kopírovatelné příkazy pro čistou instalaci nebo obnovu AppFactory na nový Linux server. +
+ +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.
+
+
+
+
+
+ 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;