Skip to content

Commit 6a8e1d1

Browse files
committed
NAS backup: add infrastructure backup (database, configs, certs)
Adds automated backup of CloudStack infrastructure to NAS storage: - MySQL databases (cloud + cloud_usage if enabled) - Management server configuration (/etc/cloudstack/management/) - Agent configuration (/etc/cloudstack/agent/) - SSL certificates and keystores Backup runs daily via BackgroundPollManager, configurable via global settings: - nas.infra.backup.enabled (default: false) - nas.infra.backup.location (NAS mount path) - nas.infra.backup.retention (default: 7 backup sets) - nas.infra.backup.include.usage.db (default: true) Backups are stored in the NAS backup storage under infra-backup/ with automatic retention management. Uses mysqldump --single-transaction for hot database backup (no table locks, InnoDB consistent snapshot). Database credentials are read from /etc/cloudstack/management/db.properties.
1 parent c1af36f commit 6a8e1d1

File tree

2 files changed

+325
-1
lines changed

2 files changed

+325
-1
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
package org.apache.cloudstack.backup;
18+
19+
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
20+
import org.apache.cloudstack.poll.BackgroundPollTask;
21+
import org.apache.logging.log4j.Logger;
22+
import org.apache.logging.log4j.LogManager;
23+
24+
import java.io.BufferedReader;
25+
import java.io.File;
26+
import java.io.FileReader;
27+
import java.io.IOException;
28+
import java.time.LocalDateTime;
29+
import java.time.format.DateTimeFormatter;
30+
import java.util.Arrays;
31+
import java.util.Comparator;
32+
import java.util.Properties;
33+
import java.util.concurrent.TimeUnit;
34+
35+
/**
36+
* Scheduled task that backs up CloudStack infrastructure to NAS storage:
37+
* <ul>
38+
* <li>MySQL databases (cloud, cloud_usage if enabled)</li>
39+
* <li>Management server configuration files</li>
40+
* <li>Agent configuration files</li>
41+
* <li>SSL certificates and keystores</li>
42+
* </ul>
43+
*
44+
* Database credentials are read from /etc/cloudstack/management/db.properties.
45+
* Backups are stored under {nasBackupPath}/infra-backup/{timestamp}/ with
46+
* automatic retention management.
47+
*/
48+
public class InfrastructureBackupTask extends ManagedContextRunnable implements BackgroundPollTask {
49+
50+
private static final Logger LOG = LogManager.getLogger(InfrastructureBackupTask.class);
51+
52+
private static final String DB_PROPERTIES_PATH = "/etc/cloudstack/management/db.properties";
53+
private static final String MANAGEMENT_CONFIG_PATH = "/etc/cloudstack/management";
54+
private static final String AGENT_CONFIG_PATH = "/etc/cloudstack/agent";
55+
private static final String SSL_CERT_PATH = "/etc/cloudstack/management/cert";
56+
57+
/** 24 hours in milliseconds */
58+
private static final long DAILY_INTERVAL_MS = 86400L * 1000L;
59+
60+
private final NASBackupProvider provider;
61+
62+
public InfrastructureBackupTask(NASBackupProvider provider) {
63+
this.provider = provider;
64+
}
65+
66+
@Override
67+
public Long getDelay() {
68+
return DAILY_INTERVAL_MS;
69+
}
70+
71+
@Override
72+
protected void runInContext() {
73+
if (!Boolean.TRUE.equals(NASBackupProvider.NASInfraBackupEnabled.value())) {
74+
LOG.debug("Infrastructure backup is disabled (nas.infra.backup.enabled=false)");
75+
return;
76+
}
77+
78+
String nasBackupPath = NASBackupProvider.NASInfraBackupLocation.value();
79+
if (nasBackupPath == null || nasBackupPath.isEmpty()) {
80+
LOG.error("Infrastructure backup location not configured (nas.infra.backup.location is empty)");
81+
return;
82+
}
83+
84+
int retentionCount = NASBackupProvider.NASInfraBackupRetention.value();
85+
boolean includeUsageDb = Boolean.TRUE.equals(NASBackupProvider.NASInfraBackupUsageDb.value());
86+
87+
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
88+
String backupDir = nasBackupPath + "/infra-backup/" + timestamp;
89+
90+
LOG.info("Starting infrastructure backup to {}", backupDir);
91+
92+
try {
93+
File dir = new File(backupDir);
94+
if (!dir.mkdirs()) {
95+
LOG.error("Failed to create backup directory: {}", backupDir);
96+
return;
97+
}
98+
99+
// Read database credentials from db.properties
100+
Properties dbProps = loadDbProperties();
101+
if (dbProps == null) {
102+
LOG.error("Failed to load database properties from {}", DB_PROPERTIES_PATH);
103+
return;
104+
}
105+
106+
String dbHost = dbProps.getProperty("db.cloud.host", "localhost");
107+
String dbUser = dbProps.getProperty("db.cloud.username", "cloud");
108+
String dbPassword = dbProps.getProperty("db.cloud.password", "");
109+
110+
// 1. Backup CloudStack database
111+
backupDatabase("cloud", backupDir, timestamp, dbHost, dbUser, dbPassword);
112+
113+
// 2. Backup usage database if enabled
114+
if (includeUsageDb) {
115+
String usageHost = dbProps.getProperty("db.usage.host", dbHost);
116+
String usageUser = dbProps.getProperty("db.usage.username", dbUser);
117+
String usagePassword = dbProps.getProperty("db.usage.password", dbPassword);
118+
backupDatabase("cloud_usage", backupDir, timestamp, usageHost, usageUser, usagePassword);
119+
}
120+
121+
// 3. Backup management server configs
122+
backupDirectory(MANAGEMENT_CONFIG_PATH, backupDir, "management-config");
123+
124+
// 4. Backup agent configs (if present on this host)
125+
File agentDir = new File(AGENT_CONFIG_PATH);
126+
if (agentDir.exists()) {
127+
backupDirectory(AGENT_CONFIG_PATH, backupDir, "agent-config");
128+
}
129+
130+
// 5. Backup SSL certificates
131+
File sslDir = new File(SSL_CERT_PATH);
132+
if (sslDir.exists()) {
133+
backupDirectory(SSL_CERT_PATH, backupDir, "ssl-certs");
134+
}
135+
136+
// 6. Cleanup old backups based on retention policy
137+
cleanupOldBackups(nasBackupPath, retentionCount);
138+
139+
LOG.info("Infrastructure backup completed successfully: {}", backupDir);
140+
141+
} catch (Exception e) {
142+
LOG.error("Infrastructure backup failed: {}", e.getMessage(), e);
143+
}
144+
}
145+
146+
private Properties loadDbProperties() {
147+
File propsFile = new File(DB_PROPERTIES_PATH);
148+
if (!propsFile.exists()) {
149+
LOG.warn("Database properties file not found: {}", DB_PROPERTIES_PATH);
150+
return null;
151+
}
152+
153+
Properties props = new Properties();
154+
try (BufferedReader reader = new BufferedReader(new FileReader(propsFile))) {
155+
props.load(reader);
156+
return props;
157+
} catch (IOException e) {
158+
LOG.error("Failed to read database properties: {}", e.getMessage());
159+
return null;
160+
}
161+
}
162+
163+
private void backupDatabase(String dbName, String backupDir, String timestamp,
164+
String dbHost, String dbUser, String dbPassword) {
165+
String dumpFile = backupDir + "/" + dbName + "-" + timestamp + ".sql.gz";
166+
167+
// Use --single-transaction for InnoDB hot backup (no table locks, consistent snapshot)
168+
String[] cmd = {"/bin/bash", "-c",
169+
String.format("mysqldump --single-transaction --routines --triggers --events " +
170+
"-h '%s' -u '%s' -p'%s' '%s' | gzip > '%s'",
171+
dbHost, dbUser, dbPassword, dbName, dumpFile)};
172+
173+
try {
174+
ProcessBuilder pb = new ProcessBuilder(cmd);
175+
pb.redirectErrorStream(true);
176+
Process process = pb.start();
177+
boolean completed = process.waitFor(300, TimeUnit.SECONDS);
178+
179+
if (!completed) {
180+
process.destroyForcibly();
181+
LOG.error("Database backup timed out for {}", dbName);
182+
return;
183+
}
184+
185+
if (process.exitValue() != 0) {
186+
LOG.error("Database backup failed for {} with exit code {}", dbName, process.exitValue());
187+
return;
188+
}
189+
190+
File dump = new File(dumpFile);
191+
LOG.info("Database {} backed up: {} ({} bytes)", dbName, dumpFile, dump.length());
192+
193+
} catch (IOException | InterruptedException e) {
194+
LOG.error("Failed to backup database {}: {}", dbName, e.getMessage());
195+
if (e instanceof InterruptedException) {
196+
Thread.currentThread().interrupt();
197+
}
198+
}
199+
}
200+
201+
private void backupDirectory(String sourcePath, String backupDir, String archiveName) {
202+
File source = new File(sourcePath);
203+
if (!source.exists() || !source.isDirectory()) {
204+
LOG.debug("Directory {} does not exist, skipping", sourcePath);
205+
return;
206+
}
207+
208+
String tarFile = backupDir + "/" + archiveName + ".tar.gz";
209+
String[] cmd = {"/bin/bash", "-c",
210+
String.format("tar czf '%s' -C '%s' .", tarFile, sourcePath)};
211+
212+
try {
213+
ProcessBuilder pb = new ProcessBuilder(cmd);
214+
pb.redirectErrorStream(true);
215+
Process process = pb.start();
216+
boolean completed = process.waitFor(60, TimeUnit.SECONDS);
217+
218+
if (completed && process.exitValue() == 0) {
219+
LOG.info("Directory {} backed up to {}", sourcePath, tarFile);
220+
} else {
221+
if (!completed) {
222+
process.destroyForcibly();
223+
}
224+
LOG.warn("Directory backup failed for {} (exit code: {})",
225+
sourcePath, completed ? process.exitValue() : "timeout");
226+
}
227+
} catch (IOException | InterruptedException e) {
228+
LOG.error("Failed to backup directory {}: {}", sourcePath, e.getMessage());
229+
if (e instanceof InterruptedException) {
230+
Thread.currentThread().interrupt();
231+
}
232+
}
233+
}
234+
235+
private void cleanupOldBackups(String nasBackupPath, int retentionCount) {
236+
File infraDir = new File(nasBackupPath + "/infra-backup");
237+
if (!infraDir.exists()) {
238+
return;
239+
}
240+
241+
File[] backups = infraDir.listFiles(File::isDirectory);
242+
if (backups == null || backups.length <= retentionCount) {
243+
return;
244+
}
245+
246+
// Sort by name (timestamp-based), oldest first
247+
Arrays.sort(backups, Comparator.comparing(File::getName));
248+
249+
int toDelete = backups.length - retentionCount;
250+
for (int i = 0; i < toDelete; i++) {
251+
LOG.info("Removing old infrastructure backup: {}", backups[i].getName());
252+
deleteDirectory(backups[i]);
253+
}
254+
}
255+
256+
private void deleteDirectory(File dir) {
257+
File[] files = dir.listFiles();
258+
if (files != null) {
259+
for (File file : files) {
260+
if (file.isDirectory()) {
261+
deleteDirectory(file);
262+
} else {
263+
if (!file.delete()) {
264+
LOG.warn("Failed to delete file: {}", file.getAbsolutePath());
265+
}
266+
}
267+
}
268+
}
269+
if (!dir.delete()) {
270+
LOG.warn("Failed to delete directory: {}", dir.getAbsolutePath());
271+
}
272+
}
273+
}

plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@
5353
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
5454
import org.apache.cloudstack.framework.config.ConfigKey;
5555
import org.apache.cloudstack.framework.config.Configurable;
56+
import org.apache.cloudstack.poll.BackgroundPollManager;
5657
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
5758
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
5859
import org.apache.cloudstack.storage.to.PrimaryDataStoreTO;
5960
import org.apache.logging.log4j.Logger;
6061
import org.apache.logging.log4j.LogManager;
6162

6263
import javax.inject.Inject;
64+
import javax.naming.ConfigurationException;
6365
import java.text.SimpleDateFormat;
6466
import java.util.ArrayList;
6567
import java.util.Collections;
@@ -84,6 +86,41 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co
8486
true,
8587
BackupFrameworkEnabled.key());
8688

89+
static final ConfigKey<Boolean> NASInfraBackupEnabled = new ConfigKey<>("Advanced", Boolean.class,
90+
"nas.infra.backup.enabled",
91+
"false",
92+
"Enable automated infrastructure backup (database, configs) to NAS storage. " +
93+
"When enabled, the management server will perform a daily backup of the CloudStack " +
94+
"database, configuration files, and SSL certificates to the configured NAS location.",
95+
true,
96+
ConfigKey.Scope.Global,
97+
BackupFrameworkEnabled.key());
98+
99+
static final ConfigKey<String> NASInfraBackupLocation = new ConfigKey<>("Advanced", String.class,
100+
"nas.infra.backup.location",
101+
"",
102+
"NAS mount path where infrastructure backups are stored (e.g. /mnt/nas-backup). " +
103+
"Backups will be written to {location}/infra-backup/{timestamp}/.",
104+
true,
105+
ConfigKey.Scope.Global,
106+
BackupFrameworkEnabled.key());
107+
108+
static final ConfigKey<Integer> NASInfraBackupRetention = new ConfigKey<>("Advanced", Integer.class,
109+
"nas.infra.backup.retention",
110+
"7",
111+
"Number of infrastructure backup sets to retain. Older backups are automatically removed.",
112+
true,
113+
ConfigKey.Scope.Global,
114+
BackupFrameworkEnabled.key());
115+
116+
static final ConfigKey<Boolean> NASInfraBackupUsageDb = new ConfigKey<>("Advanced", Boolean.class,
117+
"nas.infra.backup.include.usage.db",
118+
"true",
119+
"Include the cloud_usage database in infrastructure backup.",
120+
true,
121+
ConfigKey.Scope.Global,
122+
BackupFrameworkEnabled.key());
123+
87124
@Inject
88125
private BackupDao backupDao;
89126

@@ -129,6 +166,16 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co
129166
@Inject
130167
private DiskOfferingDao diskOfferingDao;
131168

169+
@Inject
170+
private BackgroundPollManager backgroundPollManager;
171+
172+
@Override
173+
public boolean configure(String name, Map<String, Object> params) throws ConfigurationException {
174+
super.configure(name, params);
175+
backgroundPollManager.submitTask(new InfrastructureBackupTask(this));
176+
return true;
177+
}
178+
132179
private Long getClusterIdFromRootVolume(VirtualMachine vm) {
133180
VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId());
134181
StoragePoolVO rootDiskPool = primaryDataStoreDao.findById(rootVolume.getPoolId());
@@ -594,7 +641,11 @@ public Boolean crossZoneInstanceCreationEnabled(BackupOffering backupOffering) {
594641
@Override
595642
public ConfigKey<?>[] getConfigKeys() {
596643
return new ConfigKey[]{
597-
NASBackupRestoreMountTimeout
644+
NASBackupRestoreMountTimeout,
645+
NASInfraBackupEnabled,
646+
NASInfraBackupLocation,
647+
NASInfraBackupRetention,
648+
NASInfraBackupUsageDb
598649
};
599650
}
600651

0 commit comments

Comments
 (0)