672 lines
20 KiB
Python
672 lines
20 KiB
Python
import html
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Form
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
NEW_APP_SCRIPT = "/tools/new-python-app.sh"
|
|
DEPLOY_SCRIPT = "/tools/deploy-app.sh"
|
|
DELETE_APP_SCRIPT = "/tools/delete-app.sh"
|
|
BACKUP_SCRIPT = "/tools/backup-appfactory.sh"
|
|
|
|
CATALOG_FILE = "/opt/appfactory/apps/catalog.yml"
|
|
APPFACTORY_ENV = "/opt/appfactory/config/appfactory.env"
|
|
|
|
app = FastAPI(title="AppFactory Portal")
|
|
|
|
|
|
def read_env_value(key: str, default: str = "") -> str:
|
|
try:
|
|
with open(APPFACTORY_ENV, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line.startswith(f"{key}="):
|
|
return line.split("=", 1)[1].strip().strip('"')
|
|
except Exception:
|
|
pass
|
|
return default
|
|
|
|
|
|
def load_apps():
|
|
apps = []
|
|
current = {}
|
|
|
|
path = Path(CATALOG_FILE)
|
|
if not path.exists():
|
|
return apps
|
|
|
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
|
|
if line.startswith("- id:"):
|
|
if current:
|
|
apps.append(current)
|
|
current = {"id": line.split(":", 1)[1].strip()}
|
|
elif ":" in line and current:
|
|
key, value = line.split(":", 1)
|
|
current[key.strip()] = value.strip().strip('"')
|
|
|
|
if current:
|
|
apps.append(current)
|
|
|
|
return apps
|
|
|
|
|
|
def save_apps(apps):
|
|
lines = ["apps:"]
|
|
|
|
for item in apps:
|
|
app_id = item.get("id", "")
|
|
lines.append("")
|
|
lines.append(f" - id: {app_id}")
|
|
lines.append(f" name: {item.get('name', app_id)}")
|
|
lines.append(f" language: {item.get('language', 'python')}")
|
|
lines.append(f" version: {item.get('version', '1.0.0')}")
|
|
lines.append(f" base_path: /apps/{app_id}")
|
|
lines.append(f" docs: /apps/{app_id}/docs")
|
|
lines.append(f" health: /apps/{app_id}/health")
|
|
lines.append(f" status: {item.get('status', 'deployed')}")
|
|
|
|
if item.get("memory"):
|
|
lines.append(f" memory: {item.get('memory')}")
|
|
if item.get("cpus"):
|
|
lines.append(f" cpus: \"{item.get('cpus')}\"")
|
|
|
|
Path(CATALOG_FILE).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def list_backups():
|
|
backup_dir = Path(read_env_value("BACKUP_DIR", "/opt/appfactory/backups"))
|
|
if not backup_dir.exists():
|
|
return []
|
|
|
|
return sorted(
|
|
backup_dir.glob("*.tar.gz"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
def page(title: str, body: str) -> str:
|
|
return f"""
|
|
<html>
|
|
<head>
|
|
<title>{html.escape(title)}</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #f6f7fb;
|
|
--card: #ffffff;
|
|
--text: #1f2937;
|
|
--muted: #6b7280;
|
|
--border: #e5e7eb;
|
|
--primary: #2563eb;
|
|
--primary-dark: #1d4ed8;
|
|
--danger: #dc2626;
|
|
--danger-dark: #b91c1c;
|
|
--success: #16a34a;
|
|
}}
|
|
|
|
* {{ box-sizing: border-box; }}
|
|
|
|
body {{
|
|
margin: 0;
|
|
font-family: Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}}
|
|
|
|
header {{
|
|
background: #111827;
|
|
color: white;
|
|
padding: 18px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}}
|
|
|
|
header h1 {{
|
|
margin: 0;
|
|
font-size: 22px;
|
|
}}
|
|
|
|
nav a {{
|
|
color: white;
|
|
text-decoration: none;
|
|
margin-left: 18px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
main {{
|
|
padding: 32px;
|
|
max-width: 1500px;
|
|
margin: 0 auto;
|
|
}}
|
|
|
|
.card {{
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 22px;
|
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
|
margin-bottom: 24px;
|
|
}}
|
|
|
|
.grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 16px;
|
|
}}
|
|
|
|
table {{
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin-top: 12px;
|
|
}}
|
|
|
|
th, td {{
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}}
|
|
|
|
th {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
text-transform: uppercase;
|
|
letter-spacing: .04em;
|
|
background: #f9fafb;
|
|
}}
|
|
|
|
a {{
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
a:hover {{ text-decoration: underline; }}
|
|
|
|
button, .btn {{
|
|
border: 0;
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
background: var(--primary);
|
|
color: white;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
display: inline-block;
|
|
}}
|
|
|
|
button:hover, .btn:hover {{
|
|
background: var(--primary-dark);
|
|
text-decoration: none;
|
|
}}
|
|
|
|
.danger {{
|
|
background: var(--danger);
|
|
}}
|
|
|
|
.danger:hover {{
|
|
background: var(--danger-dark);
|
|
}}
|
|
|
|
.muted {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}}
|
|
|
|
.pill {{
|
|
display: inline-block;
|
|
border-radius: 999px;
|
|
padding: 4px 10px;
|
|
font-size: 12px;
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
input, select {{
|
|
padding: 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: white;
|
|
}}
|
|
|
|
input[readonly] {{
|
|
width: 100%;
|
|
font-family: monospace;
|
|
background: #f9fafb;
|
|
}}
|
|
|
|
.cmd-row {{
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 8px;
|
|
margin: 8px 0 14px;
|
|
}}
|
|
|
|
details summary {{
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
pre {{
|
|
background: #111827;
|
|
color: #e5e7eb;
|
|
padding: 18px;
|
|
border-radius: 12px;
|
|
white-space: pre-wrap;
|
|
overflow: auto;
|
|
}}
|
|
|
|
.resource-help {{
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
line-height: 1.4;
|
|
margin-top: 8px;
|
|
}}
|
|
|
|
.inline-form {{
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}}
|
|
</style>
|
|
<script>
|
|
function copyValue(id) {{
|
|
const el = document.getElementById(id);
|
|
el.select();
|
|
el.setSelectionRange(0, 99999);
|
|
navigator.clipboard.writeText(el.value);
|
|
}}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>AppFactory</h1>
|
|
<nav>
|
|
<a href="/portal">Apps</a>
|
|
<a href="/portal/new-app">New App</a>
|
|
<a href="/portal/backups">Backups</a>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
{body}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def render_result(title, back_url, sections, extra_link=None, extra_label=None):
|
|
rendered_sections = ""
|
|
|
|
for section_title, content in sections:
|
|
rendered_sections += f"""
|
|
<h2>{html.escape(section_title)}</h2>
|
|
<pre>{html.escape(content)}</pre>
|
|
"""
|
|
|
|
extra = ""
|
|
if extra_link and extra_label:
|
|
extra = f'<p><a class="btn" href="{extra_link}">{html.escape(extra_label)}</a></p>'
|
|
|
|
return page(
|
|
title,
|
|
f"""
|
|
<div class="card">
|
|
<h2>{html.escape(title)}</h2>
|
|
{extra}
|
|
<p><a href="{back_url}">← Back</a></p>
|
|
</div>
|
|
<div class="card">
|
|
{rendered_sections}
|
|
</div>
|
|
""",
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index():
|
|
apps = load_apps()
|
|
|
|
gitea_url = read_env_value("GITEA_URL", "")
|
|
gitea_org = read_env_value("GITEA_ORG", "appfactory")
|
|
host = read_env_value("APPFACTORY_HOST", "192.168.66.130")
|
|
|
|
rows = ""
|
|
|
|
for item in apps:
|
|
app_id = html.escape(item.get("id", ""))
|
|
status = html.escape(item.get("status", ""))
|
|
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
|
|
memory = item.get("memory", "")
|
|
cpus = item.get("cpus", "")
|
|
|
|
http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git")
|
|
ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git")
|
|
|
|
memory_options = ""
|
|
for value, label in [
|
|
("", "Default"),
|
|
("256m", "256 MB - small service"),
|
|
("512m", "512 MB - normal service"),
|
|
("1g", "1 GB - larger service"),
|
|
("2g", "2 GB - heavy service"),
|
|
]:
|
|
selected = "selected" if memory == value else ""
|
|
memory_options += f'<option value="{value}" {selected}>{label}</option>'
|
|
|
|
cpu_options = ""
|
|
for value, label in [
|
|
("", "Default"),
|
|
("0.25", "0.25 CPU - very small"),
|
|
("0.50", "0.50 CPU - normal"),
|
|
("1.00", "1 CPU - full core"),
|
|
("2.00", "2 CPU - heavy"),
|
|
]:
|
|
selected = "selected" if cpus == value else ""
|
|
cpu_options += f'<option value="{value}" {selected}>{label}</option>'
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td>
|
|
<strong>{app_id}</strong><br>
|
|
<span class="muted">/apps/{app_id}</span>
|
|
</td>
|
|
<td><span class="pill">{status}</span></td>
|
|
<td><a href="{docs}">Swagger</a></td>
|
|
<td>
|
|
<form method="post" action="/portal/update-resources">
|
|
<input type="hidden" name="app_id" value="{app_id}">
|
|
<div class="inline-form">
|
|
<select name="memory">{memory_options}</select>
|
|
<select name="cpus">{cpu_options}</select>
|
|
<button type="submit">Apply</button>
|
|
</div>
|
|
<div class="resource-help">
|
|
Memory is RAM limit. CPU is max compute share.
|
|
Example: 0.50 = half CPU core, 1.00 = one full core.
|
|
</div>
|
|
</form>
|
|
</td>
|
|
<td>
|
|
<details>
|
|
<summary>Clone commands</summary>
|
|
<label class="muted">HTTP</label>
|
|
<div class="cmd-row">
|
|
<input readonly value="{http_clone}" id="http-{app_id}">
|
|
<button onclick="copyValue('http-{app_id}')">Copy</button>
|
|
</div>
|
|
|
|
<label class="muted">SSH</label>
|
|
<div class="cmd-row">
|
|
<input readonly value="{ssh_clone}" id="ssh-{app_id}">
|
|
<button onclick="copyValue('ssh-{app_id}')">Copy</button>
|
|
</div>
|
|
</details>
|
|
</td>
|
|
<td>
|
|
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Delete {app_id}? This removes container, image, workspace, catalog entry and Gitea repo.');">
|
|
<input type="hidden" name="app_id" value="{app_id}">
|
|
<button type="submit" class="danger">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="6">No apps deployed yet.</td></tr>'
|
|
|
|
return page(
|
|
"Apps",
|
|
f"""
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>Apps</h2>
|
|
<p class="muted">Create, deploy, clone, tune resources and delete services.</p>
|
|
<a class="btn" href="/portal/new-app">+ New App</a>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Backups</h2>
|
|
<p class="muted">Create backups and copy restore commands. Restore is intentionally manual and guarded.</p>
|
|
<a class="btn" href="/portal/backups">Manage Backups</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Deployed Apps</h2>
|
|
<table>
|
|
<tr>
|
|
<th>App</th>
|
|
<th>Status</th>
|
|
<th>Docs</th>
|
|
<th>Resources</th>
|
|
<th>Git</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
</div>
|
|
""",
|
|
)
|
|
|
|
|
|
@app.get("/new-app", response_class=HTMLResponse)
|
|
def new_app_form():
|
|
return page(
|
|
"New App",
|
|
"""
|
|
<div class="card">
|
|
<h2>Create New App</h2>
|
|
<p class="muted">This creates Gitea repo, webhook, local workspace, initial commit and deploys the service.</p>
|
|
|
|
<form method="post" action="/portal/new-app">
|
|
<p>
|
|
<label>App ID</label><br>
|
|
<input name="app_id" placeholder="gmail-service" required>
|
|
</p>
|
|
|
|
<p>
|
|
<label>App Name</label><br>
|
|
<input name="app_name" placeholder="Gmail Service" required>
|
|
</p>
|
|
|
|
<p>
|
|
<label>Template</label><br>
|
|
<select name="template">
|
|
<option value="python-fastapi">Python FastAPI</option>
|
|
</select>
|
|
</p>
|
|
|
|
<button type="submit">Create App</button>
|
|
</form>
|
|
|
|
<p><a href="/portal">← Back</a></p>
|
|
</div>
|
|
""",
|
|
)
|
|
|
|
|
|
@app.post("/new-app", response_class=HTMLResponse)
|
|
def create_app(app_id: str = Form(...), app_name: str = Form(...), template: str = Form(...)):
|
|
if template != "python-fastapi":
|
|
return HTMLResponse("Unsupported template", status_code=400)
|
|
|
|
create_result = subprocess.run([NEW_APP_SCRIPT, app_id, app_name], capture_output=True, text=True)
|
|
deploy_result = subprocess.run([DEPLOY_SCRIPT, app_id], capture_output=True, text=True)
|
|
|
|
status = "OK" if create_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED"
|
|
|
|
return render_result(
|
|
title=f"Create App: {status}",
|
|
back_url="/portal",
|
|
sections=[
|
|
("Create Output", create_result.stdout),
|
|
("Create Error", create_result.stderr),
|
|
("Deploy Output", deploy_result.stdout),
|
|
("Deploy Error", deploy_result.stderr),
|
|
],
|
|
extra_link=f"/apps/{html.escape(app_id)}/docs",
|
|
extra_label="Open Swagger",
|
|
)
|
|
|
|
|
|
@app.post("/delete-app", response_class=HTMLResponse)
|
|
def delete_app(app_id: str = Form(...)):
|
|
result = subprocess.run([DELETE_APP_SCRIPT, app_id], capture_output=True, text=True)
|
|
status = "OK" if result.returncode == 0 else "FAILED"
|
|
|
|
return render_result(
|
|
title=f"Delete App: {status}",
|
|
back_url="/portal",
|
|
sections=[("Output", result.stdout), ("Error", result.stderr)],
|
|
)
|
|
|
|
|
|
@app.post("/update-resources", response_class=HTMLResponse)
|
|
def update_resources(app_id: str = Form(...), memory: str = Form(""), cpus: str = Form("")):
|
|
apps = load_apps()
|
|
|
|
for item in apps:
|
|
if item.get("id") == app_id:
|
|
memory = memory.strip()
|
|
cpus = cpus.strip()
|
|
|
|
if memory:
|
|
item["memory"] = memory
|
|
else:
|
|
item.pop("memory", None)
|
|
|
|
if cpus:
|
|
item["cpus"] = cpus
|
|
else:
|
|
item.pop("cpus", None)
|
|
|
|
save_apps(apps)
|
|
|
|
compose_result = subprocess.run(["/tools/generate-apps-compose.sh"], capture_output=True, text=True)
|
|
deploy_result = subprocess.run([DEPLOY_SCRIPT, app_id], capture_output=True, text=True)
|
|
|
|
status = "OK" if compose_result.returncode == 0 and deploy_result.returncode == 0 else "FAILED"
|
|
|
|
return render_result(
|
|
title=f"Update Resources: {status}",
|
|
back_url="/portal",
|
|
sections=[
|
|
("Compose Output", compose_result.stdout),
|
|
("Compose Error", compose_result.stderr),
|
|
("Deploy Output", deploy_result.stdout),
|
|
("Deploy Error", deploy_result.stderr),
|
|
],
|
|
)
|
|
|
|
|
|
@app.get("/backups", response_class=HTMLResponse)
|
|
def backups_page():
|
|
rows = ""
|
|
|
|
for path in list_backups():
|
|
size_mb = path.stat().st_size / 1024 / 1024
|
|
backup_name = html.escape(path.name)
|
|
backup_path = html.escape(str(path))
|
|
|
|
restore_cmd = html.escape(
|
|
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}"
|
|
)
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td>
|
|
<strong>{backup_name}</strong><br>
|
|
<span class="muted">{size_mb:.2f} MB</span>
|
|
</td>
|
|
<td>
|
|
<div class="cmd-row">
|
|
<input readonly value="{backup_path}" id="path-{backup_name}">
|
|
<button onclick="copyValue('path-{backup_name}')">Copy</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="cmd-row">
|
|
<input readonly value="{restore_cmd}" id="restore-{backup_name}">
|
|
<button onclick="copyValue('restore-{backup_name}')">Copy</button>
|
|
</div>
|
|
<div class="muted">Restore is dangerous and must be run manually over SSH.</div>
|
|
</td>
|
|
<td>
|
|
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Delete backup {backup_name}?');">
|
|
<input type="hidden" name="backup_path" value="{backup_path}">
|
|
<button type="submit" class="danger">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="4">No backups found.</td></tr>'
|
|
|
|
return page(
|
|
"Backups",
|
|
f"""
|
|
<div class="card">
|
|
<h2>Backup Management</h2>
|
|
<p class="muted">
|
|
Backups do not include the backup directory itself.
|
|
Restoring an older backup should not delete newer .tar.gz files.
|
|
Restore remains manual to avoid accidental overwrite.
|
|
</p>
|
|
|
|
<form method="post" action="/portal/backups/create">
|
|
<button type="submit">Create Backup</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Available Backups</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Backup</th>
|
|
<th>Path</th>
|
|
<th>Restore Command</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
{rows}
|
|
</table>
|
|
</div>
|
|
""",
|
|
)
|
|
|
|
|
|
@app.post("/backups/create", response_class=HTMLResponse)
|
|
def create_backup():
|
|
result = subprocess.run([BACKUP_SCRIPT], capture_output=True, text=True)
|
|
status = "OK" if result.returncode == 0 else "FAILED"
|
|
|
|
return render_result(
|
|
title=f"Create Backup: {status}",
|
|
back_url="/portal/backups",
|
|
sections=[("Output", result.stdout), ("Error", result.stderr)],
|
|
)
|
|
|
|
|
|
@app.post("/backups/delete")
|
|
def delete_backup(backup_path: str = Form(...)):
|
|
backup_dir = Path(read_env_value("BACKUP_DIR", "/opt/appfactory/backups")).resolve()
|
|
target = Path(backup_path).resolve()
|
|
|
|
if backup_dir not in target.parents:
|
|
return HTMLResponse("Invalid backup path", status_code=400)
|
|
|
|
if target.exists() and target.suffixes[-2:] == [".tar", ".gz"]:
|
|
target.unlink()
|
|
|
|
return RedirectResponse(url="/portal/backups", status_code=303)
|