Files
stackpilot/backend/tests/test_backup_restore.py
menzeljandClaude Opus 5 270ddda51d
CI / check (push) Successful in 13m33s
CI / build-and-push (push) Successful in 1m51s
Let a stuck backup be stopped instead of stranding the stack (0.67.0)
A backup holds the stack lock for up to twelve hours — a large volume
legitimately takes that long — and stops the stack's containers for a
consistent snapshot. One that died without unwinding therefore left the
stack stopped and refusing every start, stop, restart and deploy with
"Stack 'npmplus' is busy: backup in progress", with nothing to click
until the TTL ran out.

Backups and restores now register themselves in stack_job_service while
they run, with their phase and the helper containers they stream
through. Cancelling sets a flag and removes those containers, which is
what actually ends the long read: the backup then unwinds through its
own finally — the half-written archive goes, the containers it stopped
are started again, the lock is released. Checks sit between two files
and two volumes, never inside one. A restore is not cancellable at all;
it is halfway through writing a stack's data back.

POST /api/stacks/{id}/unlock does that and waits for it. A backup lock
with no job behind it is a leftover, so it is released on one click; a
deploy lock takes an explicit force, because compose keeps running past
its request. GET /api/stacks/{id}/lock and the stack detail response say
what holds the stack and since when, and the stack page turns that into
"Stop backup" / "Release lock".

Startup now clears every lock rather than only expired ones: after a
restart nothing of ours is in flight, the same reasoning the leftover
helper containers already follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:39:17 +02:00

858 lines
32 KiB
Python

