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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b553c1b861
commit
22d9864436
+20
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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] = {}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user