From 40069c896969d03a547875e70aa5ffc97616a5b2 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:45:53 +0200 Subject: [PATCH] env secrets and variables --- app/environment.py | 196 +++++++++++++++++++++++++++++++++++++++ app/routes/apps.py | 207 ++++++++++++++++++++++++++++++++++++++++-- app/static/styles.css | 15 +++ 3 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 app/environment.py diff --git a/app/environment.py b/app/environment.py new file mode 100644 index 0000000..2d81fea --- /dev/null +++ b/app/environment.py @@ -0,0 +1,196 @@ +import os +import re +import subprocess +from pathlib import Path + +from app.db.apps import get_app_variables, get_apps + +APP_DATA_ROOT = Path("/opt/appfactory/data/apps") +ENV_HEADER = "# GENERATED BY APPFACTORY - DO NOT EDIT" +ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class AppEnvironmentError(RuntimeError): + pass + + +def apply_app_environment(app_id: str) -> dict: + app_dir = _app_runtime_dir(app_id) + env_path = app_dir / ".env" + variables = get_app_variables(app_id) + new_content = _render_env_content(variables) + old_content = _read_text(env_path) + + if old_content == new_content: + return {"changed": False, "restarted": False, "container": None} + + _atomic_write(env_path, new_content) + container = _find_running_container(app_id) + if not container: + return {"changed": True, "restarted": False, "container": None} + + _restart_container(container) + return {"changed": True, "restarted": True, "container": container} + + +def apply_all_app_environments() -> dict: + results = {} + errors = {} + for app in get_apps(): + app_id = app.get("id") or "" + try: + results[app_id] = apply_app_environment(app_id) + except AppEnvironmentError as exc: + errors[app_id] = str(exc) + + if errors: + failed = ", ".join(sorted(errors)) + raise AppEnvironmentError(f"Environment apply failed for apps: {failed}") + + return results + + +def _app_runtime_dir(app_id: str) -> Path: + if not app_id or "/" in app_id or "\\" in app_id or app_id in {".", ".."}: + raise AppEnvironmentError("Invalid app id") + + root = APP_DATA_ROOT.resolve() + app_dir = (root / app_id).resolve() + if root not in app_dir.parents and app_dir != root: + raise AppEnvironmentError("Invalid app runtime path") + + return app_dir + + +def _render_env_content(variables: list[dict]) -> str: + lines = [ENV_HEADER] + seen_keys: set[str] = set() + + for variable in variables: + key = (variable.get("key") or "").strip() + validate_environment_key(key) + if key in seen_keys: + raise AppEnvironmentError(f"Duplicate environment variable key: {key}") + + value = variable.get("value") + if value is None: + raise AppEnvironmentError(f"Missing value for environment variable key: {key}") + + seen_keys.add(key) + lines.append(f"{key}={_format_env_value(str(value))}") + + return "\n".join(lines) + "\n" + + +def validate_environment_key(key: str) -> None: + if not ENV_KEY_RE.match(key): + raise AppEnvironmentError(f"Invalid environment variable key: {key or ''}") + + +def _format_env_value(value: str) -> str: + if value == "": + return "" + + needs_quotes = ( + value != value.strip() + or any(char in value for char in (' ', '\t', '\r', '\n', '"', "'", "\\", "#", "$")) + ) + if not needs_quotes: + return value + + escaped = ( + value + .replace("\\", "\\\\") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t") + .replace('"', '\\"') + .replace("$", "\\$") + ) + return f'"{escaped}"' + + +def _read_text(path: Path) -> str | None: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except OSError as exc: + raise AppEnvironmentError(f"Could not read runtime environment file: {exc}") from exc + + +def _atomic_write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_name(f".{path.name}.tmp.{os.getpid()}") + fd = None + try: + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as tmp_file: + fd = None + tmp_file.write(content) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + _fsync_directory(path.parent) + except OSError as exc: + raise AppEnvironmentError(f"Could not write runtime environment file: {exc}") from exc + finally: + if fd is not None: + os.close(fd) + try: + tmp_path.unlink() + except FileNotFoundError: + pass + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + + fd = os.open(path, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _find_running_container(app_id: str) -> str | None: + for container in _container_candidates(app_id): + result = _run_docker(["inspect", "-f", "{{.State.Running}}", container]) + if result.returncode == 0 and result.stdout.strip().lower() == "true": + return container + + return None + + +def _container_candidates(app_id: str) -> list[str]: + normalized = app_id.replace("_", "-") + candidates = [app_id, normalized, f"appfactory-{app_id}", f"appfactory-{normalized}"] + unique = [] + for candidate in candidates: + if candidate not in unique: + unique.append(candidate) + return unique + + +def _restart_container(container: str) -> None: + result = _run_docker(["restart", container]) + if result.returncode != 0: + raise AppEnvironmentError(f"Could not restart container {container}: {_safe_command_error(result.stderr)}") + + +def _run_docker(args: list[str]) -> subprocess.CompletedProcess: + try: + return subprocess.run(["docker", *args], capture_output=True, text=True, timeout=30) + except FileNotFoundError as exc: + raise AppEnvironmentError("Docker command is not available") from exc + except subprocess.TimeoutExpired as exc: + raise AppEnvironmentError("Docker command timed out") from exc + + +def _safe_command_error(value: str) -> str: + value = (value or "").strip() + if not value: + return "no error output" + return value[:500] diff --git a/app/routes/apps.py b/app/routes/apps.py index 35d2dc8..d809246 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -33,6 +33,7 @@ from ..db.audit import log_audit_event from ..db.health import get_latest_service_health, get_service_health, get_service_health_history from ..db.incidents import get_service_incidents from ..db.jobs import create_job, get_jobs, has_active_deploy_job +from ..environment import AppEnvironmentError, apply_all_app_environments, apply_app_environment, validate_environment_key from ..routes.deployments import render_status_pill from ..routes.incidents import render_incident_history_rows from ..shell import run_command @@ -75,6 +76,54 @@ def bool_checked(value) -> str: return " checked" if value else "" +def app_detail_url(app_id: str, anchor: str = "", message: str = "", error: str = "") -> str: + params = {} + if message: + params["message"] = message + if error: + params["error"] = error + + url = f"/portal/apps/{quote(app_id, safe='')}" + if params: + url += f"?{urlencode(params)}" + if anchor: + url += f"#{anchor}" + return url + + +def redirect_app_detail(app_id: str, anchor: str = "", message: str = "", error: str = "") -> RedirectResponse: + return RedirectResponse(url=app_detail_url(app_id, anchor=anchor, message=message, error=error), status_code=303) + + +def apply_environment_message(app_id: str) -> str: + result = apply_app_environment(app_id) + if not result.get("changed"): + return "Environment unchanged." + if result.get("restarted"): + return "Environment applied and container restarted." + return "Environment file regenerated. Container was not running." + + +def apply_all_environments_message() -> str: + results = apply_all_app_environments() + changed = sum(1 for result in results.values() if result.get("changed")) + restarted = sum(1 for result in results.values() if result.get("restarted")) + return f"Regenerated environments for {len(results)} apps. Changed: {changed}. Restarted: {restarted}." + + +def ensure_variable_key_allowed(app_id: str, key: str, variable_id: int | None = None) -> None: + try: + validate_environment_key(key) + except AppEnvironmentError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + for variable in get_app_variables(app_id): + if variable_id is not None and int(variable.get("id")) == variable_id: + continue + if (variable.get("key") or "").strip() == key: + raise HTTPException(status_code=400, detail="Variable key already exists") + + def render_template_options(templates: list[dict], selected_template: str, include_blank: bool = True) -> str: options = [''] if include_blank else [] selected_exists = not selected_template @@ -110,6 +159,44 @@ def render_template_label(templates: list[dict], template_id: str) -> str: return "Neznámá šablona" +def render_environment_usage_example(language: str, runtime: str, template: str) -> str: + normalized = " ".join((language or "", runtime or "", template or "")).lower() + title = "Použití v kódu" + if "fastapi" in normalized or "python" in normalized: + code = """import os + +value = os.environ["MY_VARIABLE"] +optional_value = os.getenv("OPTIONAL_VARIABLE", "default")""" + description = "Python / FastAPI čte Variables i Secrets ze systémového prostředí." + elif "dotnet" in normalized or ".net" in normalized or "csharp" in normalized or "c#" in normalized: + code = """var value = Environment.GetEnvironmentVariable("MY_VARIABLE"); +var optionalValue = builder.Configuration["OPTIONAL_VARIABLE"] ?? "default";""" + description = ".NET čte Variables i Secrets z environment variables, případně přes Configuration." + elif "node" in normalized or "javascript" in normalized or "typescript" in normalized: + code = """const value = process.env.MY_VARIABLE; +const optionalValue = process.env.OPTIONAL_VARIABLE ?? "default";""" + description = "Node.js čte Variables i Secrets přes process.env." + elif "php" in normalized: + code = """$value = getenv('MY_VARIABLE'); +$optionalValue = getenv('OPTIONAL_VARIABLE') ?: 'default';""" + description = "PHP čte Variables i Secrets ze systémového prostředí." + elif "java" in normalized or "spring" in normalized: + code = """String value = System.getenv("MY_VARIABLE"); +String optionalValue = System.getenv().getOrDefault("OPTIONAL_VARIABLE", "default");""" + description = "Java čte Variables i Secrets přes System.getenv." + else: + code = """MY_VARIABLE is available as an environment variable inside the container.""" + description = "Variables i Secrets jsou dostupné jako environment variables v kontejneru." + + return f""" +
+

{html.escape(title)}

+

{html.escape(description)}

+
{html.escape(code)}
+
+ """ + + def diff_metadata(before: dict, after: dict) -> dict: changes = {} for field in METADATA_FIELDS: @@ -174,6 +261,8 @@ def apps_page( request: Request, q: str = Query(""), status: str = Query(""), + message: str = Query(""), + error: str = Query(""), page_number: int = Query(1, alias="page", ge=1), user=Depends(require_user), ): @@ -345,6 +434,11 @@ def apps_page( """ + notice = "" + if message: + notice = f'

{html.escape(message)}

' + if error: + notice = f'

{html.escape(error)}

' return page( "Služby", @@ -368,7 +462,11 @@ def apps_page(

Nasazené služby

+ {notice}

+ Nová služba

+
+ +
@@ -399,8 +497,24 @@ def apps_page( ) +@router.post("/apps/environment/apply-all") +def apply_all_app_environments_action(user=Depends(require_user)): + try: + message = apply_all_environments_message() + except AppEnvironmentError as exc: + return RedirectResponse(url="/portal/apps?error=" + quote(f"Environment apply failed: {exc}"), status_code=303) + + log_audit_event( + user, + action="app.environment.applied_all", + target_type="app", + metadata={"result": message}, + ) + return RedirectResponse(url="/portal/apps?message=" + quote(message), status_code=303) + + @router.get("/apps/{app_id}", response_class=HTMLResponse) -def app_detail(app_id: str, request: Request, user=Depends(require_user)): +def app_detail(app_id: str, request: Request, message: str = "", error: str = "", user=Depends(require_user)): app = get_app(app_id) if not app: raise HTTPException(status_code=404, detail="App not found") @@ -415,7 +529,10 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)): escaped_app_id = html.escape(app.get("id", "")) app_url_id = quote(app.get("id", ""), safe="") name = html.escape(app.get("name", "") or "") - language = html.escape(app.get("language", "") or "") + language_raw = app.get("language", "") or "" + runtime_raw = app.get("runtime", "") or "" + template_raw = app.get("template", "") or "" + language = html.escape(language_raw) version = html.escape(app.get("version", "") or "") status = html.escape(app.get("status", "") or "") memory = html.escape(app.get("memory", "") or "") @@ -423,7 +540,7 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)): updated_at = html.escape(app.get("updated_at", "") or "") description = html.escape(app.get("description", "") or "") owner = html.escape(app.get("owner", "") or "") - runtime = html.escape(app.get("runtime", "") or "") + runtime = html.escape(runtime_raw) repository_url = html.escape(app.get("repository_url", "") or "") repository_name = html.escape(app.get("repository_name", "") or "") default_branch = html.escape(app.get("default_branch", "") or "") @@ -435,6 +552,7 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)): templates = get_app_templates() template_options = render_template_options(templates, app.get("template", "") or "") template_value = render_template_label(templates, app.get("template", "") or "") + environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw) variables = get_app_variables(app.get("id", "")) incidents = get_service_incidents(app.get("id", ""), limit=20) current_health = get_service_health(app.get("id", "")) @@ -532,12 +650,19 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)): if not variable_rows: variable_rows = 'Zatím nejsou evidované žádné proměnné.' + notice = "" + if message: + notice = f'

