diff --git a/solstone/apps/backup/routes.py b/solstone/apps/backup/routes.py index 65b387b0e..462da09b9 100644 --- a/solstone/apps/backup/routes.py +++ b/solstone/apps/backup/routes.py @@ -393,9 +393,8 @@ def _restore_hosted_thunk( except HostedCredsUnavailable as exc: return OpOutcome("error", exc.reason_code) - destination = operated_destination(binding, creds) save_hosted_binding(binding) - restore_result = restore_journal_operated(destination, recovery_key) + restore_result = restore_journal_operated(binding, creds, recovery_key) return OpOutcome( status=restore_result.status, reason_code=restore_result.reason_code, diff --git a/solstone/apps/backup/tests/test_maintenance_routines.py b/solstone/apps/backup/tests/test_maintenance_routines.py index 6cc372321..9c686fdce 100644 --- a/solstone/apps/backup/tests/test_maintenance_routines.py +++ b/solstone/apps/backup/tests/test_maintenance_routines.py @@ -19,14 +19,14 @@ def test_backup_routines_are_discovered_with_expected_schedule_entries() -> None assert "backup:run" in routines assert "backup:prune" in routines assert routines["backup:run"].every == "hourly" - assert routines["backup:run"].max_runtime == "7h" + assert routines["backup:run"].max_runtime == "49h" assert routines["backup:prune"].every == "daily" assert routines["backup:prune"].max_runtime == "3h" assert expected_schedule_entry("backup:run", routines["backup:run"]) == { "cmd": ["journal", "maintenance", "run", "backup:run"], "every": "hourly", "enabled": True, - "max_runtime": "7h", + "max_runtime": "49h", } assert expected_schedule_entry("backup:prune", routines["backup:prune"]) == { "cmd": ["journal", "maintenance", "run", "backup:prune"], diff --git a/solstone/apps/backup/tests/test_routes.py b/solstone/apps/backup/tests/test_routes.py index aab6003df..19050f22d 100644 --- a/solstone/apps/backup/tests/test_routes.py +++ b/solstone/apps/backup/tests/test_routes.py @@ -63,6 +63,7 @@ def _creds() -> HostedCredentials: secret_access_key="SAK", session_token="SESS", endpoint="https://r2.example", + expires_at="2026-07-13T12:00:00Z", ) @@ -549,10 +550,7 @@ def test_restore_hosted_approved_works_without_local_keys( assert response.get_json()["operation"]["portal_url"] == CONSENT_URL assert final["reason_code"] is None save_hosted_binding.assert_called_once_with(binding) - restore_journal_operated.assert_called_once() - destination = restore_journal_operated.call_args.args[0] - assert destination.credentials["session_token"] == "SESS" - assert restore_journal_operated.call_args.args[1] == "A" * 64 + restore_journal_operated.assert_called_once_with(binding, _creds(), "A" * 64) def test_restore_hosted_needs_subscription_returns_terminal_phase( diff --git a/solstone/think/backup/engine.py b/solstone/think/backup/engine.py index c4d2af5b7..ac33d8a1d 100644 --- a/solstone/think/backup/engine.py +++ b/solstone/think/backup/engine.py @@ -7,7 +7,8 @@ from __future__ import annotations import logging import time -from collections.abc import Mapping +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path @@ -16,11 +17,14 @@ from solstone.think.backup.destination import ( assemble_backend_env, ) from solstone.think.backup.hosted import ( + HostedBinding, + HostedCredentials, HostedCredsUnavailable, fetch_hosted_credentials, load_hosted_binding, operated_destination, ) +from solstone.think.backup.hosted_provider import hosted_restic_session from solstone.think.backup.install import ensure_restic from solstone.think.backup.runner import ( reason_for_returncode, @@ -67,8 +71,9 @@ BACKUP_EXCLUDES = ( PRUNE_MAX_REPACK_SIZE = "1G" UNLOCK_TIMEOUT_SECONDS = 5 * 60 BACKUP_TIMEOUT_SECONDS = 6 * 60 * 60 +INITIAL_BACKUP_TIMEOUT_SECONDS = 48 * 60 * 60 PRUNE_TIMEOUT_SECONDS = 2 * 60 * 60 -BACKUP_MAX_RUNTIME = "7h" +BACKUP_MAX_RUNTIME = "49h" PRUNE_MAX_RUNTIME = "3h" BACKUP_RUN_CMD = ["journal", "maintenance", "run", "backup:run"] @@ -91,6 +96,8 @@ class _Runtime: destination: Destination keys: BackupKeys restic_path: Path + binding: HostedBinding | None = None + hosted_credentials: HostedCredentials | None = None class _ResticUnavailable(RuntimeError): @@ -113,6 +120,8 @@ def _resolve_runtime(scope: str) -> _Runtime | None: creds = fetch_hosted_credentials(binding, scope=scope) destination = operated_destination(binding, creds) else: + binding = None + creds = None destination = get_destination() if destination is None: return None @@ -122,7 +131,13 @@ def _resolve_runtime(scope: str) -> _Runtime | None: except Exception as exc: raise _ResticUnavailable from exc - return _Runtime(destination=destination, keys=keys, restic_path=restic_path) + return _Runtime( + destination=destination, + keys=keys, + restic_path=restic_path, + binding=binding, + hosted_credentials=creds, + ) def _backup_args() -> list[str]: @@ -149,13 +164,49 @@ def _assemble_backend_env( return None +@contextmanager +def _runtime_backend( + runtime: _Runtime, + *, + scope: str, + operation: str, +) -> Iterator[tuple[Destination, Mapping[str, str | None]] | None]: + if runtime.binding is not None and runtime.hosted_credentials is not None: + with hosted_restic_session( + runtime.binding, + scope=scope, + initial_credentials=runtime.hosted_credentials, + ) as session: + yield session.destination, session.backend_env + return + + backend_env = _assemble_backend_env(runtime.destination, operation=operation) + if backend_env is None: + yield None + return + yield runtime.destination, backend_env + + +def _backup_timeout() -> int: + last_backup = get_backup_config()["last_backup"] + snapshot_id = last_backup.get("snapshot_id") + if ( + last_backup.get("status") != "ok" + or not isinstance(snapshot_id, str) + or not snapshot_id + ): + return INITIAL_BACKUP_TIMEOUT_SECONDS + return BACKUP_TIMEOUT_SECONDS + + def _recover_stale_lock( runtime: _Runtime, + destination: Destination, backend_env: Mapping[str, str | None], ) -> None: result = run_restic( ["unlock"], - repository=runtime.destination.repository, + repository=destination.repository, password=runtime.keys.daily_key, restic_path=runtime.restic_path, backend_env=backend_env, @@ -219,20 +270,20 @@ def run_backup() -> BackupResult: if runtime is None: return BackupResult(status="skipped", snapshot_id=None, error_reason=None) - backend_env = _assemble_backend_env(runtime.destination, operation="run") - if backend_env is None: - return _record_backup_error(reason="failed") - - _recover_stale_lock(runtime, backend_env) - result = run_restic( - _backup_args(), - repository=runtime.destination.repository, - password=runtime.keys.daily_key, - restic_path=runtime.restic_path, - backend_env=backend_env, - json=True, - timeout=BACKUP_TIMEOUT_SECONDS, - ) + with _runtime_backend(runtime, scope="backup", operation="run") as backend: + if backend is None: + return _record_backup_error(reason="failed") + destination, backend_env = backend + _recover_stale_lock(runtime, destination, backend_env) + result = run_restic( + _backup_args(), + repository=destination.repository, + password=runtime.keys.daily_key, + restic_path=runtime.restic_path, + backend_env=backend_env, + json=True, + timeout=_backup_timeout(), + ) summary = select_summary(result.json) snapshot_id = None if summary is not None: @@ -288,32 +339,32 @@ def run_prune() -> PruneResult: if runtime is None: return PruneResult(status="skipped", error_reason=None) - backend_env = _assemble_backend_env(runtime.destination, operation="prune") - if backend_env is None: - return _record_prune_error(reason="failed") - - _recover_stale_lock(runtime, backend_env) - retention = get_backup_config()["retention"] - result = run_restic( - [ - "forget", - "--keep-hourly", - str(retention.get("hourly", 24)), - "--keep-daily", - str(retention.get("daily", 7)), - "--keep-weekly", - str(retention.get("weekly", 4)), - "--keep-monthly", - str(retention.get("monthly", 12)), - "--prune", - ], - repository=runtime.destination.repository, - password=runtime.keys.daily_key, - restic_path=runtime.restic_path, - backend_env=backend_env, - timeout=PRUNE_TIMEOUT_SECONDS, - max_repack_size=PRUNE_MAX_REPACK_SIZE, - ) + with _runtime_backend(runtime, scope="maintenance", operation="prune") as backend: + if backend is None: + return _record_prune_error(reason="failed") + destination, backend_env = backend + _recover_stale_lock(runtime, destination, backend_env) + retention = get_backup_config()["retention"] + result = run_restic( + [ + "forget", + "--keep-hourly", + str(retention.get("hourly", 24)), + "--keep-daily", + str(retention.get("daily", 7)), + "--keep-weekly", + str(retention.get("weekly", 4)), + "--keep-monthly", + str(retention.get("monthly", 12)), + "--prune", + ], + repository=destination.repository, + password=runtime.keys.daily_key, + restic_path=runtime.restic_path, + backend_env=backend_env, + timeout=PRUNE_TIMEOUT_SECONDS, + max_repack_size=PRUNE_MAX_REPACK_SIZE, + ) if result.returncode == 0: record_prune_result( @@ -343,6 +394,7 @@ def request_backup_now() -> bool: __all__ = [ "BACKUP_MAX_RUNTIME", "BACKUP_TIMEOUT_SECONDS", + "INITIAL_BACKUP_TIMEOUT_SECONDS", "BackupResult", "PRUNE_MAX_REPACK_SIZE", "PRUNE_MAX_RUNTIME", diff --git a/solstone/think/backup/hosted.py b/solstone/think/backup/hosted.py index 4f11a5472..9fd10baec 100644 --- a/solstone/think/backup/hosted.py +++ b/solstone/think/backup/hosted.py @@ -52,6 +52,7 @@ class HostedCredentials: secret_access_key: str session_token: str endpoint: str + expires_at: str def __repr__(self) -> str: return ( @@ -226,11 +227,13 @@ def fetch_hosted_credentials( secret_access_key = _non_blank_string(payload, "secret_access_key") session_token = _non_blank_string(payload, "session_token") endpoint = _non_blank_string(payload, "endpoint") + expires_at = _non_blank_string(payload, "expires_at") if ( access_key_id is None or secret_access_key is None or session_token is None or endpoint is None + or expires_at is None ): logger.warning("hosted credential broker response was incomplete") raise HostedCredsUnavailable("broker_error") @@ -240,6 +243,7 @@ def fetch_hosted_credentials( secret_access_key=secret_access_key, session_token=session_token, endpoint=endpoint, + expires_at=expires_at, ) diff --git a/solstone/think/backup/hosted_provider.py b/solstone/think/backup/hosted_provider.py new file mode 100644 index 000000000..907b4183a --- /dev/null +++ b/solstone/think/backup/hosted_provider.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Authenticated loopback credential provider for long operated restic runs.""" + +from __future__ import annotations + +import json +import secrets +import threading +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from solstone.think.backup.destination import Destination +from solstone.think.backup.hosted import ( + HostedBinding, + HostedCredentials, + HostedCredsUnavailable, + fetch_hosted_credentials, + operated_repository, +) + +_CREDENTIAL_PATH = "/credentials" + + +@dataclass(frozen=True) +class HostedResticSession: + destination: Destination + backend_env: Mapping[str, str] + + +class _CredentialState: + def __init__( + self, + binding: HostedBinding, + scope: str, + initial_credentials: HostedCredentials, + ) -> None: + self.binding = binding + self.scope = scope + self._initial_credentials: HostedCredentials | None = initial_credentials + self._lock = threading.Lock() + + def next_credentials(self) -> HostedCredentials: + with self._lock: + if self._initial_credentials is not None: + credentials = self._initial_credentials + self._initial_credentials = None + return credentials + return fetch_hosted_credentials(self.binding, scope=self.scope) + + +class _CredentialServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__( + self, + state: _CredentialState, + authorization_token: str, + ) -> None: + self.state = state + self.authorization_token = authorization_token + super().__init__(("127.0.0.1", 0), _CredentialHandler) + + +class _CredentialHandler(BaseHTTPRequestHandler): + server: _CredentialServer + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract + if self.path != _CREDENTIAL_PATH: + self.send_error(404) + return + if self.headers.get("Authorization") != self.server.authorization_token: + self.send_error(401) + return + + try: + credentials = self.server.state.next_credentials() + except HostedCredsUnavailable: + self.send_error(503) + return + + body = json.dumps( + { + "AccessKeyId": credentials.access_key_id, + "SecretAccessKey": credentials.secret_access_key, + "Token": credentials.session_token, + "Expiration": credentials.expires_at, + }, + separators=(",", ":"), + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +@contextmanager +def hosted_restic_session( + binding: HostedBinding, + *, + scope: str, + initial_credentials: HostedCredentials | None = None, +) -> Iterator[HostedResticSession]: + credentials = initial_credentials or fetch_hosted_credentials(binding, scope=scope) + authorization_token = secrets.token_urlsafe(32) + state = _CredentialState(binding, scope, credentials) + server = _CredentialServer(state, authorization_token) + thread = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.05}, + name="spb-credential-provider", + daemon=True, + ) + thread.start() + host, port = server.server_address + try: + yield HostedResticSession( + destination=Destination( + repository=operated_repository(binding, credentials), + backend="s3", + credentials={}, + ), + backend_env={ + "AWS_CONTAINER_CREDENTIALS_FULL_URI": ( + f"http://{host}:{port}{_CREDENTIAL_PATH}" + ), + "AWS_CONTAINER_AUTHORIZATION_TOKEN": authorization_token, + }, + ) + finally: + server.shutdown() + server.server_close() + thread.join() + + +__all__ = ["HostedResticSession", "hosted_restic_session"] diff --git a/solstone/think/backup/restore.py b/solstone/think/backup/restore.py index 558908993..db1a80e68 100644 --- a/solstone/think/backup/restore.py +++ b/solstone/think/backup/restore.py @@ -6,11 +6,13 @@ from __future__ import annotations import logging -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any from solstone.think.backup.destination import Destination, assemble_backend_env +from solstone.think.backup.hosted import HostedBinding, HostedCredentials +from solstone.think.backup.hosted_provider import hosted_restic_session from solstone.think.backup.install import ensure_restic from solstone.think.backup.keys import parse_recovery_key from solstone.think.backup.runner import ( @@ -31,8 +33,8 @@ from solstone.think.utils import get_journal logger = logging.getLogger("solstone.backup.restore") RESTORE_LIST_TIMEOUT_SECONDS = 5 * 60 -RESTORE_TIMEOUT_SECONDS = 6 * 60 * 60 -RESTORE_CHECK_TIMEOUT_SECONDS = 60 * 60 +RESTORE_TIMEOUT_SECONDS = 48 * 60 * 60 +RESTORE_CHECK_TIMEOUT_SECONDS = 6 * 60 * 60 @dataclass(frozen=True) @@ -88,16 +90,19 @@ def _run_restore( destination: Destination, entered_recovery_key: str, persist: Callable[[str], None], + *, + backend_env: Mapping[str, str | None] | None = None, ) -> RestoreResult: try: canonical = parse_recovery_key(entered_recovery_key) except ValueError: return _restore_error("invalid_key") - try: - backend_env = assemble_backend_env(destination) - except (KeyError, ValueError): - return _restore_error("failed") + if backend_env is None: + try: + backend_env = assemble_backend_env(destination) + except (KeyError, ValueError): + return _restore_error("failed") try: restic_path = ensure_restic() @@ -198,7 +203,8 @@ def restore_journal( def restore_journal_operated( - destination: Destination, + binding: HostedBinding, + initial_credentials: HostedCredentials, entered_recovery_key: str, ) -> RestoreResult: def persist(canonical: str) -> None: @@ -206,7 +212,17 @@ def restore_journal_operated( set_recovery_key(canonical) set_recovery_key_confirmed(True) - return _run_restore(destination, entered_recovery_key, persist) + with hosted_restic_session( + binding, + scope="backup", + initial_credentials=initial_credentials, + ) as session: + return _run_restore( + session.destination, + entered_recovery_key, + persist, + backend_env=session.backend_env, + ) __all__ = [ diff --git a/tests/test_backup_engine.py b/tests/test_backup_engine.py index 6afab0338..556e45159 100644 --- a/tests/test_backup_engine.py +++ b/tests/test_backup_engine.py @@ -262,7 +262,7 @@ def test_run_backup_unlocks_then_calls_restic_with_expected_argv( "AWS_SECRET_ACCESS_KEY": "secret-key", }, "json": True, - "timeout": engine.BACKUP_TIMEOUT_SECONDS, + "timeout": engine.INITIAL_BACKUP_TIMEOUT_SECONDS, }, ) record_backup_result.assert_called_once_with( @@ -661,6 +661,7 @@ def test_operated_backup_fetches_creds_and_builds_repo( secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: @@ -682,11 +683,13 @@ def test_operated_backup_fetches_creds_and_builds_repo( assert result.status == "ok" backup_call = next(call for call in calls if call[0][0] == "backup") backup_kwargs = backup_call[1] - assert backup_kwargs["backend_env"] == { - "AWS_ACCESS_KEY_ID": "AKID", - "AWS_SECRET_ACCESS_KEY": "SAK", - "AWS_SESSION_TOKEN": "SESS", - } + backend_env = backup_kwargs["backend_env"] + assert backend_env["AWS_CONTAINER_CREDENTIALS_FULL_URI"].startswith( + "http://127.0.0.1:" + ) + assert backend_env["AWS_CONTAINER_AUTHORIZATION_TOKEN"] + for secret in ("AKID", "SAK", "SESS"): + assert secret not in json.dumps(backend_env) assert ( backup_kwargs["repository"] == "s3:https://acct.r2.cloudflarestorage.com/bkt/users/acct/inst" @@ -736,6 +739,7 @@ def test_operated_prune_requests_maintenance_scope( secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: @@ -751,7 +755,33 @@ def test_operated_prune_requests_maintenance_scope( assert result.status == "ok" forget_call = next(call for call in calls if call[0][0] == "forget") assert captured["scope"] == "maintenance" - assert forget_call[1]["backend_env"]["AWS_SESSION_TOKEN"] == "SESS" + assert forget_call[1]["backend_env"][ + "AWS_CONTAINER_CREDENTIALS_FULL_URI" + ].startswith("http://127.0.0.1:") + + +def test_backup_timeout_is_long_only_until_first_snapshot( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + config = _valid_backup_config() + _write_config(tmp_path, config) + + assert engine._backup_timeout() == engine.INITIAL_BACKUP_TIMEOUT_SECONDS + + config["backup"]["last_backup"] = { + "status": "error", + "snapshot_id": "partial-snap", + } + _write_config(tmp_path, config) + + assert engine._backup_timeout() == engine.INITIAL_BACKUP_TIMEOUT_SECONDS + + config["backup"]["last_backup"] = {"status": "ok", "snapshot_id": "snap-1"} + _write_config(tmp_path, config) + + assert engine._backup_timeout() == engine.BACKUP_TIMEOUT_SECONDS def _assert_operated_backup_degrades_on_hosted_credential_error( @@ -918,6 +948,7 @@ def test_operated_does_not_persist_or_log_secrets( secret_access_key="SAK-SECRET", session_token="SESS-SECRET", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) def fake_run_restic(args: list[str], **kwargs: Any) -> ResticResult: diff --git a/tests/test_backup_hosted.py b/tests/test_backup_hosted.py index 0fec5465d..65a2717f7 100644 --- a/tests/test_backup_hosted.py +++ b/tests/test_backup_hosted.py @@ -11,6 +11,7 @@ import urllib.error import urllib.request from pathlib import Path from typing import Any +from unittest.mock import Mock import pytest @@ -25,6 +26,7 @@ from solstone.think.backup.hosted import ( operated_repository, save_hosted_binding, ) +from solstone.think.backup.hosted_provider import hosted_restic_session class _FakeResponse: @@ -64,6 +66,7 @@ def _credentials() -> HostedCredentials: secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com/", + expires_at="2026-07-13T12:00:00Z", ) @@ -158,6 +161,7 @@ def test_fetch_hosted_credentials_happy_path( "secret_access_key": "SAK", "session_token": "SESS", "endpoint": "https://acct.r2.cloudflarestorage.com", + "expires_at": "2026-07-13T12:00:00Z", } ).encode("utf-8") ) @@ -174,6 +178,7 @@ def test_fetch_hosted_credentials_happy_path( secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) @@ -275,6 +280,7 @@ def test_repr_redacts_secrets() -> None: secret_access_key="SAK-SECRET", session_token="SESS-SECRET", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) rendered = repr(creds) for secret in ("AKID-SECRET", "SAK-SECRET", "SESS-SECRET"): @@ -296,3 +302,57 @@ def test_broker_token_not_logged_on_degrade( fetch_hosted_credentials(_binding(broker_token="the-token"), scope="backup") assert "the-token" not in caplog.text + + +def test_hosted_restic_session_serves_initial_then_renewed_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + initial = _credentials() + renewed = HostedCredentials( + access_key_id="AKID-2", + secret_access_key="SAK-2", + session_token="SESS-2", + endpoint="https://acct.r2.cloudflarestorage.com/", + expires_at="2026-07-13T13:00:00Z", + ) + fetch = Mock(return_value=renewed) + monkeypatch.setattr( + "solstone.think.backup.hosted_provider.fetch_hosted_credentials", + fetch, + ) + + with hosted_restic_session( + _binding(prefix="users/acct/inst/"), + scope="backup", + initial_credentials=initial, + ) as session: + uri = session.backend_env["AWS_CONTAINER_CREDENTIALS_FULL_URI"] + token = session.backend_env["AWS_CONTAINER_AUTHORIZATION_TOKEN"] + assert uri.startswith("http://127.0.0.1:") + assert session.destination.credentials == {} + + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(uri, timeout=1) + assert exc_info.value.code == 401 + + first_request = urllib.request.Request( + uri, + headers={"Authorization": token}, + ) + with urllib.request.urlopen(first_request, timeout=1) as response: + first = json.loads(response.read()) + assert first == { + "AccessKeyId": "AKID", + "SecretAccessKey": "SAK", + "Token": "SESS", + "Expiration": "2026-07-13T12:00:00Z", + } + fetch.assert_not_called() + + with urllib.request.urlopen(first_request, timeout=1) as response: + second = json.loads(response.read()) + assert second["AccessKeyId"] == "AKID-2" + assert second["Expiration"] == "2026-07-13T13:00:00Z" + fetch.assert_called_once_with( + _binding(prefix="users/acct/inst/"), scope="backup" + ) diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 9a3cb131a..6793aeb4c 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -12,6 +12,7 @@ import pytest from solstone.think.backup import restore from solstone.think.backup.destination import Destination +from solstone.think.backup.hosted import HostedBinding, HostedCredentials from solstone.think.backup.runner import ResticResult @@ -52,6 +53,27 @@ def _operated_destination() -> Destination: ) +def _operated_binding() -> HostedBinding: + return HostedBinding( + broker_endpoint="https://broker.example", + account_id="acct", + instance_id="inst", + bucket="journal-backups", + prefix="users/acct/inst/", + broker_token="BTOKEN-OPERATED", + ) + + +def _operated_credentials() -> HostedCredentials: + return HostedCredentials( + access_key_id="AKID-OPERATED", + secret_access_key="SAK-OPERATED", + session_token="SESSION-OPERATED", + endpoint="https://r2.example", + expires_at="2026-07-13T12:00:00Z", + ) + + def _result(returncode: int, parsed_json: Any | None = None) -> ResticResult: return ResticResult( returncode=returncode, @@ -479,7 +501,11 @@ def test_restore_operated_success_persists_mode_and_key_without_destination( ) monkeypatch.setattr(restore, "scan_journal", fake_scan_journal) - result = restore.restore_journal_operated(destination, entered) + result = restore.restore_journal_operated( + _operated_binding(), + _operated_credentials(), + entered, + ) assert result == restore.RestoreResult( status="ok", @@ -497,11 +523,9 @@ def test_restore_operated_success_persists_mode_and_key_without_destination( "set_recovery_key_confirmed", "scan_journal", ] - assert calls[0][1]["backend_env"] == { - "AWS_ACCESS_KEY_ID": "AKID-OPERATED", - "AWS_SECRET_ACCESS_KEY": "SAK-OPERATED", - "AWS_SESSION_TOKEN": "SESSION-OPERATED", - } + assert calls[0][1]["backend_env"]["AWS_CONTAINER_CREDENTIALS_FULL_URI"].startswith( + "http://127.0.0.1:" + ) config = _read_config(tmp_path) serialized = json.dumps(config) assert config["backup"]["mode"] == "operated" @@ -540,7 +564,11 @@ def test_restore_operated_invalid_key_persists_nothing( lambda destination: pytest.fail("must not persist destination"), ) - result = restore.restore_journal_operated(_operated_destination(), "too-short") + result = restore.restore_journal_operated( + _operated_binding(), + _operated_credentials(), + "too-short", + ) assert result.reason_code == "invalid_key" assert _read_config(tmp_path) == original_config @@ -571,7 +599,11 @@ def test_restore_operated_restic_failure_persists_nothing( lambda key: pytest.fail("must not persist key"), ) - result = restore.restore_journal_operated(_operated_destination(), "A" * 64) + result = restore.restore_journal_operated( + _operated_binding(), + _operated_credentials(), + "A" * 64, + ) assert result.reason_code == "auth_failed" assert _read_config(tmp_path) == original_config diff --git a/tests/test_backup_teardown.py b/tests/test_backup_teardown.py index fd2ae5406..4eb08334c 100644 --- a/tests/test_backup_teardown.py +++ b/tests/test_backup_teardown.py @@ -261,6 +261,7 @@ def test_operated_teardown_wipes_prefix_and_deletes_binding_on_success_without_k secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) def fail_run_restic(*_args: Any, **_kwargs: Any) -> ResticResult: @@ -370,6 +371,7 @@ def test_operated_teardown_wipe_failure_preserves_binding_and_config( secret_access_key="SAK", session_token="SESS", endpoint="https://acct.r2.cloudflarestorage.com", + expires_at="2026-07-13T12:00:00Z", ) ), ) diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 9e55a0990..97d9f9b8d 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -1180,7 +1180,7 @@ def test_register_baseline_caps_sets_explicit_caps(): "segment": 4500, "indexer": 7200, "importer": 3600, - backup_partition: 25200, + backup_partition: 49 * 60 * 60, } for name, seconds in expected.items(): assert queue._effective_cap(name) == seconds