Skip to content

Commit 1f2aebc

Browse files
committed
feat(backup): orchestrate full vs incremental in NAS provider
Adds the Java side of the incremental NAS backup feature: TakeBackupCommand + mode, bitmapNew, bitmapParent, parentPath fields (null for legacy callers — script preserves its existing behaviour when these are omitted). BackupAnswer + bitmapCreated (echoed by the agent on success) + incrementalFallback (true when an incremental was requested but the agent had to fall back to full because the VM was stopped). LibvirtTakeBackupCommandWrapper - Forwards the new fields to nasbackup.sh. - Strips the new BITMAP_CREATED= / INCREMENTAL_FALLBACK= marker lines out of stdout before the existing numeric-suffix size parser runs, so the script can keep the same "size as last line(s)" contract. - Surfaces both markers on the BackupAnswer. NASBackupProvider - decideChain(vm) walks backup_details (chain_id, chain_position, bitmap_name) for the latest BackedUp backup of the VM and decides: * Stopped VM -> full (libvirt backup-begin needs running QEMU) * No prior chain -> full (chain_position=0) * chain_position+1 >= nas.backup.full.every -> new full * otherwise -> incremental, parent=last bitmap - Generates timestamp-based bitmap names ("backup-<epoch>") matching what the script then registers as the libvirt checkpoint name. - persistChainMetadata() writes parent_backup_id, bitmap_name, chain_id, chain_position, type into the existing backup_details key/value table (per the RFC review — no new columns on backups). - Honours the agent's INCREMENTAL_FALLBACK= signal: re-records the backup as a full and starts a fresh chain. - createBackupObject() now takes a type argument so the BackupVO reflects the actual decision instead of always being "FULL". Refs: #12899
1 parent fbb916b commit 1f2aebc

4 files changed

Lines changed: 295 additions & 9 deletions

File tree