{html.escape(message)}

' + if error: + notice = f'

{html.escape(error)}

' + return page( "Detail slu\u017eby", f"""

{escaped_app_id}

{name}

+ {notice}

← Zpět na služby Nasazení služby @@ -601,6 +726,10 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):

Proměnné

+ + + + {environment_usage_example} @@ -786,6 +915,7 @@ def add_app_variable( variable_key = clean_optional(key) if not variable_key: raise HTTPException(status_code=400, detail="Key is required") + ensure_variable_key_allowed(app_id, variable_key) create_app_variable(app_id, variable_key, value, bool(is_secret)) log_audit_event( @@ -796,7 +926,12 @@ def add_app_variable( metadata={"key": variable_key, "is_secret": bool(is_secret)}, ) - return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303) + try: + message = apply_environment_message(app_id) + except AppEnvironmentError as exc: + return redirect_app_detail(app_id, anchor="promenne", error=f"Variable saved, but environment apply failed: {exc}") + + return redirect_app_detail(app_id, anchor="promenne", message=message) @router.post("/apps/{app_id}/variables/{variable_id}/update") @@ -819,8 +954,9 @@ def save_app_variable( variable_key = clean_optional(key) if not variable_key: raise HTTPException(status_code=400, detail="Key is required") + ensure_variable_key_allowed(app_id, variable_key, variable_id=variable_id) - stored_value = None if existing.get("is_secret") and value == "" else value + stored_value = None if existing.get("is_secret") and bool(is_secret) and value == "" else value update_app_variable(variable_id, app_id, variable_key, stored_value, bool(is_secret)) log_audit_event( user, @@ -830,7 +966,12 @@ def save_app_variable( metadata={"key": variable_key, "is_secret": bool(is_secret)}, ) - return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303) + try: + message = apply_environment_message(app_id) + except AppEnvironmentError as exc: + return redirect_app_detail(app_id, anchor="promenne", error=f"Variable saved, but environment apply failed: {exc}") + + return redirect_app_detail(app_id, anchor="promenne", message=message) @router.post("/apps/{app_id}/variables/{variable_id}/delete") @@ -852,7 +993,33 @@ def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user metadata={"key": existing.get("key"), "is_secret": bool(existing.get("is_secret"))}, ) - return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}#promenne", status_code=303) + try: + message = apply_environment_message(app_id) + except AppEnvironmentError as exc: + return redirect_app_detail(app_id, anchor="promenne", error=f"Variable deleted, but environment apply failed: {exc}") + + return redirect_app_detail(app_id, anchor="promenne", message=message) + + +@router.post("/apps/{app_id}/environment/apply") +def apply_app_environment_action(app_id: str, user=Depends(require_user)): + app = get_app(app_id) + if not app: + raise HTTPException(status_code=404, detail="App not found") + + try: + message = apply_environment_message(app_id) + except AppEnvironmentError as exc: + return redirect_app_detail(app_id, anchor="promenne", error=f"Environment apply failed: {exc}") + + log_audit_event( + user, + action="app.environment.applied", + target_type="app", + target_id=app_id, + metadata={"result": message}, + ) + return redirect_app_detail(app_id, anchor="promenne", message=message) @router.post("/apps/{app_id}/redeploy") @@ -861,6 +1028,11 @@ def redeploy_app(app_id: str, user=Depends(require_user)): if not app: raise HTTPException(status_code=404, detail="App not found") + try: + apply_environment_message(app_id) + except AppEnvironmentError as exc: + return redirect_app_detail(app_id, error=f"Environment apply failed: {exc}") + active_job = has_active_deploy_job("app", app_id) if active_job: return RedirectResponse(url=f"/portal/jobs/{active_job['id']}", status_code=303) @@ -1024,6 +1196,27 @@ def create_app( user=user, ) + try: + apply_environment_message(app_id) + except AppEnvironmentError as exc: + log_audit_event( + user, + action="app.environment.apply_failed", + target_type="app", + target_id=app_id, + metadata={"error": str(exc)}, + ) + return render_result( + title="Generování prostředí: FAILED", + back_url="/portal/apps", + sections=[ + ("Výstup vytvoření", create_result.stdout), + ("Chyba vytvoření", create_result.stderr), + ("Chyba prostředí", str(exc)), + ], + user=user, + ) + job_id = create_job( job_type="deploy_app", target_type="app", diff --git a/app/static/styles.css b/app/static/styles.css index 44d4af5..fa2b3d8 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -573,6 +573,21 @@ textarea:disabled { margin-bottom: 8px; } +.env-usage-example { + margin: 14px 0 18px; +} + +.env-usage-example h3 { + margin: 0 0 6px; + color: var(--secondary); +} + +.env-usage-example pre { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.45; +} + input[readonly] { width: 100%; font-family: monospace;
Key