From ab4951126175a30ab3bfbc554858fe95faf8d8a9 Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:51:00 +0530 Subject: [PATCH 1/6] feat: add restore-site endpoint and example script Add SitesClient.restore_site() wrapping POST /restore-site/{site}, which restores an existing site from one filesystem and one database backup of its own. Add examples/sites/09_restore_site.py demonstrating the full workflow: resolve the backup pair (FS_BACKUP_ID/DB_BACKUP_ID env vars, falling back to the latest backups), set the allow_restore and suspended=503 preconditions, start the restore, poll the response ticket until it completes, and unsuspend the site on success or early failure. --- atomic_sdk/api/sites.py | 26 ++++++ examples/sites/09_restore_site.py | 150 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 examples/sites/09_restore_site.py diff --git a/atomic_sdk/api/sites.py b/atomic_sdk/api/sites.py index 6e3e457..aa254d1 100644 --- a/atomic_sdk/api/sites.py +++ b/atomic_sdk/api/sites.py @@ -545,3 +545,29 @@ def allow_ssh_migration(self, site_id: Optional[int] = None, domain: Optional[st _, identifier = self._get_service_and_identifier(site_id, domain) endpoint = f"/site-allow-ssh-migration/{identifier}" return self._post(endpoint) + + def restore_site(self, restore_from_fs: int, restore_from_db: int, site_id: Optional[int] = None, domain: Optional[str] = None) -> Dict[str, Any]: + """ + Restore a site from its own backups. + + Note: + ``allow_restore`` metadata key must be set to a recent + unix timestamp and the site must be suspended with a + 503 status code for the restore operation to start. + + Args: + restore_from_fs: FileSystem Backup to Restore From. + restore_from_db: Database Backup to Restore From. + site_id: The Atomic site ID. + domain: The domain name of the site. + + Returns: + A dict with a ``atomic_job_id`` (int) key and a + ``response_ticket_id`` (str) key. You can query + the associated response ticket for restoring the + backup. + """ + _, identifier = self._get_service_and_identifier(site_id, domain) + endpoint = f"/restore-site/{identifier}" + payload = [("restore_from[]", str(restore_from_fs)), ("restore_from[]", str(restore_from_db))] + return self._post(endpoint, data=payload) diff --git a/examples/sites/09_restore_site.py b/examples/sites/09_restore_site.py new file mode 100644 index 0000000..31b0672 --- /dev/null +++ b/examples/sites/09_restore_site.py @@ -0,0 +1,150 @@ +""" +Example: restore a site from its own filesystem + database backup pair. + +WARNING: ``client.sites.restore_site`` is DESTRUCTIVE. The site's current +files and database will be OVERWRITTEN by the selected backups. The site +must first allow restores (``allow_restore`` meta set to a recent unix +timestamp) and be suspended with a 503 status code; this script sets both, +runs the restore, polls the response ticket, then unsuspends the site. + +Usage: + python examples/sites/09_restore_site.py + +Or set SITE_DOMAIN in your .env file. Set FS_BACKUP_ID and DB_BACKUP_ID to +restore from specific backups; any ID left unset is resolved to the site's +latest backup of that type. +""" + +import os +import sys +import time +from datetime import datetime, timedelta, UTC + +from dotenv import load_dotenv # type: ignore + +from atomic_sdk import AtomicAPIError, AtomicClient + +load_dotenv() +API_KEY = os.environ.get("ATOMIC_API_KEY") +CLIENT_ID = os.environ.get("ATOMIC_CLIENT_ID") +SITE_DOMAIN = os.environ.get("SITE_DOMAIN") +FS_BACKUP_ID = os.environ.get("FS_BACKUP_ID") +DB_BACKUP_ID = os.environ.get("DB_BACKUP_ID") + +CONFIRM_TOKEN = "I-UNDERSTAND-THIS-WILL-OVERWRITE-THE-SITE" + +POLL_INTERVAL_SECONDS = 10 +POLL_TIMEOUT_SECONDS = 600 + + +def main() -> None: + if not API_KEY or not CLIENT_ID: + print("Error: set ATOMIC_API_KEY and ATOMIC_CLIENT_ID in your .env file.") + sys.exit(1) + + if len(sys.argv) >= 2: + domain = sys.argv[1] + elif SITE_DOMAIN: + domain = SITE_DOMAIN + else: + print("Usage: python examples/sites/09_restore_site.py ") + sys.exit(1) + + client = AtomicClient(api_key=API_KEY, client_id_or_name=CLIENT_ID) + + try: + fs_backup_id = int(FS_BACKUP_ID) if FS_BACKUP_ID else None + db_backup_id = int(DB_BACKUP_ID) if DB_BACKUP_ID else None + except ValueError: + print("Error: FS_BACKUP_ID and DB_BACKUP_ID must be numeric backup IDs.") + sys.exit(1) + + if fs_backup_id is None or db_backup_id is None: + print(f"\n--- Listing backups for '{domain}' to resolve the latest pair ---") + try: + backups = client.backups.list(domain=domain) + except AtomicAPIError as exc: + print(f"❌ API error while listing backups: {exc}") + sys.exit(1) + + if fs_backup_id is None: + fs_backups = [b for b in backups if b.type.endswith("fs")] + if not fs_backups: + print("Error: no filesystem backups found; set FS_BACKUP_ID or create one first.") + sys.exit(1) + latest_fs = max(fs_backups, key=lambda b: b.backup_timestamp) + fs_backup_id = int(latest_fs.atomic_backup_id) + + if db_backup_id is None: + db_backups = [b for b in backups if b.type.endswith("db")] + if not db_backups: + print("Error: no database backups found; set DB_BACKUP_ID or create one first.") + sys.exit(1) + latest_db = max(db_backups, key=lambda b: b.backup_timestamp) + db_backup_id = int(latest_db.atomic_backup_id) + + print(f"\n - Filesystem backup ID: {fs_backup_id}") + print(f" - Database backup ID: {db_backup_id}") + + print(f"\n⚠️ This will OVERWRITE the current files and database of '{domain}'") + print(" with the backups listed above. This action cannot be undone.\n") + typed = input(f"Type {CONFIRM_TOKEN!r} to continue: ").strip() + if typed != CONFIRM_TOKEN: + print("Aborted: confirmation token did not match.") + sys.exit(1) + + try: + print("\n--- Allowing restore and suspending the site with a 503 status ---") + client.sites.update_meta(key="allow_restore", value=int(datetime.now(UTC).timestamp()), domain=domain) + client.sites.update_meta(key="suspended", value=503, domain=domain) + + print("\n--- Starting the restore ---") + result = client.sites.restore_site( + restore_from_fs=fs_backup_id, + restore_from_db=db_backup_id, + domain=domain, + ) + except AtomicAPIError as exc: + print(f"❌ API error before the restore started: {exc}") + try: + client.sites.update_meta(key="suspended", value=0, domain=domain) + print(" The site was unsuspended since no restore ran.") + except AtomicAPIError as unsuspend_exc: + print(f" Could not unsuspend the site, do so manually: {unsuspend_exc}") + sys.exit(1) + + job_id = result.get("atomic_job_id") + ticket_id = result.get("response_ticket_id") + print(f" - Restore job queued. Job ID: {job_id}, Ticket ID: {ticket_id}") + + try: + print("\n--- Polling the response ticket until the restore completes ---") + status = "running" + deadline = datetime.now(UTC) + timedelta(seconds=POLL_TIMEOUT_SECONDS) + while status == "running" and datetime.now(UTC) < deadline: + time.sleep(POLL_INTERVAL_SECONDS) + summary = client.response_tickets.get_summary(ticket_id) + if not summary: + print(" - Ticket has no entries yet; restore still in progress.") + continue + status = summary.get("status", "running") + print(f" - Ticket status: {status}") + + if status == "success": + print("\n--- Unsuspending the site ---") + client.sites.remove_meta(key="suspended", domain=domain) + print(f"✅ '{domain}' was restored and is back online.") + elif status == "failure": + print(f"❌ Restore failed. Inspect the full ticket with client.response_tickets.get_full({ticket_id!r}).") + print(" The site is still suspended; unsuspend it manually once resolved.") + sys.exit(1) + else: + print(f"⚠️ Restore still running after {POLL_TIMEOUT_SECONDS}s. Keep polling ticket {ticket_id!r}.") + print(" The site remains suspended until the restore finishes.") + except AtomicAPIError as exc: + print(f"❌ API error: {exc}") + sys.exit(1) + + +if __name__ == "__main__": + main() From 106e737c55f87f50a8c44eb1b3c90d2c25013f2c Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:29:14 +0530 Subject: [PATCH 2/6] fix: address PR review feedback on restore example --- examples/sites/09_restore_site.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/examples/sites/09_restore_site.py b/examples/sites/09_restore_site.py index 31b0672..063f3ad 100644 --- a/examples/sites/09_restore_site.py +++ b/examples/sites/09_restore_site.py @@ -18,7 +18,7 @@ import os import sys import time -from datetime import datetime, timedelta, UTC +from datetime import datetime, timedelta, timezone from dotenv import load_dotenv # type: ignore @@ -95,9 +95,18 @@ def main() -> None: try: print("\n--- Allowing restore and suspending the site with a 503 status ---") - client.sites.update_meta(key="allow_restore", value=int(datetime.now(UTC).timestamp()), domain=domain) + client.sites.update_meta(key="allow_restore", value=int(datetime.now(timezone.utc).timestamp()), domain=domain) client.sites.update_meta(key="suspended", value=503, domain=domain) + except AtomicAPIError as exc: + print(f"❌ API error before the restore started: {exc}") + try: + client.sites.remove_meta(key="suspended", domain=domain) + print(" The site was unsuspended since no restore ran.") + except AtomicAPIError as unsuspend_exc: + print(f" Could not unsuspend the site, do so manually: {unsuspend_exc}") + sys.exit(1) + try: print("\n--- Starting the restore ---") result = client.sites.restore_site( restore_from_fs=fs_backup_id, @@ -105,12 +114,9 @@ def main() -> None: domain=domain, ) except AtomicAPIError as exc: - print(f"❌ API error before the restore started: {exc}") - try: - client.sites.update_meta(key="suspended", value=0, domain=domain) - print(" The site was unsuspended since no restore ran.") - except AtomicAPIError as unsuspend_exc: - print(f" Could not unsuspend the site, do so manually: {unsuspend_exc}") + print(f"❌ API error from the restore request: {exc}") + print(" The restore may still have started; the site remains suspended.") + print(" Verify the site state before unsuspending it manually.") sys.exit(1) job_id = result.get("atomic_job_id") @@ -120,8 +126,8 @@ def main() -> None: try: print("\n--- Polling the response ticket until the restore completes ---") status = "running" - deadline = datetime.now(UTC) + timedelta(seconds=POLL_TIMEOUT_SECONDS) - while status == "running" and datetime.now(UTC) < deadline: + deadline = datetime.now(timezone.utc) + timedelta(seconds=POLL_TIMEOUT_SECONDS) + while status == "running" and datetime.now(timezone.utc) < deadline: time.sleep(POLL_INTERVAL_SECONDS) summary = client.response_tickets.get_summary(ticket_id) if not summary: From 899c508bbf7709d2e4248d39356bcefe5bf95225 Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:35:38 +0530 Subject: [PATCH 3/6] docs: document restore, site list, and ssh migration examples in READMEs --- README.md | 2 +- examples/README.md | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0707fdf..45e80f4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For more examples see the [`examples/`](./examples) directory. - **Intelligent Helpers**: The SDK abstracts away complexities such as building form-data payloads, handling different SSH connection types, and managing API inconsistencies. This SDK provides clients for: -- 📂 **Sites**: Full lifecycle management, including the one-way `allow_ssh_migration` consent gate for incoming migrations. +- 📂 **Sites**: Full lifecycle management, including the one-way `allow_ssh_migration` consent gate for incoming migrations and restoring a site from its own backups with `restore_site`. - 🗄️ **Backups**: Create, list, download, and delete backups. - 🔑 **SSH**: Manage site-specific users, client-wide keys, aliases, and reverse-lookup users by name. - 📊 **Metrics**: Query detailed performance and visitor analytics. diff --git a/examples/README.md b/examples/README.md index b9c71d4..0f3c4bc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -118,8 +118,14 @@ Once your site exists, you can perform various management tasks. * **Shows:** * Generating a secure, time-limited, single-use login URL for phpMyAdmin using `client.sites.get_phpmyadmin_url()`. +### 📋 List All Sites +* **Run:** `python examples/sites/07_site_list.py` +* **Shows:** + * Listing every site on your account with `client.sites.list()`. + * Reading each site's `_data` metadata with `client.sites.get_meta()` to determine its site type. + ## 🗄️ Step 5: Manage Backups -Learn how to create, list, download, and delete backups. Note that on-demand backup creation is a "fire-and-forget" operation; the API does not provide a way to poll its status. +Learn how to create, list, download, delete, and restore from backups. Note that on-demand backup creation is a "fire-and-forget" operation; the API does not provide a way to poll its status. ### ➕ Create and List Backups * **Run:** `python examples/backups/01_create_and_list_backups.py` @@ -142,6 +148,15 @@ Learn how to create, list, download, and delete backups. Note that on-demand bac * Streaming a backup with `client.backups.download()` instead of buffering it in memory. * Writing chunks directly to a local binary file. +### ♻️ Restore a Site from Backups +* **Run:** `python examples/sites/09_restore_site.py ` +* ⚠️ **DESTRUCTIVE:** overwrites the site's current files and database with the selected backups. +* **Shows:** + * Resolving the latest filesystem and database backup pair (or using the `FS_BACKUP_ID`/`DB_BACKUP_ID` env vars). + * Setting the `allow_restore` and `suspended=503` metadata preconditions with `client.sites.update_meta()`. + * Starting the restore with `client.sites.restore_site()` and polling its response ticket until completion. + * Unsuspending the site with `client.sites.remove_meta()` once the restore succeeds. + ### 🗑️ Delete an On-Demand Backup * **Run:** `python examples/backups/99_delete_ondemand_backup.py` * **Shows:** @@ -241,6 +256,12 @@ Before you begin: - Ensure your `.env` is configured (see Getting Started above). - You will need SSH access to the source server (user + host). The scripts attempt to install a public key automatically; if that fails, you'll be shown the key to add manually to `~/.ssh/authorized_keys` on the source. +### 🔓 Allow an Incoming SSH Migration +- **Run:** `python examples/sites/08_allow_ssh_migration.py ` +- ⚠️ **DESTRUCTIVE and ONE-WAY:** cannot be revoked, and the next migration into the site will overwrite its files and database. +- **Shows:** + - Marking a destination site as willing to accept an incoming SSH migration with `client.sites.allow_ssh_migration()`. + ### 1) Prepare Destination Site - **Run:** `python examples/migrations/01_prepare_destination_site.py` - **Configure:** Update `DESTINATION_DOMAIN`, `ADMIN_USER`, and `ADMIN_EMAIL` in the script to your desired values. From ba97a3e236e57bf9c09dd3b32efa6966981c8112 Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:04:07 +0530 Subject: [PATCH 4/6] fix: drop suspended-meta cleanup from precondition error path Inside this catch the run has never successfully set suspended=503, so removing the key could only unsuspend a site that was suspended before the script ran. Report the state as unchanged instead. --- examples/sites/09_restore_site.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/sites/09_restore_site.py b/examples/sites/09_restore_site.py index 063f3ad..641f3d8 100644 --- a/examples/sites/09_restore_site.py +++ b/examples/sites/09_restore_site.py @@ -99,11 +99,7 @@ def main() -> None: client.sites.update_meta(key="suspended", value=503, domain=domain) except AtomicAPIError as exc: print(f"❌ API error before the restore started: {exc}") - try: - client.sites.remove_meta(key="suspended", domain=domain) - print(" The site was unsuspended since no restore ran.") - except AtomicAPIError as unsuspend_exc: - print(f" Could not unsuspend the site, do so manually: {unsuspend_exc}") + print(" The site's suspension state was not changed by this run; verify the site meta if unsure.") sys.exit(1) try: From 0796b4b79884590bdf5995fb6dda906058d2a028 Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:11:38 +0530 Subject: [PATCH 5/6] fix: harden restore example failure paths per review - Exit nonzero on poll timeout and distinguish unexpected ticket statuses - Raise poll timeout to 6h, matching the migration monitor example - Explain suspension state and manual recovery in the API error handler - Handle KeyboardInterrupt during the poll instead of a bare traceback --- examples/sites/09_restore_site.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/sites/09_restore_site.py b/examples/sites/09_restore_site.py index 641f3d8..d91502c 100644 --- a/examples/sites/09_restore_site.py +++ b/examples/sites/09_restore_site.py @@ -34,7 +34,7 @@ CONFIRM_TOKEN = "I-UNDERSTAND-THIS-WILL-OVERWRITE-THE-SITE" POLL_INTERVAL_SECONDS = 10 -POLL_TIMEOUT_SECONDS = 600 +POLL_TIMEOUT_SECONDS = 6 * 60 * 60 # 6 hours def main() -> None: @@ -140,11 +140,22 @@ def main() -> None: print(f"❌ Restore failed. Inspect the full ticket with client.response_tickets.get_full({ticket_id!r}).") print(" The site is still suspended; unsuspend it manually once resolved.") sys.exit(1) - else: + elif status == "running": print(f"⚠️ Restore still running after {POLL_TIMEOUT_SECONDS}s. Keep polling ticket {ticket_id!r}.") print(" The site remains suspended until the restore finishes.") + sys.exit(1) + else: + print(f"⚠️ Unexpected ticket status {status!r}. Inspect ticket {ticket_id!r}.") + print(" The site remains suspended; unsuspend it manually once resolved.") + sys.exit(1) except AtomicAPIError as exc: - print(f"❌ API error: {exc}") + print(f"❌ API error while polling the restore or unsuspending the site: {exc}") + print(" If the restore completed, the site may still be suspended and serving 503.") + print(f" Check ticket {ticket_id!r} and remove the 'suspended' meta manually if needed.") + sys.exit(1) + except KeyboardInterrupt: + print(f"\n⚠️ Interrupted. The restore may still be running; keep polling ticket {ticket_id!r}.") + print(" The site is still suspended and serving 503 — unsuspend it only once the restore finishes.") sys.exit(1) From 3dabfac0d26b5f26bebfdbce23803259c0abd9c4 Mon Sep 17 00:00:00 2001 From: L0RD-ZER0 <68327382+L0RD-ZER0@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:12:34 +0530 Subject: [PATCH 6/6] fix: harden restore example and pass SDK timeout per dual review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore example: - split allow_restore/suspended writes; report actual state on ambiguous transport failures instead of claiming nothing changed - validate response_ticket_id before polling - tolerate transient API errors in the 6h poll loop - only remove the suspended meta when it is still the 503 this run set - KeyboardInterrupt handling across the whole mutation window - treat ""/0/"0" as not suspended; leftover-503 re-run guidance - verify env-provided backup IDs via backups.info (site + type) - show backup type/timestamp at the confirmation prompt and warn on >24h fs/db skew - monotonic poll deadline, NotFoundError precheck, misc cleanups SDK: pass the configured timeout through _request (requests ignores the timeout attribute set on the Session, so no request had a timeout and a dead connection could hang forever). Also: restore_site docstring/section-header fixes, README wording. Note: no allow_restore cleanup on success — WP Cloud removes the key automatically after a successful restore. --- README.md | 2 +- atomic_sdk/api/base.py | 3 + atomic_sdk/api/sites.py | 13 +- examples/sites/09_restore_site.py | 202 ++++++++++++++++++++++++------ 4 files changed, 173 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 45e80f4..75405cf 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ For more examples see the [`examples/`](./examples) directory. - **Intelligent Helpers**: The SDK abstracts away complexities such as building form-data payloads, handling different SSH connection types, and managing API inconsistencies. This SDK provides clients for: -- 📂 **Sites**: Full lifecycle management, including the one-way `allow_ssh_migration` consent gate for incoming migrations and restoring a site from its own backups with `restore_site`. +- 📂 **Sites**: Full lifecycle management, including the one-way `allow_ssh_migration` consent gate for incoming migrations, and site restores from backups via `restore_site`. - 🗄️ **Backups**: Create, list, download, and delete backups. - 🔑 **SSH**: Manage site-specific users, client-wide keys, aliases, and reverse-lookup users by name. - 📊 **Metrics**: Query detailed performance and visitor analytics. diff --git a/atomic_sdk/api/base.py b/atomic_sdk/api/base.py index 66f1728..1cc7dae 100644 --- a/atomic_sdk/api/base.py +++ b/atomic_sdk/api/base.py @@ -151,6 +151,9 @@ def _request(self, method: str, endpoint: str, **kwargs) -> dict: InvalidRequestError: For 4xx client errors with a message. """ url = self._base_url.rstrip('/') + endpoint + # requests ignores a `timeout` attribute set on a Session, so the + # client-configured timeout must be passed per request. + kwargs.setdefault("timeout", getattr(self._session, "timeout", None)) try: response = self._session.request(method, url, **kwargs) response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) diff --git a/atomic_sdk/api/sites.py b/atomic_sdk/api/sites.py index aa254d1..deb7d37 100644 --- a/atomic_sdk/api/sites.py +++ b/atomic_sdk/api/sites.py @@ -546,6 +546,8 @@ def allow_ssh_migration(self, site_id: Optional[int] = None, domain: Optional[st endpoint = f"/site-allow-ssh-migration/{identifier}" return self._post(endpoint) + # --- Backup Restore --- + def restore_site(self, restore_from_fs: int, restore_from_db: int, site_id: Optional[int] = None, domain: Optional[str] = None) -> Dict[str, Any]: """ Restore a site from its own backups. @@ -556,16 +558,15 @@ def restore_site(self, restore_from_fs: int, restore_from_db: int, site_id: Opti 503 status code for the restore operation to start. Args: - restore_from_fs: FileSystem Backup to Restore From. - restore_from_db: Database Backup to Restore From. + restore_from_fs: The ID of the filesystem backup to restore from. + restore_from_db: The ID of the database backup to restore from. site_id: The Atomic site ID. domain: The domain name of the site. Returns: - A dict with a ``atomic_job_id`` (int) key and a - ``response_ticket_id`` (str) key. You can query - the associated response ticket for restoring the - backup. + A dict with an ``atomic_job_id`` (int) key and a + ``response_ticket_id`` (str) key; poll the response + ticket to track the restore's progress. """ _, identifier = self._get_service_and_identifier(site_id, domain) endpoint = f"/restore-site/{identifier}" diff --git a/examples/sites/09_restore_site.py b/examples/sites/09_restore_site.py index d91502c..8108be9 100644 --- a/examples/sites/09_restore_site.py +++ b/examples/sites/09_restore_site.py @@ -6,6 +6,10 @@ must first allow restores (``allow_restore`` meta set to a recent unix timestamp) and be suspended with a 503 status code; this script sets both, runs the restore, polls the response ticket, then unsuspends the site. +If the site is already suspended before the run, the script refuses to +proceed so it never clears a suspension it did not set. WP Cloud removes +the ``allow_restore`` meta automatically after a successful restore, so +the script does not need to clean it up. Usage: python examples/sites/09_restore_site.py @@ -18,11 +22,10 @@ import os import sys import time -from datetime import datetime, timedelta, timezone -from dotenv import load_dotenv # type: ignore +from dotenv import load_dotenv # type: ignore -from atomic_sdk import AtomicAPIError, AtomicClient +from atomic_sdk import AtomicAPIError, AtomicClient, NotFoundError load_dotenv() API_KEY = os.environ.get("ATOMIC_API_KEY") @@ -35,6 +38,8 @@ POLL_INTERVAL_SECONDS = 10 POLL_TIMEOUT_SECONDS = 6 * 60 * 60 # 6 hours +MAX_CONSECUTIVE_POLL_FAILURES = 6 # tolerate ~1 minute of transient API errors +BACKUP_SKEW_WARN_SECONDS = 24 * 60 * 60 def main() -> None: @@ -58,6 +63,13 @@ def main() -> None: except ValueError: print("Error: FS_BACKUP_ID and DB_BACKUP_ID must be numeric backup IDs.") sys.exit(1) + for env_id in (fs_backup_id, db_backup_id): + if env_id is not None and env_id <= 0: + print("Error: FS_BACKUP_ID and DB_BACKUP_ID must be positive backup IDs.") + sys.exit(1) + + fs_backup = None + db_backup = None if fs_backup_id is None or db_backup_id is None: print(f"\n--- Listing backups for '{domain}' to resolve the latest pair ---") @@ -72,19 +84,48 @@ def main() -> None: if not fs_backups: print("Error: no filesystem backups found; set FS_BACKUP_ID or create one first.") sys.exit(1) - latest_fs = max(fs_backups, key=lambda b: b.backup_timestamp) - fs_backup_id = int(latest_fs.atomic_backup_id) + fs_backup = max(fs_backups, key=lambda b: b.backup_timestamp) if db_backup_id is None: db_backups = [b for b in backups if b.type.endswith("db")] if not db_backups: print("Error: no database backups found; set DB_BACKUP_ID or create one first.") sys.exit(1) - latest_db = max(db_backups, key=lambda b: b.backup_timestamp) - db_backup_id = int(latest_db.atomic_backup_id) + db_backup = max(db_backups, key=lambda b: b.backup_timestamp) + + # IDs supplied via env are verified against the site before anything + # destructive happens: the backup must exist for this site and be of + # the expected type. + try: + if fs_backup is None: + fs_backup = client.backups.info(fs_backup_id, domain=domain) + if db_backup is None: + db_backup = client.backups.info(db_backup_id, domain=domain) + except AtomicAPIError as exc: + print(f"❌ Could not verify the selected backups against '{domain}': {exc}") + sys.exit(1) - print(f"\n - Filesystem backup ID: {fs_backup_id}") - print(f" - Database backup ID: {db_backup_id}") + if not fs_backup.type.endswith("fs"): + print(f"Error: backup {fs_backup.atomic_backup_id} has type {fs_backup.type!r}; expected a filesystem backup.") + sys.exit(1) + if not db_backup.type.endswith("db"): + print(f"Error: backup {db_backup.atomic_backup_id} has type {db_backup.type!r}; expected a database backup.") + sys.exit(1) + + try: + fs_backup_id = int(fs_backup.atomic_backup_id) + db_backup_id = int(db_backup.atomic_backup_id) + except ValueError: + print("Error: the API returned a non-numeric backup ID " + f"({fs_backup.atomic_backup_id!r} / {db_backup.atomic_backup_id!r}).") + sys.exit(1) + + print(f"\n - Filesystem backup: ID {fs_backup_id}, type {fs_backup.type}, taken {fs_backup.backup_timestamp}") + print(f" - Database backup: ID {db_backup_id}, type {db_backup.type}, taken {db_backup.backup_timestamp}") + skew_seconds = abs((fs_backup.backup_timestamp - db_backup.backup_timestamp).total_seconds()) + if skew_seconds > BACKUP_SKEW_WARN_SECONDS: + print(f"\n⚠️ These backups were taken {skew_seconds / 3600:.1f} hours apart; restoring them") + print(" together may leave the files and database inconsistent with each other.") print(f"\n⚠️ This will OVERWRITE the current files and database of '{domain}'") print(" with the backups listed above. This action cannot be undone.\n") @@ -93,39 +134,100 @@ def main() -> None: print("Aborted: confirmation token did not match.") sys.exit(1) + print("\n--- Checking that the site is not already suspended ---") try: - print("\n--- Allowing restore and suspending the site with a 503 status ---") - client.sites.update_meta(key="allow_restore", value=int(datetime.now(timezone.utc).timestamp()), domain=domain) - client.sites.update_meta(key="suspended", value=503, domain=domain) + existing_suspended = client.sites.get_meta(key="suspended", domain=domain) + except NotFoundError: + existing_suspended = None except AtomicAPIError as exc: - print(f"❌ API error before the restore started: {exc}") - print(" The site's suspension state was not changed by this run; verify the site meta if unsure.") + print(f"❌ API error while reading the 'suspended' meta: {exc}") + sys.exit(1) + if existing_suspended in ("", 0, "0"): + existing_suspended = None + if existing_suspended is not None: + print(f"❌ '{domain}' is already suspended (suspended={existing_suspended!r}).") + if str(existing_suspended) == "503": + print(" This may be a leftover from a previous run of this script.") + print(" Verify no restore is in flight (check its response ticket), then unsuspend with") + print(f" client.sites.remove_meta(key='suspended', domain={domain!r}).") + else: + print(" The suspension looks unrelated to this script; resolve it first.") + print(" This script only removes a suspension it set itself.") sys.exit(1) + suspended_by_run = False + ticket_id = None try: + print("\n--- Allowing restore and suspending the site with a 503 status ---") + try: + client.sites.update_meta(key="allow_restore", value=int(time.time()), domain=domain) + except AtomicAPIError as exc: + print(f"❌ API error while setting the 'allow_restore' meta: {exc}") + print(" The site was not suspended and no restore was started by this run.") + sys.exit(1) + + try: + client.sites.update_meta(key="suspended", value=503, domain=domain) + except AtomicAPIError as exc: + print(f"❌ API error while suspending the site: {exc}") + print(" The 'allow_restore' meta was already set by this run.") + if exc.status_code is None: + # Transport-level failure: the response was lost, so the + # server may still have applied the write. Check. + try: + current = client.sites.get_meta(key="suspended", domain=domain) + except NotFoundError: + print(" Verified: the site is NOT suspended.") + except AtomicAPIError: + print(" Could not verify the 'suspended' meta; check it manually before retrying.") + else: + print(f" The site IS suspended (suspended={current!r}); remove the meta to bring it back online.") + else: + print(" The server rejected the update; the site's suspension state was not changed.") + sys.exit(1) + suspended_by_run = True + print("\n--- Starting the restore ---") - result = client.sites.restore_site( - restore_from_fs=fs_backup_id, - restore_from_db=db_backup_id, - domain=domain, - ) - except AtomicAPIError as exc: - print(f"❌ API error from the restore request: {exc}") - print(" The restore may still have started; the site remains suspended.") - print(" Verify the site state before unsuspending it manually.") - sys.exit(1) + try: + result = client.sites.restore_site( + restore_from_fs=fs_backup_id, + restore_from_db=db_backup_id, + domain=domain, + ) + except AtomicAPIError as exc: + print(f"❌ API error from the restore request: {exc}") + print(" The restore may still have started; the site remains suspended.") + print(" Verify the site state before unsuspending it manually.") + sys.exit(1) - job_id = result.get("atomic_job_id") - ticket_id = result.get("response_ticket_id") - print(f" - Restore job queued. Job ID: {job_id}, Ticket ID: {ticket_id}") + job_id = result.get("atomic_job_id") + raw_ticket = result.get("response_ticket_id") + if not isinstance(raw_ticket, str) or not raw_ticket: + print(f"❌ The restore request returned no response ticket. Raw response: {result!r}") + print(" The restore may or may not have been queued; the site remains suspended.") + print(" Check the site's response tickets manually and unsuspend once resolved.") + sys.exit(1) + ticket_id = raw_ticket + print(f" - Restore job queued. Job ID: {job_id}, Ticket ID: {ticket_id}") - try: print("\n--- Polling the response ticket until the restore completes ---") status = "running" - deadline = datetime.now(timezone.utc) + timedelta(seconds=POLL_TIMEOUT_SECONDS) - while status == "running" and datetime.now(timezone.utc) < deadline: + consecutive_failures = 0 + deadline = time.monotonic() + POLL_TIMEOUT_SECONDS + while status == "running" and time.monotonic() < deadline: time.sleep(POLL_INTERVAL_SECONDS) - summary = client.response_tickets.get_summary(ticket_id) + try: + summary = client.response_tickets.get_summary(ticket_id) + except AtomicAPIError as exc: + consecutive_failures += 1 + if consecutive_failures >= MAX_CONSECUTIVE_POLL_FAILURES: + print(f"❌ Polling failed {consecutive_failures} times in a row; last error: {exc}") + print(f" The restore may still be running; keep polling ticket {ticket_id!r} manually.") + print(" The site remains suspended until the restore finishes.") + sys.exit(1) + print(f" - Transient API error while polling ({consecutive_failures}/{MAX_CONSECUTIVE_POLL_FAILURES}): {exc}") + continue + consecutive_failures = 0 if not summary: print(" - Ticket has no entries yet; restore still in progress.") continue @@ -134,8 +236,25 @@ def main() -> None: if status == "success": print("\n--- Unsuspending the site ---") - client.sites.remove_meta(key="suspended", domain=domain) - print(f"✅ '{domain}' was restored and is back online.") + try: + try: + current = client.sites.get_meta(key="suspended", domain=domain) + except NotFoundError: + current = None + if current is None: + print(f"✅ '{domain}' was restored; the suspension was already cleared.") + elif str(current) == "503": + client.sites.remove_meta(key="suspended", domain=domain) + print(f"✅ '{domain}' was restored and is back online.") + else: + print(f"⚠️ Restore finished, but 'suspended' is now {current!r} — not the 503 this run set.") + print(" Leaving the suspension in place; it was changed by something else during the restore.") + sys.exit(1) + except AtomicAPIError as exc: + print(f"❌ API error while unsuspending the site: {exc}") + print(" The restore completed but the site may still be suspended and serving 503.") + print(" Check and remove the 'suspended' meta manually.") + sys.exit(1) elif status == "failure": print(f"❌ Restore failed. Inspect the full ticket with client.response_tickets.get_full({ticket_id!r}).") print(" The site is still suspended; unsuspend it manually once resolved.") @@ -148,14 +267,17 @@ def main() -> None: print(f"⚠️ Unexpected ticket status {status!r}. Inspect ticket {ticket_id!r}.") print(" The site remains suspended; unsuspend it manually once resolved.") sys.exit(1) - except AtomicAPIError as exc: - print(f"❌ API error while polling the restore or unsuspending the site: {exc}") - print(" If the restore completed, the site may still be suspended and serving 503.") - print(f" Check ticket {ticket_id!r} and remove the 'suspended' meta manually if needed.") - sys.exit(1) except KeyboardInterrupt: - print(f"\n⚠️ Interrupted. The restore may still be running; keep polling ticket {ticket_id!r}.") - print(" The site is still suspended and serving 503 — unsuspend it only once the restore finishes.") + print("\n⚠️ Interrupted.") + if ticket_id: + print(f" The restore may still be running; keep polling ticket {ticket_id!r}.") + print(" The site is suspended and serving 503 — unsuspend it only once the restore finishes.") + elif suspended_by_run: + print(" The site is suspended (503) and a restore may have been requested.") + print(" Check the site's response tickets before unsuspending it manually.") + else: + print(" The 'allow_restore' and 'suspended' meta may or may not have been set;") + print(" verify both before retrying. No restore was started by this run.") sys.exit(1)