Files
FabledCurator/backend/app/services/backup_s3.py
T
bvandeusenandClaude Opus 5.5 0cf8edaa10
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / integration (push) Failing after 2m28s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
feat: images and attachments go to the off-site bucket once each, by fingerprint
With an off-site destination set up, the library's files are sent to the
bucket's media/ folder as media/ab/cd/<sha256><ext>, beside the database
dumps under database/. Each file goes once: a new backup_blob table records
what each destination holds (migration 0121), and a run's work list is every
image and attachment sha with no row yet, so runs are incremental and a new
bucket starts its copy from the beginning.

- The bucket checks each file against the sha recorded at import; a file
  over 1 GiB goes in parts and is checked before the upload is completed.
  A file missing or changed on disk is reported, not stored.
- A run is chunked: each 45-minute chunk commits after every file and queues
  the next, so other long-maintenance jobs get turns. One run at a time.
  The stall sweep judges a media run by its progress heartbeat
  (backup_run.last_progress_at), not its start.
- A file that can't be sent is listed on the run (status ok, with the error
  line) and tried again by the next run. A refused put for an object
  already in an append-only bucket counts as sent.
- Nightly, with a destination set, runs the dump and then the media.
- Weekly reconcile lists media/ and re-sends what the bucket lost, and
  adopts what it already holds (a restored database sends nothing twice).
- Uploads use the bandwidth limit; read transactions end before transfers.
- Backups card: with a destination, an "Images and attachments" section
  shows coverage (files and bytes in the bucket), "Send new files now", and
  the media runs. Without one, the local tarball section is unchanged.
- Media run history keeps the latest 60 rows.

Task #6879, milestone 586.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:07:15 -05:00

506 lines
21 KiB
Python

