feat: add restore-site endpoint and example script - #25
Conversation
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.
| except AtomicAPIError as exc: | ||
| print(f"❌ API error before the restore started: {exc}") | ||
| try: | ||
| client.sites.remove_meta(key="suspended", domain=domain) |
There was a problem hiding this comment.
I checked this against the site-meta contract and EasyDash's suspend helper, where removing suspended is the unsuspend operation. Since this catch also runs when the earlier allow_restore call fails, could we avoid removing suspended unless this run successfully set it, or preserve the previous value?
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.
| except AtomicAPIError as exc: | ||
| print(f"❌ API error: {exc}") | ||
| sys.exit(1) |
There was a problem hiding this comment.
The unsuspend call at line 137 runs inside this try, so a failure there lands here and prints a bare error. This is the only failure branch that doesn't tell the operator the site is still suspended — and it's the one where the restore already succeeded, so the site is serving 503 with good content on disk. It's also reachable from a transient get_summary blip anywhere in the ten-minute poll, which invites the operator to re-run a destructive restore that already worked.
| except AtomicAPIError as exc: | |
| print(f"❌ API error: {exc}") | |
| 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) |
| 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.") |
There was a problem hiding this comment.
Every other non-success path in this script exits 1 — including the failure branch just above — but the timeout path exits 0 while leaving the site suspended at 503, so a timed-out restore is indistinguishable from success to anything checking the exit code. The message is also wrong when the loop exits for another reason: the condition is while status == "running", so any status that is neither success nor failure lands here and prints "still running after 600s". Worth considering a larger POLL_TIMEOUT_SECONDS too — the migration monitor in examples/migrations/04_start_migration_and_monitor.py allows 6h for the same class of data move, and a full filesystem + database restore is unlikely to finish in 600s.
| 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.") | |
| 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) |
| 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: |
There was a problem hiding this comment.
Between suspending the site and unsuspending it there's no KeyboardInterrupt handling, and the poll loop sleeps up to ten minutes — the likeliest place for an operator to hit Ctrl-C. The script dies with a bare traceback and no indication the site is left serving 503. Adding an except KeyboardInterrupt: that prints the same guidance as the other paths — site is still suspended, keep polling ticket X, unsuspend only once it finishes — and exits nonzero would make the example safe to copy. examples/sites/99_delete_site.py does this.
Alongside the AtomicAPIError clause here:
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)It needs to stay a KeyboardInterrupt clause rather than a finally — after a timeout or a failure the restore may still be writing, and the branches above deliberately leave the site suspended in exactly those cases, so unconditional cleanup on exit would be the wrong behaviour.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (6)
atomic_sdk/api/sites.py:549
- The parameter order is inconsistent with other
SitesClientmethods (e.g.,allow_ssh_migration(self, site_id=None, domain=None, ...)) where identification parameters come first. For a public SDK API, this increases the likelihood of positional-arg misuse and makes call sites less readable. Consider reordering torestore_site(self, site_id=None, domain=None, restore_from_fs=..., restore_from_db=...)and/or making the backup IDs keyword-only.
def restore_site(self, restore_from_fs: int, restore_from_db: int, site_id: Optional[int] = None, domain: Optional[str] = None) -> Dict[str, Any]:
examples/sites/09_restore_site.py:102
- If
allow_restoresucceeds and thesuspendedupdate fails, the current message is misleading: the suspension meta may indeed be unchanged, butallow_restoremay already have been modified by this run. Split these calls so you can report precisely what changed (and optionally attempt cleanup/rollback), or update the message to reflect the partial-update possibility.
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)
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.")
sys.exit(1)
examples/sites/09_restore_site.py:119
- If the API response shape changes or an unexpected payload is returned,
ticket_idcan beNonedue to.get(...), which will then be passed intoget_summary(...)and likely fail with a runtime/API error. Prefer validating required keys (e.g., access viaresult[\"response_ticket_id\"]and fail fast with a clear message) before entering the polling loop.
job_id = result.get("atomic_job_id")
ticket_id = result.get("response_ticket_id")
examples/sites/09_restore_site.py:128
- If the API response shape changes or an unexpected payload is returned,
ticket_idcan beNonedue to.get(...), which will then be passed intoget_summary(...)and likely fail with a runtime/API error. Prefer validating required keys (e.g., access viaresult[\"response_ticket_id\"]and fail fast with a clear message) before entering the polling loop.
summary = client.response_tickets.get_summary(ticket_id)
examples/sites/09_restore_site.py:23
- PEP 8 recommends at least two spaces before an inline comment. Update to
from dotenv import load_dotenv # type: ignorefor consistent formatting.
from dotenv import load_dotenv # type: ignore
atomic_sdk/api/sites.py:568
- The return description has minor grammar/clarity issues: it should be 'an
atomic_job_id', and the last lines read like the ticket restores the backup rather than reporting restore progress. Consider rewording to clarify the ticket is used to track the restore status/results.
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
examples/sites/09_restore_site.py:116
- The PR description says the script unsuspends on early failure, but this branch treats a definite 4xx rejection the same as an ambiguous transport/server failure and always leaves the site suspended. Handle client-side rejection separately and remove the suspension there; retain the conservative manual-verification path only when the restore may actually have been queued.
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)
examples/sites/09_restore_site.py:145
- When polling times out, the restore is incomplete and the site remains suspended, but this branch falls through and the script exits with status 0. That reports success to callers despite requiring intervention; exit nonzero after printing the timeout guidance.
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.")
examples/sites/09_restore_site.py:102
- This message is not reliable: the
allow_restoreupdate may already have succeeded, and a timeout while updatingsuspendedcan occur after the server applied that write. Reporting that suspension was unchanged can prompt an unsafe retry; state explicitly that both metadata values must be verified.
This issue also appears in the following locations of the same file:
- line 112
- line 143
print(" The site's suspension state was not changed by this run; verify the site meta if unsure.")
atomic_sdk/api/sites.py:565
- Use the article “an” before
atomic_job_id.
A dict with a ``atomic_job_id`` (int) key and a
- 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
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.
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.
Refer: https://wp.cloud/docs/api/#tag/sites/POST/restore-site/%7Bsite%7D