From 22d986443675e078dcc9c702def016d6ed291378 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 7 Jun 2026 16:49:38 +0000 Subject: [PATCH] Phase 3: env masking, image updates, port conflicts, resources, templates (0.3.0) Backend: - update_service: registry manifest digest check (Docker Hub/ghcr/lscr/private v2 token auth) vs local RepoDigests; in-memory cache + background loop - port_service: parse compose ports, check /proc/net/tcp[6] + docker bindings - template_service + bundled templates (jellyfin/vaultwarden/uptime-kuma/ paperless-ngx/gitea) with {{VAR}} placeholders; custom templates in DB - compose_edit set_resources (deploy.resources.limits/reservations) - routers: images, ports, templates, editor/set-resources - Template model; background update task wired into lifespan Frontend: - EnvEditor (table + raw, sensitive masking, quick-insert) - Images page + UpdateBadge + dashboard 'updates available' banner - PortConflictDialog pre-deploy check on Deploy - ResourcePanel (CPU/RAM sliders) as editor Limits tab - Templates page with per-variable instantiate form Co-Authored-By: Claude Opus 4.8 --- README.md | 27 ++- backend/main.py | 22 +- backend/models/__init__.py | 3 +- backend/models/template.py | 57 +++++ backend/routers/editor.py | 26 +++ backend/routers/images.py | 64 ++++++ backend/routers/ports.py | 21 ++ backend/routers/templates.py | 99 +++++++++ backend/services/compose_edit_service.py | 54 +++++ backend/services/port_service.py | 158 ++++++++++++++ backend/services/template_service.py | 167 ++++++++++++++ backend/services/update_service.py | 204 ++++++++++++++++++ backend/templates/gitea.yaml | 15 ++ backend/templates/jellyfin.yaml | 14 ++ backend/templates/manifest.json | 74 +++++++ backend/templates/paperless-ngx.yaml | 38 ++++ backend/templates/uptime-kuma.yaml | 9 + backend/templates/vaultwarden.yaml | 13 ++ frontend/src/App.tsx | 4 +- frontend/src/api/editor.ts | 13 ++ frontend/src/api/images.ts | 27 +++ frontend/src/api/ports.ts | 18 ++ frontend/src/api/templates.ts | 38 ++++ frontend/src/components/env/EnvEditor.tsx | 157 ++++++++++++++ .../components/stacks/EditorHelperPanel.tsx | 14 +- .../components/stacks/PortConflictDialog.tsx | 45 ++++ .../src/components/stacks/ResourcePanel.tsx | 82 +++++++ frontend/src/pages/Dashboard.tsx | 17 +- frontend/src/pages/Images.tsx | 91 ++++++++ frontend/src/pages/Placeholder.tsx | 4 +- frontend/src/pages/StackEditor.tsx | 45 +++- frontend/src/pages/Templates.tsx | 129 +++++++++++ 32 files changed, 1728 insertions(+), 21 deletions(-) create mode 100644 backend/models/template.py create mode 100644 backend/routers/images.py create mode 100644 backend/routers/ports.py create mode 100644 backend/routers/templates.py create mode 100644 backend/services/port_service.py create mode 100644 backend/services/template_service.py create mode 100644 backend/services/update_service.py create mode 100644 backend/templates/gitea.yaml create mode 100644 backend/templates/jellyfin.yaml create mode 100644 backend/templates/manifest.json create mode 100644 backend/templates/paperless-ngx.yaml create mode 100644 backend/templates/uptime-kuma.yaml create mode 100644 backend/templates/vaultwarden.yaml create mode 100644 frontend/src/api/images.ts create mode 100644 frontend/src/api/ports.ts create mode 100644 frontend/src/api/templates.ts create mode 100644 frontend/src/components/env/EnvEditor.tsx create mode 100644 frontend/src/components/stacks/PortConflictDialog.tsx create mode 100644 frontend/src/components/stacks/ResourcePanel.tsx create mode 100644 frontend/src/pages/Images.tsx create mode 100644 frontend/src/pages/Templates.tsx diff --git a/README.md b/README.md index a09df2c..d27fba9 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ A self-hosted Docker Compose manager for power users and homelab enthusiasts — as intuitive as Dockge, as capable as Portainer for Compose workflows. -> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) complete. Image-update -> checks, templates, multi-host agents and backups land in later phases. +> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of +> Life) complete. Multi-host agents, backups and notifications land in Phase 4. ## What works today (Phase 1) @@ -41,6 +41,20 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > GPU/device detection needs host visibility. The bundled compose bind-mounts > `/dev:/dev:ro`; NVIDIA additionally requires the NVIDIA container runtime on the host. +### Phase 3 — Quality of Life + +- **Env editor**: table mode with sensitive-value masking (`PASS`/`SECRET`/`KEY`/… + auto-detected) + raw mode, plus PUID/PGID/TZ quick-insert. +- **Image update checker**: background task compares the local manifest digest with + the registry (Docker Hub / ghcr / lscr / private v2 with token auth); update + badges on the Images page + an "updates available" banner on the dashboard. +- **Port conflict detector**: pre-deploy check against host-bound ports + (`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog. +- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`. +- **Template library**: bundled templates (Jellyfin, Vaultwarden, Uptime-Kuma, + Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template. +- **Healthcheck status** surfaced per container in the stack overview. + ## Architecture ``` @@ -120,6 +134,15 @@ GET /api/host/paths?path=&show_hidden= (sandboxed browser) POST /api/editor/services | add-volume | set-gpu | add-device | remove-device | set-privileged ``` +### Phase 3 endpoints + +``` +GET /api/images | /updates POST /api/images/check +POST /api/ports/conflicts POST /api/editor/set-resources +GET /api/templates | /{id} POST /api/templates/{id}/instantiate +POST /api/templates DELETE /api/templates/custom/{slug} +``` + ## Security notes - The Docker socket is only ever touched by the backend process; it is never diff --git a/backend/main.py b/backend/main.py index 3292ca5..5b426e9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ """StackPilot backend — FastAPI application entry point.""" from __future__ import annotations +import asyncio import logging from contextlib import asynccontextmanager @@ -12,7 +13,19 @@ from sqlmodel import Session from config import settings from database import engine, init_db from docker_client import DockerError -from routers import audit, auth, editor, stacks, system, volumes, ws +from routers import ( + audit, + auth, + editor, + images, + ports, + stacks, + system, + templates, + volumes, + ws, +) +from services import update_service logging.basicConfig(level=logging.INFO) logger = logging.getLogger("stackpilot") @@ -27,11 +40,13 @@ async def lifespan(app: FastAPI): stacks.sync_discovered_stacks(session) except Exception as exc: # noqa: BLE001 logger.warning("Stack discovery failed: %s", exc) + update_task = asyncio.create_task(update_service.background_loop()) logger.info("StackPilot backend ready on port %s", settings.PORT) yield + update_task.cancel() -app = FastAPI(title="StackPilot", version="0.2.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.3.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -55,6 +70,9 @@ app.include_router(stacks.router) app.include_router(system.router) app.include_router(volumes.router) app.include_router(editor.router) +app.include_router(images.router) +app.include_router(ports.router) +app.include_router(templates.router) app.include_router(audit.router) app.include_router(ws.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 6dc387b..a49f322 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,6 +1,7 @@ """SQLModel table models. Importing this package registers all tables.""" from models.audit import AuditLog from models.stack import Stack +from models.template import Template from models.user import User -__all__ = ["User", "Stack", "AuditLog"] +__all__ = ["User", "Stack", "AuditLog", "Template"] diff --git a/backend/models/template.py b/backend/models/template.py new file mode 100644 index 0000000..0efdcd0 --- /dev/null +++ b/backend/models/template.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Template(SQLModel, table=True): + """User-saved custom template (bundled ones live on disk).""" + + id: Optional[int] = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + name: str + description: Optional[str] = None + tags: str = "" # comma separated + yaml: str = "" + created_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class TemplateVariable(SQLModel): + name: str + description: str = "" + default: str = "" + + +class TemplateSummary(SQLModel): + id: str + name: str + description: Optional[str] = None + tags: list[str] = [] + gpu: Optional[str] = None + source: str = "bundled" # "bundled" | "custom" + + +class TemplateDetail(TemplateSummary): + yaml: str + variables: list[TemplateVariable] = [] + + +class TemplateSaveRequest(SQLModel): + name: str + description: Optional[str] = None + tags: list[str] = [] + yaml: str + + +class TemplateInstantiateRequest(SQLModel): + name: str # new stack name + values: dict[str, str] = {} diff --git a/backend/routers/editor.py b/backend/routers/editor.py index 50bf2d1..1346b33 100644 --- a/backend/routers/editor.py +++ b/backend/routers/editor.py @@ -36,6 +36,15 @@ class PrivilegedBody(BaseModel): value: bool +class ResourcesBody(BaseModel): + yaml: str + service: str + cpus: float | None = None + memory: str | None = None + cpus_reserve: float | None = None + memory_reserve: str | None = None + + def _run(fn, *args) -> dict: try: return {"yaml": fn(*args)} @@ -74,3 +83,20 @@ def remove_device(body: DeviceBody, _user: User = Depends(get_current_user)) -> @router.post("/set-privileged") def set_privileged(body: PrivilegedBody, _user: User = Depends(get_current_user)) -> dict: return _run(edit.set_privileged, body.yaml, body.service, body.value) + + +@router.post("/set-resources") +def set_resources(body: ResourcesBody, _user: User = Depends(get_current_user)) -> dict: + try: + return { + "yaml": edit.set_resources( + body.yaml, + body.service, + cpus=body.cpus, + memory=body.memory, + cpus_reserve=body.cpus_reserve, + memory_reserve=body.memory_reserve, + ) + } + except edit.EditError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/routers/images.py b/backend/routers/images.py new file mode 100644 index 0000000..9d5e347 --- /dev/null +++ b/backend/routers/images.py @@ -0,0 +1,64 @@ +"""Image listing + update-check endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from auth import get_current_user, require_admin +from docker_client import DockerError, get_client, safe_call +from models.user import User +from services import update_service + +router = APIRouter(prefix="/api/images", tags=["images"]) + +COMPOSE_PROJECT_LABEL = "com.docker.compose.project" + + +@router.get("") +def list_images(_user: User = Depends(get_current_user)) -> list[dict]: + try: + client = get_client() + images = safe_call(client.images.list) + containers = safe_call(client.containers.list, all=True) + except DockerError: + return [] + + # Map image ref -> stacks using it. + usage: dict[str, set[str]] = {} + for c in containers: + ref = c.attrs.get("Config", {}).get("Image") + stack = c.labels.get(COMPOSE_PROJECT_LABEL) + if ref: + usage.setdefault(ref, set()) + if stack: + usage[ref].add(stack) + + cache = update_service.get_cache() + result = [] + for img in images: + tags = img.tags or [] + if not tags: + continue + for tag in tags: + upd = cache.get(tag) + result.append( + { + "id": img.short_id, + "tag": tag, + "size": img.attrs.get("Size", 0), + "created": img.attrs.get("Created"), + "stacks": sorted(usage.get(tag, set())), + "update": upd, + } + ) + result.sort(key=lambda r: r["tag"]) + return result + + +@router.get("/updates") +def updates(_user: User = Depends(get_current_user)) -> dict: + return update_service.get_cache() + + +@router.post("/check") +async def check(_user: User = Depends(require_admin)) -> dict: + return await update_service.check_all() diff --git a/backend/routers/ports.py b/backend/routers/ports.py new file mode 100644 index 0000000..58c8ed6 --- /dev/null +++ b/backend/routers/ports.py @@ -0,0 +1,21 @@ +"""Port conflict detection endpoint.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from auth import get_current_user +from models.user import User +from services import port_service + +router = APIRouter(prefix="/api/ports", tags=["ports"]) + + +class ConflictBody(BaseModel): + yaml: str + ignore_stack: str | None = None + + +@router.post("/conflicts") +def conflicts(body: ConflictBody, _user: User = Depends(get_current_user)) -> dict: + return {"conflicts": port_service.detect_conflicts(body.yaml, body.ignore_stack)} diff --git a/backend/routers/templates.py b/backend/routers/templates.py new file mode 100644 index 0000000..b20330d --- /dev/null +++ b/backend/routers/templates.py @@ -0,0 +1,99 @@ +"""Template library endpoints.""" +from __future__ import annotations + +import os + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session + +from auth import get_current_user, require_admin +from database import get_session +from models.stack import Stack +from models.template import TemplateInstantiateRequest, TemplateSaveRequest +from models.user import User +from services import audit_service, compose_service, template_service + +router = APIRouter(prefix="/api/templates", tags=["templates"]) + + +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +@router.get("") +def list_templates( + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + return template_service.list_templates(session) + + +@router.get("/{template_id}") +def get_template( + template_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + tpl = template_service.get_template(session, template_id) + if not tpl: + raise HTTPException(status_code=404, detail="Template not found") + return tpl + + +@router.post("") +def save_template( + body: TemplateSaveRequest, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + tpl = template_service.save_custom( + session, body.name, body.yaml, body.description or "", body.tags + ) + audit_service.record( + session, user=user.username, action="template.save", target=tpl.slug, ip=_ip(request) + ) + return {"id": f"custom:{tpl.slug}", "name": tpl.name} + + +@router.delete("/custom/{slug}") +def delete_template( + slug: str, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + if not template_service.delete_custom(session, slug): + raise HTTPException(status_code=404, detail="Custom template not found") + audit_service.record( + session, user=user.username, action="template.delete", target=slug, ip=_ip(request) + ) + return {"ok": True} + + +@router.post("/{template_id}/instantiate", status_code=201) +def instantiate( + template_id: str, + body: TemplateInstantiateRequest, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + tpl = template_service.get_template(session, template_id) + if not tpl: + raise HTTPException(status_code=404, detail="Template not found") + + stack_id = compose_service.slugify(body.name) + if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): + raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") + + rendered = template_service.render(tpl["yaml"], body.values) + compose_service.write_compose(stack_id, rendered) + stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) + session.add(stack) + session.commit() + audit_service.record( + session, user=user.username, action="template.instantiate", + target=stack_id, detail=template_id, ip=_ip(request), + ) + return {"id": stack_id, "name": body.name} diff --git a/backend/services/compose_edit_service.py b/backend/services/compose_edit_service.py index d69b594..259c163 100644 --- a/backend/services/compose_edit_service.py +++ b/backend/services/compose_edit_service.py @@ -144,6 +144,60 @@ def set_privileged(yaml_str: str, service: str, value: bool) -> str: return _dump(data) +def set_resources( + yaml_str: str, + service: str, + *, + cpus: float | None = None, + memory: str | None = None, + cpus_reserve: float | None = None, + memory_reserve: str | None = None, +) -> str: + """Set deploy.resources limits/reservations for a service. + + Pass None / empty to clear an individual value. + """ + data = _load(yaml_str) + svc = _get_service(data, service) + deploy = svc.setdefault("deploy", {}) + resources = deploy.setdefault("resources", {}) + + limits = resources.get("limits", {}) + if cpus: + limits["cpus"] = str(cpus) + else: + limits.pop("cpus", None) + if memory: + limits["memory"] = memory + else: + limits.pop("memory", None) + if limits: + resources["limits"] = limits + else: + resources.pop("limits", None) + + reservations = resources.get("reservations", {}) + if cpus_reserve: + reservations["cpus"] = str(cpus_reserve) + else: + reservations.pop("cpus", None) + if memory_reserve: + reservations["memory"] = memory_reserve + else: + reservations.pop("memory", None) + # Don't drop reservations entirely — it may hold GPU devices. + if reservations: + resources["reservations"] = reservations + elif "reservations" in resources and not resources["reservations"]: + resources.pop("reservations", None) + + if not resources: + deploy.pop("resources", None) + if not deploy: + svc.pop("deploy", None) + return _dump(data) + + def list_services(yaml_str: str) -> list[str]: data = _load(yaml_str) return list(data["services"].keys()) diff --git a/backend/services/port_service.py b/backend/services/port_service.py new file mode 100644 index 0000000..eb5465f --- /dev/null +++ b/backend/services/port_service.py @@ -0,0 +1,158 @@ +"""Port conflict detection before deploying a stack.""" +from __future__ import annotations + +import os +from typing import Optional + +import yaml + +from config import settings +from docker_client import DockerError, get_client, safe_call + + +# --------------------------------------------------------------------------- # +# Parse compose `ports:` entries +# --------------------------------------------------------------------------- # + + +def parse_compose_ports(yaml_str: str) -> list[dict]: + """Return [{host_port:int, protocol:'tcp'|'udp', service:str}].""" + try: + data = yaml.safe_load(yaml_str) or {} + except yaml.YAMLError: + return [] + services = data.get("services") or {} + out: list[dict] = [] + for svc_name, svc in services.items(): + if not isinstance(svc, dict): + continue + for entry in svc.get("ports", []) or []: + parsed = _parse_port_entry(entry) + if parsed: + parsed["service"] = svc_name + out.append(parsed) + return out + + +def _parse_port_entry(entry) -> Optional[dict]: + # Long form: {target, published, protocol} + if isinstance(entry, dict): + published = entry.get("published") + if published is None: + return None + try: + host_port = int(str(published).split("-")[0]) + except ValueError: + return None + return {"host_port": host_port, "protocol": entry.get("protocol", "tcp")} + + # Short form string: "[ip:]host:container[/proto]" or "container" + s = str(entry) + proto = "tcp" + if "/" in s: + s, proto = s.rsplit("/", 1) + parts = s.split(":") + # No host mapping (only container port) -> random host port, no conflict. + if len(parts) == 1: + return None + # host:container or ip:host:container + host = parts[-2] + try: + host_port = int(host.split("-")[0]) + except ValueError: + return None + return {"host_port": host_port, "protocol": proto} + + +# --------------------------------------------------------------------------- # +# Host bound ports +# --------------------------------------------------------------------------- # + + +def _read_proc_net(name: str) -> set[int]: + ports: set[int] = set() + path = os.path.join(settings.HOST_PROC_PATH, "net", name) + if not os.path.isfile(path): + path = os.path.join("/proc/net", name) + try: + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines()[1:] + except OSError: + return ports + for line in lines: + cols = line.split() + if len(cols) < 4: + continue + local = cols[1] # hexip:hexport + state = cols[3] + # TCP listen state is 0A; for UDP accept all. + if name.startswith("tcp") and state != "0A": + continue + try: + port = int(local.split(":")[1], 16) + ports.add(port) + except (IndexError, ValueError): + continue + return ports + + +def host_listening_ports() -> dict[str, set[int]]: + return { + "tcp": _read_proc_net("tcp") | _read_proc_net("tcp6"), + "udp": _read_proc_net("udp") | _read_proc_net("udp6"), + } + + +def docker_bound_ports() -> dict[tuple[int, str], str]: + """Return {(host_port, proto): container_name}.""" + out: dict[tuple[int, str], str] = {} + try: + client = get_client() + for c in safe_call(client.containers.list): + bindings = (c.attrs.get("NetworkSettings") or {}).get("Ports") or {} + for container_port, hosts in bindings.items(): + if not hosts: + continue + proto = container_port.split("/")[-1] if "/" in container_port else "tcp" + for h in hosts: + hp = h.get("HostPort") + if hp: + out[(int(hp), proto)] = c.name + except (DockerError, ValueError): + pass + return out + + +# --------------------------------------------------------------------------- # +# Detect conflicts +# --------------------------------------------------------------------------- # + + +def detect_conflicts(yaml_str: str, ignore_stack: Optional[str] = None) -> list[dict]: + wanted = parse_compose_ports(yaml_str) + host_ports = host_listening_ports() + docker_ports = docker_bound_ports() + + conflicts: list[dict] = [] + for w in wanted: + port = w["host_port"] + proto = w.get("protocol", "tcp") + used_by = None + owner = docker_ports.get((port, proto)) + if owner: + # A container from the same stack (re-deploy) is not a conflict. + if ignore_stack and owner.startswith(f"{ignore_stack}-"): + continue + used_by = f"container {owner}" + elif port in host_ports.get(proto, set()): + used_by = "host process" + if used_by: + conflicts.append( + { + "port": port, + "protocol": proto, + "service": w.get("service"), + "used_by": used_by, + } + ) + return conflicts diff --git a/backend/services/template_service.py b/backend/services/template_service.py new file mode 100644 index 0000000..6316b31 --- /dev/null +++ b/backend/services/template_service.py @@ -0,0 +1,167 @@ +"""Template library — bundled (on-disk) + custom (DB).""" +from __future__ import annotations + +import json +import os +import re +from functools import lru_cache +from typing import Optional + +from sqlmodel import Session, select + +from models.template import Template + +_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates") +_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}") + + +@lru_cache(maxsize=1) +def _manifest() -> list[dict]: + path = os.path.join(_TEMPLATES_DIR, "manifest.json") + try: + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + except (OSError, json.JSONDecodeError): + return [] + + +def _read_template_file(filename: str) -> str: + path = os.path.join(_TEMPLATES_DIR, filename) + try: + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + except OSError: + return "" + + +def extract_variables(yaml_str: str) -> list[str]: + seen: list[str] = [] + for m in _VAR_RE.finditer(yaml_str): + if m.group(1) not in seen: + seen.append(m.group(1)) + return seen + + +def render(yaml_str: str, values: dict[str, str]) -> str: + def repl(m: re.Match) -> str: + key = m.group(1) + return str(values.get(key, m.group(0))) + + return _VAR_RE.sub(repl, yaml_str) + + +# --------------------------------------------------------------------------- # +# Listing +# --------------------------------------------------------------------------- # + + +def list_templates(session: Session) -> list[dict]: + out: list[dict] = [] + for entry in _manifest(): + out.append( + { + "id": entry["id"], + "name": entry["name"], + "description": entry.get("description"), + "tags": entry.get("tags", []), + "gpu": entry.get("gpu"), + "source": "bundled", + } + ) + for tpl in session.exec(select(Template)).all(): + out.append( + { + "id": f"custom:{tpl.slug}", + "name": tpl.name, + "description": tpl.description, + "tags": [t for t in tpl.tags.split(",") if t], + "gpu": None, + "source": "custom", + } + ) + return out + + +def get_template(session: Session, template_id: str) -> Optional[dict]: + if template_id.startswith("custom:"): + slug = template_id.split(":", 1)[1] + tpl = session.exec(select(Template).where(Template.slug == slug)).first() + if not tpl: + return None + variables = [ + {"name": v, "description": "", "default": ""} + for v in extract_variables(tpl.yaml) + ] + return { + "id": template_id, + "name": tpl.name, + "description": tpl.description, + "tags": [t for t in tpl.tags.split(",") if t], + "gpu": None, + "source": "custom", + "yaml": tpl.yaml, + "variables": variables, + } + + for entry in _manifest(): + if entry["id"] == template_id: + yaml_str = _read_template_file(entry["file"]) + declared = {v["name"]: v for v in entry.get("variables", [])} + # Merge declared metadata with any vars actually present. + variables = [] + for name in extract_variables(yaml_str): + meta = declared.get(name, {}) + variables.append( + { + "name": name, + "description": meta.get("description", ""), + "default": meta.get("default", ""), + } + ) + return { + "id": entry["id"], + "name": entry["name"], + "description": entry.get("description"), + "tags": entry.get("tags", []), + "gpu": entry.get("gpu"), + "source": "bundled", + "yaml": yaml_str, + "variables": variables, + } + return None + + +def save_custom( + session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None +) -> Template: + slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template" + existing = session.exec(select(Template).where(Template.slug == slug)).first() + if existing: + existing.name = name + existing.description = description + existing.tags = ",".join(tags or []) + existing.yaml = yaml_str + session.add(existing) + session.commit() + session.refresh(existing) + return existing + tpl = Template( + slug=slug, + name=name, + description=description, + tags=",".join(tags or []), + yaml=yaml_str, + ) + session.add(tpl) + session.commit() + session.refresh(tpl) + return tpl + + +def delete_custom(session: Session, slug: str) -> bool: + tpl = session.exec(select(Template).where(Template.slug == slug)).first() + if not tpl: + return False + session.delete(tpl) + session.commit() + return True diff --git a/backend/services/update_service.py b/backend/services/update_service.py new file mode 100644 index 0000000..a3987b7 --- /dev/null +++ b/backend/services/update_service.py @@ -0,0 +1,204 @@ +"""Image update checker. + +Compares the locally-pulled manifest digest (from RepoDigests) against the +current manifest digest in the registry. Supports Docker Hub, ghcr.io, lscr.io +and other token-auth v2 registries. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import asdict, dataclass +from typing import Optional + +import httpx + +from config import settings +from docker_client import DockerError, get_client, safe_call + +logger = logging.getLogger("stackpilot.update") + +_MANIFEST_ACCEPT = ", ".join( + [ + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.oci.image.index.v1+json", + ] +) + + +@dataclass +class UpdateStatus: + image: str + update_available: bool + current_digest: Optional[str] + remote_digest: Optional[str] + checked_at: float + error: Optional[str] = None + + def to_dict(self) -> dict: + return asdict(self) + + +# image ref -> UpdateStatus +_CACHE: dict[str, UpdateStatus] = {} + + +# --------------------------------------------------------------------------- # +# Image reference parsing +# --------------------------------------------------------------------------- # + + +def parse_ref(image: str) -> tuple[str, str, str]: + """Return (registry_host, repository, tag/digest).""" + ref = image + tag = "latest" + # split tag (but not the registry port colon) + if "@" in ref: + ref, tag = ref.split("@", 1) + else: + # find last colon after last slash + slash = ref.rfind("/") + colon = ref.rfind(":") + if colon > slash: + tag = ref[colon + 1 :] + ref = ref[:colon] + + parts = ref.split("/", 1) + if len(parts) == 2 and ("." in parts[0] or ":" in parts[0] or parts[0] == "localhost"): + registry = parts[0] + repo = parts[1] + else: + registry = "registry-1.docker.io" + repo = ref + if "/" not in repo: + repo = "library/" + repo + return registry, repo, tag + + +def _local_digest(image: str) -> Optional[str]: + try: + client = get_client() + img = safe_call(client.images.get, image) + except DockerError: + return None + repo_digests = img.attrs.get("RepoDigests") or [] + for rd in repo_digests: + if "@" in rd: + return rd.split("@", 1)[1] + return None + + +# --------------------------------------------------------------------------- # +# Registry manifest digest +# --------------------------------------------------------------------------- # + + +async def _get_token(client: httpx.AsyncClient, www_auth: str) -> Optional[str]: + # Parse: Bearer realm="...",service="...",scope="..." + params = {} + if not www_auth.lower().startswith("bearer"): + return None + for part in www_auth[len("Bearer ") :].split(","): + if "=" in part: + k, v = part.split("=", 1) + params[k.strip()] = v.strip().strip('"') + realm = params.pop("realm", None) + if not realm: + return None + try: + resp = await client.get(realm, params=params, timeout=10) + resp.raise_for_status() + data = resp.json() + return data.get("token") or data.get("access_token") + except (httpx.HTTPError, ValueError): + return None + + +async def remote_digest(image: str) -> Optional[str]: + registry, repo, tag = parse_ref(image) + if tag.startswith("sha256:"): + return tag + scheme = "https" + url = f"{scheme}://{registry}/v2/{repo}/manifests/{tag}" + headers = {"Accept": _MANIFEST_ACCEPT} + async with httpx.AsyncClient(follow_redirects=True) as client: + try: + resp = await client.head(url, headers=headers, timeout=10) + if resp.status_code == 401: + token = await _get_token(client, resp.headers.get("WWW-Authenticate", "")) + if not token: + return None + headers["Authorization"] = f"Bearer {token}" + resp = await client.head(url, headers=headers, timeout=10) + if resp.status_code == 405 or "Docker-Content-Digest" not in resp.headers: + # Some registries don't support HEAD; fall back to GET. + resp = await client.get(url, headers=headers, timeout=10) + digest = resp.headers.get("Docker-Content-Digest") + return digest + except httpx.HTTPError as exc: + logger.debug("remote_digest failed for %s: %s", image, exc) + return None + + +# --------------------------------------------------------------------------- # +# Public API +# --------------------------------------------------------------------------- # + + +async def check_image(image: str) -> UpdateStatus: + local = _local_digest(image) + remote = await remote_digest(image) + error = None + if remote is None: + error = "could not reach registry" + update_available = bool(local and remote and local != remote) + status = UpdateStatus( + image=image, + update_available=update_available, + current_digest=local, + remote_digest=remote, + checked_at=time.time(), + error=error, + ) + _CACHE[image] = status + return status + + +def _all_running_images() -> set[str]: + images: set[str] = set() + try: + client = get_client() + for c in safe_call(client.containers.list, all=True): + cfg_image = c.attrs.get("Config", {}).get("Image") + if cfg_image: + images.add(cfg_image) + except DockerError: + pass + return images + + +async def check_all() -> dict[str, dict]: + images = _all_running_images() + for image in images: + await check_image(image) + return {k: v.to_dict() for k, v in _CACHE.items()} + + +def get_cache() -> dict[str, dict]: + return {k: v.to_dict() for k, v in _CACHE.items()} + + +async def background_loop(): + interval = max(settings.UPDATE_CHECK_INTERVAL_MINUTES, 5) * 60 + # initial delay so startup isn't blocked + await asyncio.sleep(30) + while True: + try: + await check_all() + logger.info("Image update check complete (%d images)", len(_CACHE)) + except Exception as exc: # noqa: BLE001 + logger.warning("Image update check failed: %s", exc) + await asyncio.sleep(interval) diff --git a/backend/templates/gitea.yaml b/backend/templates/gitea.yaml new file mode 100644 index 0000000..6a270b4 --- /dev/null +++ b/backend/templates/gitea.yaml @@ -0,0 +1,15 @@ +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: unless-stopped + environment: + - USER_UID={{PUID}} + - USER_GID={{PGID}} + ports: + - "{{HTTP_PORT}}:3000" + - "{{SSH_PORT}}:22" + volumes: + - {{DATA_PATH}}:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro diff --git a/backend/templates/jellyfin.yaml b/backend/templates/jellyfin.yaml new file mode 100644 index 0000000..c144949 --- /dev/null +++ b/backend/templates/jellyfin.yaml @@ -0,0 +1,14 @@ +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + environment: + - PUID={{PUID}} + - PGID={{PGID}} + - TZ={{TZ}} + ports: + - "{{HTTP_PORT}}:8096" + volumes: + - {{CONFIG_PATH}}:/config + - {{MEDIA_PATH}}:/media diff --git a/backend/templates/manifest.json b/backend/templates/manifest.json new file mode 100644 index 0000000..eb5c367 --- /dev/null +++ b/backend/templates/manifest.json @@ -0,0 +1,74 @@ +[ + { + "id": "jellyfin", + "name": "Jellyfin", + "description": "Free media streaming server with optional hardware transcoding.", + "tags": ["media", "streaming"], + "gpu": "NVIDIA / Intel optional", + "file": "jellyfin.yaml", + "variables": [ + { "name": "PUID", "description": "User ID", "default": "1000" }, + { "name": "PGID", "description": "Group ID", "default": "1000" }, + { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, + { "name": "CONFIG_PATH", "description": "Host path for config", "default": "/srv/jellyfin/config" }, + { "name": "MEDIA_PATH", "description": "Host path to media library", "default": "/srv/media" }, + { "name": "HTTP_PORT", "description": "Web UI port", "default": "8096" } + ] + }, + { + "id": "vaultwarden", + "name": "Vaultwarden", + "description": "Lightweight Bitwarden-compatible password manager.", + "tags": ["password-manager", "security"], + "gpu": null, + "file": "vaultwarden.yaml", + "variables": [ + { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, + { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/vaultwarden" }, + { "name": "HTTP_PORT", "description": "Web UI port", "default": "8200" }, + { "name": "ADMIN_TOKEN", "description": "Admin panel token", "default": "change-me" } + ] + }, + { + "id": "uptime-kuma", + "name": "Uptime Kuma", + "description": "Self-hosted uptime / status monitoring tool.", + "tags": ["monitoring"], + "gpu": null, + "file": "uptime-kuma.yaml", + "variables": [ + { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/uptime-kuma" }, + { "name": "HTTP_PORT", "description": "Web UI port", "default": "3001" } + ] + }, + { + "id": "paperless-ngx", + "name": "Paperless-NGX", + "description": "Document management system that indexes scanned documents.", + "tags": ["documents"], + "gpu": null, + "file": "paperless-ngx.yaml", + "variables": [ + { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, + { "name": "DATA_PATH", "description": "Host path base", "default": "/srv/paperless" }, + { "name": "HTTP_PORT", "description": "Web UI port", "default": "8000" }, + { "name": "ADMIN_USER", "description": "Admin username", "default": "admin" }, + { "name": "ADMIN_PASSWORD", "description": "Admin password", "default": "change-me" } + ] + }, + { + "id": "gitea", + "name": "Gitea", + "description": "Lightweight self-hosted Git service.", + "tags": ["git", "dev"], + "gpu": null, + "file": "gitea.yaml", + "variables": [ + { "name": "PUID", "description": "User ID", "default": "1000" }, + { "name": "PGID", "description": "Group ID", "default": "1000" }, + { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/gitea" }, + { "name": "HTTP_PORT", "description": "Web UI port", "default": "3000" }, + { "name": "SSH_PORT", "description": "SSH port", "default": "2222" } + ] + } +] diff --git a/backend/templates/paperless-ngx.yaml b/backend/templates/paperless-ngx.yaml new file mode 100644 index 0000000..fb60ae2 --- /dev/null +++ b/backend/templates/paperless-ngx.yaml @@ -0,0 +1,38 @@ +services: + broker: + image: redis:7-alpine + container_name: paperless-redis + restart: unless-stopped + volumes: + - {{DATA_PATH}}/redis:/data + + db: + image: postgres:16-alpine + container_name: paperless-db + restart: unless-stopped + environment: + - POSTGRES_DB=paperless + - POSTGRES_USER=paperless + - POSTGRES_PASSWORD=paperless + volumes: + - {{DATA_PATH}}/db:/var/lib/postgresql/data + + webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + container_name: paperless + restart: unless-stopped + depends_on: + - db + - broker + environment: + - PAPERLESS_REDIS=redis://broker:6379 + - PAPERLESS_DBHOST=db + - PAPERLESS_TIME_ZONE={{TZ}} + - PAPERLESS_ADMIN_USER={{ADMIN_USER}} + - PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}} + ports: + - "{{HTTP_PORT}}:8000" + volumes: + - {{DATA_PATH}}/data:/usr/src/paperless/data + - {{DATA_PATH}}/media:/usr/src/paperless/media + - {{DATA_PATH}}/consume:/usr/src/paperless/consume diff --git a/backend/templates/uptime-kuma.yaml b/backend/templates/uptime-kuma.yaml new file mode 100644 index 0000000..0cbd726 --- /dev/null +++ b/backend/templates/uptime-kuma.yaml @@ -0,0 +1,9 @@ +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + restart: unless-stopped + ports: + - "{{HTTP_PORT}}:3001" + volumes: + - {{DATA_PATH}}:/app/data diff --git a/backend/templates/vaultwarden.yaml b/backend/templates/vaultwarden.yaml new file mode 100644 index 0000000..0a55e56 --- /dev/null +++ b/backend/templates/vaultwarden.yaml @@ -0,0 +1,13 @@ +services: + vaultwarden: + image: vaultwarden/server:latest + container_name: vaultwarden + restart: unless-stopped + environment: + - TZ={{TZ}} + - ADMIN_TOKEN={{ADMIN_TOKEN}} + - WEBSOCKET_ENABLED=true + ports: + - "{{HTTP_PORT}}:80" + volumes: + - {{DATA_PATH}}:/data diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b58d85d..8acf5f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,9 @@ import { Dashboard } from "@/pages/Dashboard"; import { Stacks } from "@/pages/Stacks"; import { StackDetail } from "@/pages/StackDetail"; import { StackEditor } from "@/pages/StackEditor"; -import { Networks, Images, Templates, Settings } from "@/pages/Placeholder"; +import { Images } from "@/pages/Images"; +import { Templates } from "@/pages/Templates"; +import { Networks, Settings } from "@/pages/Placeholder"; import { useAuthStore } from "@/store/auth"; import { useThemeStore } from "@/store/theme"; diff --git a/frontend/src/api/editor.ts b/frontend/src/api/editor.ts index 846dc62..d052602 100644 --- a/frontend/src/api/editor.ts +++ b/frontend/src/api/editor.ts @@ -30,4 +30,17 @@ export const editorApi = { value, }) .then((r) => r.data.yaml), + setResources: ( + yaml: string, + service: string, + res: { + cpus?: number | null; + memory?: string | null; + cpus_reserve?: number | null; + memory_reserve?: string | null; + } + ) => + api + .post<{ yaml: string }>("/api/editor/set-resources", { yaml, service, ...res }) + .then((r) => r.data.yaml), }; diff --git a/frontend/src/api/images.ts b/frontend/src/api/images.ts new file mode 100644 index 0000000..058f460 --- /dev/null +++ b/frontend/src/api/images.ts @@ -0,0 +1,27 @@ +import api from "./client"; + +export interface UpdateStatus { + image: string; + update_available: boolean; + current_digest: string | null; + remote_digest: string | null; + checked_at: number; + error: string | null; +} + +export interface ImageRow { + id: string; + tag: string; + size: number; + created: string; + stacks: string[]; + update: UpdateStatus | null; +} + +export const imagesApi = { + list: () => api.get("/api/images").then((r) => r.data), + updates: () => + api.get>("/api/images/updates").then((r) => r.data), + check: () => + api.post>("/api/images/check").then((r) => r.data), +}; diff --git a/frontend/src/api/ports.ts b/frontend/src/api/ports.ts new file mode 100644 index 0000000..abb6c50 --- /dev/null +++ b/frontend/src/api/ports.ts @@ -0,0 +1,18 @@ +import api from "./client"; + +export interface PortConflict { + port: number; + protocol: string; + service: string | null; + used_by: string; +} + +export const portsApi = { + conflicts: (yaml: string, ignore_stack?: string) => + api + .post<{ conflicts: PortConflict[] }>("/api/ports/conflicts", { + yaml, + ignore_stack, + }) + .then((r) => r.data.conflicts), +}; diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts new file mode 100644 index 0000000..409bd9a --- /dev/null +++ b/frontend/src/api/templates.ts @@ -0,0 +1,38 @@ +import api from "./client"; + +export interface TemplateVariable { + name: string; + description: string; + default: string; +} + +export interface TemplateSummary { + id: string; + name: string; + description?: string | null; + tags: string[]; + gpu?: string | null; + source: "bundled" | "custom"; +} + +export interface TemplateDetail extends TemplateSummary { + yaml: string; + variables: TemplateVariable[]; +} + +export const templatesApi = { + list: () => api.get("/api/templates").then((r) => r.data), + get: (id: string) => + api.get(`/api/templates/${id}`).then((r) => r.data), + instantiate: (id: string, name: string, values: Record) => + api + .post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, { + name, + values, + }) + .then((r) => r.data), + save: (body: { name: string; description?: string; tags: string[]; yaml: string }) => + api.post("/api/templates", body).then((r) => r.data), + remove: (slug: string) => + api.delete(`/api/templates/custom/${slug}`).then((r) => r.data), +}; diff --git a/frontend/src/components/env/EnvEditor.tsx b/frontend/src/components/env/EnvEditor.tsx new file mode 100644 index 0000000..d63a8f9 --- /dev/null +++ b/frontend/src/components/env/EnvEditor.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState } from "react"; +import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react"; +import { Button, Input } from "@/components/ui"; + +interface Row { + key: string; + value: string; +} + +const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i; + +function parseEnv(text: string): Row[] { + return text + .split("\n") + .filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("=")) + .map((l) => { + const idx = l.indexOf("="); + return { key: l.slice(0, idx).trim(), value: l.slice(idx + 1) }; + }); +} + +function serialize(rows: Row[]): string { + return rows + .filter((r) => r.key.trim()) + .map((r) => `${r.key.trim()}=${r.value}`) + .join("\n") + .concat(rows.length ? "\n" : ""); +} + +const QUICK = [ + { key: "PUID", value: "1000" }, + { key: "PGID", value: "1000" }, + { key: "TZ", value: "Europe/Berlin" }, +]; + +export function EnvEditor({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const [mode, setMode] = useState<"table" | "raw">("table"); + const [reveal, setReveal] = useState>({}); + const rows = useMemo(() => parseEnv(value), [value]); + + const update = (next: Row[]) => onChange(serialize(next)); + + const setRow = (i: number, patch: Partial) => + update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + const addRow = (row: Row = { key: "", value: "" }) => update([...rows, row]); + const delRow = (i: number) => update(rows.filter((_, idx) => idx !== i)); + + return ( +
+
+