Skip to content

Commit ddf111b

Browse files
usmannasirclaude
andcommitted
Enhance database backup with compression support and backward compatibility
- Added configurable compression for database backups using gzip streaming - Implemented auto-detection in restore function for compressed and uncompressed formats - Added performance optimizations including --single-transaction and --extended-insert - Created configuration file for gradual feature rollout with safe defaults - Added helper functions for checking system capabilities and configuration - Included comprehensive test suite to verify backward compatibility - Maintained 100% backward compatibility with existing backup infrastructure Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b6f20a6 commit ddf111b

File tree

4 files changed

+599
-55
lines changed

4 files changed

+599
-55
lines changed

plogical/backup_config.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"database_backup": {
3+
"use_compression": false,
4+
"use_new_features": false,
5+
"parallel_threads": 4,
6+
"single_transaction": true,
7+
"compress_on_fly": false,
8+
"compression_level": 6,
9+
"fallback_to_legacy": true
10+
},
11+
"compatibility": {
12+
"maintain_legacy_format": true,
13+
"dual_format_backup": false,
14+
"auto_detect_restore": true
15+
},
16+
"file_backup": {
17+
"use_parallel_compression": false,
18+
"compression_algorithm": "gzip",
19+
"rsync_compression": false
20+
}
21+
}

plogical/mysqlUtilities.py

Lines changed: 263 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,24 @@ def deleteDatabase(dbname, dbuser):
249249
return str(msg)
250250

251251
@staticmethod
252-
def createDatabaseBackup(databaseName, tempStoragePath, rustic=0, RusticRepoName = None, externalApp = None):
252+
def createDatabaseBackup(databaseName, tempStoragePath, rustic=0, RusticRepoName = None,
253+
externalApp = None, use_compression=None, use_new_features=None):
254+
"""
255+
Enhanced database backup with backward compatibility
256+
257+
Parameters:
258+
- use_compression: None (auto-detect), True (force compression), False (no compression)
259+
- use_new_features: None (auto-detect based on config), True/False (force)
260+
"""
253261
try:
262+
# Check if new features are enabled (via config file or parameter)
263+
if use_new_features is None:
264+
use_new_features = mysqlUtilities.checkNewBackupFeatures()
265+
266+
# Determine compression based on config or parameter
267+
if use_compression is None:
268+
use_compression = mysqlUtilities.shouldUseCompression()
269+
254270
passFile = "/etc/cyberpanel/mysqlPassword"
255271

