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
215 changes: 194 additions & 21 deletions src/helper/Site_Backup_Restore.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,20 @@ class Site_Backup_Restore {
// Global backup lock handle for serializing backups
private $global_backup_lock_handle = null;

// Local staging dir (EE_BACKUP_DIR/<site_url>) holding the in-progress archive
// (backup) or the downloaded archive (restore). Cleared once the success path
// removes it; the shutdown handler purges it only when an error/crash left it
// behind, so a failed run can't fill the disk or leave a stale partial download.
private $staging_dir = null;

// Temp SQL query files written into the live web root, run, then removed. Each
// is removed inline on the success path; the shutdown handler is a safety net
// for an interrupt between write and remove.
private $temp_query_files = [];

public function __construct() {
$this->fs = new Filesystem();
register_shutdown_function( [ $this, 'cleanup_temp_query_files' ] );
}

public function backup( $args, $assoc_args = [] ) {
Expand Down Expand Up @@ -127,6 +139,11 @@ public function backup( $args, $assoc_args = [] ) {
$this->pre_backup_check();
$backup_dir = EE_BACKUP_DIR . '/' . $this->site_data['site_url'];

// Track the staging dir so an abnormal exit (error, crash, OOM, Ctrl-C)
// purges the half-built archive instead of leaving it to fill the disk.
$this->staging_dir = $backup_dir;
register_shutdown_function( [ $this, 'cleanup_staging_dir' ] );

$this->fs->remove( $backup_dir );
$this->fs->mkdir( $backup_dir );

Expand All @@ -151,8 +168,9 @@ public function backup( $args, $assoc_args = [] ) {

$this->rclone_upload( $backup_dir );
$this->fs->remove( $backup_dir );
$this->staging_dir = null; // Removed cleanly; nothing for the shutdown handler to do.

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

// Mark backup as completed and send success callback
$this->dash_backup_completed = true;
Expand Down Expand Up @@ -265,10 +283,6 @@ public function restore( $args, $assoc_args = [] ) {
$backup_id = \EE\Utils\get_flag_value( $assoc_args, 'id' );
$backup_dir = EE_BACKUP_DIR . '/' . $this->site_data['site_url'];

if ( ! $this->fs->exists( $backup_dir ) ) {
$this->fs->mkdir( $backup_dir );
}

if ( $backup_id ) {

// verify_backup_id() lists remote backups (rclone lsf) before the
Expand All @@ -286,6 +300,17 @@ public function restore( $args, $assoc_args = [] ) {

$this->pre_restore_check();

// Track the staging dir for shutdown cleanup only AFTER pre_restore_check()
// has acquired this site's lock. Setting it earlier would let an early exit
// (e.g. invalid backup id, lock held by another process) delete a dir that a
// concurrent backup/restore of the same site is actively writing into.
$this->staging_dir = $backup_dir;
register_shutdown_function( [ $this, 'cleanup_staging_dir' ] );

if ( ! $this->fs->exists( $backup_dir ) ) {
$this->fs->mkdir( $backup_dir );
}

if ( 'wp' === $this->site_data['site_type'] ) {
$this->restore_wp( $backup_dir );
} else {
Expand All @@ -296,11 +321,12 @@ public function restore( $args, $assoc_args = [] ) {
$this->maybe_restore_custom_docker_compose( $backup_dir );

$this->fs->remove( $backup_dir );
$this->staging_dir = null; // Removed cleanly; nothing for the shutdown handler to do.

EE::log( 'Reloading site.' );
EE::run_command( [ 'site', 'reload', $this->site_data['site_url'] ], [], [] );

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

EE::success( 'Site restored successfully.' );

Expand Down Expand Up @@ -342,10 +368,13 @@ private function backup_site_details( $backup_dir ) {

$query = 'SELECT COUNT(*) FROM ' . $table_prefix . 'posts WHERE post_type = "attachment"';
$query_file = EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'] . '/app/htdocs/query.sql';
// Track for shutdown cleanup in case an interrupt hits before the remove below.
$this->temp_query_files[] = $query_file;
$this->fs->dumpFile( $query_file, $query );
$upload_count = $this->run_wp_cli_command( 'db query < /var/www/htdocs/query.sql --skip-column-names | tr -d \'[:space:]\'', true );
$upload_count = empty( $upload_count ) ? 0 : $upload_count;
$this->fs->remove( $query_file );
$this->temp_query_files = array_values( array_diff( $this->temp_query_files, [ $query_file ] ) );

$plugin_count = $this->run_wp_cli_command( 'plugin list --format=count' );
// if it is not a number, then make it -
Expand Down Expand Up @@ -961,37 +990,87 @@ private function pre_backup_restore_checks() {
private function pre_backup_check() {
$this->pre_backup_restore_checks();

$site_path = EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'] . '/app/htdocs';
$site_size = $this->dir_size( $site_path );
$site_root = EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'];

if ( ! $this->fs->exists( $site_root . '/app' ) ) {
$this->capture_error(
sprintf( 'Site app directory not found: %s/app', $site_root ),
self::ERROR_TYPE_FILESYSTEM,
3003
);
$this->try_remove_site_lock();
EE::error( "Site app directory does not exist: $site_root/app" );
}

EE::debug( 'Site size: ' . $site_size );
// Size everything actually archived, not just app/htdocs: every site type
// archives the whole app/ dir, and all types also archive config/ (nginx
// for all, php for php/wp). The previous estimate sized only app/htdocs and
// so missed the rest of app/ and the config archive entirely.
$site_size = $this->dir_size( $site_root . '/app' );
$site_size += $this->dir_size( $site_root . '/config' );

EE::debug( 'Site size (files): ' . $site_size );

if ( in_array( $this->site_data['site_type'], [ 'php', 'wp' ] ) && ! empty( $this->site_data['db_name'] ) ) {
$site_size += $this->get_db_size();
EE::debug( 'Site size with db: ' . $site_size );
}

// Require free space >= the full uncompressed source ($site_size already
// includes the DB) plus 10% slack. The compressed archive is bounded by the
// source (7z output <= input), and the transient uncompressed SQL dump is a
// copy of the DB that already lives in $site_size, so a flat 10% headroom
// for filesystem slack / archive overhead is enough -- without re-adding the
// DB a second time, which would false-reject DB-heavy sites.
$required_size = (int) ceil( $site_size * 1.1 );

$free_space = disk_free_space( EE_BACKUP_DIR );
EE::debug( 'Free space: ' . $free_space );
if ( false === $free_space ) {
$this->capture_error(
'Unable to determine free disk space for backup directory',
self::ERROR_TYPE_FILESYSTEM,
3004
);
$this->try_remove_site_lock();
EE::error( 'Unable to determine free disk space for backup directory.' );
}
EE::debug( 'Required space (with headroom): ' . $required_size . ', Free space: ' . $free_space );

if ( $site_size > $free_space ) {
$error_message = $this->build_disk_space_error_message( 'backup', $site_size, $free_space );
if ( $required_size > $free_space ) {
$error_message = $this->build_disk_space_error_message( 'backup', $required_size, $free_space );

$this->capture_error(
sprintf(
'Insufficient disk space for backup. Required: %s, Available: %s',
$this->format_bytes( $site_size ),
$this->format_bytes( $required_size ),
$this->format_bytes( $free_space )
),
self::ERROR_TYPE_DISK_SPACE,
3001
);

$this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' );
$this->try_remove_site_lock();
EE::error( $error_message );
}
}

/**
* Best-effort removal of the per-site lock file.
*
* Filesystem::remove() throws on a failed unlink; callers on an error path are
* about to EE::error(), so a lock-cleanup failure must not throw and mask the
* real cause. Swallow any failure (logged at debug level).
*
* @return void
*/
private function try_remove_site_lock() {
try {
$this->fs->remove( EE_BACKUP_DIR . '/' . $this->site_data['site_url'] . '.lock' );
} catch ( \Throwable $e ) {
EE::debug( 'Could not remove site lock: ' . $e->getMessage() );
}
}

/**
* Build a disk space error message for backup/restore operations.
*
Expand Down Expand Up @@ -1160,22 +1239,48 @@ private function format_bytes( $bytes, $precision = 2 ) {
return round( $size, $precision ) . ' ' . $units[ $pow ];
}

/**
* Sum the byte size of every file under $directory.
*
* A missing directory returns 0 (some archived dirs are optional, e.g. a
* site's config/ may not exist yet) rather than aborting. Files whose size
* can't be read are counted as 0 but raise a warning so the disk-space
* pre-check isn't silently under-estimating. CATCH_GET_CHILD makes an
* untraversable subdirectory (e.g. permission denied) be skipped rather than
* throw UnexpectedValueException and abort the whole backup; such a subtree is
* simply not counted (the estimate may then be low).
*/
private function dir_size( string $directory ) {
$size = 0;

EE::debug( "Calculating size of $directory" );

if ( ! $this->fs->exists( $directory ) ) {
EE::error( "Directory does not exist: $directory" );
return 0;
}

$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ) );
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::LEAVES_ONLY,
\RecursiveIteratorIterator::CATCH_GET_CHILD
);
$unreadable = 0;

foreach ( $files as $file ) {
if ( ! $file->isReadable() ) {
$unreadable++;
continue;
}
$file_size = $file->getSize();
if ( false === $file_size ) {
$unreadable++;
continue;
}
$size += $file->getSize();
$size += $file_size;
}

if ( $unreadable > 0 ) {
EE::warning( sprintf( 'Could not size %d file(s) under %s; disk-space estimate may be low.', $unreadable, $directory ) );
}

EE::debug( "Size of $directory: $size" );
Expand Down Expand Up @@ -1204,6 +1309,8 @@ private function get_db_size() {


$query_file = EE_ROOT_DIR . '/sites/' . $this->site_data['site_url'] . '/app/htdocs/db_size_query.sql';
// Track for shutdown cleanup in case an interrupt hits before the remove below.
$this->temp_query_files[] = $query_file;
$this->fs->dumpFile( $query_file, $query );


Expand All @@ -1213,14 +1320,25 @@ private function get_db_size() {


$this->fs->remove( $query_file );
$this->temp_query_files = array_values( array_diff( $this->temp_query_files, [ $query_file ] ) );


$size = 0;
$size_found = false;
$size_output = explode( "\n", $output->stdout );

if ( count( $size_output ) > 1 ) {
$size_array = explode( "\t", $size_output[1] );
$size = isset( $size_array[1] ) ? $size_array[1] : 0;
if ( isset( $size_array[1] ) && is_numeric( $size_array[1] ) ) {
$size = $size_array[1];
$size_found = true;
}
}

// A backup proceeds even if the DB size can't be read, but warn so the
// disk-space pre-check isn't silently treating an unknown DB as 0 bytes.
if ( ! $size_found ) {
EE::warning( 'Could not determine database size; disk-space estimate may be low.' );
}

EE::debug( "DB size: $size" );
Expand All @@ -1241,7 +1359,10 @@ private function list_remote_backups( $return = false ) {
return [];
}

$backups = explode( PHP_EOL, trim( $output->stdout ) ); // Remove extra whitespace and split
// trim()+explode() on empty output yields [''] (one empty element), so
// filter blank lines before the empty() check below -- otherwise the
// "No remote backups found" branch is unreachable for an empty listing.
$backups = array_filter( array_map( 'trim', explode( PHP_EOL, $output->stdout ) ), 'strlen' );

if ( empty( $backups ) ) {
if ( ! $return ) {
Expand Down Expand Up @@ -1598,8 +1719,10 @@ private function cleanup_old_backups() {
return;
}

// Check if we have more backups than allowed
if ( count( $backups ) > ( $no_of_backups + 1 ) ) {
// Check if we have more backups than allowed. array_slice() below keeps the
// first $no_of_backups, so trigger as soon as the count exceeds that (the
// previous `+ 1` retained N+1).
if ( count( $backups ) > $no_of_backups ) {
$backups_to_delete = array_slice( $backups, $no_of_backups );

EE::log( sprintf( 'Cleaning up old backups. Keeping %d most recent backups.', $no_of_backups ) );
Expand Down Expand Up @@ -1963,4 +2086,54 @@ public function release_global_backup_lock() {
EE::debug( 'Released global backup lock' );
}
}

/**
* Remove the local staging dir on abnormal exit.
*
* The success path removes it and clears $this->staging_dir, so this only
* fires when an error/crash left a half-built archive (backup) or a partial
* download (restore) behind. Idempotent and a no-op once cleared.
*
* @return void
*/
public function cleanup_staging_dir() {
// Never throw from a shutdown safety net: Filesystem::remove() raises
// IOException on a failed unlink (e.g. permission denied), and a throw here
// would abort the remaining shutdown handlers (lock release, dash callback).
try {
if ( ! empty( $this->staging_dir ) && $this->fs->exists( $this->staging_dir ) ) {
$this->fs->remove( $this->staging_dir );
EE::debug( 'Cleaned up staging dir after abnormal exit: ' . $this->staging_dir );
}
} catch ( \Throwable $e ) {
EE::debug( 'Could not clean up staging dir ' . $this->staging_dir . ': ' . $e->getMessage() );
}
$this->staging_dir = null;
}

/**
* Remove any temp SQL query files written into the live web root.
*
* Safety net for an interrupt between a query file being written and its
* inline removal; the success path removes them and clears the list, so this
* is normally a no-op. Idempotent.
*
* @return void
*/
public function cleanup_temp_query_files() {
// First-registered shutdown handler: must never throw, or the remaining
// handlers (staging cleanup, lock release, dash callback) would be skipped.
// Filesystem::remove() raises IOException on a failed unlink.
foreach ( $this->temp_query_files as $query_file ) {
try {
if ( $this->fs->exists( $query_file ) ) {
$this->fs->remove( $query_file );
EE::debug( 'Cleaned up leftover query file: ' . $query_file );
}
} catch ( \Throwable $e ) {
EE::debug( 'Could not clean up query file ' . $query_file . ': ' . $e->getMessage() );
}
}
$this->temp_query_files = [];
}
}
Loading