env secrets and variables

This commit is contained in:
JiriUhlir
2026-06-12 10:45:53 +02:00
parent 428d9681e0
commit 40069c8969
3 changed files with 411 additions and 7 deletions
+196
View File
@@ -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 '<empty>'}")
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]
+200 -7
View File
@@ -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 = ['<option value="">Bez &scaron;ablony</option>'] 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&aacute;m&aacute; &scaron;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"""
<div class="env-usage-example">
<h3>{html.escape(title)}</h3>
<p class="muted">{html.escape(description)}</p>
<pre><code>{html.escape(code)}</code></pre>
</div>
"""
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(
</div>
</div>
"""
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
return page(
"Služby",
@@ -368,7 +462,11 @@ def apps_page(
<div class="card">
<h2>Nasazené služby</h2>
{notice}
<p><a class="btn" href="/portal/new-app">+ Nová služba</a></p>
<form method="post" action="/portal/apps/environment/apply-all" class="inline-form">
<button type="submit" class="btn-secondary">Regenerovat v&scaron;echny .env z datab&aacute;ze</button>
</form>
<form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select>
@@ -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 = '<tr><td colspan="4">Zat&iacute;m nejsou evidovan&eacute; &zcaron;&aacute;dn&eacute; prom&ecaron;nn&eacute;.</td></tr>'
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
return page(
"Detail slu\u017eby",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
{notice}
<p>
<a class="btn" href="/portal/apps">&larr; Zpět na služby</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení služby</a>
@@ -601,6 +726,10 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
<div class="card" id="promenne">
<h2>Prom&ecaron;nn&eacute;</h2>
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form">
<button type="submit" class="btn-secondary">Regenerovat .env z datab&aacute;ze</button>
</form>
{environment_usage_example}
<table>
<tr>
<th>Key</th>
@@ -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",
+15
View File
@@ -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;