4848
4949
5050import org .apache .cloudstack .backup .dao .BackupDao ;
51+ import org .apache .cloudstack .backup .dao .BackupDetailsDao ;
5152import org .apache .cloudstack .backup .dao .BackupRepositoryDao ;
5253import org .apache .cloudstack .engine .subsystem .api .storage .DataStore ;
5354import 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