core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ public class BackupAnswer extends Answer {
2929
private Long virtualSize;
3030
private Map<String, String> volumes;
3131
Boolean needsCleanup;
32+
// Set by the NAS backup provider after a checkpoint/bitmap was created during this backup.
33+
// The provider persists it in backup_details under NASBackupChainKeys.BITMAP_NAME.
34+
private String bitmapCreated;
35+
// Set when an incremental was requested but the agent had to fall back to a full
36+
// (e.g. VM was stopped). Provider should record this backup as type=full.
37+
private Boolean incrementalFallback;
3238

3339
public BackupAnswer(final Command command, final boolean success, final String details) {
3440
super(command, success, details);
@@ -68,4 +74,20 @@ public Boolean getNeedsCleanup() {
6874
public void setNeedsCleanup(Boolean needsCleanup) {
6975
this.needsCleanup = needsCleanup;
7076
}
77+
78+
public String getBitmapCreated() {
79+
return bitmapCreated;
80+
}
81+
82+
public void setBitmapCreated(String bitmapCreated) {
83+
this.bitmapCreated = bitmapCreated;
84+
}
85+
86+
public Boolean getIncrementalFallback() {
87+
return incrementalFallback != null && incrementalFallback;
88+
}
89+
90+
public void setIncrementalFallback(Boolean incrementalFallback) {
91+
this.incrementalFallback = incrementalFallback;
92+
}
7193
}

core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ public class TakeBackupCommand extends Command {
3636
@LogLevel(LogLevel.Log4jLevel.Off)
3737
private String mountOptions;
3838

39+
// Incremental backup fields (NAS provider; null/empty for legacy full-only callers).
40+
private String mode; // "full" or "incremental"; null => legacy behaviour (script default)
41+
private String bitmapNew; // Checkpoint/bitmap name to create with this backup (timestamp-based)
42+
private String bitmapParent; // Incremental: parent bitmap to read changes since
43+
private String parentPath; // Incremental: parent backup file path on the mounted NAS (for qemu-img rebase)
44+
3945
public TakeBackupCommand(String vmName, String backupPath) {
4046
super();
4147
this.vmName = vmName;
@@ -106,6 +112,38 @@ public void setQuiesce(Boolean quiesce) {
106112
this.quiesce = quiesce;
107113
}
108114

115+
public String getMode() {
116+
return mode;
117+
}
118+
119+
public void setMode(String mode) {
120+
this.mode = mode;
121+
}
122+
123+
public String getBitmapNew() {
124+
return bitmapNew;
125+
}
126+
127+
public void setBitmapNew(String bitmapNew) {
128+
this.bitmapNew = bitmapNew;
129+
}
130+
131+
public String getBitmapParent() {
132+
return bitmapParent;
133+
}
134+
135+
public void setBitmapParent(String bitmapParent) {
136+
this.bitmapParent = bitmapParent;
137+
}
138+
139+
public String getParentPath() {
140+
return parentPath;
141+
}
142+
143+
public void setParentPath(String parentPath) {
144+
this.parentPath = parentPath;
145+
}
146+
109147
@Override
110148
public boolean executeInSequence() {
111149
return true;

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

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949

5050
import org.apache.cloudstack.backup.dao.BackupDao;
51+
import org.apache.cloudstack.backup.dao.BackupDetailsDao;
5152
import org.apache.cloudstack.backup.dao.BackupRepositoryDao;
5253
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
5354
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
@@ -140,6 +141,9 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co
140141
@Inject
141142
private DiskOfferingDao diskOfferingDao;
142143

144+
@Inject
145+
private BackupDetailsDao backupDetailsDao;
146+
143147
private Long getClusterIdFromRootVolume(VirtualMachine vm) {
144148
VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId());
145149
StoragePoolVO rootDiskPool = primaryDataStoreDao.findById(rootVolume.getPoolId());
@@ -178,6 +182,168 @@ protected Host getVMHypervisorHost(VirtualMachine vm) {
178182
return resourceManager.findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM, vm.getDataCenterId());
179183
}
180184

185+
/**
186+
* Returned by {@link #decideChain(VirtualMachine)} to describe the next backup's place in
187+
* the chain: full vs incremental, the bitmap name to create, and (for incrementals) the
188+
* parent bitmap and parent file path.
189+
*/
190+
static final class ChainDecision {
191+
final String mode; // "full" or "incremental"
192+
final String bitmapNew;
193+
final String bitmapParent; // null for full
194+
final String parentPath; // null for full
195+
final String chainId; // chain identifier this backup belongs to
196+
final int chainPosition; // 0 for full, N for the Nth incremental in the chain
197+
198+
private ChainDecision(String mode, String bitmapNew, String bitmapParent, String parentPath,
199+
String chainId, int chainPosition) {
200+
this.mode = mode;
201+
this.bitmapNew = bitmapNew;
202+
this.bitmapParent = bitmapParent;
203+
this.parentPath = parentPath;
204+
this.chainId = chainId;
205+
this.chainPosition = chainPosition;
206+
}
207+
208+
static ChainDecision fullStart(String bitmapName) {
209+
return new ChainDecision(NASBackupChainKeys.TYPE_FULL, bitmapName, null, null,
210+
UUID.randomUUID().toString(), 0);
211+
}
212+
213+
static ChainDecision incremental(String bitmapNew, String bitmapParent, String parentPath,
214+
String chainId, int chainPosition) {
215+
return new ChainDecision(NASBackupChainKeys.TYPE_INCREMENTAL, bitmapNew, bitmapParent,
216+
parentPath, chainId, chainPosition);
217+
}
218+
219+
boolean isIncremental() {
220+
return NASBackupChainKeys.TYPE_INCREMENTAL.equals(mode);
221+
}
222+
}
223+
224+
/**
225+
* Decides whether the next backup for {@code vm} should be a fresh full or an incremental
226+
* appended to the existing chain. Stopped VMs are always full (libvirt {@code backup-begin}
227+
* requires a running QEMU process). The {@code nas.backup.full.every} ConfigKey controls
228+
* how many backups (full + incrementals) form one chain before a new full is forced.
229+
*/
230+
protected ChainDecision decideChain(VirtualMachine vm) {
231+
final String newBitmap = "backup-" + System.currentTimeMillis() / 1000L;
232+
233+
// Stopped VMs cannot do incrementals — script will also fall back, but we make the
234+
// decision here so we register the right type up-front.
235+
if (VirtualMachine.State.Stopped.equals(vm.getState())) {
236+
return ChainDecision.fullStart(newBitmap);
237+
}
238+
239+
Integer fullEvery = NASBackupFullEvery.valueIn(vm.getDataCenterId());
240+
if (fullEvery == null || fullEvery <= 1) {
241+
// Disabled or every-backup-is-full mode.
242+
return ChainDecision.fullStart(newBitmap);
243+
}
244+
245+
// Walk this VM's backups newest→oldest, find the most recent BackedUp backup that has a
246+
// bitmap stored. If we don't find one, this is the first backup in a chain — start full.
247+
List<Backup> history = backupDao.listByVmId(vm.getDataCenterId(), vm.getId());
248+
if (history == null || history.isEmpty()) {
249+
return ChainDecision.fullStart(newBitmap);
250+
}
251+
history.sort(Comparator.comparing(Backup::getDate).reversed());
252+
253+
Backup parent = null;
254+
String parentBitmap = null;
255+
String parentChainId = null;
256+
int parentChainPosition = -1;
257+
for (Backup b : history) {
258+
if (!Backup.Status.BackedUp.equals(b.getStatus())) {
259+
continue;
260+
}
261+
String bm = readDetail(b, NASBackupChainKeys.BITMAP_NAME);
262+
if (bm == null) {
263+
continue;
264+
}
265+
parent = b;
266+
parentBitmap = bm;
267+
parentChainId = readDetail(b, NASBackupChainKeys.CHAIN_ID);
268+
String posStr = readDetail(b, NASBackupChainKeys.CHAIN_POSITION);
269+
try {
270+
parentChainPosition = posStr == null ? 0 : Integer.parseInt(posStr);
271+
} catch (NumberFormatException e) {
272+
parentChainPosition = 0;
273+
}
274+
break;
275+
}
276+
if (parent == null || parentBitmap == null || parentChainId == null) {
277+
return ChainDecision.fullStart(newBitmap);
278+
}
279+
280+
// Force a fresh full when the chain has reached the configured length.
281+
if (parentChainPosition + 1 >= fullEvery) {
282+
return ChainDecision.fullStart(newBitmap);
283+
}
284+
285+
// The script needs the parent backup's on-NAS file path so it can rebase the new
286+
// qcow2 onto it. The path is stored relative to the NAS mount point — the script
287+
// resolves it inside its mount session.
288+
String parentPath = composeParentBackupPath(parent);
289+
return ChainDecision.incremental(newBitmap, parentBitmap, parentPath,
290+
parentChainId, parentChainPosition + 1);
291+
}
292+
293+
private String readDetail(Backup backup, String key) {
294+
BackupDetailVO d = backupDetailsDao.findDetail(backup.getId(), key);
295+
return d == null ? null : d.getValue();
296+
}
297+
298+
/**
299+
* Compose the on-NAS path of a parent backup's root-disk qcow2. Relative to the NAS mount,
300+
* matches the layout written by {@code nasbackup.sh} ({@code <backupPath>/root.<volUuid>.qcow2}).
301+
*/
302+
private String composeParentBackupPath(Backup parent) {
303+
// backupPath is stored as externalId by createBackupObject — e.g. "i-2-1234-VM/2026.04.27.13.45.00".
304+
// Volume UUID for the root volume is what the script keys backup files on.
305+
VolumeVO rootVolume = volumeDao.getInstanceRootVolume(parent.getVmId());
306+
String volUuid = rootVolume == null ? "root" : rootVolume.getUuid();
307+
return parent.getExternalId() + "/root." + volUuid + ".qcow2";
308+
}
309+
310+
/**
311+
* Persist chain metadata under backup_details. Stored here (not on the backups table) so
312+
* other providers can implement their own chain semantics without schema changes.
313+
*/
314+
private void persistChainMetadata(Backup backup, ChainDecision decision, String bitmapFromAgent) {
315+
// Prefer the bitmap name confirmed by the agent (BITMAP_CREATED= line). Fall back to
316+
// what we asked it to create — they should match.
317+
String bitmap = bitmapFromAgent != null ? bitmapFromAgent : decision.bitmapNew;
318+
if (bitmap != null) {
319+
backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.BITMAP_NAME, bitmap, true));
320+
}
321+
backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.CHAIN_ID, decision.chainId, true));
322+
backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.CHAIN_POSITION,
323+
String.valueOf(decision.chainPosition), true));
324+
backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.TYPE, decision.mode, true));
325+
if (decision.isIncremental()) {
326+
// Resolve the parent backup's UUID so restore can walk the chain by id, not by path.
327+
String parentUuid = lookupParentBackupUuid(backup.getVmId(), decision.bitmapParent);
328+
if (parentUuid != null) {
329+
backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.PARENT_BACKUP_ID, parentUuid, true));
330+
}
331+
}
332+
}
333+
334+
private String lookupParentBackupUuid(long vmId, String parentBitmap) {
335+
if (parentBitmap == null) {
336+
return null;
337+
}
338+
for (Backup b : backupDao.listByVmId(null, vmId)) {
339+
String bm = readDetail(b, NASBackupChainKeys.BITMAP_NAME);
340+
if (parentBitmap.equals(bm)) {
341+
return b.getUuid();
342+
}
343+
}
344+
return null;
345+
}
346+
181347
protected Host getVMHypervisorHostForBackup(VirtualMachine vm) {
182348
Long hostId = vm.getHostId();
183349
if (hostId == null && VirtualMachine.State.Running.equals(vm.getState())) {
@@ -215,12 +381,20 @@ public Pair<Boolean, Backup> takeBackup(final VirtualMachine vm, Boolean quiesce
215381
final String backupPath = String.format("%s/%s", vm.getInstanceName(),
216382
new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(creationDate));
217383

218-
BackupVO backupVO = createBackupObject(vm, backupPath);
384+
// Decide full vs incremental for this backup. Stopped VMs are always full
385+
// (libvirt backup-begin requires a running QEMU process).
386+
ChainDecision decision = decideChain(vm);
387+
388+
BackupVO backupVO = createBackupObject(vm, backupPath, decision.isIncremental() ? "INCREMENTAL" : "FULL");
219389
TakeBackupCommand command = new TakeBackupCommand(vm.getInstanceName(), backupPath);
220390
command.setBackupRepoType(backupRepository.getType());
221391
command.setBackupRepoAddress(backupRepository.getAddress());
222392
command.setMountOptions(backupRepository.getMountOptions());
223393
command.setQuiesce(quiesceVM);
394+
command.setMode(decision.mode);
395+
command.setBitmapNew(decision.bitmapNew);
396+
command.setBitmapParent(decision.bitmapParent);
397+
command.setParentPath(decision.parentPath);
224398

225399
if (VirtualMachine.State.Stopped.equals(vm.getState())) {
226400
List<VolumeVO> vmVolumes = volumeDao.findByInstance(vm.getId());
@@ -249,9 +423,17 @@ public Pair<Boolean, Backup> takeBackup(final VirtualMachine vm, Boolean quiesce
249423
backupVO.setDate(new Date());
250424
backupVO.setSize(answer.getSize());
251425
backupVO.setStatus(Backup.Status.BackedUp);
426+
// If the agent fell back to full (stopped VM mid-incremental cycle), record this
427+
// backup as a full and start a new chain.
428+
ChainDecision effective = decision;
429+
if (answer.getIncrementalFallback()) {
430+
effective = ChainDecision.fullStart(decision.bitmapNew);
431+
backupVO.setType("FULL");
432+
}
252433
List<Volume> volumes = new ArrayList<>(volumeDao.findByInstance(vm.getId()));
253434
backupVO.setBackedUpVolumes(backupManager.createVolumeInfoFromVolumes(volumes));
254435
if (backupDao.update(backupVO.getId(), backupVO)) {
436+
persistChainMetadata(backupVO, effective, answer.getBitmapCreated());
255437
return new Pair<>(true, backupVO);
256438
} else {
257439
throw new CloudRuntimeException("Failed to update backup");
@@ -270,11 +452,11 @@ public Pair<Boolean, Backup> takeBackup(final VirtualMachine vm, Boolean quiesce
270452
}
271453
}
272454

273-
private BackupVO createBackupObject(VirtualMachine vm, String backupPath) {
455+
private BackupVO createBackupObject(VirtualMachine vm, String backupPath, String type) {
274456
BackupVO backup = new BackupVO();
275457
backup.setVmId(vm.getId());
276458
backup.setExternalId(backupPath);
277-
backup.setType("FULL");
459+
backup.setType(type);
278460
backup.setDate(new Date());
279461
long virtualSize = 0L;
280462
for (final Volume volume: volumeDao.findByInstance(vm.getId())) {

0 commit comments

Comments
 (0)