Skip to content

Commit 092418c

Browse files
committed
Merge branch 'develop' for v3.7.2
2 parents 4fc12e9 + 877a78c commit 092418c

File tree

1 file changed

+146
-19
lines changed

1 file changed

+146
-19
lines changed

src/helper/Site_Backup_Restore.php

Lines changed: 146 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class Site_Backup_Restore {
4141
private $dash_error_type = 'unknown';
4242
private $dash_error_code = 0;
4343

44+
// Global backup lock handle for serializing backups
45+
private $global_backup_lock_handle = null;
46+
4447
public function __construct() {
4548
$this->fs = new Filesystem();
4649
}
@@ -97,6 +100,12 @@ public function backup( $args, $assoc_args = [] ) {
97100
register_shutdown_function( [ $this, 'dash_shutdown_handler' ] );
98101
}
99102

103+
// Acquire global lock to serialize backups (prevents OOM from concurrent backups)
104+
$this->acquire_global_backup_lock();
105+
106+
// Register shutdown handler to release lock on any exit (error, crash, etc.)
107+
register_shutdown_function( [ $this, 'release_global_backup_lock' ] );
108+
100109
$this->pre_backup_check();
101110
$backup_dir = EE_BACKUP_DIR . '/' . $this->site_data['site_url'];
102111

@@ -146,6 +155,9 @@ public function backup( $args, $assoc_args = [] ) {
146155
}
147156
}
148157

158+
// Release global backup lock (also released by shutdown handler as safety net)
159+
$this->release_global_backup_lock();
160+
149161
delem_log( 'site backup end' );
150162
}
151163

@@ -419,7 +431,13 @@ private function maybe_backup_custom_docker_compose( $backup_dir ) {
419431
if ( $this->fs->exists( $custom_docker_compose_dir ) ) {
420432
$custom_docker_compose_dir_archive = $backup_dir . '/user-docker-compose.zip';
421433
$archive_command = sprintf( 'cd %s && 7z a -mx=1 %s .', $custom_docker_compose_dir, $custom_docker_compose_dir_archive );
422-
EE::exec( $archive_command );
434+
$result = EE::launch( $archive_command );
435+
436+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
437+
// This is optional, so we just log a warning instead of failing
438+
if ( $result->return_code >= 2 ) {
439+
EE::warning( 'Failed to backup custom docker-compose directory. Continuing with backup.' );
440+
}
423441
}
424442
}
425443

