CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The nightly database backup (and "Run DB backup now") now sends pg_dump's output straight to the off-site bucket as a multipart upload, with no copy staged on this server. The object is named database/fabledcurator-<UTC time>.dump and appears only once pg_dump has exited cleanly and every part has landed; a failed dump aborts the upload and records pg_dump's last line. Without a destination, dumps are written to /images/_backups as before. - backup_s3: Upload (start / add part / complete / abort), with a busy or silent bucket rested and asked again (15 s, 60 s, Retry-After obeyed), and a Pacer that holds uploads to the new bandwidth limit. - Settings: "Upload limit (Mbit/s)" on the destination card, 200 by default, 0 for none (migration 0120). - pg_dump runs under nice (and ionice where installed), locally too. - Backups card says where dumps go; the runs table gains "Saved to" and shows a failed run's error. Off-site rows have no restore/tag/delete here: restoring from the bucket is a later step (#6880). Task #6878, milestone 586. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
5.1 KiB
Python
139 lines
5.1 KiB
Python
"""The off-site backup destination: an S3 bucket (Fabled Archives, or any S3
|
|
service) that the database dumps and the media blobs are sent to.
|
|
|
|
The address, bucket, access key and region are plain settings. The secret key
|
|
is sealed at rest with the credential key (`/images/secrets`), the same key
|
|
that seals account logins, and no API ever returns it: the settings answer
|
|
only whether one is set. Because the credential key is kept out of every
|
|
backup, a database dump carries the secret only as ciphertext nobody can open
|
|
without the original server.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
|
|
from .backup_s3 import S3Error, S3Target, list_objects
|
|
from .credential_crypto import CredentialCrypto, InvalidCredentialBlob
|
|
|
|
KEY_PATH = Path("/images/secrets/credential_key.b64")
|
|
DEFAULT_REGION = "us-east-1"
|
|
_TEST_LISTING = 5
|
|
|
|
|
|
def _crypto() -> CredentialCrypto:
|
|
return CredentialCrypto(KEY_PATH)
|
|
|
|
|
|
def seal(secret: str) -> str:
|
|
return _crypto().encrypt(secret).decode("ascii")
|
|
|
|
|
|
def unseal(sealed: str) -> str:
|
|
return _crypto().decrypt(sealed.encode("ascii"))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DestinationFields:
|
|
endpoint: str | None
|
|
bucket: str | None
|
|
access_key: str | None
|
|
region: str
|
|
|
|
|
|
def clean_fields(body: dict) -> tuple[DestinationFields | None, str | None]:
|
|
"""The destination fields from a request body, trimmed, with empty ones as
|
|
None; or (None, why) when the address can't be an S3 endpoint."""
|
|
def text(name: str) -> str | None:
|
|
v = body.get(name)
|
|
if v is None:
|
|
return None
|
|
if not isinstance(v, str):
|
|
raise TypeError(name)
|
|
return v.strip() or None
|
|
|
|
try:
|
|
endpoint = text("endpoint")
|
|
bucket = text("bucket")
|
|
access_key = text("access_key")
|
|
region = text("region") or DEFAULT_REGION
|
|
except TypeError as exc:
|
|
return None, f"{exc.args[0]} must be text"
|
|
if endpoint is not None:
|
|
u = urlsplit(endpoint)
|
|
if u.scheme not in ("http", "https") or not u.hostname:
|
|
return None, ("the address must start with http:// or https:// and name "
|
|
"a host, e.g. http://archives.example:8081")
|
|
if u.query or u.fragment:
|
|
return None, "the address can't carry a query or a fragment"
|
|
endpoint = endpoint.rstrip("/")
|
|
if bucket is not None and "/" in bucket:
|
|
return None, "the bucket is a name, not a path: put any path in the address"
|
|
return DestinationFields(endpoint, bucket, access_key, region), None
|
|
|
|
|
|
def target_for(fields: DestinationFields, secret: str | None) -> S3Target | None:
|
|
"""The S3 target, or None while any part of it is missing."""
|
|
if not (fields.endpoint and fields.bucket and fields.access_key and secret):
|
|
return None
|
|
return S3Target(fields.endpoint, fields.bucket, fields.access_key, secret,
|
|
region=fields.region)
|
|
|
|
|
|
def saved_target(settings) -> S3Target | None:
|
|
"""The destination saved in ImportSettings, or None when none is set up.
|
|
Raises S3Error when the secret can't be opened (the credential key
|
|
changed since it was saved)."""
|
|
sealed = settings.backup_s3_secret_sealed
|
|
if not sealed:
|
|
return None
|
|
try:
|
|
secret = unseal(sealed)
|
|
except InvalidCredentialBlob:
|
|
raise S3Error(
|
|
"The saved secret key can't be opened on this server (the credential "
|
|
"key changed since it was saved). Enter the secret key again."
|
|
) from None
|
|
return target_for(fields_of(settings), secret)
|
|
|
|
|
|
def fields_of(settings) -> DestinationFields:
|
|
return DestinationFields(
|
|
settings.backup_s3_endpoint, settings.backup_s3_bucket,
|
|
settings.backup_s3_access_key, settings.backup_s3_region or DEFAULT_REGION,
|
|
)
|
|
|
|
|
|
def describe(settings) -> dict:
|
|
"""The destination as the settings screen shows it: never the secret."""
|
|
f = fields_of(settings)
|
|
return {
|
|
"endpoint": f.endpoint or "",
|
|
"bucket": f.bucket or "",
|
|
"access_key": f.access_key or "",
|
|
"region": f.region,
|
|
"secret_set": bool(settings.backup_s3_secret_sealed),
|
|
"upload_limit_mbps": settings.backup_upload_limit_mbps,
|
|
"configured": bool(
|
|
f.endpoint and f.bucket and f.access_key and settings.backup_s3_secret_sealed
|
|
),
|
|
}
|
|
|
|
|
|
def check_target(target: S3Target) -> dict:
|
|
"""Reach the bucket by listing it. A listing proves the address, the
|
|
bucket and both keys at once, and writes nothing, because an Archives key
|
|
is allowed to add objects but a test object could never be removed."""
|
|
try:
|
|
page = list_objects(target, limit=_TEST_LISTING)
|
|
except S3Error as exc:
|
|
return {"ok": False, "message": str(exc)}
|
|
if not page.objects:
|
|
said = f"Reached {target.bucket} at {target.endpoint}. The bucket is empty."
|
|
else:
|
|
more = "+" if page.next_token else ""
|
|
said = (f"Reached {target.bucket} at {target.endpoint}. It holds "
|
|
f"{len(page.objects)}{more} object(s), e.g. {page.objects[0].key}.")
|
|
return {"ok": True, "message": said}
|