Phase 6: remote backup destinations — SFTP & S3 (0.6.0)
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
59037f4287
commit
7bd449101d
@@ -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.
|
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||||
|
|
||||||
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
|
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
|
||||||
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup
|
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
|
||||||
> destinations (SFTP/S3) are planned for a later phase.
|
> destinations) complete.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## 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
|
with full lifecycle (start/stop/restart/pull/update/down), live logs, and
|
||||||
compose/.env editing — all proxied to the agent.
|
compose/.env editing — all proxied to the agent.
|
||||||
|
|
||||||
> **Not yet:** remote backup destinations (SFTP/S3) — backups currently download
|
### Phase 6 — Backup destinations
|
||||||
> to / upload from the browser.
|
|
||||||
|
- **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
|
## 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}
|
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
|
## Security notes
|
||||||
|
|
||||||
- The Docker socket is only ever touched by the backend process; it is never
|
- The Docker socket is only ever touched by the backend process; it is never
|
||||||
|
|||||||
+3
-1
@@ -18,6 +18,7 @@ from routers import (
|
|||||||
audit,
|
audit,
|
||||||
auth,
|
auth,
|
||||||
backups,
|
backups,
|
||||||
|
destinations,
|
||||||
editor,
|
editor,
|
||||||
images,
|
images,
|
||||||
ports,
|
ports,
|
||||||
@@ -49,7 +50,7 @@ async def lifespan(app: FastAPI):
|
|||||||
update_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -79,6 +80,7 @@ app.include_router(templates.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
|
app.include_router(destinations.router)
|
||||||
app.include_router(agents.router)
|
app.include_router(agents.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"""SQLModel table models. Importing this package registers all tables."""
|
"""SQLModel table models. Importing this package registers all tables."""
|
||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.audit import AuditLog
|
from models.audit import AuditLog
|
||||||
|
from models.backup_destination import BackupDestination
|
||||||
from models.setting import Setting, Webhook
|
from models.setting import Setting, Webhook
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"]
|
__all__ = [
|
||||||
|
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
|
||||||
|
"BackupDestination",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,3 +11,5 @@ python-multipart==0.0.20
|
|||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
|
paramiko==3.5.0
|
||||||
|
boto3==1.35.99
|
||||||
|
|||||||
+118
-4
@@ -1,18 +1,26 @@
|
|||||||
"""Stack backup (incl. volumes) and restore."""
|
"""Stack backup (incl. volumes) and restore."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from auth import require_admin
|
from auth import require_admin
|
||||||
from database import get_session
|
from database import get_session
|
||||||
|
from models.backup_destination import BackupDestination
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
from models.user import User
|
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"])
|
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"
|
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")
|
@router.get("/{stack_id}/backup")
|
||||||
async def backup_stack(
|
async def backup_stack(
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
@@ -43,12 +57,10 @@ async def backup_stack(
|
|||||||
session, user=user.username, action="stack.backup", target=stack_id,
|
session, user=user.username, action="stack.backup", target=stack_id,
|
||||||
detail=f"volumes={include_volumes}", ip=_ip(request),
|
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(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
media_type="application/gzip",
|
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:
|
finally:
|
||||||
if os.path.exists(tmp.name):
|
if os.path.exists(tmp.name):
|
||||||
os.unlink(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)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface BackupDestination {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: "sftp" | "s3";
|
||||||
|
config: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackup {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modified: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function triggerDownload(blob: Blob, filename: string) {
|
function triggerDownload(blob: Blob, filename: string) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -42,4 +56,50 @@ export const backupsApi = {
|
|||||||
}>("/api/stacks/restore", form);
|
}>("/api/stacks/restore", form);
|
||||||
return res.data;
|
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<BackupDestination[]>("/api/backups/destinations").then((r) => r.data),
|
||||||
|
create: (body: { name: string; type: string; config: Record<string, string> }) =>
|
||||||
|
api.post<BackupDestination>("/api/backups/destinations", body).then((r) => r.data),
|
||||||
|
update: (id: number, body: { name?: string; config?: Record<string, string> }) =>
|
||||||
|
api.put<BackupDestination>(`/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<RemoteBackup[]>(`/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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Archive, Upload } from "lucide-react";
|
import { Archive, Upload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { backupsApi } from "@/api/backups";
|
import { backupsApi, destinationsApi } from "@/api/backups";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
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({
|
function Checkbox({
|
||||||
checked,
|
checked,
|
||||||
@@ -50,14 +54,30 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [includeVolumes, setIncludeVolumes] = useState(true);
|
const [includeVolumes, setIncludeVolumes] = useState(true);
|
||||||
const [stopFirst, setStopFirst] = useState(true);
|
const [stopFirst, setStopFirst] = useState(true);
|
||||||
|
const [target, setTarget] = useState("download"); // "download" | destination id
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const destinations = useQuery({
|
||||||
|
queryKey: ["destinations"],
|
||||||
|
queryFn: destinationsApi.list,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const tid = toast.loading("Creating backup…");
|
const tid = toast.loading("Creating backup…");
|
||||||
try {
|
try {
|
||||||
await backupsApi.download(stackId, { includeVolumes, stopFirst });
|
if (target === "download") {
|
||||||
toast.success("Backup downloaded", { id: tid });
|
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);
|
setOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(apiErrorMessage(e), { id: tid });
|
toast.error(apiErrorMessage(e), { id: tid });
|
||||||
@@ -75,6 +95,17 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
|||||||
<Modal onClose={() => !busy && setOpen(false)}>
|
<Modal onClose={() => !busy && setOpen(false)}>
|
||||||
<h2 className="mb-3 text-lg font-semibold">Back up “{stackId}”</h2>
|
<h2 className="mb-3 text-lg font-semibold">Back up “{stackId}”</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||||
|
<select className={selectClass} value={target} onChange={(e) => setTarget(e.target.value)}>
|
||||||
|
<option value="download">Download to browser</option>
|
||||||
|
{destinations.data?.map((d) => (
|
||||||
|
<option key={d.id} value={String(d.id)}>
|
||||||
|
{d.name} ({d.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={includeVolumes}
|
checked={includeVolumes}
|
||||||
onChange={setIncludeVolumes}
|
onChange={setIncludeVolumes}
|
||||||
@@ -93,7 +124,7 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={run} loading={busy}>
|
<Button onClick={run} loading={busy}>
|
||||||
Download backup
|
{target === "download" ? "Download backup" : "Push backup"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -105,33 +136,62 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
|||||||
export function RestoreButton() {
|
export function RestoreButton() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"upload" | "destination">("upload");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [destId, setDestId] = useState<string>("");
|
||||||
|
const [remoteName, setRemoteName] = useState<string>("");
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [overwrite, setOverwrite] = useState(false);
|
const [overwrite, setOverwrite] = useState(false);
|
||||||
const [restoreVolumes, setRestoreVolumes] = useState(true);
|
const [restoreVolumes, setRestoreVolumes] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
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 () => {
|
const run = async () => {
|
||||||
if (!file) {
|
|
||||||
toast.error("Select a backup file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const tid = toast.loading("Restoring…");
|
const tid = toast.loading("Restoring…");
|
||||||
try {
|
try {
|
||||||
const res = await backupsApi.restore(file, {
|
let res;
|
||||||
targetId: targetId.trim() || undefined,
|
if (mode === "upload") {
|
||||||
overwrite,
|
if (!file) {
|
||||||
restoreVolumes,
|
toast.error("Select a backup file", { id: tid });
|
||||||
});
|
setBusy(false);
|
||||||
toast.success(
|
return;
|
||||||
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
|
}
|
||||||
{ id: tid }
|
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"] });
|
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setTargetId("");
|
setTargetId("");
|
||||||
|
setRemoteName("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(apiErrorMessage(e), { id: tid });
|
toast.error(apiErrorMessage(e), { id: tid });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,13 +207,73 @@ export function RestoreButton() {
|
|||||||
{open && (
|
{open && (
|
||||||
<Modal onClose={() => !busy && setOpen(false)}>
|
<Modal onClose={() => !busy && setOpen(false)}>
|
||||||
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
|
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
|
||||||
|
{(["upload", "destination"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={
|
||||||
|
mode === m
|
||||||
|
? "flex-1 rounded-md bg-card px-3 py-1.5 font-medium shadow-sm dark:bg-card-dark"
|
||||||
|
: "flex-1 rounded-md px-3 py-1.5 text-slate-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m === "upload" ? "Upload file" : "From destination"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<input
|
{mode === "upload" ? (
|
||||||
type="file"
|
<input
|
||||||
accept=".tar.gz,.tgz,application/gzip"
|
type="file"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
accept=".tar.gz,.tgz,application/gzip"
|
||||||
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"
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={destId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDestId(e.target.value);
|
||||||
|
setRemoteName("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{destinations.data?.map((d) => (
|
||||||
|
<option key={d.id} value={String(d.id)}>
|
||||||
|
{d.name} ({d.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{destId && (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">
|
||||||
|
Backup {remoteBackups.isFetching && "(loading…)"}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={remoteName}
|
||||||
|
onChange={(e) => setRemoteName(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{remoteBackups.data?.map((b) => (
|
||||||
|
<option key={b.name} value={b.name}>
|
||||||
|
{b.name} — {formatBytes(b.size)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs font-medium text-slate-500">
|
<span className="text-xs font-medium text-slate-500">
|
||||||
Restore as (optional — leave blank to use the original name)
|
Restore as (optional — leave blank to use the original name)
|
||||||
@@ -162,14 +282,10 @@ export function RestoreButton() {
|
|||||||
value={targetId}
|
value={targetId}
|
||||||
onChange={(e) => setTargetId(e.target.value)}
|
onChange={(e) => setTargetId(e.target.value)}
|
||||||
placeholder="new-stack-name"
|
placeholder="new-stack-name"
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
className={selectClass}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Checkbox
|
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
|
||||||
checked={restoreVolumes}
|
|
||||||
onChange={setRestoreVolumes}
|
|
||||||
label="Restore volume data"
|
|
||||||
/>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={overwrite}
|
checked={overwrite}
|
||||||
onChange={setOverwrite}
|
onChange={setOverwrite}
|
||||||
@@ -181,7 +297,11 @@ export function RestoreButton() {
|
|||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={run} loading={busy} disabled={!file}>
|
<Button
|
||||||
|
onClick={run}
|
||||||
|
loading={busy}
|
||||||
|
disabled={mode === "upload" ? !file : !destId || !remoteName}
|
||||||
|
>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
Server,
|
Server,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
HardDrive,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
type Webhook,
|
type Webhook,
|
||||||
type WebhookInput,
|
type WebhookInput,
|
||||||
} from "@/api/settings";
|
} from "@/api/settings";
|
||||||
|
import { destinationsApi, type BackupDestination } from "@/api/backups";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { HostDot } from "@/components/hosts/HostDot";
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -45,12 +47,171 @@ export function Settings() {
|
|||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
<HostsSection />
|
<HostsSection />
|
||||||
|
<DestinationsSection />
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
<UsersSection />
|
<UsersSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 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<string, { key: string; label: string; secret?: boolean; area?: boolean; placeholder?: string }[]> = {
|
||||||
|
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 (
|
||||||
|
<section>
|
||||||
|
<SectionTitle icon={<HardDrive className="h-4 w-4" />}>Backup destinations</SectionTitle>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
data?.map((d) => <DestinationRow key={d.id} dest={d} onChange={invalidate} />)
|
||||||
|
)}
|
||||||
|
{data?.length === 0 && !adding && (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No destinations. Add an SFTP server or S3-compatible bucket to push stack
|
||||||
|
backups off-box and restore from them.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{adding ? (
|
||||||
|
<DestinationForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||||
|
<Plus className="h-4 w-4" /> Add destination
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{dest.name}</span>
|
||||||
|
<Badge>{dest.type}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="break-all font-mono text-xs text-slate-500">{summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Test
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [type, setType] = useState("sftp");
|
||||||
|
const [config, setConfig] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Name</span>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="offsite-nas" />
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Type</span>
|
||||||
|
<select className={selectClass} value={type} onChange={(e) => { setType(e.target.value); setConfig({}); }}>
|
||||||
|
<option value="sftp">SFTP</option>
|
||||||
|
<option value="s3">S3-compatible</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{FIELDS[type].map((f) => (
|
||||||
|
<label key={f.key} className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">{f.label}</span>
|
||||||
|
{f.area ? (
|
||||||
|
<textarea
|
||||||
|
value={config[f.key] ?? ""}
|
||||||
|
onChange={(e) => setField(f.key, e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={selectClass + " font-mono"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={f.secret ? "password" : "text"}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
value={config[f.key] ?? ""}
|
||||||
|
onChange={(e) => setField(f.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!name.trim()}>
|
||||||
|
Add destination
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Remote hosts (agents) */
|
/* Remote hosts (agents) */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user