"""A small S3 client for sending backups off the box: AWS Signature Version 4,
path-style addressing (endpoint/bucket/key), and the calls a backup needs —
put an object (whole, or in parts when its size isn't known up front), read
one back, list a prefix.
FabledCurator puts, lists and reads; it never deletes. The bucket keeps the
history and thins it on its own policy, so a key that may only add and read
objects is enough, and is the safer key to hand a service: a stolen one cannot
wipe the history it writes into.
Built on the standard library's http.client rather than boto3: three calls
don't justify a large dependency, and signing is a pure function the tests
hold to AWS's own published examples. Every call is synchronous, because the
uploads run in Celery workers; the API runs the Test destination call in a
thread.
"""
from __future__ import annotations
import hashlib
import hmac
import http.client
import os
import socket
import ssl
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
TIMEOUT_SECONDS = 30.0
# A single object can be a multi-gigabyte video or archive; a slow link still
# gets this long between bytes before the call gives up.
TRANSFER_TIMEOUT_SECONDS = 15 * 60
CHUNK = 1 << 20
class S3Error(Exception):
"""Why a call to the bucket failed, said to the person."""
@dataclass(frozen=True)
class S3Target:
endpoint: str # http(s)://host[:port][/prefix], no bucket
bucket: str
access_key: str
secret_key: str
region: str = "us-east-1"
verify_tls: bool = True
def url(self, key: str = "") -> str:
base = f"{self.endpoint.rstrip('/')}/{quote(self.bucket, safe='')}"
return f"{base}/{quote(key, safe='/-_.~')}" if key else f"{base}/"
@dataclass(frozen=True)
class StoredObject:
key: str
size: int
last_modified: str
@dataclass(frozen=True)
class ListPage:
objects: list[StoredObject]
next_token: str | None # pass back as `continuation` for the next page
# ── Signing ──────────────────────────────────────────────────────────────────
def _encode(text: str, keep_slash: bool = False) -> str:
return quote(text, safe="-_.~" + ("/" if keep_slash else ""))
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
def sign(method: str, url: str, headers: dict[str, str], payload_sha256: str,
access_key: str, secret_key: str, region: str, at: datetime,
service: str = "s3") -> dict[str, str]:
"""The request's headers with Host, x-amz-date, x-amz-content-sha256 and
Authorization added. Every header given is signed, Host always."""
u = urlsplit(url)
stamp = at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
day = stamp[:8]
out = dict(headers)
out["Host"] = u.netloc
out["x-amz-date"] = stamp
out["x-amz-content-sha256"] = payload_sha256
signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()}
names = sorted(signed)
query = sorted(
(_encode(k), _encode(v)) for k, v in parse_qsl(u.query, keep_blank_values=True)
)
canonical = "\n".join([
method.upper(),
_encode(unquote(u.path) or "/", keep_slash=True),
"&".join(f"{k}={v}" for k, v in query),
"".join(f"{n}:{signed[n]}\n" for n in names),
";".join(names),
payload_sha256,
])
scope = f"{day}/{region}/{service}/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope,
hashlib.sha256(canonical.encode()).hexdigest()])
key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region),
service), "aws4_request")
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
out["Authorization"] = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
f"SignedHeaders={';'.join(names)}, Signature={signature}")
return out
# ── Transport ────────────────────────────────────────────────────────────────
def _connect(target: S3Target, url: str, timeout: float) -> http.client.HTTPConnection:
u = urlsplit(url)
if u.scheme == "https":
ctx = ssl.create_default_context()
if not target.verify_tls:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(
u.hostname, u.port, timeout=timeout, context=ctx, blocksize=CHUNK,
)
if u.scheme != "http":
raise S3Error(f"{target.endpoint} isn't an http:// or https:// address.")
return http.client.HTTPConnection(u.hostname, u.port, timeout=timeout, blocksize=CHUNK)
def _request_target(url: str) -> str:
u = urlsplit(url)
return (u.path or "/") + (f"?{u.query}" if u.query else "")
def _reach_error(target: S3Target, exc: Exception) -> S3Error:
if isinstance(exc, TimeoutError):
return S3Error(f"{target.endpoint} didn't answer in time.")
if isinstance(exc, socket.gaierror):
return S3Error(f"Couldn't reach {target.endpoint}: the host name doesn't resolve.")
if isinstance(exc, ConnectionRefusedError):
return S3Error(
f"Couldn't reach {target.endpoint}: nothing is listening there "
"(check the port)."
)
if isinstance(exc, ssl.SSLError):
return S3Error(f"Couldn't reach {target.endpoint}: TLS failed ({exc.reason}).")
return S3Error(f"Couldn't reach {target.endpoint}: {exc}.")
def _says(status: int, reason: str, content: bytes) -> str:
"""An S3 error response in words: its Code and Message when it has them."""
try:
root = ET.fromstring(content) if content else None
code = (root.findtext("Code") or "") if root is not None else ""
message = (root.findtext("Message") or "") if root is not None else ""
except ET.ParseError:
code, message = "", ""
hints = {
"SignatureDoesNotMatch": "the secret key is wrong",
"InvalidAccessKeyId": "the access key isn't known there",
"NoSuchBucket": "there is no such bucket",
"AccessDenied": "the key isn't allowed to do that",
"XAmzContentSHA256Mismatch": "the file's bytes don't match its recorded checksum",
}
said = hints.get(code) or message or reason
if not code and 400 <= status < 500:
# S3 refuses with an <Error><Code> body. A 4xx without one came from
# something else at that address — most often a web server on the
# default port, when the S3 service listens on a port of its own.
said = ("the address answered, but not as S3 — check the scheme and "
"the port (an S3 service usually listens on its own port)")
return f"HTTP {status}{f' {code}' if code else ''}: {said}."
# ── Calls ────────────────────────────────────────────────────────────────────
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(CHUNK):
h.update(chunk)
return h.hexdigest()
def put_file(target: S3Target, key: str, path: Path, *,
content_type: str = "application/octet-stream",
payload_sha256: str | None = None,
pacer: Pacer | None = None,
now: datetime | None = None) -> None:
"""Upload one file as one object, streamed from disk. `payload_sha256` is
the file's sha256 when the caller already knows it (the library stores
every file's), which saves reading the file twice; the bucket checks the
bytes against it, so a file that changed on disk is refused rather than
stored under the wrong name."""
digest = payload_sha256 or file_sha256(path)
size = path.stat().st_size
url = target.url(key)
headers = sign("PUT", url, {"Content-Length": str(size), "Content-Type": content_type},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
with open(path, "rb") as f:
conn.request("PUT", _request_target(url),
body=pacer.file_chunks(f) if pacer else f, headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status // 100 != 2:
raise S3Error(f"Upload of {key} refused: "
f"{_says(response.status, response.reason, content)}")
def get_file(target: S3Target, key: str, path: Path, *,
now: datetime | None = None) -> None:
"""Download one object to `path`, streamed to disk. The bytes land under a
`.partial` name and are renamed into place when complete, so a broken
transfer never leaves a truncated file at `path`."""
url = target.url(key)
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
partial = path.with_name(path.name + ".partial")
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
if response.status != 200:
raise S3Error(f"Reading {key} refused: "
f"{_says(response.status, response.reason, response.read())}")
with open(partial, "wb") as f:
while chunk := response.read(CHUNK):
f.write(chunk)
os.replace(partial, path)
except (OSError, http.client.HTTPException) as exc:
partial.unlink(missing_ok=True)
raise _reach_error(target, exc) from None
finally:
conn.close()
def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *,
continuation: str | None = None,
now: datetime | None = None) -> ListPage:
"""One page (up to `limit`) of the objects under `prefix`, ListObjectsV2."""
params = {"list-type": "2", "max-keys": str(limit)}
if prefix:
params["prefix"] = prefix
if continuation:
params["continuation-token"] = continuation
url = f"{target.url()}?{urlencode(params, quote_via=quote)}"
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
conn = _connect(target, url, TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status != 200:
raise S3Error(f"Listing {target.bucket} refused: "
f"{_says(response.status, response.reason, content)}")
try:
root = ET.fromstring(content)
except ET.ParseError:
raise S3Error(
f"{target.endpoint} answered a listing with something that isn't S3's XML."
) from None
objects, next_token = [], None
for item in root.iter():
tag = item.tag.rsplit("}", 1)[-1]
if tag == "NextContinuationToken":
next_token = item.text or None
if tag != "Contents":
continue
fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item}
objects.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0),
fields.get("LastModified", "")))
return ListPage(objects, next_token)
# ── Multipart: one object sent in parts ─────────────────────────────────────
# Each part is held in memory while it is sent, so this bounds the memory a
# streamed upload uses. S3 allows 10,000 parts, which at this size is 640 GiB
# for one object, and every part but the last must be at least 5 MiB.
PART_SIZE = 64 * CHUNK
# A part the bucket couldn't take (it didn't answer, or said it was busy) is
# sent again after a rest that grows each time: 15 s, then 60 s.
PART_RETRY_WAITS = (15, 60)
_BUSY = frozenset({429, 500, 502, 503, 504})
_MAX_RETRY_AFTER = 300
class Pacer:
"""Holds the bytes a job sends to an average rate, across every request
it makes, so a backup leaves room on the link for everything else."""
def __init__(self, mbps: int | None):
self.rate = mbps * 1_000_000 / 8 if mbps else None # bytes per second
self.sent = 0
self.started = time.monotonic()
def _wait(self, n: int) -> None:
if self.rate:
self.sent += n
ahead = self.sent / self.rate - (time.monotonic() - self.started)
if ahead > 0:
time.sleep(ahead)
def chunks(self, data: bytes) -> Iterator[memoryview]:
view = memoryview(data)
for at in range(0, len(view), CHUNK):
piece = view[at:at + CHUNK]
self._wait(len(piece))
yield piece
def file_chunks(self, f) -> Iterator[bytes]:
while piece := f.read(CHUNK):
self._wait(len(piece))
yield piece
def _send(target: S3Target, method: str, url: str, body: bytes = b"",
headers: dict[str, str] | None = None, *, timeout: float = TIMEOUT_SECONDS,
pacer: Pacer | None = None,
now: datetime | None = None) -> tuple[int, str, http.client.HTTPMessage, bytes]:
"""Sign and send one request with an in-memory body, paced when a pacer
is given. Returns (status, reason, headers, content)."""
digest = hashlib.sha256(body).hexdigest()
signed = sign(method, url, {"Content-Length": str(len(body)), **(headers or {})},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, timeout)
try:
conn.request(method, _request_target(url),
body=pacer.chunks(body) if pacer and body else body, headers=signed)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
return response.status, response.reason, response.headers, content
def _xml_field(content: bytes, name: str) -> str | None:
try:
root = ET.fromstring(content)
except ET.ParseError:
return None
for item in root.iter():
if item.tag.rsplit("}", 1)[-1] == name:
return item.text
return None
def _is_error_body(content: bytes) -> bool:
try:
return bool(content) and ET.fromstring(content).tag.rsplit("}", 1)[-1] == "Error"
except ET.ParseError:
return False
def _retry_after(headers: http.client.HTTPMessage | None, default: int) -> int:
try:
asked = int((headers or {}).get("Retry-After") or default)
except ValueError:
asked = default
return max(0, min(asked, _MAX_RETRY_AFTER))
class Upload:
"""One object written as a series of parts, for data whose size isn't known
when it starts, such as a database dump streamed from pg_dump.
The object appears in the bucket only when `complete()` succeeds. Until
then the parts are invisible, so a stream that fails partway leaves
nothing behind that looks like a finished backup. `abort()` asks the
bucket to drop the parts at once; if the abort is refused or never
arrives, S3 services expire an unfinished upload on their own (Fabled
Archives after a day)."""
def __init__(self, target: S3Target, key: str, *,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None):
self.target = target
self.key = key
self.size = 0
self.pacer = pacer
self._etags: list[str] = []
status, reason, _, content = _send(
target, "POST", f"{target.url(key)}?uploads", headers={"Content-Type": content_type},
)
upload_id = _xml_field(content, "UploadId") if status == 200 else None
if not upload_id:
raise S3Error(f"Starting the upload of {key} refused: "
f"{_says(status, reason, content)}")
self.upload_id = upload_id
def _url(self, **params: str) -> str:
query = urlencode({**params, "uploadId": self.upload_id}, quote_via=quote)
return f"{self.target.url(self.key)}?{query}"
@property
def parts(self) -> int:
return len(self._etags)
def add(self, data: bytes) -> None:
"""Send the next part, resting and sending it again when the bucket
doesn't answer or says it is busy."""
n = len(self._etags) + 1
for wait in (*PART_RETRY_WAITS, None):
headers = None
try:
status, reason, headers, content = _send(
self.target, "PUT", self._url(partNumber=str(n)), data,
timeout=TRANSFER_TIMEOUT_SECONDS, pacer=self.pacer,
)
except S3Error as exc:
if wait is None:
raise S3Error(f"Part {n} of {self.key}: {exc}") from None
time.sleep(wait)
continue
etag = headers.get("ETag") if status == 200 else None
if etag:
self._etags.append(etag)
self.size += len(data)
return
if status not in _BUSY or wait is None:
raise S3Error(f"Part {n} of {self.key} refused: "
f"{_says(status, reason, content)}")
time.sleep(_retry_after(headers, wait))
def complete(self) -> None:
"""Join the parts into the object. S3 can refuse a completion with an
error body under a 200 status, so the body is read too."""
if not self._etags:
raise S3Error(f"Nothing was sent for {self.key}.")
body = "<CompleteMultipartUpload>" + "".join(
f"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
for n, etag in enumerate(self._etags, start=1)
) + "</CompleteMultipartUpload>"
status, reason, _, content = _send(
self.target, "POST", self._url(), body.encode(),
headers={"Content-Type": "application/xml"}, timeout=TRANSFER_TIMEOUT_SECONDS,
)
if status != 200 or _is_error_body(content):
raise S3Error(f"Finishing the upload of {self.key} refused: "
f"{_says(status, reason, content)}")
def abort(self) -> None:
"""Drop the parts sent so far. Never raises: it runs while another
failure is already being reported, and the bucket expires the parts on
its own if this doesn't reach it."""
try:
_send(self.target, "DELETE", self._url())
except Exception: # noqa: BLE001 — best effort, see above
pass
def put_file_in_parts(target: S3Target, key: str, path: Path, *, sha256: str,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None) -> int:
"""Upload a large file as a multipart object, read from disk a part at a
time. The whole file's sha256 is computed on the way and must equal
`sha256` before the upload is completed; otherwise it is aborted, so a file
that changed on disk is never stored under its old name. Returns the
number of bytes sent."""
upload = Upload(target, key, content_type=content_type, pacer=pacer)
digest = hashlib.sha256()
try:
with open(path, "rb") as f:
while part := f.read(PART_SIZE):
digest.update(part)
upload.add(part)
if digest.hexdigest() != sha256:
raise S3Error(f"{path} no longer matches its recorded checksum; not stored.")
upload.complete()
except BaseException:
upload.abort()
raise
return upload.size
def object_present(target: S3Target, key: str, size: int) -> bool:
"""Whether `key` is already in the bucket at `size` bytes. Used when a put
is refused: an append-only bucket refuses to replace an object that is
already there, which for a content-addressed key means the work is done."""
try:
page = list_objects(target, prefix=key, limit=1)
except S3Error:
return False
return any(o.key == key and o.size == size for o in page.objects)