@@ -431,10 +449,11 @@ private function backup_site_dir( $backup_dir ) {
431449
$backup_file = $backup_dir . '/' . $this->site_data['site_url'] . '.zip';
432450
$backup_command = sprintf( 'cd %s && 7z a -mx=1 %s .', $site_dir, $backup_file );
433451

434-
$result = EE::exec( $backup_command );
452+
$result = EE::launch( $backup_command );
435453

436-
// Check if archive was created successfully
437-
if ( ! $result || ! $this->fs->exists( $backup_file ) ) {
454+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
455+
// Exit code 1 means warnings (e.g., missing symlink targets) but archive is still created
456+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
438457
$this->capture_error(
439458
'Failed to create site backup archive',
440459
self::ERROR_TYPE_FILESYSTEM,
@@ -470,9 +489,10 @@ private function backup_wp_content_dir( $backup_dir ) {
470489
}
471490

472491
$backup_command = sprintf( 'cd %s && 7z a -mx=1 %s wp-config.php', $site_dir . '/../', $backup_file );
473-
$result = EE::exec( $backup_command );
492+
$result = EE::launch( $backup_command );
474493

475-
if ( ! $result ) {
494+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
495+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
476496
$this->capture_error(
477497
'Failed to create WordPress content backup archive',
478498
self::ERROR_TYPE_FILESYSTEM,
@@ -486,15 +506,34 @@ private function backup_wp_content_dir( $backup_dir ) {
486506

487507
// Include meta.json in the zip archive (Corrected logic)
488508
$backup_command = sprintf( 'cd %s && 7z u -snl -mx=1 %s %s wp-content', $site_dir, $backup_file, $meta_file );
489-
EE::exec( $backup_command );
509+
$result = EE::launch( $backup_command );
490510
// Remove the file
491511
$this->fs->remove( $meta_file );
492512

513+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
514+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
515+
$this->capture_error(
516+
'Failed to create WordPress content backup archive',
517+
self::ERROR_TYPE_FILESYSTEM,
518+
3002
519+
);
520+
EE::error( 'Failed to create backup archive. Please check disk space and file permissions.' );
521+
}
493522

494523
$uploads_dir = $site_dir . '/wp-content/uploads';
495524
if ( is_link( $uploads_dir ) ) {
496525
$backup_command = sprintf( 'cd %s && 7z u -mx=1 %s wp-content/uploads', $site_dir, $backup_file );
497-
EE::exec( $backup_command );
526+
$result = EE::launch( $backup_command );
527+
528+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
529+
if ( $result->return_code >= 2 ) {
530+
$this->capture_error(
531+
'Failed to create WordPress content backup archive',
532+
self::ERROR_TYPE_FILESYSTEM,
533+
3002
534+
);
535+
EE::error( 'Failed to create backup archive. Please check disk space and file permissions.' );
536+
}
498537
}
499538

500539
// Final check that backup file was created successfully
@@ -517,9 +556,10 @@ private function backup_nginx_conf( $backup_dir ) {
517556
$backup_file = $backup_dir . '/conf.zip';
518557
$backup_command = sprintf( 'cd %s && 7z a -mx=1 %s nginx', $conf_dir, $backup_file );
519558

520-
$result = EE::exec( $backup_command );
559+
$result = EE::launch( $backup_command );
521560

522-
if ( ! $result ) {
561+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
562+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
523563
$this->capture_error(
524564
'Failed to create nginx configuration backup archive',
525565
self::ERROR_TYPE_FILESYSTEM,
@@ -536,9 +576,10 @@ private function backup_php_conf( $backup_dir ) {
536576
$backup_file = $backup_dir . '/conf.zip';
537577
$backup_command = sprintf( 'cd %s && 7z u -mx=1 %s php', $conf_dir, $backup_file );
538578

539-
$result = EE::exec( $backup_command );
579+
$result = EE::launch( $backup_command );
540580

541-
if ( ! $result ) {
581+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
582+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
542583
$this->capture_error(
543584
'Failed to create PHP configuration backup archive',
544585
self::ERROR_TYPE_FILESYSTEM,
@@ -610,15 +651,18 @@ private function backup_db( $backup_dir ) {
610651
EE::exec( sprintf( 'mv %s %s', $sql_dump_path, $sql_file ) );
611652
$backup_command = sprintf( 'cd %s && 7z u -mx=1 %s sql', $backup_dir, $backup_file );
612653

613-
$result = EE::exec( $backup_command );
614-
if ( ! $result ) {
654+
$result = EE::launch( $backup_command );
655+
656+
// 7z exit codes: 0=success, 1=warning (non-fatal), 2+=fatal error
657+
if ( $result->return_code >= 2 || ! $this->fs->exists( $backup_file ) ) {
615658
$this->capture_error(
616659
'Failed to compress database backup into archive',
617660
self::ERROR_TYPE_FILESYSTEM,
618661
3002
619662
);
620663
EE::error( 'Failed to compress database backup. Please check disk space.' );
621664
}
665+
622666
$this->fs->remove( $backup_dir . '/sql' );
623667
}
624668

@@ -960,7 +1004,7 @@ private function pre_restore_check() {
9601004
$this->pre_backup_restore_checks();
9611005

9621006
$remote_path = $this->get_remote_path( false );
963-
$command = sprintf( 'rclone size --json %s', $remote_path );
1007+
$command = sprintf( 'rclone size --json %s', escapeshellarg( $remote_path ) );
9641008
$output = EE::launch( $command );
9651009

9661010
if ( $output->return_code ) {
@@ -1137,7 +1181,7 @@ private function list_remote_backups( $return = false ) {
11371181

11381182
$remote_path = $this->get_rclone_config_path(); // Get remote path without creating a new timestamped folder
11391183

1140-
$command = sprintf( 'rclone lsf --dirs-only %s', $remote_path ); // List only directories
1184+
$command = sprintf( 'rclone lsf --dirs-only %s', escapeshellarg( $remote_path ) ); // List only directories
11411185
$output = EE::launch( $command );
11421186

11431187
if ( $output->return_code !== 0 && ! $return ) {
@@ -1216,7 +1260,7 @@ private function get_remote_path( $upload = true ) {
12161260
private function rclone_download( $path ) {
12171261
$cpu_cores = intval( EE::launch( 'nproc' )->stdout );
12181262
$multi_threads = min( intval( $cpu_cores ) * 2, 32 );
1219-
$command = sprintf( "rclone copy -P --multi-thread-streams %d %s %s", $multi_threads, $this->get_remote_path( false ), $path );
1263+
$command = sprintf( "rclone copy -P --multi-thread-streams %d %s %s", $multi_threads, escapeshellarg( $this->get_remote_path( false ) ), escapeshellarg( $path ) );
12201264
$output = EE::launch( $command );
12211265

12221266
if ( $output->return_code ) {
@@ -1245,7 +1289,7 @@ private function rclone_upload( $path ) {
12451289
$s3_flag = ' --s3-chunk-size=64M --s3-upload-concurrency ' . min( intval( $cpu_cores ) * 2, 32 );
12461290
}
12471291

1248-
$command = sprintf( "rclone copy -P %s --transfers %d --checkers %d --buffer-size %s %s %s", $s3_flag, $transfers, $transfers, $buffer_size, $path, $this->get_remote_path() );
1292+
$command = sprintf( "rclone copy -P %s --transfers %d --checkers %d --buffer-size %s %s %s", $s3_flag, $transfers, $transfers, $buffer_size, escapeshellarg( $path ), escapeshellarg( $this->get_remote_path() ) );
12491293
$output = EE::launch( $command );
12501294

12511295
if ( $output->return_code ) {
@@ -1257,7 +1301,7 @@ private function rclone_upload( $path ) {
12571301
EE::error( 'Error uploading backup to remote storage.' );
12581302
} else {
12591303

1260-
$command = sprintf( 'rclone lsf %s', $this->get_remote_path( false ) );
1304+
$command = sprintf( 'rclone lsf %s', escapeshellarg( $this->get_remote_path( false ) ) );
12611305
$output = EE::launch( $command );
12621306
$remote_path = $output->stdout;
12631307
EE::success( 'Backup uploaded to remote storage. Remote path: ' . $remote_path );
@@ -1571,4 +1615,87 @@ private function sanitize_count( $value ) {
15711615

15721616
return intval( $value );
15731617
}
1618+
1619+
/**
1620+
* Acquire a global backup lock to ensure only one backup runs at a time.
1621+
* Uses flock() for atomic, race-condition-free locking.
1622+
*
1623+
* This prevents multiple concurrent backups from exhausting system resources
1624+
* (RAM, CPU, disk I/O, network bandwidth) when triggered simultaneously.
1625+
*
1626+
* Note: flock() may not work reliably on NFS or other network filesystems.
1627+
* EE_BACKUP_DIR should be on a local filesystem for proper lock behavior.
1628+
*
1629+
* @return void
1630+
*/
1631+
private function acquire_global_backup_lock() {
1632+
$lock_file = EE_BACKUP_DIR . '/backup-global.lock';
1633+
$max_wait = 86400; // 24 hours max wait
1634+
$waited = 0;
1635+
$interval = 60; // Check every 60 seconds
1636+
1637+
// Ensure backup directory exists
1638+
if ( ! $this->fs->exists( EE_BACKUP_DIR ) ) {
1639+
$this->fs->mkdir( EE_BACKUP_DIR );
1640+
}
1641+
1642+
// Open file handle (creates if doesn't exist)
1643+
$this->global_backup_lock_handle = fopen( $lock_file, 'c+' );
1644+
1645+
if ( ! $this->global_backup_lock_handle ) {
1646+
$this->capture_error(
1647+
'Cannot create backup lock file',
1648+
self::ERROR_TYPE_FILESYSTEM,
1649+
5002
1650+
);
1651+
EE::error( 'Cannot create backup lock file.' );
1652+
}
1653+
1654+
// Try to acquire exclusive lock (non-blocking first to log status)
1655+
while ( ! flock( $this->global_backup_lock_handle, LOCK_EX | LOCK_NB ) ) {
1656+
if ( $waited >= $max_wait ) {
1657+
fclose( $this->global_backup_lock_handle );
1658+
$this->global_backup_lock_handle = null;
1659+
$this->capture_error(
1660+
'Timeout waiting for another backup to complete',
1661+
self::ERROR_TYPE_LOCK,
1662+
5003
1663+
);
1664+
EE::error( 'Timeout waiting for another backup. Try again later.' );
1665+
}
1666+
1667+
// Read who has the lock
1668+
rewind( $this->global_backup_lock_handle );
1669+
$lock_info = stream_get_contents( $this->global_backup_lock_handle );
1670+
1671+
EE::log( sprintf( 'Another backup in progress (%s). Waiting... (%d/%d sec)',
1672+
trim( $lock_info ) ?: 'unknown', $waited, $max_wait ) );
1673+
1674+
sleep( $interval );
1675+
$waited += $interval;
1676+
}
1677+
1678+
// Got the lock! Write our info
1679+
ftruncate( $this->global_backup_lock_handle, 0 );
1680+
rewind( $this->global_backup_lock_handle );
1681+
fwrite( $this->global_backup_lock_handle, $this->site_data['site_url'] . ' (PID: ' . getmypid() . ')' );
1682+
fflush( $this->global_backup_lock_handle );
1683+
1684+
EE::debug( 'Acquired global backup lock for: ' . $this->site_data['site_url'] );
1685+
}
1686+
1687+
/**
1688+
* Release the global backup lock.
1689+
* Safe to call multiple times (idempotent).
1690+
*
1691+
* @return void
1692+
*/
1693+
public function release_global_backup_lock() {
1694+
if ( $this->global_backup_lock_handle ) {
1695+
flock( $this->global_backup_lock_handle, LOCK_UN );
1696+
fclose( $this->global_backup_lock_handle );
1697+
$this->global_backup_lock_handle = null;
1698+
EE::debug( 'Released global backup lock' );
1699+
}
1700+
}
15741701
}

0 commit comments

Comments
 (0)