From fa4307852c65691b548e4f58dba43b43a8951bba Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:07:16 +0200 Subject: [PATCH] icons, main .env --- app/db/jobs.py | 12 +- app/env_file.py | 142 +++++++++++++++++ app/main.py | 3 +- app/routes/alerting.py | 26 +++- app/routes/developers.py | 4 +- app/routes/environment.py | 195 ++++++++++++++++++++++++ app/routes/runtime.py | 259 ++++++++++++++++++++++---------- app/routes/scheduled_scripts.py | 91 +---------- app/static/portal.js | 8 + app/static/styles.css | 101 ++++++++++--- app/templates/layout.py | 53 ++++--- app/tools_repo.py | 99 ++++++++++++ 12 files changed, 773 insertions(+), 220 deletions(-) create mode 100644 app/env_file.py create mode 100644 app/routes/environment.py create mode 100644 app/tools_repo.py diff --git a/app/db/jobs.py b/app/db/jobs.py index 5d101e5..c159b52 100644 --- a/app/db/jobs.py +++ b/app/db/jobs.py @@ -292,23 +292,23 @@ def get_jobs( return [dict(row) for row in rows] -def get_jobs_by_types(job_types, limit: int = 20): +def get_jobs_by_target_ids(target_ids, limit: int = 20): run_migrations() - job_types = list(job_types) - if not job_types: + target_ids = list(target_ids) + if not target_ids: return [] con = get_connection() - placeholders = ",".join("?" for _ in job_types) + placeholders = ",".join("?" for _ in target_ids) rows = con.execute( f""" SELECT * FROM jobs - WHERE type IN ({placeholders}) + WHERE target_id IN ({placeholders}) ORDER BY id DESC LIMIT ? """, - (*job_types, limit), + (*target_ids, limit), ).fetchall() con.close() diff --git a/app/env_file.py b/app/env_file.py new file mode 100644 index 0000000..8cbc025 --- /dev/null +++ b/app/env_file.py @@ -0,0 +1,142 @@ +"""Bezpečné čtení a zápis hlavního AppFactory env souboru (/opt/appfactory/config/appfactory.env). + +Zdroj pravdy zůstává soubor na disku — nikdy se neukládá do DB. Modul pracuje výhradně +s pevnou cestou APPFACTORY_ENV; žádná jiná cesta není povolená. +""" + +import os +import re +import shutil +from datetime import datetime + +from app.config import APPFACTORY_ENV + +ENV_PATH = APPFACTORY_ENV + +# Povolený tvar klíče (shell env konvence). +KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") + +# Hodnota, kterou lze zapsat bez uvozovek (žádné mezery ani shell-speciální znaky). +_SIMPLE_VALUE_RE = re.compile(r"^[A-Za-z0-9_./:@%+,=-]*$") + + +def _parse_line(line: str): + """Vrátí (key, value) pro řádek typu KEY=value, jinak None (komentář/prázdné/neparsovatelné).""" + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + return None + key, _, raw_value = line.partition("=") + key = key.strip() + if not KEY_RE.match(key): + return None + return key, _parse_value(raw_value) + + +def _unescape_double(value: str) -> str: + out = [] + i = 0 + while i < len(value): + char = value[i] + if char == "\\" and i + 1 < len(value) and value[i + 1] in '"\\$`': + out.append(value[i + 1]) + i += 2 + else: + out.append(char) + i += 1 + return "".join(out) + + +def _parse_value(raw: str) -> str: + value = raw.strip() + if len(value) >= 2 and value[0] == '"' and value[-1] == '"': + return _unescape_double(value[1:-1]) + if len(value) >= 2 and value[0] == "'" and value[-1] == "'": + return value[1:-1] + return value + + +def format_value(value: str) -> str: + """Naformátuje hodnotu pro shell env soubor — prosté hodnoty bez uvozovek, + hodnoty s mezerami/speciálními znaky v uvozovkách s bezpečným escapem.""" + if value == "": + return "" + if _SIMPLE_VALUE_RE.match(value): + return value + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("$", "\\$") + .replace("`", "\\`") + ) + return f'"{escaped}"' + + +def read_entries() -> list[tuple[str, str]]: + """Načte uspořádaný seznam (key, value) ze souboru. Duplicitní klíče bere jen poprvé.""" + try: + with open(ENV_PATH, "r", encoding="utf-8") as handle: + lines = handle.readlines() + except FileNotFoundError: + return [] + + entries: list[tuple[str, str]] = [] + seen: set[str] = set() + for line in lines: + parsed = _parse_line(line) + if not parsed: + continue + key, value = parsed + if key in seen: + continue + seen.add(key) + entries.append((key, value)) + return entries + + +def save_entries(desired_pairs: list[tuple[str, str]]) -> str | None: + """Zapíše nový obsah souboru z požadovaného uspořádaného seznamu (key, value). + + Zachová komentáře, prázdné řádky a původní pořadí klíčů. Smazané klíče vypustí, + nové přidá na konec. Před zápisem vytvoří časově označený backup. Vrací cestu backupu. + """ + desired_map = dict(desired_pairs) + desired_order = [key for key, _ in desired_pairs] + + try: + with open(ENV_PATH, "r", encoding="utf-8") as handle: + original_lines = handle.readlines() + except FileNotFoundError: + original_lines = [] + + out_lines: list[str] = [] + emitted: set[str] = set() + for line in original_lines: + parsed = _parse_line(line) + if not parsed: + out_lines.append(line.rstrip("\n")) + continue + key, _value = parsed + if key not in desired_map or key in emitted: + continue # smazaný klíč nebo duplicitní původní řádek + out_lines.append(f"{key}={format_value(desired_map[key])}") + emitted.add(key) + + for key in desired_order: + if key not in emitted: + out_lines.append(f"{key}={format_value(desired_map[key])}") + emitted.add(key) + + content = "\n".join(out_lines) + "\n" + + backup_path = None + if os.path.exists(ENV_PATH): + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = f"{ENV_PATH}.bak-{timestamp}" + shutil.copy2(ENV_PATH, backup_path) + + tmp_path = f"{ENV_PATH}.tmp-{os.getpid()}" + with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + os.replace(tmp_path, ENV_PATH) + + return backup_path diff --git a/app/main.py b/app/main.py index 05661f5..8b5ff4d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_bool, read_env_value -from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, health, incidents, jobs, migration_readiness, operations, runtime, scheduled_scripts, users, workers +from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, migration_readiness, operations, runtime, scheduled_scripts, users, workers def create_app() -> FastAPI: @@ -39,6 +39,7 @@ def create_app() -> FastAPI: app.include_router(users.router) app.include_router(audit.router) app.include_router(runtime.router) + app.include_router(environment.router) return app diff --git a/app/routes/alerting.py b/app/routes/alerting.py index 7225838..c5c00e1 100644 --- a/app/routes/alerting.py +++ b/app/routes/alerting.py @@ -20,10 +20,11 @@ from app.db.alerting import ( from app.db.audit import log_audit_event from app.routes.jobs import pretty_json from app.templates.layout import page +from app.tools_repo import TOOLS_REPO_DIR, commit_and_push router = APIRouter() -ALERTS_DIR = Path("/opt/appfactory/workspace/appfactory-tools/alerts") +ALERTS_DIR = TOOLS_REPO_DIR / "alerts" MAX_SCRIPT_BYTES = 100 * 1024 EVENT_TYPES = ("incident.opened", "incident.resolved") DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash @@ -325,7 +326,7 @@ def alert_rules_page(request: Request, user=Depends(require_user)): "Alert pravidla", f"""
Správa pravidel pro spouštění alert skriptů při událostech incidentů.
+ Nové alert pravidlo @@ -360,7 +361,7 @@ def new_alert_rule_form(request: Request, user=Depends(require_user)): "Nové alert pravidlo", f"""
← Zpět na alert pravidla Upravit @@ -469,7 +474,7 @@ def edit_alert_rule_form(rule_id: int, request: Request, user=Depends(require_us "Upravit alert pravidlo", f"""
← Zpět na alert eventy Alert pravidla diff --git a/app/routes/developers.py b/app/routes/developers.py index 74cf053..9f564a3 100644 --- a/app/routes/developers.py +++ b/app/routes/developers.py @@ -24,7 +24,9 @@ PORTAL_SECTIONS = [ ("fa-gauge-high", "Přehled", "/portal/operations", "Operační přehled systému, běžící nasazení a stav služeb.", "admin"), ("fa-server", "Runtime Management", "/portal/admin/runtime", - "Redeploy klíčových komponent (Portal, Tools, Webhook, Worker, Caddy, Gitea, Registry) přes job frontu.", "admin"), + "Redeploy core služeb (Portal, Worker, Webhook, Monitor, Gateway, Gitea, Registry) přes deploy-core-service.sh v job frontě.", "admin"), + ("fa-sliders", "Environment", "/portal/admin/environment", + "Bezpečná úprava hlavního appfactory.env (backup, validace) + navazující restart/regenerate akce přes job frontu.", "admin"), ("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness", "Připravenost a deploy core služeb AppFactory.", "admin"), ("fa-rocket", "Nasazení", "/portal/deployments", diff --git a/app/routes/environment.py b/app/routes/environment.py new file mode 100644 index 0000000..da8915c --- /dev/null +++ b/app/routes/environment.py @@ -0,0 +1,195 @@ +import html +import os +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth import require_user +from app.db.audit import log_audit_event +from app.env_file import ENV_PATH, KEY_RE, read_entries, save_entries +from app.routes.runtime import ENV_ACTION_KEYS, render_action_buttons +from app.templates.layout import page + +router = APIRouter() + + +def require_admin(user: dict) -> None: + if (user.get("role") or "").lower() != "admin": + raise HTTPException(status_code=403, detail="Environment je dostupný pouze administrátorům") + + +def _render_rows(entries: list[tuple[str, str]]) -> str: + rows = "" + for key, value in entries: + key_html = html.escape(key, quote=True) + value_html = html.escape(value, quote=True) + rows += f""" +
{html.escape(message)}
' + if error: + notice = f'{html.escape(error)}
' + + action_buttons = render_action_buttons(ENV_ACTION_KEYS, "/portal/admin/environment") + + return page( + "Environment", + f""" +
+ Úprava hlavní AppFactory konfigurace {html.escape(ENV_PATH)}.
+ Zdroj pravdy zůstává tento soubor. Při uložení se vytvoří časově označený backup.
+ Dostupné pouze administrátorům.
+
generate-caddyfile.sh (Regenerate Caddy).Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v + Runtime Management a Úlohách.
+