From 7bd449101d7ba425d5a21b4fec555d4c0957e609 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sun, 7 Jun 2026 21:50:03 +0000 Subject: [PATCH] =?UTF-8?q?Phase=206:=20remote=20backup=20destinations=20?= =?UTF-8?q?=E2=80=94=20SFTP=20&=20S3=20(0.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackupDestination model + backup_destination_service (SFTP via paramiko, S3-compatible via boto3): upload/list/download/delete/test. - routers/destinations.py: destinations CRUD (secrets masked, merge-on-update), test, list/delete remote backups. backups.py: POST /{id}/backup/push and POST /restore-from (download from a destination + restore, volumes included). - Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog can push to a destination; Restore dialog can pick a destination + backup. - deps: paramiko 3.5.0, boto3 1.35.99. Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations, push (incl. volumes), list, restore-from to a fresh stack (volume data intact), delete remote backup. Co-Authored-By: Claude Opus 4.8 --- README.md | 25 +- backend/main.py | 4 +- backend/models/__init__.py | 6 +- backend/models/backup_destination.py | 48 ++++ backend/requirements.txt | 2 + backend/routers/backups.py | 122 ++++++++- backend/routers/destinations.py | 171 ++++++++++++ .../services/backup_destination_service.py | 258 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/api/backups.ts | 60 ++++ .../src/components/stacks/BackupRestore.tsx | 182 +++++++++--- frontend/src/pages/Settings.tsx | 161 +++++++++++ 12 files changed, 999 insertions(+), 42 deletions(-) create mode 100644 backend/models/backup_destination.py create mode 100644 backend/routers/destinations.py create mode 100644 backend/services/backup_destination_service.py diff --git a/README.md b/README.md index 97b8d0d..3fe5d31 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,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) + Phase 3 (Quality of -> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup -> destinations (SFTP/S3) are planned for a later phase. +> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup +> destinations) complete. ## What works today (Phase 1) @@ -81,8 +81,15 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. with full lifecycle (start/stop/restart/pull/update/down), live logs, and compose/.env editing — all proxied to the agent. -> **Not yet:** remote backup destinations (SFTP/S3) — backups currently download -> to / upload from the browser. +### Phase 6 — Backup destinations + +- **Off-box backups**: define **SFTP** or **S3-compatible** (MinIO, Backblaze B2, + AWS S3, …) destinations under **Settings → Backup destinations** (with a Test + button; secrets are masked in API responses). +- **Push & restore**: the stack Backup dialog can push straight to a destination + instead of downloading; the Restore dialog can browse a destination's backups + and restore (volumes included) directly from it. Remote backups can also be + deleted from the UI. ## Deploying an agent on another host @@ -210,6 +217,16 @@ GET /agent/ping | /system | /stacks | /stacks/{id} | /stacks/{id}/logs POST /agent/stacks | /stacks/{id}/{action} PUT/DELETE /agent/stacks/{id} ``` +### Phase 6 endpoints + +``` +GET /api/backups/destinations POST /api/backups/destinations +PUT /api/backups/destinations/{id} DELETE /api/backups/destinations/{id} +POST /api/backups/destinations/{id}/test GET /api/backups/destinations/{id}/backups +DELETE /api/backups/destinations/{id}/backups/{name} +POST /api/stacks/{id}/backup/push POST /api/stacks/restore-from +``` + ## 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 3f05b73..43eeaf1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from routers import ( audit, auth, backups, + destinations, editor, images, ports, @@ -49,7 +50,7 @@ async def lifespan(app: FastAPI): update_task.cancel() -app = FastAPI(title="StackPilot", version="0.5.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.6.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -79,6 +80,7 @@ app.include_router(templates.router) app.include_router(audit.router) app.include_router(settings_router.router) app.include_router(backups.router) +app.include_router(destinations.router) app.include_router(agents.router) app.include_router(ws.router) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 946315b..f9ce5c9 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,9 +1,13 @@ """SQLModel table models. Importing this package registers all tables.""" from models.agent import Agent from models.audit import AuditLog +from models.backup_destination import BackupDestination from models.setting import Setting, Webhook from models.stack import Stack from models.template import Template from models.user import User -__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"] +__all__ = [ + "User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent", + "BackupDestination", +] diff --git a/backend/models/backup_destination.py b/backend/models/backup_destination.py new file mode 100644 index 0000000..1a965ea --- /dev/null +++ b/backend/models/backup_destination.py @@ -0,0 +1,48 @@ +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) + + +DESTINATION_TYPES = ["sftp", "s3"] + +# config keys that hold secrets — masked in API responses. +SECRET_KEYS = {"password", "private_key", "secret_key"} + + +class BackupDestination(SQLModel, table=True): + """A remote target for stack backups (SFTP or S3-compatible).""" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str + type: str # one of DESTINATION_TYPES + config: str = "{}" # JSON-encoded, type-specific (incl. secrets) + created_at: datetime = Field(default_factory=_now) + + +# --- API schemas --- + + +class DestinationCreate(SQLModel): + name: str + type: str + config: dict + + +class DestinationUpdate(SQLModel): + name: Optional[str] = None + config: Optional[dict] = None + + +class DestinationRead(SQLModel): + id: int + name: str + type: str + config: dict # secrets masked + created_at: datetime diff --git a/backend/requirements.txt b/backend/requirements.txt index be3af9d..0097896 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,5 @@ python-multipart==0.0.20 watchdog==6.0.0 httpx==0.28.1 PyYAML==6.0.2 +paramiko==3.5.0 +boto3==1.35.99 diff --git a/backend/routers/backups.py b/backend/routers/backups.py index 8095862..ace14a0 100644 --- a/backend/routers/backups.py +++ b/backend/routers/backups.py @@ -1,18 +1,26 @@ """Stack backup (incl. volumes) and restore.""" from __future__ import annotations +import asyncio import os import tempfile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import FileResponse +from pydantic import BaseModel from sqlmodel import Session from auth import require_admin from database import get_session +from models.backup_destination import BackupDestination from models.stack import Stack from models.user import User -from services import audit_service, backup_service, compose_service +from services import ( + audit_service, + backup_destination_service as dest_service, + backup_service, + compose_service, +) router = APIRouter(prefix="/api/stacks", tags=["backups"]) @@ -21,6 +29,12 @@ def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" +def _backup_filename(stack_id: str, include_volumes: bool) -> str: + date = compose_service.now().strftime("%Y%m%d-%H%M%S") + suffix = "full" if include_volumes else "config" + return f"backup-{stack_id}-{suffix}-{date}.tar.gz" + + @router.get("/{stack_id}/backup") async def backup_stack( stack_id: str, @@ -43,12 +57,10 @@ async def backup_stack( session, user=user.username, action="stack.backup", target=stack_id, detail=f"volumes={include_volumes}", ip=_ip(request), ) - date = compose_service.now().strftime("%Y%m%d-%H%M%S") - suffix = "full" if include_volumes else "config" return FileResponse( path, media_type="application/gzip", - filename=f"backup-{stack_id}-{suffix}-{date}.tar.gz", + filename=_backup_filename(stack_id, include_volumes), ) @@ -94,3 +106,105 @@ async def restore_stack( finally: if os.path.exists(tmp.name): os.unlink(tmp.name) + + +# --------------------------------------------------------------------------- # +# Push to / restore from a remote destination +# --------------------------------------------------------------------------- # + + +class PushBody(BaseModel): + destination_id: int + include_volumes: bool = True + stop_first: bool = True + + +class RestoreFromBody(BaseModel): + destination_id: int + name: str + target_id: str | None = None + overwrite: bool = False + restore_volumes: bool = True + + +def _get_dest(session: Session, dest_id: int) -> BackupDestination: + d = session.get(BackupDestination, dest_id) + if not d: + raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found") + return d + + +@router.post("/{stack_id}/backup/push") +async def push_backup( + stack_id: str, + body: PushBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + stack = session.get(Stack, stack_id) + if not stack: + raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found") + dest = _get_dest(session, body.destination_id) + try: + path = await backup_service.create_backup( + stack_id, stack.name, + include_volumes=body.include_volumes, stop_first=body.stop_first, + ) + except backup_service.BackupError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + filename = _backup_filename(stack_id, body.include_volumes) + try: + remote = await asyncio.to_thread(dest_service.upload, dest, path, filename) + except dest_service.DestinationError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + finally: + if os.path.exists(path): + os.unlink(path) + + audit_service.record( + session, user=user.username, action="stack.backup.push", + target=stack_id, detail=f"{dest.name}:{filename}", ip=_ip(request), + ) + return {"ok": True, "destination": dest.name, "name": filename, "remote": remote} + + +@router.post("/restore-from") +async def restore_from_destination( + body: RestoreFromBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + dest = _get_dest(session, body.destination_id) + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") + tmp.close() + try: + try: + await asyncio.to_thread(dest_service.download, dest, body.name, tmp.name) + except dest_service.DestinationError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + target = compose_service.slugify(body.target_id) if body.target_id else None + try: + result = backup_service.restore_backup( + tmp.name, target_id=target, + overwrite=body.overwrite, restore_volumes=body.restore_volumes, + ) + except backup_service.BackupError as exc: + code = 409 if "already exists" in str(exc) else 400 + raise HTTPException(status_code=code, detail=str(exc)) from exc + + stack_id = result["stack_id"] + if not session.get(Stack, stack_id): + session.add(Stack(id=stack_id, name=result.get("name", stack_id))) + session.commit() + audit_service.record( + session, user=user.username, action="stack.restore", + target=stack_id, detail=f"from {dest.name}:{body.name}", ip=_ip(request), + ) + return result + finally: + if os.path.exists(tmp.name): + os.unlink(tmp.name) diff --git a/backend/routers/destinations.py b/backend/routers/destinations.py new file mode 100644 index 0000000..20368ac --- /dev/null +++ b/backend/routers/destinations.py @@ -0,0 +1,171 @@ +"""Backup destination management (SFTP / S3-compatible).""" +from __future__ import annotations + +import asyncio +import json + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session, select + +from auth import get_current_user, require_admin +from database import get_session +from models.backup_destination import ( + DESTINATION_TYPES, + SECRET_KEYS, + BackupDestination, + DestinationCreate, + DestinationRead, + DestinationUpdate, +) +from models.user import User +from services import audit_service, backup_destination_service as dest_service + +router = APIRouter(prefix="/api/backups/destinations", tags=["backups"]) + + +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _mask(config: dict) -> dict: + return {k: ("••••••" if k in SECRET_KEYS and v else v) for k, v in config.items()} + + +def _to_read(d: BackupDestination) -> DestinationRead: + return DestinationRead( + id=d.id, + name=d.name, + type=d.type, + config=_mask(dest_service.parse_config(d)), + created_at=d.created_at, + ) + + +def _get_or_404(session: Session, dest_id: int) -> BackupDestination: + d = session.get(BackupDestination, dest_id) + if not d: + raise HTTPException(status_code=404, detail=f"Destination {dest_id} not found") + return d + + +@router.get("", response_model=list[DestinationRead]) +def list_destinations( + session: Session = Depends(get_session), + _user: User = Depends(require_admin), +) -> list[DestinationRead]: + rows = session.exec(select(BackupDestination).order_by(BackupDestination.id)).all() + return [_to_read(d) for d in rows] + + +@router.post("", response_model=DestinationRead, status_code=201) +def create_destination( + body: DestinationCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> DestinationRead: + if body.type not in DESTINATION_TYPES: + raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'") + d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config)) + session.add(d) + session.commit() + session.refresh(d) + audit_service.record( + session, user=user.username, action="destination.create", target=body.name, + detail=body.type, ip=_ip(request), + ) + return _to_read(d) + + +@router.put("/{dest_id}", response_model=DestinationRead) +def update_destination( + dest_id: int, + body: DestinationUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> DestinationRead: + d = _get_or_404(session, dest_id) + if body.name is not None: + d.name = body.name + if body.config is not None: + # Merge so masked/blank secrets don't wipe stored ones. + existing = dest_service.parse_config(d) + for k, v in body.config.items(): + if k in SECRET_KEYS and (v == "" or v == "••••••"): + continue # keep existing secret + existing[k] = v + d.config = json.dumps(existing) + session.add(d) + session.commit() + session.refresh(d) + audit_service.record( + session, user=user.username, action="destination.update", target=d.name, + ip=_ip(request), + ) + return _to_read(d) + + +@router.delete("/{dest_id}") +def delete_destination( + dest_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + d = _get_or_404(session, dest_id) + name = d.name + session.delete(d) + session.commit() + audit_service.record( + session, user=user.username, action="destination.delete", target=name, + ip=_ip(request), + ) + return {"ok": True} + + +@router.post("/{dest_id}/test") +async def test_destination( + dest_id: int, + session: Session = Depends(get_session), + _user: User = Depends(require_admin), +) -> dict: + d = _get_or_404(session, dest_id) + try: + await asyncio.to_thread(dest_service.test, d) + return {"ok": True} + except dest_service.DestinationError as exc: + return {"ok": False, "error": str(exc)} + + +@router.get("/{dest_id}/backups") +async def list_destination_backups( + dest_id: int, + session: Session = Depends(get_session), + _user: User = Depends(require_admin), +) -> list[dict]: + d = _get_or_404(session, dest_id) + try: + return await asyncio.to_thread(dest_service.list_backups, d) + except dest_service.DestinationError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +@router.delete("/{dest_id}/backups/{name}") +async def delete_destination_backup( + dest_id: int, + name: str, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + d = _get_or_404(session, dest_id) + try: + await asyncio.to_thread(dest_service.delete, d, name) + except dest_service.DestinationError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + audit_service.record( + session, user=user.username, action="destination.backup.delete", + target=f"{d.name}/{name}", ip=_ip(request), + ) + return {"ok": True} diff --git a/backend/services/backup_destination_service.py b/backend/services/backup_destination_service.py new file mode 100644 index 0000000..16efbc4 --- /dev/null +++ b/backend/services/backup_destination_service.py @@ -0,0 +1,258 @@ +"""Push/pull stack backups to remote destinations (SFTP or S3-compatible). + +All operations are synchronous (paramiko / boto3); async callers should wrap +them with ``asyncio.to_thread``. Destination config is a plain dict parsed from +the ``BackupDestination.config`` JSON column. +""" +from __future__ import annotations + +import io +import json +import logging +import os +import posixpath +import stat +from typing import Any + +from models.backup_destination import BackupDestination + +logger = logging.getLogger("stackpilot.backup_dest") + + +class DestinationError(Exception): + pass + + +def parse_config(dest: BackupDestination) -> dict: + try: + return json.loads(dest.config or "{}") + except json.JSONDecodeError: + return {} + + +# --------------------------------------------------------------------------- # +# SFTP (paramiko) +# --------------------------------------------------------------------------- # + + +def _sftp_connect(cfg: dict): + import paramiko + + host = cfg.get("host") + if not host: + raise DestinationError("SFTP host is required") + port = int(cfg.get("port") or 22) + username = cfg.get("username") + transport = paramiko.Transport((host, port)) + try: + pkey = None + if cfg.get("private_key"): + pkey = _load_key(cfg["private_key"]) + transport.connect(username=username, password=cfg.get("password") or None, pkey=pkey) + except Exception as exc: # noqa: BLE001 + transport.close() + raise DestinationError(f"SFTP connection failed: {exc}") from exc + return paramiko.SFTPClient.from_transport(transport), transport + + +def _load_key(key_str: str): + import paramiko + + for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey): + try: + return cls.from_private_key(io.StringIO(key_str)) + except Exception: # noqa: BLE001 + continue + raise DestinationError("Could not parse SFTP private key") + + +def _sftp_makedirs(sftp, path: str) -> None: + if not path or path in (".", "/"): + return + parts = path.strip("/").split("/") + cur = "/" if path.startswith("/") else "" + for p in parts: + cur = posixpath.join(cur, p) if cur else p + try: + sftp.stat(cur) + except IOError: + sftp.mkdir(cur) + + +def _sftp_upload(cfg: dict, local_path: str, filename: str) -> str: + sftp, transport = _sftp_connect(cfg) + try: + base = cfg.get("path") or "." + if base not in (".", ""): + _sftp_makedirs(sftp, base) + remote = posixpath.join(base, filename) if base not in (".", "") else filename + sftp.put(local_path, remote) + return remote + finally: + sftp.close() + transport.close() + + +def _sftp_list(cfg: dict) -> list[dict]: + sftp, transport = _sftp_connect(cfg) + try: + base = cfg.get("path") or "." + out = [] + try: + entries = sftp.listdir_attr(base) + except IOError: + return [] + for e in entries: + if stat.S_ISDIR(e.st_mode): + continue + if not e.filename.endswith(".tar.gz"): + continue + out.append({"name": e.filename, "size": e.st_size, "modified": e.st_mtime}) + return sorted(out, key=lambda x: x["modified"] or 0, reverse=True) + finally: + sftp.close() + transport.close() + + +def _sftp_download(cfg: dict, name: str, local_path: str) -> None: + sftp, transport = _sftp_connect(cfg) + try: + base = cfg.get("path") or "." + remote = posixpath.join(base, name) if base not in (".", "") else name + sftp.get(remote, local_path) + finally: + sftp.close() + transport.close() + + +def _sftp_delete(cfg: dict, name: str) -> None: + sftp, transport = _sftp_connect(cfg) + try: + base = cfg.get("path") or "." + remote = posixpath.join(base, name) if base not in (".", "") else name + sftp.remove(remote) + finally: + sftp.close() + transport.close() + + +# --------------------------------------------------------------------------- # +# S3-compatible (boto3) +# --------------------------------------------------------------------------- # + + +def _s3_client(cfg: dict): + import boto3 + + bucket = cfg.get("bucket") + if not bucket: + raise DestinationError("S3 bucket is required") + return boto3.client( + "s3", + endpoint_url=cfg.get("endpoint_url") or None, + region_name=cfg.get("region") or None, + aws_access_key_id=cfg.get("access_key") or None, + aws_secret_access_key=cfg.get("secret_key") or None, + ) + + +def _s3_key(cfg: dict, filename: str) -> str: + prefix = (cfg.get("prefix") or "").strip("/") + return f"{prefix}/{filename}" if prefix else filename + + +def _s3_upload(cfg: dict, local_path: str, filename: str) -> str: + client = _s3_client(cfg) + key = _s3_key(cfg, filename) + try: + client.upload_file(local_path, cfg["bucket"], key) + except Exception as exc: # noqa: BLE001 + raise DestinationError(f"S3 upload failed: {exc}") from exc + return key + + +def _s3_list(cfg: dict) -> list[dict]: + client = _s3_client(cfg) + prefix = (cfg.get("prefix") or "").strip("/") + kwargs: dict[str, Any] = {"Bucket": cfg["bucket"]} + if prefix: + kwargs["Prefix"] = prefix + "/" + try: + resp = client.list_objects_v2(**kwargs) + except Exception as exc: # noqa: BLE001 + raise DestinationError(f"S3 list failed: {exc}") from exc + out = [] + for obj in resp.get("Contents", []): + name = obj["Key"].split("/")[-1] + if not name.endswith(".tar.gz"): + continue + out.append( + { + "name": name, + "size": obj.get("Size", 0), + "modified": obj["LastModified"].timestamp() if obj.get("LastModified") else None, + } + ) + return sorted(out, key=lambda x: x["modified"] or 0, reverse=True) + + +def _s3_download(cfg: dict, name: str, local_path: str) -> None: + client = _s3_client(cfg) + try: + client.download_file(cfg["bucket"], _s3_key(cfg, name), local_path) + except Exception as exc: # noqa: BLE001 + raise DestinationError(f"S3 download failed: {exc}") from exc + + +def _s3_delete(cfg: dict, name: str) -> None: + client = _s3_client(cfg) + client.delete_object(Bucket=cfg["bucket"], Key=_s3_key(cfg, name)) + + +# --------------------------------------------------------------------------- # +# Dispatch +# --------------------------------------------------------------------------- # + + +def upload(dest: BackupDestination, local_path: str, filename: str) -> str: + cfg = parse_config(dest) + if dest.type == "sftp": + return _sftp_upload(cfg, local_path, filename) + if dest.type == "s3": + return _s3_upload(cfg, local_path, filename) + raise DestinationError(f"Unknown destination type '{dest.type}'") + + +def list_backups(dest: BackupDestination) -> list[dict]: + cfg = parse_config(dest) + if dest.type == "sftp": + return _sftp_list(cfg) + if dest.type == "s3": + return _s3_list(cfg) + raise DestinationError(f"Unknown destination type '{dest.type}'") + + +def download(dest: BackupDestination, name: str, local_path: str) -> None: + cfg = parse_config(dest) + if dest.type == "sftp": + _sftp_download(cfg, name, local_path) + elif dest.type == "s3": + _s3_download(cfg, name, local_path) + else: + raise DestinationError(f"Unknown destination type '{dest.type}'") + + +def delete(dest: BackupDestination, name: str) -> None: + cfg = parse_config(dest) + if dest.type == "sftp": + _sftp_delete(cfg, name) + elif dest.type == "s3": + _s3_delete(cfg, name) + else: + raise DestinationError(f"Unknown destination type '{dest.type}'") + + +def test(dest: BackupDestination) -> bool: + """Connectivity check — lists the target (cheap, validates auth + path).""" + list_backups(dest) + return True diff --git a/frontend/package.json b/frontend/package.json index faf234f..e491e31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts index 103caa0..e65e440 100644 --- a/frontend/src/api/backups.ts +++ b/frontend/src/api/backups.ts @@ -1,5 +1,19 @@ import api from "./client"; +export interface BackupDestination { + id: number; + name: string; + type: "sftp" | "s3"; + config: Record; + created_at: string; +} + +export interface RemoteBackup { + name: string; + size: number; + modified: number | null; +} + function triggerDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -42,4 +56,50 @@ export const backupsApi = { }>("/api/stacks/restore", form); return res.data; }, + + push: ( + stackId: string, + body: { destination_id: number; include_volumes: boolean; stop_first: boolean } + ) => + api + .post<{ ok: boolean; destination: string; name: string }>( + `/api/stacks/${stackId}/backup/push`, + body + ) + .then((r) => r.data), + + restoreFrom: (body: { + destination_id: number; + name: string; + target_id?: string; + overwrite: boolean; + restore_volumes: boolean; + }) => + api + .post<{ stack_id: string; name: string; volumes_restored: number }>( + "/api/stacks/restore-from", + body + ) + .then((r) => r.data), +}; + +export const destinationsApi = { + list: () => + api.get("/api/backups/destinations").then((r) => r.data), + create: (body: { name: string; type: string; config: Record }) => + api.post("/api/backups/destinations", body).then((r) => r.data), + update: (id: number, body: { name?: string; config?: Record }) => + api.put(`/api/backups/destinations/${id}`, body).then((r) => r.data), + remove: (id: number) => + api.delete(`/api/backups/destinations/${id}`).then((r) => r.data), + test: (id: number) => + api + .post<{ ok: boolean; error?: string }>(`/api/backups/destinations/${id}/test`) + .then((r) => r.data), + backups: (id: number) => + api.get(`/api/backups/destinations/${id}/backups`).then((r) => r.data), + deleteBackup: (id: number, name: string) => + api + .delete(`/api/backups/destinations/${id}/backups/${encodeURIComponent(name)}`) + .then((r) => r.data), }; diff --git a/frontend/src/components/stacks/BackupRestore.tsx b/frontend/src/components/stacks/BackupRestore.tsx index 1b22334..e8c8dda 100644 --- a/frontend/src/components/stacks/BackupRestore.tsx +++ b/frontend/src/components/stacks/BackupRestore.tsx @@ -1,10 +1,14 @@ import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Archive, Upload } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui"; -import { backupsApi } from "@/api/backups"; +import { backupsApi, destinationsApi } from "@/api/backups"; import { apiErrorMessage } from "@/api/client"; +import { formatBytes } from "@/lib/utils"; + +const selectClass = + "w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"; function Checkbox({ checked, @@ -50,14 +54,30 @@ export function BackupButton({ stackId }: { stackId: string }) { const [open, setOpen] = useState(false); const [includeVolumes, setIncludeVolumes] = useState(true); const [stopFirst, setStopFirst] = useState(true); + const [target, setTarget] = useState("download"); // "download" | destination id const [busy, setBusy] = useState(false); + const destinations = useQuery({ + queryKey: ["destinations"], + queryFn: destinationsApi.list, + enabled: open, + }); + const run = async () => { setBusy(true); const tid = toast.loading("Creating backup…"); try { - await backupsApi.download(stackId, { includeVolumes, stopFirst }); - toast.success("Backup downloaded", { id: tid }); + if (target === "download") { + await backupsApi.download(stackId, { includeVolumes, stopFirst }); + toast.success("Backup downloaded", { id: tid }); + } else { + const res = await backupsApi.push(stackId, { + destination_id: Number(target), + include_volumes: includeVolumes, + stop_first: stopFirst, + }); + toast.success(`Backup pushed to ${res.destination}`, { id: tid }); + } setOpen(false); } catch (e) { toast.error(apiErrorMessage(e), { id: tid }); @@ -75,6 +95,17 @@ export function BackupButton({ stackId }: { stackId: string }) { !busy && setOpen(false)}>

