feat: Settings can point backups at an off-site S3 bucket, with a Test destination check
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
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
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
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
Settings → Maintenance → Storage gains "Off-site backup destination": the
address (with its port), bucket, region, access key and secret key of an S3
bucket such as Fabled Archives. The secret key is sealed with the credential
key, never returned by the API, and kept when the field is left blank. Test
destination lists the bucket with the fields as typed and writes nothing, and
names the cause when it fails ("nothing is listening there (check the port)",
"the secret key is wrong", "answered, but not as S3").
services/backup_s3 is a standard-library SigV4 client (put, get, paged list)
ported from Grimoire's; it is held to AWS's published signing examples and
exercised against a local HTTP server. Migration 0118 adds the columns; no
destination is set up until someone saves one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -236,6 +236,109 @@ async def get_settings():
|
||||
})
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["GET"])
|
||||
async def get_destination():
|
||||
"""The off-site destination as the settings screen shows it. The secret
|
||||
key is never returned, only whether one is saved."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["PATCH"])
|
||||
async def patch_destination():
|
||||
"""Save the destination. Body {endpoint, bucket, access_key, region,
|
||||
secret_key}; an empty field clears it. A missing or empty secret_key
|
||||
keeps the saved one, so the screen can save the other fields without the
|
||||
secret being typed again."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
body = await request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", detail="body must be a JSON object")
|
||||
fields, why = dest.clean_fields(body)
|
||||
if fields is None:
|
||||
return _bad("invalid_destination", detail=why)
|
||||
secret = body.get("secret_key")
|
||||
if secret is not None and not isinstance(secret, str):
|
||||
return _bad("invalid_destination", detail="secret_key must be text")
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
row.backup_s3_endpoint = fields.endpoint
|
||||
row.backup_s3_bucket = fields.bucket
|
||||
row.backup_s3_access_key = fields.access_key
|
||||
row.backup_s3_region = fields.region
|
||||
if secret and secret.strip():
|
||||
row.backup_s3_secret_sealed = dest.seal(secret.strip())
|
||||
await session.commit()
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination", methods=["DELETE"])
|
||||
async def delete_destination():
|
||||
"""Forget the destination, secret included. What was already sent stays in
|
||||
the bucket: FabledCurator never deletes there."""
|
||||
from ..services import backup_destination as dest
|
||||
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
row.backup_s3_endpoint = None
|
||||
row.backup_s3_bucket = None
|
||||
row.backup_s3_access_key = None
|
||||
row.backup_s3_secret_sealed = None
|
||||
row.backup_s3_region = dest.DEFAULT_REGION
|
||||
await session.commit()
|
||||
return jsonify(dest.describe(row))
|
||||
|
||||
|
||||
@system_backup_bp.route("/destination/test", methods=["POST"])
|
||||
async def try_destination():
|
||||
"""Reach the bucket with the fields in the body (the form as typed, saved
|
||||
or not); a missing secret_key uses the saved one. Lists the bucket and
|
||||
writes nothing. Answers {ok, message} either way: a destination that
|
||||
can't be reached is a result to show, not an API error."""
|
||||
import asyncio
|
||||
|
||||
from ..services import backup_destination as dest
|
||||
from ..services.backup_s3 import S3Error
|
||||
from ..services.credential_crypto import InvalidCredentialBlob
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return _bad("invalid_body", detail="body must be a JSON object")
|
||||
fields, why = dest.clean_fields(body)
|
||||
if fields is None:
|
||||
return jsonify({"ok": False, "message": f"Not tested: {why}."})
|
||||
secret = body.get("secret_key")
|
||||
secret = secret.strip() if isinstance(secret, str) else ""
|
||||
if not secret:
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
sealed = row.backup_s3_secret_sealed
|
||||
if sealed:
|
||||
try:
|
||||
secret = dest.unseal(sealed)
|
||||
except InvalidCredentialBlob:
|
||||
return jsonify({"ok": False, "message": (
|
||||
"Not tested: the saved secret key can't be opened on this server. "
|
||||
"Enter the secret key again."
|
||||
)})
|
||||
target = dest.target_for(fields, secret or None)
|
||||
if target is None:
|
||||
missing = [name for name, v in (
|
||||
("address", fields.endpoint), ("bucket", fields.bucket),
|
||||
("access key", fields.access_key), ("secret key", secret),
|
||||
) if not v]
|
||||
return jsonify({"ok": False, "message": f"Not tested: fill in the {', '.join(missing)}."})
|
||||
try:
|
||||
return jsonify(await asyncio.to_thread(dest.check_target, target))
|
||||
except S3Error as exc:
|
||||
return jsonify({"ok": False, "message": str(exc)})
|
||||
|
||||
|
||||
@system_backup_bp.route("/settings", methods=["PATCH"])
|
||||
async def patch_settings():
|
||||
body = await request.get_json(silent=True)
|
||||
|
||||
Reference in New Issue
Block a user