scheduled commits + devs info

This commit is contained in:
JiriUhlir
2026-06-15 11:50:33 +02:00
parent a948324b2e
commit be2e71c7d0
2 changed files with 124 additions and 1 deletions
+42
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from ..auth import require_user from ..auth import require_user
from ..config import DEFAULT_GITEA_ORG, get_gitea_public_url, read_env_value
from ..templates.layout import page from ..templates.layout import page
router = APIRouter() router = APIRouter()
@@ -86,6 +87,11 @@ def developers_page(user=Depends(require_user)):
</tr> </tr>
""" """
gitea_url = get_gitea_public_url()
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
clone_base = gitea_url or "<gitea-url>"
clone_cmd = html.escape(f"git clone {clone_base}/{gitea_org}/<app-id>.git", quote=True)
return page( return page(
"Pro vývojáře", "Pro vývojáře",
f""" f"""
@@ -97,6 +103,42 @@ def developers_page(user=Depends(require_user)):
</p> </p>
</div> </div>
<div class="card">
<h2><i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i> Jak publikovat</h2>
<p class="muted">Postup od založení nové služby po nasazení změn.</p>
<ol class="publish-steps">
<li>
<strong>Vytvořte novou službu.</strong>
Otevřete <a href="/portal/new-app" target="_blank" rel="noopener">formulář Nová služba</a>;
vznikne Gitea repozitář, workspace a první nasazení.
</li>
<li>
<strong>Naklonujte repozitář.</strong>
<div class="cmd-row">
<input readonly value="{clone_cmd}" id="publish-clone">
<button type="button" onclick="return copyText(this)">Kopírovat</button>
</div>
<span class="muted">Přesný příkaz pro konkrétní službu (HTTP i SSH) najdete také na stránce
<a href="/portal/apps">Služby</a>.</span>
</li>
<li>
<strong>Upravte kód.</strong>
Buď se řiďte souborem <code>AGENTS.md</code> v kořenové složce projektu, nebo úpravy
nechte provést AI (agent si <code>AGENTS.md</code> přečte sám).
</li>
<li>
<strong>Commit a push.</strong>
Zacommitujte a pushněte změny — build a nasazení se spustí automaticky
(worker je vždy spuštěný a změnu rovnou zpracuje).
</li>
<li>
<strong>Zkontrolujte výsledek.</strong>
Stav nasazení a běh služby ověříte na stránce
<a href="/portal/apps">Služby</a> (stav, health, dokumentace).
</li>
</ol>
</div>
<div class="card"> <div class="card">
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Sekce portálu (agendy)</h2> <h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Sekce portálu (agendy)</h2>
<table> <table>
+82 -1
View File
@@ -1,11 +1,18 @@
import html import html
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user 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.audit import log_audit_event
from app.db.jobs import create_job from app.db.jobs import create_job
from app.db.scheduled_scripts import ( from app.db.scheduled_scripts import (
@@ -20,7 +27,11 @@ from app.routes.deployments import render_status_pill
from app.templates.layout import page from app.templates.layout import page
router = APIRouter() 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 MAX_SCRIPT_BYTES = 100 * 1024
DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash
set -euo pipefail 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&rcaron;ilo ulo&zcaron;it") raise HTTPException(status_code=500, detail="Soubor se nepoda&rcaron;ilo ulo&zcaron;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&rcaron;&iacute;kaz selhal nebo vypr&scaron;el limit")
def _git_actor(user: dict) -> str:
return (user.get("email") or user.get("display_name") or user.get("username") or "nezn&aacute;m&yacute;").strip()
def _gitea_push_remote() -> tuple[str, str | None]:
"""Vr&aacute;t&iacute; (remote, token). Remote je autentizovan&aacute; gitea URL z token&uring; v prom&ecaron;nn&yacute;ch,
p&rcaron;i absenci tokenu fallback na origin. Token vrac&iacute;me zvl&aacute;&scaron;&tcaron;, aby &scaron;el zamaskovat v chyb&aacute;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&ecaron;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&rcaron;ilo p&rcaron;idat do indexu")
# &Zcaron;&aacute;dn&aacute; zm&ecaron;na oproti HEAD -> p&rcaron;esko&ccaron;&iacute;me, a&tcaron; nevznikaj&iacute; pr&aacute;zdn&eacute; 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&aacute;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: def validate_schedule_type(value: str) -> str:
schedule_type = clean_optional(value) schedule_type = clean_optional(value)
if schedule_type not in SCHEDULE_TYPES: 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 = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled)
metadata["script_name"] = validate_script_name(script_name) metadata["script_name"] = validate_script_name(script_name)
script_id = create_scheduled_script(metadata) 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&rcaron;en skript {created_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.created", action="scheduled_script.created",
@@ -557,6 +631,7 @@ def update_scheduled_script_file(
raise HTTPException(status_code=404, detail="Scheduled script not found") raise HTTPException(status_code=404, detail="Scheduled script not found")
script_name = validate_script_name(script.get("script_name", "") or "") script_name = validate_script_name(script.get("script_name", "") or "")
save_script_file(script_name, content) save_script_file(script_name, content)
commit_and_push(script_name, f"&Uacute;prava skriptu {script_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.file_updated", 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&ecaron;&zcaron;&iacute;c&iacute; skript nelze smazat") raise HTTPException(status_code=409, detail="B&ecaron;&zcaron;&iacute;c&iacute; skript nelze smazat")
if not delete_scheduled_script(script_id): if not delete_scheduled_script(script_id):
raise HTTPException(status_code=409, detail="Skript nelze smazat") 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&aacute;n skript {script_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.deleted", action="scheduled_script.deleted",