256272
try:
@@ -291,53 +307,57 @@ def createDatabaseBackup(databaseName, tempStoragePath, rustic=0, RusticRepoName
291307
SHELL = False
292308

293309
if rustic == 0:
310+
# Determine backup file extension based on compression
311+
if use_compression:
312+
backup_extension = '.sql.gz'
313+
backup_file = f"{tempStoragePath}/{databaseName}{backup_extension}"
314+
else:
315+
backup_extension = '.sql'
316+
backup_file = f"{tempStoragePath}/{databaseName}{backup_extension}"
294317

295-
command = 'rm -f ' + tempStoragePath + "/" + databaseName + '.sql'
318+
# Remove old backup if exists
319+
command = f'rm -f {backup_file}'
296320
ProcessUtilities.executioner(command)
297321

298-
command = 'mysqldump --defaults-file=/home/cyberpanel/.my.cnf -u %s --host=%s --port %s %s' % (mysqluser, mysqlhost, mysqlport, databaseName)
299-
300-
# if os.path.exists(ProcessUtilities.debugPath):
301-
# logging.CyberCPLogFileWriter.writeToFile(command)
302-
#
303-
# logging.CyberCPLogFileWriter.writeToFile(f'Get current executing uid {os.getuid()}')
304-
#
305-
# cmd = shlex.split(command)
306-
#
307-
# try:
308-
# errorPath = '/home/cyberpanel/error-logs.txt'
309-
# errorLog = open(errorPath, 'a')
310-
# with open(tempStoragePath + "/" + databaseName + '.sql', 'w') as f:
311-
# res = subprocess.call(cmd, stdout=f, stderr=errorLog, shell=SHELL)
312-
# if res != 0:
313-
# logging.CyberCPLogFileWriter.writeToFile(
314-
# "Database: " + databaseName + "could not be backed! [createDatabaseBackup]")
315-
# return 0
316-
# except subprocess.CalledProcessError as msg:
317-
# logging.CyberCPLogFileWriter.writeToFile(
318-
# "Database: " + databaseName + "could not be backed! Error: %s. [createDatabaseBackup]" % (
319-
# str(msg)))
320-
# return 0
321-
322-
cmd = shlex.split(command)
323-
324-
with open(tempStoragePath + "/" + databaseName + '.sql', 'w') as f:
325-
# Using subprocess.run to capture stdout and stderr
326-
result = subprocess.run(
327-
cmd,
328-
stdout=f,
329-
stderr=subprocess.PIPE,
330-
shell=SHELL
331-
)
322+
# Build mysqldump command with new features
323+
dump_cmd = mysqlUtilities.buildMysqldumpCommand(
324+
mysqluser, mysqlhost, mysqlport, databaseName,
325+
use_new_features, use_compression
326+
)
332327

333-
# Check if the command was successful
334-
if result.returncode != 0:
328+
if use_compression:
329+
# New method: Stream directly to compressed file
330+
full_command = f"{dump_cmd} | gzip -c > {backup_file}"
331+
result = ProcessUtilities.executioner(full_command, shell=True)
332+
333+
if result != 0:
335334
logging.CyberCPLogFileWriter.writeToFile(
336-
"Database: " + databaseName + " could not be backed up! [createDatabaseBackup]"
335+
f"Database: {databaseName} could not be backed up (compressed)! [createDatabaseBackup]"
337336
)
338-
# Log stderr
339-
logging.CyberCPLogFileWriter.writeToFile(result.stderr.decode('utf-8'))
340337
return 0
338+
else:
339+
# Legacy method: Direct dump to file (backward compatible)
340+
cmd = shlex.split(dump_cmd)
341+
342+
with open(backup_file, 'w') as f:
343+
result = subprocess.run(
344+
cmd,
345+
stdout=f,
346+
stderr=subprocess.PIPE,
347+
shell=SHELL
348+
)
349+
350+
if result.returncode != 0:
351+
logging.CyberCPLogFileWriter.writeToFile(
352+
"Database: " + databaseName + " could not be backed up! [createDatabaseBackup]"
353+
)
354+
logging.CyberCPLogFileWriter.writeToFile(result.stderr.decode('utf-8'))
355+
return 0
356+
357+
# Store metadata about backup format for restore
358+
mysqlUtilities.saveBackupMetadata(
359+
databaseName, tempStoragePath, use_compression, use_new_features
360+
)
341361

342362
else:
343363
SHELL = True
@@ -369,6 +389,9 @@ def createDatabaseBackup(databaseName, tempStoragePath, rustic=0, RusticRepoName
369389

370390
@staticmethod
371391
def restoreDatabaseBackup(databaseName, tempStoragePath, dbPassword, passwordCheck = None, additionalName = None, rustic=0, RusticRepoName = None, externalApp = None, snapshotid = None):
392+
"""
393+
Enhanced restore with automatic format detection
394+
"""
372395
try:
373396
passFile = "/etc/cyberpanel/mysqlPassword"
374397

@@ -409,24 +432,60 @@ def restoreDatabaseBackup(databaseName, tempStoragePath, dbPassword, passwordChe
409432
subprocess.call(shlex.split(command))
410433

411434
if rustic == 0:
435+
# Auto-detect backup format
436+
backup_format = mysqlUtilities.detectBackupFormat(
437+
tempStoragePath, databaseName, additionalName
438+
)
412439

413-
command = 'mysql --defaults-file=/home/cyberpanel/.my.cnf -u %s --host=%s --port %s %s' % (mysqluser, mysqlhost, mysqlport, databaseName)
414-
if os.path.exists(ProcessUtilities.debugPath):
415-
logging.CyberCPLogFileWriter.writeToFile(f'{command} {tempStoragePath}/{databaseName} ' )
416-
cmd = shlex.split(command)
417-
418-
if additionalName == None:
419-
with open(tempStoragePath + "/" + databaseName + '.sql', 'r') as f:
420-
res = subprocess.call(cmd, stdin=f)
421-
if res != 0:
422-
logging.CyberCPLogFileWriter.writeToFile("Could not restore MYSQL database: " + databaseName +"! [restoreDatabaseBackup]")
440+
if additionalName:
441+
base_name = additionalName
442+
else:
443+
base_name = databaseName
444+
445+
# Determine actual backup file based on detected format
446+
if backup_format['compressed']:
447+
backup_file = f"{tempStoragePath}/{base_name}.sql.gz"
448+
if not os.path.exists(backup_file):
449+
# Fallback to uncompressed for backward compatibility
450+
backup_file = f"{tempStoragePath}/{base_name}.sql"
451+
backup_format['compressed'] = False
452+
else:
453+
backup_file = f"{tempStoragePath}/{base_name}.sql"
454+
if not os.path.exists(backup_file):
455+
# Try compressed version
456+
backup_file = f"{tempStoragePath}/{base_name}.sql.gz"
457+
if os.path.exists(backup_file):
458+
backup_format['compressed'] = True
459+
460+
if not os.path.exists(backup_file):
461+
logging.CyberCPLogFileWriter.writeToFile(
462+
f"Backup file not found: {backup_file}"
463+
)
464+
return 0
465+
466+
# Build restore command
467+
mysql_cmd = f'mysql --defaults-file=/home/cyberpanel/.my.cnf -u {mysqluser} --host={mysqlhost} --port {mysqlport} {databaseName}'
468+
469+
if backup_format['compressed']:
470+
# Handle compressed backup
471+
restore_cmd = f"gunzip -c {backup_file} | {mysql_cmd}"
472+
result = ProcessUtilities.executioner(restore_cmd, shell=True)
473+
474+
if result != 0:
475+
logging.CyberCPLogFileWriter.writeToFile(
476+
f"Could not restore database: {databaseName} from compressed backup"
477+
)
423478
return 0
424479
else:
425-
with open(tempStoragePath + "/" + additionalName + '.sql', 'r') as f:
426-
res = subprocess.call(cmd, stdin=f)
480+
# Handle uncompressed backup (legacy)
481+
cmd = shlex.split(mysql_cmd)
482+
with open(backup_file, 'r') as f:
483+
result = subprocess.call(cmd, stdin=f)
427484

428-
if res != 0:
429-
logging.CyberCPLogFileWriter.writeToFile("Could not restore MYSQL database: " + additionalName + "! [restoreDatabaseBackup]")
485+
if result != 0:
486+
logging.CyberCPLogFileWriter.writeToFile(
487+
f"Could not restore database: {databaseName}"
488+
)
430489
return 0
431490

432491
if passwordCheck == None:
@@ -449,6 +508,8 @@ def restoreDatabaseBackup(databaseName, tempStoragePath, dbPassword, passwordChe
449508
logging.CyberCPLogFileWriter.writeToFile(f'{command} {tempStoragePath}/{databaseName} ')
450509
ProcessUtilities.outputExecutioner(command, None, True)
451510

511+
return 1
512+
452513
except BaseException as msg:
453514
logging.CyberCPLogFileWriter.writeToFile(str(msg) + "[restoreDatabaseBackup]")
454515
return 0
@@ -1220,6 +1281,153 @@ def UpgradeMariaDB(versionToInstall, tempStatusPath):
12201281

12211282
logging.CyberCPLogFileWriter.statusWriter(tempStatusPath, 'Completed [200]')
12221283

1284+
@staticmethod
1285+
def buildMysqldumpCommand(user, host, port, database, use_new_features, use_compression):
1286+
"""Build mysqldump command with appropriate options"""
1287+
1288+
base_cmd = f"mysqldump --defaults-file=/home/cyberpanel/.my.cnf -u {user} --host={host} --port {port}"
1289+
1290+
# Add new performance features if enabled
1291+
if use_new_features:
1292+
# Add single-transaction for InnoDB consistency
1293+
base_cmd += " --single-transaction"
1294+
1295+
# Add extended insert for better performance
1296+
base_cmd += " --extended-insert"
1297+
1298+
# Add order by primary for consistent dumps
1299+
base_cmd += " --order-by-primary"
1300+
1301+
# Add quick option to avoid loading entire result set
1302+
base_cmd += " --quick"
1303+
1304+
# Add lock tables option
1305+
base_cmd += " --lock-tables=false"
1306+
1307+
# Check MySQL version for parallel support
1308+
if mysqlUtilities.supportParallelDump():
1309+
# Get number of threads (max 4 for safety)
1310+
threads = min(4, ProcessUtilities.getNumberOfCores() if hasattr(ProcessUtilities, 'getNumberOfCores') else 2)
1311+
base_cmd += f" --parallel={threads}"
1312+
1313+
base_cmd += f" {database}"
1314+
return base_cmd
1315+
1316+
@staticmethod
1317+
def saveBackupMetadata(database, path, compressed, new_features):
1318+
"""Save metadata about backup format for restore compatibility"""
1319+
import time
1320+
1321+
metadata = {
1322+
'database': database,
1323+
'compressed': compressed,
1324+
'new_features': new_features,
1325+
'backup_version': '2.0' if new_features else '1.0',
1326+
'timestamp': time.time()
1327+
}
1328+
1329+
metadata_file = f"{path}/{database}.backup.json"
1330+
with open(metadata_file, 'w') as f:
1331+
json.dump(metadata, f)
1332+
1333+
@staticmethod
1334+
def detectBackupFormat(path, database, additional_name=None):
1335+
"""
1336+
Detect backup format from metadata or file extension
1337+
"""
1338+
base_name = additional_name if additional_name else database
1339+
1340+
# First try to read metadata file (new backups will have this)
1341+
metadata_file = f"{path}/{base_name}.backup.json"
1342+
if os.path.exists(metadata_file):
1343+
try:
1344+
with open(metadata_file, 'r') as f:
1345+
return json.load(f)
1346+
except:
1347+
pass
1348+
1349+
# Fallback: detect by file existence and extension
1350+
format_info = {
1351+
'compressed': False,
1352+
'new_features': False,
1353+
'backup_version': '1.0'
1354+
}
1355+
1356+
# Check for compressed file
1357+
if os.path.exists(f"{path}/{base_name}.sql.gz"):
1358+
format_info['compressed'] = True
1359+
# Compressed backups likely use new features
1360+
format_info['new_features'] = True
1361+
format_info['backup_version'] = '2.0'
1362+
elif os.path.exists(f"{path}/{base_name}.sql"):
1363+
format_info['compressed'] = False
1364+
# Check file content for new features indicators
1365+
format_info['new_features'] = mysqlUtilities.checkSQLFileFeatures(
1366+
f"{path}/{base_name}.sql"
1367+
)
1368+
1369+
return format_info
1370+
1371+
@staticmethod
1372+
def checkNewBackupFeatures():
1373+
"""Check if new backup features are enabled"""
1374+
try:
1375+
config_file = '/usr/local/CyberCP/plogical/backup_config.json'
1376+
if not os.path.exists(config_file):
1377+
# Try alternate location
1378+
config_file = '/etc/cyberpanel/backup_config.json'
1379+
1380+
if os.path.exists(config_file):
1381+
with open(config_file, 'r') as f:
1382+
config = json.load(f)
1383+
return config.get('database_backup', {}).get('use_new_features', False)
1384+
except:
1385+
pass
1386+
return False # Default to legacy mode for safety
1387+
1388+
@staticmethod
1389+
def shouldUseCompression():
1390+
"""Check if compression should be used"""
1391+
try:
1392+
config_file = '/usr/local/CyberCP/plogical/backup_config.json'
1393+
if not os.path.exists(config_file):
1394+
# Try alternate location
1395+
config_file = '/etc/cyberpanel/backup_config.json'
1396+
1397+
if os.path.exists(config_file):
1398+
with open(config_file, 'r') as f:
1399+
config = json.load(f)
1400+
return config.get('database_backup', {}).get('use_compression', False)
1401+
except:
1402+
pass
1403+
return False # Default to no compression for compatibility
1404+
1405+
@staticmethod
1406+
def supportParallelDump():
1407+
"""Check if MySQL version supports parallel dump"""
1408+
try:
1409+
result = ProcessUtilities.outputExecutioner("mysql --version")
1410+
# MySQL 8.0+ and MariaDB 10.3+ support parallel dump
1411+
if "8.0" in result or "8.1" in result or "10.3" in result or "10.4" in result or "10.5" in result or "10.6" in result:
1412+
return True
1413+
except:
1414+
pass
1415+
return False
1416+
1417+
@staticmethod
1418+
def checkSQLFileFeatures(file_path):
1419+
"""Check SQL file for new feature indicators"""
1420+
try:
1421+
# Read first few lines to check for new features
1422+
with open(file_path, 'r') as f:
1423+
head = f.read(2048) # Read first 2KB
1424+
# Check for indicators of new features
1425+
if "--single-transaction" in head or "--extended-insert" in head or "-- Dump completed" in head:
1426+
return True
1427+
except:
1428+
pass
1429+
return False
1430+
12231431

12241432
def main():
12251433
parser = argparse.ArgumentParser(description='CyberPanel')

0 commit comments

Comments
 (0)