Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 45 additions & 11 deletions src/helper/Site_Backup_Restore.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Site_Backup_Restore {
private $dash_api_url;
private $dash_backup_metadata;
private $dash_backup_completed = false;
private $dash_callback_sent = false; // Guard: exactly one terminal callback (success XOR failure) may be emitted
private $dash_new_backup_path; // Track new backup path for potential rollback

// Error tracking for EasyDash failure callbacks
Expand Down Expand Up @@ -154,8 +155,6 @@ public function backup( $args, $assoc_args = [] ) {

$this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' );

// Mark backup as completed and send success callback
$this->dash_backup_completed = true;
if ( $this->dash_auth_enabled ) {
$api_success = $this->send_dash_success_callback(
$this->dash_api_url,
Expand All @@ -164,13 +163,27 @@ public function backup( $args, $assoc_args = [] ) {
$this->dash_backup_metadata
);

// Only cleanup old backups if API callback succeeded
// If API failed, rollback the newly uploaded backup
if ( $api_success ) {
// Backup is now registered with EasyDash; only then is it safe to
// mark complete (suppressing the shutdown failure callback) and prune.
$this->dash_backup_completed = true;
$this->cleanup_old_backups();
} else {
// Success callback failed: the upload is orphaned (EasyDash never
// recorded it), so roll it back and report failure. dash_backup_completed
// stays false so the shutdown handler emits the single failure callback;
// EE::error() makes the exit code/message reflect that no backup remains.
$this->capture_error(
'Backup uploaded but EasyDash success callback failed; rolled back the orphaned upload.',
self::ERROR_TYPE_NETWORK,
4004
);
$this->rollback_failed_backup();
EE::error( 'EasyDash success callback failed; the uploaded backup was rolled back. No backup was created.' );
}
} else {
// Non-dash path: backup is done once the upload succeeds.
$this->dash_backup_completed = true;
}

// Release global backup lock (also released by shutdown handler as safety net)
Expand All @@ -189,8 +202,9 @@ public function backup( $args, $assoc_args = [] ) {
* explicitly captured during backup execution.
*/
public function dash_shutdown_handler() {
// Only send failure callback if dash auth was enabled and backup didn't complete
if ( $this->dash_auth_enabled && ! $this->dash_backup_completed ) {
// Only send a failure callback if dash auth was enabled, the backup didn't
// complete, and no terminal callback (success or failure) has been sent yet.
if ( $this->dash_auth_enabled && ! $this->dash_backup_completed && ! $this->dash_callback_sent ) {

// If no error was captured yet, try to capture shutdown error
if ( empty( $this->dash_error_message ) ) {
Expand Down Expand Up @@ -1635,12 +1649,21 @@ private function rollback_failed_backup() {
$result = EE::launch( sprintf( 'rclone purge %s', escapeshellarg( $this->dash_new_backup_path ) ) );

if ( $result->return_code ) {
EE::warning( sprintf(
'Failed to delete backup from remote storage. Please manually delete: %s',
// Rollback purge failed: the untracked backup genuinely survives on the
// remote, so the operator must delete it manually. Force-overwrite any
// optimistic "rolled back" error captured by the caller (capture_error is
// first-wins) so EasyDash receives this accurate path, not the inverse.
$message = sprintf(
'Failed to delete orphaned backup from remote storage. Please manually delete: %s',
$this->dash_new_backup_path
) );
);
$this->dash_error_message = '';
$this->capture_error( $message, self::ERROR_TYPE_FILESYSTEM, 4003 );
EE::error( $message );
} else {
EE::success( 'Successfully removed unregistered backup from remote storage.' );
// Demoted from EE::success: this always precedes a terminal EE::error, so a
// green success line would be misleading on a failing command.
EE::log( 'Successfully removed unregistered backup from remote storage.' );
}
}

Expand Down Expand Up @@ -1727,7 +1750,15 @@ private function send_dash_success_callback( $ed_api_url, $backup_id, $verify_to

EE::debug( 'Payload being sent: ' . json_encode( $payload ) );

return $this->send_dash_request( $endpoint, $payload );
$success = $this->send_dash_request( $endpoint, $payload );

// A success that actually reached EasyDash is the terminal callback; a failed
// attempt is not, so the failure path can still emit the failure callback.
if ( $success ) {
$this->dash_callback_sent = true;
}

return $success;
}

/**
Expand Down Expand Up @@ -1756,6 +1787,9 @@ private function send_dash_failure_callback( $ed_api_url, $backup_id, $verify_to
'error_code' => $payload['error_code'],
] ) );

// Failure is terminal: mark before sending so a concurrent shutdown pass
// can never emit a second (duplicate) terminal callback.
$this->dash_callback_sent = true;
$this->send_dash_request( $endpoint, $payload );
}

Expand Down
Loading