mirror of
https://github.com/lobaro/restic-backup-docker.git
synced 2026-11-13 15:45:22 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
195 lines
7.1 KiB
Python
195 lines
7.1 KiB
Python
"""Helpers around a running test container.
|
|
|
|
Rule for everything in here: a failing command is never turned into an empty or
|
|
zero result. Helpers that return parsed data raise on a non-zero exit, so a broken
|
|
restic call fails the test instead of looking like "no snapshots".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import tarfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
|
|
import docker
|
|
from docker.models.containers import Container as DockerContainer
|
|
|
|
START_TIMEOUT = 60
|
|
START_MARKER = "Container started."
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecResult:
|
|
rc: int
|
|
out: str # stdout and stderr, interleaved like `2>&1`
|
|
|
|
|
|
class CommandError(AssertionError):
|
|
pass
|
|
|
|
|
|
class Container:
|
|
"""A started image container with exec/file helpers."""
|
|
|
|
def __init__(self, raw: DockerContainer, started: bool, start_log: str) -> None:
|
|
self.raw = raw
|
|
self.started = started
|
|
self.start_log = start_log
|
|
|
|
def exec(self, *cmd: str, env: dict[str, str] | None = None) -> ExecResult:
|
|
"""Run a command; the exit code is returned, not checked."""
|
|
code, out = self.raw.exec_run(list(cmd), environment=env)
|
|
return ExecResult(code, out.decode(errors="replace").replace("\r", ""))
|
|
|
|
def run(self, *cmd: str, env: dict[str, str] | None = None) -> str:
|
|
"""Run a command that must succeed; return its output."""
|
|
res = self.exec(*cmd, env=env)
|
|
if res.rc != 0:
|
|
raise CommandError(f"{' '.join(cmd)} exited {res.rc}:\n{res.out}")
|
|
return res.out
|
|
|
|
def write_file(self, path: str, content: str, *, executable: bool = False) -> None:
|
|
"""Create a file (and its directory) without a bind mount."""
|
|
self.run("mkdir", "-p", str(PurePosixPath(path).parent))
|
|
put_file(self.raw, path, content, executable=executable)
|
|
|
|
def read_file(self, path: str) -> str:
|
|
return self.run("cat", path)
|
|
|
|
def file_exists(self, path: str) -> bool:
|
|
res = self.exec("test", "-f", path)
|
|
if res.rc not in (0, 1):
|
|
raise CommandError(f"test -f {path} exited {res.rc}:\n{res.out}")
|
|
return res.rc == 0
|
|
|
|
def logs(self) -> str:
|
|
return self.raw.logs().decode(errors="replace").replace("\r", "")
|
|
|
|
# restic -------------------------------------------------------------
|
|
|
|
def restic_json(self, *args: str) -> Any:
|
|
"""Run restic with --json and parse stdout; any failure raises."""
|
|
code, (out, err) = self.raw.exec_run(["restic", *args, "--json"], demux=True)
|
|
if code != 0:
|
|
raise CommandError(f"restic {' '.join(args)} exited {code}:\n{(err or b'').decode()}")
|
|
return json.loads((out or b"").decode())
|
|
|
|
def snapshots(self) -> list[dict[str, Any]]:
|
|
return self.restic_json("snapshots") or []
|
|
|
|
def backed_up_paths(self, snapshot: str = "latest") -> set[str]:
|
|
"""Paths contained in a snapshot (`restic ls --json` emits one JSON object per line)."""
|
|
code, (out, err) = self.raw.exec_run(["restic", "ls", snapshot, "--json"], demux=True)
|
|
if code != 0:
|
|
raise CommandError(f"restic ls {snapshot} exited {code}:\n{(err or b'').decode()}")
|
|
lines = (out or b"").decode().splitlines()
|
|
return {n["path"] for n in map(json.loads, lines) if n.get("struct_type") == "node"}
|
|
|
|
def restore_latest(self, target: str) -> ExecResult:
|
|
return self.exec("restic", "restore", "latest", "--target", target)
|
|
|
|
|
|
def _dir_exists(raw: DockerContainer, path: str) -> bool:
|
|
try:
|
|
raw.get_archive(path, chunk_size=1)
|
|
except docker.errors.NotFound:
|
|
return False
|
|
return True
|
|
|
|
|
|
def put_file(raw: DockerContainer, path: str, content: str, *, executable: bool = False) -> None:
|
|
"""Copy a file into an existing (also not yet started) container via a tar archive.
|
|
|
|
Missing parent directories are created (like `mkdir -p`); existing ones are left
|
|
untouched, so their mode and owner stay as the image defines them.
|
|
"""
|
|
target = PurePosixPath(path)
|
|
missing = []
|
|
for parent in target.parents:
|
|
if _dir_exists(raw, str(parent)):
|
|
break
|
|
missing.append(parent)
|
|
data = content.encode()
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w") as tar:
|
|
for directory in reversed(missing):
|
|
dir_info = tarfile.TarInfo(str(directory).lstrip("/"))
|
|
dir_info.type = tarfile.DIRTYPE
|
|
dir_info.mode = 0o755
|
|
tar.addfile(dir_info)
|
|
info = tarfile.TarInfo(str(target).lstrip("/"))
|
|
info.size = len(data)
|
|
info.mode = 0o755 if executable else 0o644
|
|
tar.addfile(info, io.BytesIO(data))
|
|
buf.seek(0)
|
|
if not raw.put_archive("/", buf):
|
|
raise CommandError(f"could not write {path}")
|
|
|
|
|
|
def start_container(
|
|
client: docker.DockerClient,
|
|
image: str,
|
|
name: str,
|
|
labels: dict[str, str],
|
|
env: dict[str, str] | None = None,
|
|
files: dict[str, str] | None = None,
|
|
) -> Container:
|
|
"""Start the image with a cron far in the future, so only explicit backups run.
|
|
|
|
`files` (path in container -> content) are placed before entry.sh runs, without a
|
|
bind mount, so tests can cover what the entrypoint does with them at startup.
|
|
"""
|
|
environment = {
|
|
"RESTIC_PASSWORD": "testpassword",
|
|
"RESTIC_TAG": "testtag",
|
|
"BACKUP_CRON": "0 0 31 2 *",
|
|
**(env or {}),
|
|
}
|
|
if files:
|
|
raw = client.containers.create(image, name=name, environment=environment, labels=labels)
|
|
for path, content in files.items():
|
|
put_file(raw, path, content)
|
|
raw.start()
|
|
else:
|
|
raw = client.containers.run(
|
|
image, detach=True, name=name, environment=environment, labels=labels
|
|
)
|
|
deadline = time.monotonic() + START_TIMEOUT
|
|
while time.monotonic() < deadline:
|
|
log = raw.logs().decode(errors="replace")
|
|
if START_MARKER in log:
|
|
return Container(raw, True, log)
|
|
raw.reload()
|
|
if raw.status != "running":
|
|
return Container(raw, False, f"container exited early:\n{log}")
|
|
time.sleep(0.5)
|
|
return Container(raw, False, f"timeout waiting for start:\n{raw.logs().decode()}")
|
|
|
|
|
|
def remove_quietly(container: Container) -> None:
|
|
with contextlib.suppress(docker.errors.NotFound):
|
|
container.raw.remove(force=True, v=True)
|
|
|
|
|
|
def run_once(client: docker.DockerClient, image: str, **kwargs: Any) -> str:
|
|
"""Run a short-lived container and return its output.
|
|
|
|
`containers.run(remove=True)` keeps the image's anonymous volumes (VOLUME /data),
|
|
so run detached and remove the container together with its volumes.
|
|
"""
|
|
container = client.containers.run(image, detach=True, **kwargs)
|
|
try:
|
|
result = container.wait()
|
|
output = container.logs(stdout=True, stderr=False).decode()
|
|
if result["StatusCode"] != 0:
|
|
raise CommandError(f"container exited {result['StatusCode']}:\n{output}")
|
|
return output
|
|
finally:
|
|
with contextlib.suppress(docker.errors.NotFound):
|
|
container.remove(force=True, v=True)
|