Skip to content

Commit 3fa88ea

Browse files
committed
address review (#12900): per-management-server backup directory, extract helpers, drop unused provider field
- Backups now go to <location>/infra-backup/<ms-hostname>/<timestamp>, so the configs and certificates of different management servers never overwrite each other and the retention count applies per server. The cluster-wide GlobalLock still serialises runs. - backupDatabases() and backupDirectoryIfPresent() replace the numbered inline steps; the include-database rationale is javadoc. - InfrastructureBackupTask no longer takes an unused NASBackupProvider. Signed-off-by: James Peru <jmsperu@gmail.com>
1 parent 5b82495 commit 3fa88ea

3 files changed

Lines changed: 106 additions & 59 deletions

File tree

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

Lines changed: 69 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import org.apache.cloudstack.managed.context.ManagedContextRunnable;
2222
import org.apache.cloudstack.poll.BackgroundPollTask;
23+
import org.apache.cloudstack.utils.identity.ManagementServerNode;
2324
import org.apache.logging.log4j.Logger;
2425
import org.apache.logging.log4j.LogManager;
2526

@@ -32,6 +33,8 @@
3233
import java.io.InputStream;
3334
import java.io.OutputStream;
3435
import java.io.Writer;
36+
import java.net.InetAddress;
37+
import java.net.UnknownHostException;
3538
import java.nio.charset.StandardCharsets;
3639
import java.nio.file.FileVisitResult;
3740
import java.nio.file.Files;
@@ -73,12 +76,6 @@ public class InfrastructureBackupTask extends ManagedContextRunnable implements
7376
/** 24 hours in milliseconds */
7477
private static final long DAILY_INTERVAL_MS = 86400L * 1000L;
7578

76-
private final NASBackupProvider provider;
77-
78-
public InfrastructureBackupTask(NASBackupProvider provider) {
79-
this.provider = provider;
80-
}
81-
8279
@Override
8380
public Long getDelay() {
8481
return DAILY_INTERVAL_MS;
@@ -123,7 +120,8 @@ protected void runInContext() {
123120
boolean includeUsageDb = isUsageDbIncluded();
124121

125122
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
126-
String backupDir = nasBackupPath + "/infra-backup/" + timestamp;
123+
String infraBackupRoot = nasBackupPath + "/infra-backup/" + getManagementServerLabel();
124+
String backupDir = infraBackupRoot + "/" + timestamp;
127125

128126
LOG.info("Starting infrastructure backup to {} (database included: {})", backupDir, includeDatabase);
129127

@@ -143,49 +141,11 @@ protected void runInContext() {
143141
return;
144142
}
145143

146-
// 1 & 2. Database backup — opt-in via nas.infra.backup.include.database.
147-
// Production deployments typically run their own mysqldump cron jobs and disable this;
148-
// it exists for small/edge deployments wanting unified DR on the same NAS as VM backups.
149-
if (includeDatabase) {
150-
Properties dbProps = loadDbProperties();
151-
if (dbProps == null) {
152-
LOG.error("Database backup requested but failed to load properties from {} — skipping DB component", DB_PROPERTIES_PATH);
153-
} else {
154-
String dbHost = dbProps.getProperty("db.cloud.host", "localhost");
155-
String dbUser = dbProps.getProperty("db.cloud.username", "cloud");
156-
String dbPassword = dbProps.getProperty("db.cloud.password", "");
157-
158-
backupDatabase("cloud", backupDir, timestamp, dbHost, dbUser, dbPassword);
159-
160-
if (includeUsageDb) {
161-
String usageHost = dbProps.getProperty("db.usage.host", dbHost);
162-
String usageUser = dbProps.getProperty("db.usage.username", dbUser);
163-
String usagePassword = dbProps.getProperty("db.usage.password", dbPassword);
164-
backupDatabase("cloud_usage", backupDir, timestamp, usageHost, usageUser, usagePassword);
165-
}
166-
}
167-
} else {
168-
LOG.debug("Database backup skipped (nas.infra.backup.include.database=false). " +
169-
"Manage DB backups externally for production deployments.");
170-
}
171-
172-
// 3. Backup management server configs
144+
backupDatabases(backupDir, timestamp, includeDatabase, includeUsageDb);
173145
backupDirectory(MANAGEMENT_CONFIG_PATH, backupDir, "management-config");
174-
175-
// 4. Backup agent configs (if present on this host)
176-
File agentDir = new File(AGENT_CONFIG_PATH);
177-
if (agentDir.exists()) {
178-
backupDirectory(AGENT_CONFIG_PATH, backupDir, "agent-config");
179-
}
180-
181-
// 5. Backup SSL certificates
182-
File sslDir = new File(SSL_CERT_PATH);
183-
if (sslDir.exists()) {
184-
backupDirectory(SSL_CERT_PATH, backupDir, "ssl-certs");
185-
}
186-
187-
// 6. Cleanup old backups based on retention policy
188-
cleanupOldBackups(nasBackupPath, retentionCount);
146+
backupDirectoryIfPresent(AGENT_CONFIG_PATH, backupDir, "agent-config");
147+
backupDirectoryIfPresent(SSL_CERT_PATH, backupDir, "ssl-certs");
148+
cleanupOldBackups(infraBackupRoot, retentionCount);
189149

190150
LOG.info("Infrastructure backup completed successfully: {}", backupDir);
191151

@@ -196,6 +156,63 @@ protected void runInContext() {
196156
}
197157
}
198158

159+
/**
160+
* Name of the sub-directory that keeps this management server's backups apart from those of the
161+
* other servers in the cluster. Management configs and certificates are per server, so they must
162+
* not overwrite each other, and the retention count applies per server. Uses the host name and
163+
* falls back to the management server id.
164+
*/
165+
protected String getManagementServerLabel() {
166+
String label = null;
167+
try {
168+
label = InetAddress.getLocalHost().getHostName();
169+
} catch (UnknownHostException e) {
170+
LOG.debug("Could not determine the local host name for the infrastructure backup directory: {}", e.getMessage());
171+
}
172+
if (label == null || label.isBlank()) {
173+
label = "ms-" + ManagementServerNode.getManagementServerId();
174+
}
175+
return label.replaceAll("[^A-Za-z0-9._-]", "_");
176+
}
177+
178+
/**
179+
* Dumps the cloud database, and the usage database when requested, into {@code backupDir}.
180+
* The database component is opt-in ({@code nas.infra.backup.include.database}): production
181+
* deployments typically run their own mysqldump jobs and leave it off; it exists for small and
182+
* edge deployments that want unified disaster recovery on the same NAS as their VM backups.
183+
*/
184+
protected void backupDatabases(String backupDir, String timestamp, boolean includeDatabase, boolean includeUsageDb) {
185+
if (!includeDatabase) {
186+
LOG.debug("Database backup skipped (nas.infra.backup.include.database=false). " +
187+
"Manage DB backups externally for production deployments.");
188+
return;
189+
}
190+
Properties dbProps = loadDbProperties();
191+
if (dbProps == null) {
192+
LOG.error("Database backup requested but failed to load properties from {}, skipping DB component", DB_PROPERTIES_PATH);
193+
return;
194+
}
195+
String dbHost = dbProps.getProperty("db.cloud.host", "localhost");
196+
String dbUser = dbProps.getProperty("db.cloud.username", "cloud");
197+
String dbPassword = dbProps.getProperty("db.cloud.password", "");
198+
199+
backupDatabase("cloud", backupDir, timestamp, dbHost, dbUser, dbPassword);
200+
201+
if (includeUsageDb) {
202+
String usageHost = dbProps.getProperty("db.usage.host", dbHost);
203+
String usageUser = dbProps.getProperty("db.usage.username", dbUser);
204+
String usagePassword = dbProps.getProperty("db.usage.password", dbPassword);
205+
backupDatabase("cloud_usage", backupDir, timestamp, usageHost, usageUser, usagePassword);
206+
}
207+
}
208+
209+
/** Archives {@code sourcePath} like {@link #backupDirectory} but silently skips it when it does not exist on this server. */
210+
protected void backupDirectoryIfPresent(String sourcePath, String backupDir, String archiveName) {
211+
if (new File(sourcePath).exists()) {
212+
backupDirectory(sourcePath, backupDir, archiveName);
213+
}
214+
}
215+
199216
/**
200217
* Acquire the cluster-wide run lock so only one management server performs the infrastructure
201218
* backup at a time. Returns null if the lock can't be taken (another MS holds it). Overridable
@@ -345,13 +362,14 @@ protected void backupDirectory(String sourcePath, String backupDir, String archi
345362
}
346363
}
347364

348-
protected void cleanupOldBackups(String nasBackupPath, int retentionCount) {
365+
/** Keeps the newest {@code retentionCount} backups under {@code infraBackupRoot} (this server's directory) and deletes the rest. */
366+
protected void cleanupOldBackups(String infraBackupRoot, int retentionCount) {
349367
// A negative retention (misconfiguration) would make toDelete exceed backups.length below and
350368
// throw ArrayIndexOutOfBoundsException; clamp it so we never compute a delete count > available.
351369
if (retentionCount < 0) {
352370
retentionCount = 0;
353371
}
354-
File infraDir = new File(nasBackupPath + "/infra-backup");
372+
File infraDir = new File(infraBackupRoot);
355373
if (!infraDir.exists()) {
356374
return;
357375
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co
190190
@Override
191191
public boolean configure(String name, Map<String, Object> params) throws ConfigurationException {
192192
super.configure(name, params);
193-
backgroundPollManager.submitTask(new InfrastructureBackupTask(this));
193+
backgroundPollManager.submitTask(new InfrastructureBackupTask());
194194
return true;
195195
}
196196

plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/InfrastructureBackupTaskTest.java

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,10 @@ private static class RecordingTask extends InfrastructureBackupTask {
6262
final List<String> directoryBackupNames = new ArrayList<>();
6363
final AtomicInteger retentionCalls = new AtomicInteger(0);
6464

65-
RecordingTask() {
66-
super(null);
67-
}
65+
final List<String> directoryBackupDirs = new ArrayList<>();
66+
67+
@Override
68+
protected String getManagementServerLabel() { return "ms-a"; }
6869

6970
@Override
7071
protected boolean isEnabled() { return enabled; }
@@ -91,6 +92,7 @@ protected void backupDatabase(String dbName, String backupDir, String timestamp,
9192
@Override
9293
protected void backupDirectory(String sourcePath, String backupDir, String archiveName) {
9394
directoryBackupNames.add(archiveName);
95+
directoryBackupDirs.add(backupDir);
9496
}
9597

9698
@Override
@@ -235,6 +237,33 @@ public void retentionRunsOnEverySuccessfulPass() {
235237
Assert.assertEquals(1, task.retentionCalls.get());
236238
}
237239

240+
@Test
241+
public void backupsAreWrittenUnderThisManagementServersDirectory() {
242+
RecordingTask task = new RecordingTask();
243+
task.location = tmpRoot.toString();
244+
task.runInContext();
245+
Assert.assertFalse(task.directoryBackupDirs.isEmpty());
246+
for (String dir : task.directoryBackupDirs) {
247+
Assert.assertTrue(dir, dir.startsWith(tmpRoot + "/infra-backup/ms-a/"));
248+
}
249+
}
250+
251+
@Test
252+
public void retentionIsCountedPerManagementServer() throws IOException {
253+
File msA = new File(infraBackupDir(), "ms-a");
254+
File msB = new File(infraBackupDir(), "ms-b");
255+
for (String name : new String[] {"20240101-000000", "20240102-000000", "20240103-000000"}) {
256+
Assert.assertTrue(new File(msA, name).mkdirs());
257+
}
258+
Assert.assertTrue(new File(msB, "20240101-000000").mkdirs());
259+
260+
realTask.cleanupOldBackups(msA.toString(), 2);
261+
262+
Assert.assertArrayEquals(new String[] {"20240102-000000", "20240103-000000"},
263+
Arrays.stream(msA.listFiles(File::isDirectory)).map(File::getName).sorted().toArray(String[]::new));
264+
Assert.assertEquals(1, msB.listFiles(File::isDirectory).length);
265+
}
266+
238267
@Test
239268
public void dailyIntervalIs24Hours() {
240269
InfrastructureBackupTask task = new RecordingTask();
@@ -244,7 +273,7 @@ public void dailyIntervalIs24Hours() {
244273
// ---- retention / delete logic (exercises the REAL cleanupOldBackups + deleteDirectory,
245274
// which RecordingTask above stubs out) ----
246275

247-
private final InfrastructureBackupTask realTask = new InfrastructureBackupTask(null);
276+
private final InfrastructureBackupTask realTask = new InfrastructureBackupTask();
248277

249278
private File infraBackupDir() {
250279
return new File(tmpRoot.toFile(), "infra-backup");
@@ -274,7 +303,7 @@ public void cleanupClampsNegativeRetentionInsteadOfThrowing() throws IOException
274303
// A negative retention (misconfiguration) previously made toDelete (= count - retention)
275304
// larger than the array length and threw ArrayIndexOutOfBoundsException. It must now clamp
276305
// to 0 and simply remove everything, without throwing.
277-
realTask.cleanupOldBackups(tmpRoot.toString(), -5);
306+
realTask.cleanupOldBackups(infraBackupDir().toString(), -5);
278307

279308
Assert.assertEquals(0, remainingBackups().length);
280309
}
@@ -287,7 +316,7 @@ public void cleanupKeepsNewestAndDeletesOldest() throws IOException {
287316
makeBackup("20240104-000000");
288317
makeBackup("20240105-000000");
289318

290-
realTask.cleanupOldBackups(tmpRoot.toString(), 2);
319+
realTask.cleanupOldBackups(infraBackupDir().toString(), 2);
291320

292321
Assert.assertArrayEquals(new String[] {"20240104-000000", "20240105-000000"}, remainingBackups());
293322
}
@@ -312,7 +341,7 @@ public void deleteDoesNotFollowSymlinkOutOfTheBackupTree() throws IOException {
312341
}
313342

314343
// Retain only the newest; the two oldest (including the one holding the symlink) are deleted.
315-
realTask.cleanupOldBackups(tmpRoot.toString(), 1);
344+
realTask.cleanupOldBackups(infraBackupDir().toString(), 1);
316345

317346
Assert.assertArrayEquals(new String[] {"20240103-000000"}, remainingBackups());
318347
// The symlink target and its contents MUST survive — the delete must not follow the link out.

0 commit comments

Comments
 (0)