"""Backup and restore, end to end on real files and real archives.
Docker is faked (containers, volumes, the helper container); everything that
touches the stack directory and the archive runs for real. The cases are the
ways a backup used to fail to be one:
* data folders inside the stack directory were archived while the stack kept
writing to them (it was only stopped when named volumes were selected);
* the stack came back with ``up -d`` — starting services that had been
stopped on purpose and applying undeployed compose edits;
* a restore with "overwrite" deleted the whole stack directory, including the
folders the backup had left out (too large, deselected, NFS);
* a truncated archive was only noticed after the old stack was gone;
* a restore under a new name overwrote the original stack's volumes;
* retention for stack "app" deleted the backups of stack "app-db";
* a backup with a failed volume was reported as "ok".
"""
from __future__ import annotations
import asyncio
import gzip
import io
import json
import os
import tarfile
import uuid
import pytest
from services import backup_service, compose_service, stack_assets_service as assets
from services.compose_service import ContainerInfo
# --------------------------------------------------------------------------- #
# Fakes
# --------------------------------------------------------------------------- #
class FakeDocker:
"""Records what the backup asked Docker/compose to do."""
def __init__(self):
self.containers: list[ContainerInfo] = []
self.compose_calls: list[list[str]] = []
self.compose_fail: set[str] = set()
self.volumes: dict[str, dict] = {} # name -> {"files": {...}, "labels", ...}
self.running_users: dict[str, list[str]] = {}
self.imported: list[dict] = []
self.export_fail: set[str] = set()
def container(self, service: str, state: str = "running") -> ContainerInfo:
c = ContainerInfo(
id=f"id-{service}", name=f"x-{service}-1", service=service,
image="img", state=state, status=state,
)
self.containers.append(c)
return c
# compose_service
def containers_for_stack(self, stack_id):
return list(self.containers)
async def run_compose(self, stack_id, args, override=None, timeout=600.0):
self.compose_calls.append(list(args))
failed = args[0] in self.compose_fail
if not failed and args[0] == "stop":
for c in self.containers:
if c.service in args[1:]:
c.state = "exited"
if not failed and args[0] in ("start", "up"):
for c in self.containers:
if args[0] == "up" or c.service in args[1:]:
c.state = "running"
return {
"returncode": 1 if failed else 0,
"stdout": "",
"stderr": f"{args[0]} failed" if failed else "",
"command": " ".join(args),
}
# stack_assets_service
def export_volume(self, name, dest_file):
if name in self.export_fail:
raise RuntimeError("helper container could not mount the volume")
with tarfile.open(dest_file, "w") as tar:
for rel, content in self.volumes[name]["files"].items():
info = tarfile.TarInfo(rel)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
return os.path.getsize(dest_file)
def import_volume(self, name, labels, src_file, wipe=True, driver=None, driver_opts=None):
with tarfile.open(src_file) as tar:
files = {m.name: tar.extractfile(m).read() for m in tar.getmembers() if m.isreg()}
self.imported.append(
{"name": name, "labels": labels, "wipe": wipe, "driver": driver,
"driver_opts": driver_opts, "files": files}
)
self.volumes[name] = {"files": files, "labels": labels}
def volume_attrs(self, name):
vol = self.volumes.get(name)
return None if vol is None else {"Labels": vol.get("labels") or {}}
@pytest.fixture
def docker(monkeypatch, db):
fake = FakeDocker()
monkeypatch.setattr(compose_service, "containers_for_stack", fake.containers_for_stack)
monkeypatch.setattr(compose_service, "run_compose", fake.run_compose)
monkeypatch.setattr(assets, "export_volume", fake.export_volume)
monkeypatch.setattr(assets, "import_volume", fake.import_volume)
monkeypatch.setattr(assets, "volume_attrs", fake.volume_attrs)
monkeypatch.setattr(assets, "running_users", lambda name: fake.running_users.get(name, []))
def no_docker(ids, action):
raise RuntimeError("docker unavailable")
monkeypatch.setattr(backup_service, "_docker_each", no_docker)
return fake
def _bind(source, directory, size=1024, include=True, reason=None, via="compose"):
inside = source == directory or source.startswith(directory + "/")
return {
"source": source, "mounts": [{"service": "app", "target": "/data"}],
"kind": "dir", "size": size, "inside_stack_dir": inside,
"covered_by_compose": via == "compose", "via": via, "system": False,
"remote": None, "include_default": include, "reason": reason,
}
def _volume(name, stack_id, include=True, reason=None, external=False, remote=False):
labels = {} if external else {
assets.COMPOSE_PROJECT_LABEL: stack_id,
assets.COMPOSE_VOLUME_LABEL: name.split("_", 1)[-1],
}
return {
"name": name, "short": name.split("_", 1)[-1], "labels": labels,
"driver": "local", "options": {"type": "none", "o": "bind", "device": "/srv/x"} if remote else {},
"remote": remote, "remote_type": "nfs" if remote else None,
"external": external, "bind_device": None,
"include_default": include, "reason": reason,
}
@pytest.fixture
def stack(monkeypatch, docker):
"""A stack laid out the way the user keeps them: compose, .env and data
folders side by side in /opt/stacks/<stack>."""
stack_id = f"s{uuid.uuid4().hex[:8]}"
directory = compose_service.stack_dir(stack_id)
os.makedirs(os.path.join(directory, "config"))
os.makedirs(os.path.join(directory, "media", "movies"))
_write(directory, "compose.yaml", "services:\n app:\n image: img\n")
_write(directory, ".env", "PASSWORD=hunter2\n")
_write(directory, "config/app.db", "state-at-backup")
_write(directory, "media/movies/big.mkv", "x" * 100)
inv = {
"stack_id": stack_id, "stack_dir": directory, "stack_dir_visible": True,
"path_mismatch": None,
"binds": [
_bind(os.path.join(directory, "config"), directory),
# Too big for a backup: listed, not selected, left out of compose/.
_bind(os.path.join(directory, "media"), directory, size=500 * 1024**3,
include=False, reason="larger than 2 GiB"),
],
"volumes": [],
}
monkeypatch.setattr(assets, "inventory", lambda sid, max_bind_bytes=0: json.loads(json.dumps(inv)))
return stack_id, directory, inv
def _write(directory, rel, content):
path = os.path.join(directory, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write(content)
def _read(directory, rel):
with open(os.path.join(directory, rel)) as fh:
return fh.read()
def _names(archive):
with tarfile.open(archive, "r:gz") as tar:
return set(tar.getnames())
def run(coro):
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# Backup
# --------------------------------------------------------------------------- #
def test_data_folder_in_stack_dir_stops_the_stack_and_starts_exactly_what_ran(stack, docker):
stack_id, directory, _ = stack
docker.container("app", "running")
docker.container("worker", "exited") # stopped on purpose
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
try:
assert docker.compose_calls == [["stop", "app"], ["start", "app"]]
assert report["stopped"] == ["app"]
assert report["restart_error"] is None and report["errors"] == []
assert [c.state for c in docker.containers] == ["running", "exited"]
finally:
os.unlink(path)
def test_stopped_stack_is_not_started_by_a_backup(stack, docker):
stack_id, _, _ = stack
docker.container("app", "exited")
path, _ = run(backup_service.create_backup_ex(stack_id, "S"))
os.unlink(path)
assert docker.compose_calls == []
def test_large_folder_stays_out_but_its_mount_point_stays_in(stack):
stack_id, directory, _ = stack
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
try:
names = _names(path)
manifest = backup_service.read_manifest(path)
finally:
os.unlink(path)
assert {"compose/compose.yaml", "compose/.env", "compose/config/app.db"} <= names
assert "compose/media" in names
assert not any(n.startswith("compose/media/") for n in names)
assert manifest["format_version"] == 3
assert manifest["excluded"] == ["media"]
assert manifest["compose_binds"] == ["config"]
assert [b["source"] for b in report["binds"]] == [os.path.join(directory, "config")]
def test_stack_that_cannot_be_stopped_is_not_backed_up(stack, docker):
stack_id, _, _ = stack
docker.container("app", "running")
docker.compose_fail.add("stop")
with pytest.raises(backup_service.BackupError, match="consistent snapshot"):
run(backup_service.create_backup_ex(stack_id, "S"))
# Whatever did stop is started again.
assert docker.compose_calls[-1] == ["start", "app"]
def test_restart_failure_is_reported_not_swallowed(stack, docker):
stack_id, _, _ = stack
docker.container("app", "running")
docker.compose_fail.add("start")
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
os.unlink(path)
assert report["restart_error"]
def test_failed_volume_is_an_error_and_listed_as_skipped(stack, docker):
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.export_fail.add(f"{stack_id}_db")
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
try:
manifest = backup_service.read_manifest(path)
finally:
os.unlink(path)
assert report["errors"] and f"{stack_id}_db" in report["errors"][0]
assert manifest["volumes"] == []
assert any(s.get("name") == f"{stack_id}_db" and "backup failed" in s["reason"]
for s in manifest["skipped"])
# --------------------------------------------------------------------------- #
# Cancelling a backup
# --------------------------------------------------------------------------- #
#
# A backup holds the stack lock for up to twelve hours and stops the stack's
# containers for a consistent snapshot. One that hangs — an unreachable share,
# a helper container reading a volume that never ends — therefore used to
# leave the stack stopped *and* refusing every start, stop and restart with
# "busy: backup in progress" until the TTL ran out. It can be stopped now, and
# stopping it has to put back exactly what the backup took away.
def _cancelling_export(stack_id, docker):
"""An export that is cancelled halfway, the way the endpoint does it:
the helper container is removed, so the read fails."""
def export_volume(name, dest_file):
from services import stack_job_service
stack_job_service.cancel(stack_id, "tester")
raise RuntimeError("helper container removed while reading the volume")
return export_volume
def test_cancelling_a_backup_starts_the_stack_again(stack, docker, monkeypatch):
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
monkeypatch.setattr(assets, "export_volume", _cancelling_export(stack_id, docker))
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
assert docker.compose_calls == [["stop", "app"], ["start", "app"]]
assert [c.state for c in docker.containers] == ["running"]
def test_a_cancelled_backup_frees_the_lock(stack, docker, monkeypatch):
"""The reason to cancel at all: the stack must be usable again."""
from sqlmodel import Session
from database import engine
from services import stack_lock_service
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
monkeypatch.setattr(assets, "export_volume", _cancelling_export(stack_id, docker))
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
with Session(engine) as session:
assert not stack_lock_service.is_busy(session, stack_id)
def test_a_cancelled_backup_leaves_no_half_written_archive(stack, docker, monkeypatch):
import glob
import tempfile as tempfile_mod
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.container("app", "running")
def cancel_while_writing(name, dest_file):
from services import stack_job_service
with tarfile.open(dest_file, "w"):
pass
stack_job_service.cancel(stack_id, "tester")
return os.path.getsize(dest_file)
monkeypatch.setattr(assets, "export_volume", cancel_while_writing)
with pytest.raises(backup_service.BackupCancelled):
run(backup_service.create_backup_ex(stack_id, "S"))
leftovers = glob.glob(os.path.join(tempfile_mod.gettempdir(), f"sp-{stack_id}-*.tar.gz"))
assert leftovers == []
def test_a_backup_nobody_cancelled_is_not_disturbed(stack, docker):
"""The check runs on every file and volume; it must only fire when asked."""
stack_id, _, inv = stack
inv["volumes"].append(_volume(f"{stack_id}_db", stack_id))
docker.volumes[f"{stack_id}_db"] = {"files": {"a": b"1"}, "labels": {}}
docker.container("app", "running")
path, report = run(backup_service.create_backup_ex(stack_id, "S"))
try:
assert report["errors"] == []
assert {v["name"] for v in report["volumes"]} == {f"{stack_id}_db"}
finally:
os.unlink(path)
def test_file_that_shrinks_while_read_keeps_the_archive_valid(tmp_path, monkeypatch):
root = tmp_path / "stack"
root.mkdir()
log = root / "log.txt"
log.write_bytes(b"a" * 5000)
class RotatedMidRead:
"""The file is truncated after its size was taken, before it is read."""
def __init__(self, fh):
self._fh = fh
self.name = fh.name
def fileno(self):
return self._fh.fileno()
def read(self, n=-1):
if log.stat().st_size > 100:
os.truncate(log, 100)
return self._fh.read(n)
def __enter__(self):
return self
def __exit__(self, *exc):
self._fh.close()
real_open = open
def rotating_open(path, mode="r", *args, **kwargs):
fh = real_open(path, mode, *args, **kwargs)
return RotatedMidRead(fh) if str(path) == str(log) else fh
monkeypatch.setattr("builtins.open", rotating_open)
archive = tmp_path / "a.tar.gz"
warnings: list[str] = []
with tarfile.open(archive, "w:gz") as tar:
backup_service._add_tree(tar, str(root), "compose", set(), warnings)
monkeypatch.undo()
backup_service.verify_archive(str(archive))
assert warnings == ["log.txt: changed while it was read"]
with tarfile.open(archive, "r:gz") as tar:
data = tar.extractfile("compose/log.txt").read()
assert data == b"a" * 100 + b"\0" * 4900
def test_backup_of_a_busy_stack_is_refused(stack):
from sqlmodel import Session
from database import engine
from services import stack_lock_service
stack_id, _, _ = stack
with Session(engine) as session:
stack_lock_service.acquire(session, stack_id, "update", "someone")
try:
with pytest.raises(backup_service.StackBusyError):
run(backup_service.create_backup_ex(stack_id, "S"))
finally:
stack_lock_service.release(session, stack_id)
# --------------------------------------------------------------------------- #
# Restore
# --------------------------------------------------------------------------- #
def _backup(stack_id):
path, _ = run(backup_service.create_backup_ex(stack_id, "S"))
return path
def test_overwrite_restores_the_snapshot_and_keeps_what_was_left_out(stack, docker):
stack_id, directory, _ = stack
archive = _backup(stack_id)
try:
# Life goes on after the backup...
_write(directory, "config/app.db", "state-after-backup")
_write(directory, "config/wal-0002", "newer than the snapshot")
_write(directory, "media/movies/new.mkv", "y")
_write(directory, "compose.yaml", "services: {}\n")
result = run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert _read(directory, "config/app.db") == "state-at-backup"
assert not os.path.exists(os.path.join(directory, "config/wal-0002"))
assert _read(directory, "compose.yaml").startswith("services:\n app:")
assert _read(directory, ".env") == "PASSWORD=hunter2\n"
# Not in the backup, so not the restore's to delete.
assert _read(directory, "media/movies/big.mkv") == "x" * 100
assert _read(directory, "media/movies/new.mkv") == "y"
assert result["stack_id"] == stack_id
# "media" was left out when the backup was made: listed, but not a problem.
assert result["problems"] == []
# Nothing staged is left behind or picked up as a stack.
root = os.path.dirname(directory)
assert not [n for n in os.listdir(root) if n.startswith(".restore-")]
def test_restore_stops_a_running_stack_and_brings_it_back_up(stack, docker):
stack_id, _, _ = stack
archive = _backup(stack_id)
docker.container("app", "running")
docker.compose_calls.clear()
try:
result = run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert docker.compose_calls == [["stop", "app"], ["up", "-d", "--remove-orphans"]]
assert result["restarted"] is True
def test_without_restore_binds_the_data_folders_are_kept(stack):
stack_id, directory, _ = stack
archive = _backup(stack_id)
try:
_write(directory, "config/app.db", "state-after-backup")
_write(directory, "compose.yaml", "services: {}\n")
run(backup_service.restore(archive, overwrite=True, restore_binds=False))
finally:
os.unlink(archive)
assert _read(directory, "config/app.db") == "state-after-backup"
assert _read(directory, "compose.yaml").startswith("services:\n app:")
def test_restore_to_a_new_name_creates_the_stack(stack):
stack_id, directory, _ = stack
archive = _backup(stack_id)
new_id = stack_id + "-copy"
try:
result = run(backup_service.restore(archive, target_id=new_id))
finally:
os.unlink(archive)
new_dir = compose_service.stack_dir(new_id)
assert result["stack_id"] == new_id
assert _read(new_dir, "config/app.db") == "state-at-backup"
assert os.path.isdir(os.path.join(new_dir, "media")) # the mount point
assert os.listdir(os.path.join(new_dir, "media")) == []
def test_existing_stack_without_overwrite_is_refused(stack):
stack_id, _, _ = stack
archive = _backup(stack_id)
try:
with pytest.raises(backup_service.BackupError, match="already exists"):
run(backup_service.restore(archive))
finally:
os.unlink(archive)
def test_truncated_archive_is_refused_before_anything_is_touched(stack):
stack_id, directory, _ = stack
archive = _backup(stack_id)
with open(archive, "rb") as fh:
data = fh.read()
with open(archive, "wb") as fh:
fh.write(data[: len(data) // 2])
_write(directory, "config/app.db", "current")
try:
with pytest.raises(backup_service.BackupError, match="damaged or incomplete"):
run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert _read(directory, "config/app.db") == "current"
def test_archive_cannot_write_through_its_own_symlink(tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("untouched")
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
link = tarfile.TarInfo("compose/x")
link.type = tarfile.SYMTYPE
link.linkname = str(outside)
tar.addfile(link)
payload = b"pwned"
info = tarfile.TarInfo("compose/x")
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
dest = tmp_path / "dest"
with tarfile.open(archive, "r:gz") as tar:
backup_service._extract_tree(tar, "compose/", str(dest))
assert outside.read_text() == "untouched"
assert (dest / "x").read_bytes() == b"pwned"
def test_volumes_restore_with_their_driver_and_options(stack, docker):
stack_id, _, inv = stack
vol = _volume(f"{stack_id}_db", stack_id)
vol["options"] = {"type": "none", "o": "bind", "device": "/srv/db"}
inv["volumes"].append(vol)
docker.volumes[f"{stack_id}_db"] = {"files": {"PG_VERSION": b"16"}, "labels": vol["labels"]}
archive = _backup(stack_id)
del docker.volumes[f"{stack_id}_db"]
try:
result = run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert result["volumes_restored"] == 1
imported = docker.imported[0]
assert imported["driver_opts"] == {"type": "none", "o": "bind", "device": "/srv/db"}
assert imported["files"] == {"PG_VERSION": b"16"}
def test_renamed_restore_never_touches_the_originals_volumes(stack, docker):
stack_id, _, inv = stack
inv["volumes"] += [
_volume(f"{stack_id}_db", stack_id),
# explicit `name:` in compose, so not prefixed with the stack id
{**_volume("shared_media", stack_id), "short": "media"},
]
docker.volumes[f"{stack_id}_db"] = {"files": {"a": b"1"}, "labels": inv["volumes"][0]["labels"]}
docker.volumes["shared_media"] = {"files": {"b": b"2"}, "labels": inv["volumes"][1]["labels"]}
archive = _backup(stack_id)
new_id = stack_id + "-test"
try:
result = run(backup_service.restore(archive, target_id=new_id))
finally:
os.unlink(archive)
assert [i["name"] for i in docker.imported] == [f"{new_id}_db"]
assert docker.imported[0]["labels"][assets.COMPOSE_PROJECT_LABEL] == new_id
reasons = {s.get("name"): s["reason"] for s in result["skipped"] if s["kind"] == "volume"}
assert "belongs to stack" in reasons["shared_media"]
assert any(p.startswith("shared_media: belongs to stack") for p in result["problems"])
assert docker.volumes["shared_media"]["files"] == {"b": b"2"}
def test_volume_in_use_by_another_stack_is_not_overwritten(stack, docker):
stack_id, _, inv = stack
inv["volumes"].append(_volume("common", stack_id, external=True))
docker.volumes["common"] = {"files": {"a": b"1"}, "labels": {}}
archive = _backup(stack_id)
docker.running_users["common"] = ["other-app-1"]
try:
result = run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert docker.imported == []
assert any("other-app-1" in s["reason"] for s in result["skipped"] if s.get("name") == "common")
def test_external_volume_keeps_its_labels(stack, docker):
stack_id, _, inv = stack
inv["volumes"].append(_volume("common", stack_id, external=True))
docker.volumes["common"] = {"files": {"a": b"1"}, "labels": {}}
archive = _backup(stack_id)
del docker.volumes["common"]
try:
run(backup_service.restore(archive, overwrite=True))
finally:
os.unlink(archive)
assert docker.imported[0]["labels"] == {}
def test_version_2_backup_keeps_its_skipped_folders(stack, tmp_path):
"""Backups made before this change have no "excluded" list."""
stack_id, directory, _ = stack
archive = tmp_path / "v2.tar.gz"
manifest = {
"format_version": 2, "stack_id": stack_id, "name": "S", "stack_dir": directory,
"volumes": [], "binds": [],
"skipped": [{"kind": "bind", "source": os.path.join(directory, "media"),
"reason": "larger than 2 GiB"}],
}
with tarfile.open(archive, "w:gz") as tar:
data = json.dumps(manifest).encode()
info = tarfile.TarInfo("manifest.json")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
body = b"services: {}\n"
info = tarfile.TarInfo("compose/compose.yaml")
info.size = len(body)
tar.addfile(info, io.BytesIO(body))
run(backup_service.restore(str(archive), overwrite=True))
assert _read(directory, "compose.yaml") == "services: {}\n"
assert _read(directory, "media/movies/big.mkv") == "x" * 100
assert not os.path.exists(os.path.join(directory, "config"))
def test_verify_accepts_a_good_archive_and_rejects_garbage(stack, tmp_path):
stack_id, _, _ = stack
archive = _backup(stack_id)
try:
backup_service.verify_archive(archive)
finally:
os.unlink(archive)
junk = tmp_path / "junk.tar.gz"
junk.write_bytes(gzip.compress(b"not a tar"))
with pytest.raises(backup_service.BackupError):
backup_service.verify_archive(str(junk))
# --------------------------------------------------------------------------- #
# Retention (schedules: test_backup_schedules.py)
# --------------------------------------------------------------------------- #
def test_retention_pattern_is_exact():
pattern = backup_service.backup_name_pattern("app")
assert pattern.match("backup-app-full-20260918-030000.tar.gz")
assert pattern.match("backup-app-config-20260918-030000.tar.gz")
assert not pattern.match("backup-app-db-full-20260918-030000.tar.gz")
assert not pattern.match("backup-app-full-20260918-030000.tar.gz.part")
def test_retention_leaves_other_stacks_alone(monkeypatch):
from services import schedule_service
names = [
"backup-app-full-20260910-030000.tar.gz",
"backup-app-full-20260911-030000.tar.gz",
"backup-app-full-20260912-030000.tar.gz",
"backup-app-db-full-20260901-030000.tar.gz",
]
deleted: list[str] = []
monkeypatch.setattr(
schedule_service.dest_service, "list_backups",
lambda dest: [{"name": n, "modified": i} for i, n in enumerate(names)],
)
monkeypatch.setattr(schedule_service.dest_service, "delete", lambda dest, n: deleted.append(n))
assert schedule_service._prune(object(), "app", keep=2) == 1
assert deleted == ["backup-app-full-20260910-030000.tar.gz"]
# --------------------------------------------------------------------------- #
# Routes
# --------------------------------------------------------------------------- #
def test_download_removes_its_temp_file(stack, as_admin, db):
from sqlmodel import Session
from models.stack import Stack
stack_id, _, _ = stack
with Session(db) as session:
session.add(Stack(id=stack_id, name=stack_id))
session.commit()
created: list[str] = []
real = backup_service.create_backup_ex
async def spy(*args, **kwargs):
path, report = await real(*args, **kwargs)
created.append(path)
return path, report
import routers.backups as routes
original = routes.backup_service.create_backup_ex
routes.backup_service.create_backup_ex = spy
try:
r = as_admin.get(f"/api/stacks/{stack_id}/backup")
finally:
routes.backup_service.create_backup_ex = original
assert r.status_code == 200
header = json.loads(r.headers["X-Stackpilot-Backup"])
assert header["errors"] == [] and header["restart_error"] is None
assert created and not os.path.exists(created[0])
# --------------------------------------------------------------------------- #
# Inventory
# --------------------------------------------------------------------------- #
class _HelperClient:
"""Stands in for the helper container that runs the inspection script."""
def __init__(self, output: str):
self.output = output
self.images = self
self.containers = self
def get(self, image):
return object()
def create(self, image, **kwargs):
return self
def start(self):
pass
def wait(self, timeout=None):
return {"StatusCode": 0}
def logs(self, stdout=True, stderr=True):
return self.output.encode() if stdout else b""
def remove(self, force=False):
pass
def test_inspect_paths_flags_remote_and_unmeasured(monkeypatch):
out = "\n".join([
"0 dir 1024 ef53", # local ext4, 1 MiB
"1 dir - 6969", # NFS: not walked
"2 dir - ef53", # du ran out of time
"3 file 12 ff534d42", # a file on CIFS
])
monkeypatch.setattr(assets, "get_long_client", lambda: _HelperClient(out))
res = assets.inspect_paths(["/a", "/b", "/c", "/d"])
assert res["/a"] == {"kind": "dir", "size": 1024 * 1024, "fs": "ef53", "remote": None, "measured": True}
assert res["/b"]["remote"] == "nfs" and res["/b"]["measured"] is True
assert res["/c"]["measured"] is False
assert res["/d"]["remote"] == "cifs"
def test_inventory_keeps_remote_and_unmeasured_folders_out(monkeypatch):
stack_id = "inv"
directory = compose_service.stack_dir(stack_id)
specs = [
{"source": "/mnt/nas/media", "service": "app", "target": "/media"},
{"source": f"{directory}/cache", "service": "app", "target": "/cache"},
{"source": f"{directory}/config", "service": "app", "target": "/config"},
]
monkeypatch.setattr(assets, "_bind_specs_from_containers", lambda sid: specs)
monkeypatch.setattr(assets, "_bind_specs_from_compose", lambda sid: [])
monkeypatch.setattr(assets, "compose_volumes", lambda sid: [
{**_volume(f"{stack_id}_db", stack_id), "bind_device": "/mnt/nas/db"},
_volume("a" * 64, stack_id),
])
monkeypatch.setattr(assets, "remote_mount_type", lambda path: None)
monkeypatch.setattr(assets, "inspect_paths", lambda paths: {
"/mnt/nas/media": {"kind": "dir", "size": None, "fs": "6969", "remote": "nfs", "measured": True},
f"{directory}/cache": {"kind": "dir", "size": None, "fs": "ef53", "remote": None, "measured": False},
f"{directory}/config": {"kind": "dir", "size": 4096, "fs": "ef53", "remote": None, "measured": True},
"/mnt/nas/db": {"kind": "dir", "size": None, "fs": "6969", "remote": "nfs", "measured": True},
})
inv = assets.inventory(stack_id)
binds = {b["source"]: b for b in inv["binds"]}
assert binds["/mnt/nas/media"]["include_default"] is False
assert binds["/mnt/nas/media"]["reason"] == "remote storage (nfs)"
assert binds[f"{directory}/cache"]["reason"] == "too large to measure"
assert binds[f"{directory}/config"]["include_default"] is True
vols = {v["name"]: v for v in inv["volumes"]}
assert vols[f"{stack_id}_db"]["include_default"] is False
assert vols[f"{stack_id}_db"]["reason"] == "remote storage (nfs)"
assert "anonymous" in vols["a" * 64]["reason"]
def test_external_volumes_are_found(monkeypatch):
stack_id = f"s{uuid.uuid4().hex[:8]}"
directory = compose_service.stack_dir(stack_id)
os.makedirs(directory)
_write(directory, "compose.yaml",
"services:\n app:\n image: img\n volumes: [shared:/data]\n"
"volumes:\n shared:\n external: true\n name: ${SHARED:-common}\n")
class Vol:
def __init__(self, name, attrs):
self.name, self.attrs = name, attrs
class Client:
def __init__(self):
self.volumes = self
self.containers = self
def list(self, **kwargs):
if "all" in kwargs: # containers
return []
return [Vol(f"{stack_id}_db", {"Labels": {assets.COMPOSE_PROJECT_LABEL: stack_id}})]
def get(self, name):
if name != "common":
raise assets.DockerError("not_found")
return Vol("common", {"Labels": {}, "Driver": "local"})
client = Client()
monkeypatch.setattr(assets, "get_client", lambda: client)
vols = {v["name"]: v for v in assets.compose_volumes(stack_id)}
assert set(vols) == {f"{stack_id}_db", "common"}
assert vols["common"]["external"] is True
assert vols[f"{stack_id}_db"]["external"] is False