Skip to content
60 changes: 49 additions & 11 deletions src/helper/Site_Backup_Restore.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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 @@ -156,8 +157,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 @@ -166,13 +165,30 @@ 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 the EasyEngine Dashboard; 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 (the EasyEngine Dashboard 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 EasyEngine Dashboard success callback failed; rolled back the orphaned upload.',
self::ERROR_TYPE_NETWORK,
4004
);
// Hold the lock through the purge so no other backup prunes against the orphan,
// but not through the shutdown failure callback's retries.
$this->rollback_failed_backup();
$this->release_global_backup_lock();
EE::error( 'EasyEngine Dashboard 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 @@ -191,8 +207,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 @@ -1643,12 +1660,22 @@ 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 the EasyEngine Dashboard 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 );
$this->release_global_backup_lock();
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 @@ -1734,7 +1761,15 @@ private function send_dash_success_callback( $ed_api_url, $backup_id, $verify_to

EE::debug( 'Payload being sent: ' . json_encode( array_merge( $payload, [ 'verify' => '******' ] ) ) );

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

// A success that actually reached the EasyEngine Dashboard 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 @@ -1763,6 +1798,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