diff --git a/app/routes/developers.py b/app/routes/developers.py index aef4b48..19512c9 100644 --- a/app/routes/developers.py +++ b/app/routes/developers.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import HTMLResponse from ..auth import require_user +from ..config import DEFAULT_GITEA_ORG, get_gitea_public_url, read_env_value from ..templates.layout import page router = APIRouter() @@ -86,6 +87,11 @@ def developers_page(user=Depends(require_user)): """ + gitea_url = get_gitea_public_url() + gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG) + clone_base = gitea_url or "" + clone_cmd = html.escape(f"git clone {clone_base}/{gitea_org}/.git", quote=True) + return page( "Pro vývojáře", f""" @@ -97,6 +103,42 @@ def developers_page(user=Depends(require_user)):

+
+

Jak publikovat

+

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

+
    +
  1. + Vytvořte novou službu. + Otevřete formulář Nová služba; + vznikne Gitea repozitář, workspace a první nasazení. +
  2. +
  3. + Naklonujte repozitář. +
    + + +
    + Přesný příkaz pro konkrétní službu (HTTP i SSH) najdete také na stránce + Služby. +
  4. +
  5. + Upravte kód. + Buď se řiďte souborem AGENTS.md v kořenové složce projektu, nebo úpravy + nechte provést AI (agent si AGENTS.md přečte sám). +
  6. +
  7. + Commit a push. + Zacommitujte a pushněte změny — build a nasazení se spustí automaticky + (worker je vždy spuštěný a změnu rovnou zpracuje). +
  8. +
  9. + Zkontrolujte výsledek. + Stav nasazení a běh služby ověříte na stránce + Služby (stav, health, dokumentace). +
  10. +
+
+

Sekce portálu (agendy)

diff --git a/app/routes/scheduled_scripts.py b/app/routes/scheduled_scripts.py index 3026a69..863746f 100644 --- a/app/routes/scheduled_scripts.py +++ b/app/routes/scheduled_scripts.py @@ -1,11 +1,18 @@ import html import os +import subprocess from pathlib import Path from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import require_user +from app.config import ( + DEFAULT_GITEA_ORG, + get_gitea_admin_token, + get_gitea_server_url, + read_env_value, +) from app.db.audit import log_audit_event from app.db.jobs import create_job from app.db.scheduled_scripts import ( @@ -20,7 +27,11 @@ from app.routes.deployments import render_status_pill from app.templates.layout import page router = APIRouter() -MAINTENANCE_DIR = Path("/opt/appfactory/workspace/appfactory-tools/maintenance") +TOOLS_REPO_DIR = Path("/opt/appfactory/workspace/appfactory-tools") +TOOLS_REPO_NAME = "appfactory-tools" +MAINTENANCE_DIR = TOOLS_REPO_DIR / "maintenance" +GIT_AUTHOR_NAME = "AppFactory Portal" +GIT_AUTHOR_EMAIL = "portal@appfactory.local" MAX_SCRIPT_BYTES = 100 * 1024 DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash set -euo pipefail @@ -142,6 +153,65 @@ def save_script_file(script_name: str, content: str) -> None: raise HTTPException(status_code=500, detail="Soubor se nepodařilo uložit") +def _git(args: list[str]): + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + try: + return subprocess.run( + ["git", "-C", str(TOOLS_REPO_DIR), *args], + capture_output=True, + text=True, + env=env, + timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + raise HTTPException(status_code=500, detail="Git příkaz selhal nebo vypršel limit") + + +def _git_actor(user: dict) -> str: + return (user.get("email") or user.get("display_name") or user.get("username") or "neznámý").strip() + + +def _gitea_push_remote() -> tuple[str, str | None]: + """Vrátí (remote, token). Remote je autentizovaná gitea URL z tokenů v proměnných, + při absenci tokenu fallback na origin. Token vracíme zvlášť, aby šel zamaskovat v chybách.""" + token = get_gitea_admin_token() + server = get_gitea_server_url() + if not token or "://" not in server: + return "origin", None + scheme, rest = server.split("://", 1) + org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG) + return f"{scheme}://{token}@{rest}/{org}/{TOOLS_REPO_NAME}.git", token + + +def commit_and_push(script_name: str, message: str, user: dict) -> None: + """Zacommituje a pushne změnu jednoho maintenance skriptu do gitea (appfactory-tools).""" + rel_path = f"maintenance/{script_name}" + + if _git(["add", "--", rel_path]).returncode != 0: + raise HTTPException(status_code=500, detail="Git: soubor se nepodařilo přidat do indexu") + + # Žádná změna oproti HEAD -> přeskočíme, ať nevznikají prázdné commity. + if _git(["diff", "--cached", "--quiet", "--", rel_path]).returncode == 0: + return + + commit = _git([ + "-c", f"user.name={GIT_AUTHOR_NAME}", + "-c", f"user.email={GIT_AUTHOR_EMAIL}", + "commit", "-m", f"{message} (portál: {_git_actor(user)})", + ]) + if commit.returncode != 0: + raise HTTPException(status_code=500, detail="Git commit selhal") + + branch = (_git(["rev-parse", "--abbrev-ref", "HEAD"]).stdout or "").strip() or "main" + remote, token = _gitea_push_remote() + push = _git(["push", remote, f"HEAD:{branch}"]) + if push.returncode != 0: + detail = (push.stderr or "").strip() + if token: + detail = detail.replace(token, "***") + raise HTTPException(status_code=500, detail=f"Git push selhal: {html.escape(detail)}") + + def validate_schedule_type(value: str) -> str: schedule_type = clean_optional(value) if schedule_type not in SCHEDULE_TYPES: @@ -393,6 +463,10 @@ def create_scheduled_script_action( metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled) metadata["script_name"] = validate_script_name(script_name) script_id = create_scheduled_script(metadata) + created_name = metadata["script_name"] + if not script_path(created_name).exists(): + save_script_file(created_name, DEFAULT_SCRIPT_CONTENT) + commit_and_push(created_name, f"Vytvořen skript {created_name}", user) log_audit_event( user, action="scheduled_script.created", @@ -557,6 +631,7 @@ def update_scheduled_script_file( raise HTTPException(status_code=404, detail="Scheduled script not found") script_name = validate_script_name(script.get("script_name", "") or "") save_script_file(script_name, content) + commit_and_push(script_name, f"Úprava skriptu {script_name}", user) log_audit_event( user, action="scheduled_script.file_updated", @@ -596,6 +671,12 @@ def delete_scheduled_script_action(script_id: int, user=Depends(require_user)): raise HTTPException(status_code=409, detail="Běžící skript nelze smazat") if not delete_scheduled_script(script_id): raise HTTPException(status_code=409, detail="Skript nelze smazat") + script_name = clean_optional(script.get("script_name")) + if script_name: + path = script_path(script_name) + if path.exists(): + path.unlink() + commit_and_push(script_name, f"Smazán skript {script_name}", user) log_audit_event( user, action="scheduled_script.deleted",