Back up “{stackId}”

+
@@ -105,33 +136,62 @@ export function BackupButton({ stackId }: { stackId: string }) { export function RestoreButton() { const qc = useQueryClient(); const [open, setOpen] = useState(false); + const [mode, setMode] = useState<"upload" | "destination">("upload"); const [file, setFile] = useState(null); + const [destId, setDestId] = useState(""); + const [remoteName, setRemoteName] = useState(""); const [targetId, setTargetId] = useState(""); const [overwrite, setOverwrite] = useState(false); const [restoreVolumes, setRestoreVolumes] = useState(true); const [busy, setBusy] = useState(false); + const destinations = useQuery({ + queryKey: ["destinations"], + queryFn: destinationsApi.list, + enabled: open, + }); + const remoteBackups = useQuery({ + queryKey: ["dest-backups", destId], + queryFn: () => destinationsApi.backups(Number(destId)), + enabled: open && mode === "destination" && !!destId, + }); + const run = async () => { - if (!file) { - toast.error("Select a backup file"); - return; - } setBusy(true); const tid = toast.loading("Restoring…"); try { - const res = await backupsApi.restore(file, { - targetId: targetId.trim() || undefined, - overwrite, - restoreVolumes, - }); - toast.success( - `Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, - { id: tid } - ); + let res; + if (mode === "upload") { + if (!file) { + toast.error("Select a backup file", { id: tid }); + setBusy(false); + return; + } + res = await backupsApi.restore(file, { + targetId: targetId.trim() || undefined, + overwrite, + restoreVolumes, + }); + } else { + if (!destId || !remoteName) { + toast.error("Pick a destination and a backup", { id: tid }); + setBusy(false); + return; + } + res = await backupsApi.restoreFrom({ + destination_id: Number(destId), + name: remoteName, + target_id: targetId.trim() || undefined, + overwrite, + restore_volumes: restoreVolumes, + }); + } + toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid }); qc.invalidateQueries({ queryKey: ["stacks"] }); setOpen(false); setFile(null); setTargetId(""); + setRemoteName(""); } catch (e) { toast.error(apiErrorMessage(e), { id: tid }); } finally { @@ -147,13 +207,73 @@ export function RestoreButton() { {open && ( !busy && setOpen(false)}>

Restore from backup

+ +
+ {(["upload", "destination"] as const).map((m) => ( + + ))} +
+
- setFile(e.target.files?.[0] ?? null)} - className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900" - /> + {mode === "upload" ? ( + setFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900" + /> + ) : ( + <> + + {destId && ( + + )} + + )} + - + setOpen(false)} disabled={busy}> Cancel -
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 7884027..bede300 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import { Power, Server, RefreshCw, + HardDrive, } from "lucide-react"; import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; @@ -20,6 +21,7 @@ import { type Webhook, type WebhookInput, } from "@/api/settings"; +import { destinationsApi, type BackupDestination } from "@/api/backups"; import { agentsApi } from "@/api/agents"; import { HostDot } from "@/components/hosts/HostDot"; import { apiErrorMessage } from "@/api/client"; @@ -45,12 +47,171 @@ export function Settings() {
+
); } +/* -------------------------------------------------------------------------- */ +/* Backup destinations */ +/* -------------------------------------------------------------------------- */ + +const selectClass = + "w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"; + +const FIELDS: Record = { + sftp: [ + { key: "host", label: "Host" }, + { key: "port", label: "Port", placeholder: "22" }, + { key: "username", label: "Username" }, + { key: "password", label: "Password", secret: true }, + { key: "private_key", label: "Private key (optional, instead of password)", secret: true, area: true }, + { key: "path", label: "Remote directory", placeholder: "/backups/stackpilot" }, + ], + s3: [ + { key: "endpoint_url", label: "Endpoint URL (blank = AWS)", placeholder: "https://minio.example:9000" }, + { key: "region", label: "Region", placeholder: "us-east-1" }, + { key: "bucket", label: "Bucket" }, + { key: "access_key", label: "Access key", secret: true }, + { key: "secret_key", label: "Secret key", secret: true }, + { key: "prefix", label: "Key prefix (optional)", placeholder: "stackpilot/" }, + ], +}; + +function DestinationsSection() { + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list }); + const [adding, setAdding] = useState(false); + const invalidate = () => qc.invalidateQueries({ queryKey: ["destinations"] }); + + return ( +
+ }>Backup destinations +
+ {isLoading ? ( + + ) : ( + data?.map((d) => ) + )} + {data?.length === 0 && !adding && ( + +

+ No destinations. Add an SFTP server or S3-compatible bucket to push stack + backups off-box and restore from them. +

+
+ )} + {adding ? ( + { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} /> + ) : ( + + )} +
+
+ ); +} + +function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) { + const test = useMutation({ + mutationFn: () => destinationsApi.test(dest.id), + onSuccess: (r) => + r.ok ? toast.success("Reachable") : toast.error(r.error || "Connection failed"), + onError: (e) => toast.error(apiErrorMessage(e)), + }); + const remove = useMutation({ + mutationFn: () => destinationsApi.remove(dest.id), + onSuccess: () => { toast.success("Destination removed"); onChange(); }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const summary = + dest.type === "s3" + ? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}` + : `${dest.config.username}@${dest.config.host}:${dest.config.path || "."}`; + + return ( + +
+
+ {dest.name} + {dest.type} +
+

{summary}

+
+
+ + +
+
+ ); +} + +function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { + const [name, setName] = useState(""); + const [type, setType] = useState("sftp"); + const [config, setConfig] = useState>({}); + + const create = useMutation({ + mutationFn: () => destinationsApi.create({ name, type, config }), + onSuccess: () => { toast.success("Destination added"); onDone(); }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const setField = (k: string, v: string) => setConfig((c) => ({ ...c, [k]: v })); + + return ( + +
+ + +
+ {FIELDS[type].map((f) => ( +