feat: implement Changed Block Tracking (CBT) support for incremental VM backups

This commit is contained in:
bocil kematian 2026-06-22 23:37:43 +07:00
parent a0bbe9a3b8
commit f7dba1d11c
5 changed files with 592 additions and 21 deletions

View File

@ -14,7 +14,7 @@ from pathlib import Path
import requests import requests
from pyVim.connect import SmartConnect, Disconnect from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim from pyVmomi import vim, vmodl
try: try:
import paramiko import paramiko
@ -229,6 +229,206 @@ def remove_snapshot(snapshot_obj):
raise e raise e
# ── CBT (Changed Block Tracking) helpers ─────────────────────────────────────
def enable_cbt(vm, content):
"""Enable changeTrackingEnabled on a VM if not already set.
CBT requires a snapshot cycle to activate. We create+delete a transient
snapshot here so the flag takes effect before the real backup snapshot.
Returns True if CBT was already enabled, False if we just enabled it.
"""
cfg = vm.config
if cfg.changeTrackingEnabled:
print("CBT: changeTrackingEnabled is already ON")
return True
print("CBT: Enabling changeTrackingEnabled on VM…")
spec = vim.vm.ConfigSpec()
spec.changeTrackingEnabled = True
task = vm.ReconfigVM_Task(spec=spec)
wait_for_task(task, 'EnableCBT')
print("CBT: changeTrackingEnabled set to True")
# Force a snapshot cycle so CBT activates on all disks
print("CBT: Creating transient activation snapshot…")
act_snap_name = f"cbt-activate-{int(time.time())}"
task = vm.CreateSnapshot_Task(
name=act_snap_name,
description="CBT activation (auto-deleted)",
memory=False, quiesce=False
)
wait_for_task(task, 'CBTActivateSnapshot')
# Immediately delete it
snap_root = getattr(vm, 'snapshot', None)
if snap_root and snap_root.rootSnapshotList:
snap_obj = find_snapshot_by_name(snap_root.rootSnapshotList, act_snap_name)
if snap_obj:
task = snap_obj.RemoveSnapshot_Task(removeChildren=False)
wait_for_task(task, 'CBTActivateSnapshotRemove')
print("CBT: Transient snapshot removed — CBT is now active")
return False
def get_disk_device_by_key(vm, device_key):
"""Return the VirtualDisk device object for a given device key."""
for dev in vm.config.hardware.device:
if isinstance(dev, vim.vm.device.VirtualDisk) and dev.key == device_key:
return dev
return None
def get_disk_change_id(snapshot_ref, device_key):
"""Return the changeId for a disk at a given snapshot.
changeId '*' means "give me all changes since disk was created" used
to seed the first incremental after a full backup.
"""
try:
for disk_layout in snapshot_ref.config.hardware.device:
if isinstance(disk_layout, vim.vm.device.VirtualDisk) and disk_layout.key == device_key:
backing = disk_layout.backing
cid = getattr(backing, 'changeId', None)
if cid:
return cid
except Exception as e:
print(f"CBT: Could not get changeId for device key {device_key}: {e}")
return None
def query_changed_areas(vm_snapshot, device_key, change_id, start_offset=0):
"""Call QueryChangedDiskAreas and return a list of {start, length} extents.
change_id: the changeId from the *previous* backup snapshot.
Use '*' to get all changed areas since disk creation.
Returns list of dicts: [{'start': int, 'length': int}, ...]
"""
extents = []
try:
result = vm_snapshot.QueryChangedDiskAreas(
id=device_key,
startOffset=start_offset,
changeId=change_id
)
if result and result.changedArea:
for area in result.changedArea:
extents.append({'start': area.start, 'length': area.length})
print(f"CBT: QueryChangedDiskAreas returned {len(extents)} extent(s), "
f"{sum(e['length'] for e in extents) // (1024*1024)} MB changed")
except vmodl.fault.InvalidArgument as e:
print(f"CBT: InvalidArgument querying changed areas (changeId may be stale): {e}")
raise
except Exception as e:
print(f"CBT: Error querying changed areas: {e}")
raise
return extents
def download_disk_changed_ranges(host, dc_name, ds_name, ds_path, extents,
local_path, session_cookie,
total_disk_size, verify_ssl=True,
progress_cb=None):
"""Download only the changed byte extents from a flat VMDK via HTTP Range requests.
Writes a sparse file: changed extents are filled with downloaded data;
unchanged regions remain as zero bytes (seek over them).
Returns (sha256_hex, bytes_downloaded).
"""
encoded_path = urllib.parse.quote(ds_path, safe='/')
url = (f"https://{host}/folder/{encoded_path}"
f"?dcPath={urllib.parse.quote(dc_name)}&dsName={urllib.parse.quote(ds_name)}")
headers_base = {"Cookie": f"vmware_soap_session={session_cookie}"}
total_changed = sum(e['length'] for e in extents)
print(f"CBT: Downloading {len(extents)} changed extent(s), "
f"{total_changed // (1024*1024)} MB / "
f"{total_disk_size // (1024*1024)} MB total")
sha256 = hashlib.sha256()
bytes_downloaded = 0
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# We build a sparse file matching the full disk geometry so restore works
with open(local_path, 'wb') as f:
# Pre-allocate to full disk size (sparse/hole-punched on Linux)
if total_disk_size > 0:
f.seek(total_disk_size - 1)
f.write(b'\x00')
f.seek(0)
# Track file position for SHA-256 over the full logical disk
# We hash the file after writing instead of on-the-fly to handle seeks correctly
for i, extent in enumerate(extents):
start = extent['start']
length = extent['length']
end_byte = start + length - 1
range_header = f"bytes={start}-{end_byte}"
req_headers = {**headers_base, "Range": range_header}
with requests.get(url, headers=req_headers, stream=True,
verify=verify_ssl,
proxies={"http": None, "https": None}) as r:
if r.status_code not in (200, 206):
raise Exception(f"HTTP {r.status_code} for Range {range_header}")
f.seek(start)
for chunk in r.iter_content(chunk_size=4 * 1024 * 1024):
if chunk:
f.write(chunk)
bytes_downloaded += len(chunk)
if progress_cb and total_changed > 0:
progress_cb(bytes_downloaded, total_changed)
# Compute SHA-256 of the resulting file
sha256 = hashlib.sha256()
with open(local_path, 'rb') as f:
while True:
chunk = f.read(4 * 1024 * 1024)
if not chunk:
break
sha256.update(chunk)
print(f"CBT: Incremental download complete — {bytes_downloaded // (1024*1024)} MB written")
return sha256.hexdigest(), bytes_downloaded
CBT_STATE_FILENAME = 'cbt_state.json'
def load_cbt_state(vm_base_dir):
"""Load the CBT state dict from <vm_base_dir>/cbt_state.json.
Returns dict with structure:
{ 'last_backup_ts': str, 'disks': { disk_path: { 'change_id': str, ... } } }
or empty dict if not found.
"""
state_path = os.path.join(vm_base_dir, CBT_STATE_FILENAME)
if not os.path.exists(state_path):
return {}
try:
with open(state_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"CBT: Could not read state file {state_path}: {e}")
return {}
def save_cbt_state(vm_base_dir, state):
"""Persist the CBT state dict to <vm_base_dir>/cbt_state.json."""
os.makedirs(vm_base_dir, exist_ok=True)
state_path = os.path.join(vm_base_dir, CBT_STATE_FILENAME)
try:
with open(state_path, 'w', encoding='utf-8') as f:
json.dump(state, f, indent=2)
print(f"CBT: State saved to {state_path}")
except Exception as e:
print(f"CBT: Could not save state file: {e}")
def get_file_sha256(filepath, decompress_if_zst=False): def get_file_sha256(filepath, decompress_if_zst=False):
"""Compute the SHA-256 hash of a file. Optionally decompress on-the-fly if it is a .zst file.""" """Compute the SHA-256 hash of a file. Optionally decompress on-the-fly if it is a .zst file."""
sha256 = hashlib.sha256() sha256 = hashlib.sha256()
@ -344,10 +544,12 @@ def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir):
def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ssl=False, def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ssl=False,
sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None, sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None,
log_path=None, progress_cb=None, disk_filter=None, job_id=None, log_path=None, progress_cb=None, disk_filter=None, job_id=None,
is_cancelled_cb=None): is_cancelled_cb=None, use_cbt=False):
"""Run full backup flow. """Run backup flow (full or CBT incremental).
disk_filter: if not None, a set/list of VMDK file-ref strings to include. disk_filter: if not None, a set/list of VMDK file-ref strings to include.
The VMX config file is always included regardless. The VMX config file is always included regardless.
use_cbt: if True, attempt Changed Block Tracking incremental backup.
Falls back to full download if CBT state is unavailable.
""" """
if log_path: if log_path:
logfile = open(log_path, 'a', encoding='utf-8', buffering=1) logfile = open(log_path, 'a', encoding='utf-8', buffering=1)
@ -356,7 +558,7 @@ def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ss
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password, sftp_key, sftp_host, sftp_user, sftp_password, sftp_key,
progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id, progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id,
is_cancelled_cb=is_cancelled_cb) is_cancelled_cb=is_cancelled_cb, use_cbt=use_cbt)
try: try:
return _wrap() return _wrap()
finally: finally:
@ -365,13 +567,13 @@ def run_backup(host, user, password, vm_name, dest, compress=False, no_verify_ss
return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password, sftp_key, sftp_host, sftp_user, sftp_password, sftp_key,
progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id, progress_cb=progress_cb, disk_filter=disk_filter, job_id=job_id,
is_cancelled_cb=is_cancelled_cb) is_cancelled_cb=is_cancelled_cb, use_cbt=use_cbt)
def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl,
sftp_host, sftp_user, sftp_password, sftp_key, sftp_host, sftp_user, sftp_password, sftp_key,
progress_cb=None, disk_filter=None, job_id=None, progress_cb=None, disk_filter=None, job_id=None,
is_cancelled_cb=None): is_cancelled_cb=None, use_cbt=False):
def _prog(phase, pct, detail=''): def _prog(phase, pct, detail=''):
if progress_cb: if progress_cb:
try: try:
@ -399,6 +601,28 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
snap_name = f"backup-{int(time.time())}" snap_name = f"backup-{int(time.time())}"
created_snapshot = False created_snapshot = False
# ── CBT pre-snapshot setup ────────────────────────────────────────────
# vm_base_dir is where cbt_state.json lives (shared across all run dirs)
vm_base_dir = os.path.join(dest, vm_name) if not dest.endswith(vm_name) else dest
# Normalize: dest passed in is already the run-specific dir (backup-YYYYMMDDHHMMSS)
# so we go one level up to find the VM base dir for CBT state
vm_base_dir = str(Path(dest).parent)
cbt_state = {}
if use_cbt:
_prog('snapshot', 1, 'Enabling Changed Block Tracking (CBT)…')
try:
enable_cbt(vm, content)
cbt_state = load_cbt_state(vm_base_dir)
if cbt_state:
print(f"CBT: Found prior state from {cbt_state.get('last_backup_ts', 'unknown')}")
else:
print("CBT: No prior state — this will be a FULL backup (seeding CBT)")
except Exception as e:
print(f"CBT: Failed to enable CBT, falling back to full backup: {e}")
use_cbt = False
try: try:
_prog('snapshot', 3, 'Creating snapshot…') _prog('snapshot', 3, 'Creating snapshot…')
create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False) create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False)
@ -415,6 +639,29 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
vmdk_refs = [re.sub(r'-\d+\.vmdk$', '.vmdk', r, flags=re.IGNORECASE) for r in raw_vmdk_refs] vmdk_refs = [re.sub(r'-\d+\.vmdk$', '.vmdk', r, flags=re.IGNORECASE) for r in raw_vmdk_refs]
vmx_ref = vm_config_vmx_path(vm) vmx_ref = vm_config_vmx_path(vm)
# Build a map of normalized vmdk_ref -> VirtualDisk device for CBT
disk_devices = {}
for dev in vm.config.hardware.device:
if isinstance(dev, vim.vm.device.VirtualDisk):
fn = getattr(dev.backing, 'fileName', None)
if fn:
norm = re.sub(r'-\d+\.vmdk$', '.vmdk', fn, flags=re.IGNORECASE)
disk_devices[norm] = dev
# Locate the backup snapshot object for CBT queries
snap_ref = None
if use_cbt:
try:
snap_root = getattr(vm, 'snapshot', None)
if snap_root and snap_root.rootSnapshotList:
snap_ref = find_snapshot_by_name(snap_root.rootSnapshotList, snap_name)
if not snap_ref:
print("CBT: Could not locate backup snapshot for QueryChangedDiskAreas — falling back to full")
use_cbt = False
except Exception as e:
print(f"CBT: Snapshot lookup failed: {e} — falling back to full")
use_cbt = False
# Apply disk filter — only download selected VMDKs # Apply disk filter — only download selected VMDKs
if disk_filter is not None: if disk_filter is not None:
disk_filter_set = {re.sub(r'-\d+\.vmdk$', '.vmdk', f, flags=re.IGNORECASE) for f in disk_filter} disk_filter_set = {re.sub(r'-\d+\.vmdk$', '.vmdk', f, flags=re.IGNORECASE) for f in disk_filter}
@ -431,11 +678,18 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
if not vmdk_refs: if not vmdk_refs:
print("Warning: no disks selected — backing up VMX config only.") print("Warning: no disks selected — backing up VMX config only.")
# ── Build download list ───────────────────────────────────────────
# Descriptor (.vmdk) + flat data (-flat.vmdk) pairs, plus VMX
# For CBT mode, we only do range-downloads on the flat file; the
# small descriptor is always fetched in full.
all_refs = [] all_refs = []
flat_vmdk_refs = set() # track which refs are flat data disks
for ref in vmdk_refs: for ref in vmdk_refs:
all_refs.append(ref) all_refs.append(ref) # descriptor (small)
if ref.lower().endswith('.vmdk') and not ref.lower().endswith('-flat.vmdk'): if ref.lower().endswith('.vmdk') and not ref.lower().endswith('-flat.vmdk'):
all_refs.append(ref[:-5] + '-flat.vmdk') flat_ref = ref[:-5] + '-flat.vmdk'
all_refs.append(flat_ref)
flat_vmdk_refs.add(flat_ref)
if vmx_ref: if vmx_ref:
all_refs.append(vmx_ref) all_refs.append(vmx_ref)
@ -447,6 +701,12 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
downloaded_files = [] downloaded_files = []
files_manifest_info = [] files_manifest_info = []
# Track CBT savings across all disks for manifest
cbt_total_changed_bytes = 0
cbt_total_disk_bytes = 0
new_cbt_disk_state = {} # updated state to persist after success
for file_idx, ref in enumerate(all_refs): for file_idx, ref in enumerate(all_refs):
if is_cancelled_cb and is_cancelled_cb(): if is_cancelled_cb and is_cancelled_cb():
raise RuntimeError("Backup cancelled by user") raise RuntimeError("Backup cancelled by user")
@ -481,25 +741,102 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
_prog('downloading', file_base_pct, _prog('downloading', file_base_pct,
f'Starting file {file_idx+1}/{total_files}: {os.path.basename(ds_path)}') f'Starting file {file_idx+1}/{total_files}: {os.path.basename(ds_path)}')
file_sha = download_datastore_file(
host, dc_name, ds_name, ds_path, local_file, session_cookie, # ── CBT incremental path for flat VMDK data disks ─────────────
verify_ssl=not no_verify_ssl, is_flat_disk = ref in flat_vmdk_refs
progress_cb=make_dl_cb(file_idx, total_files, file_base_pct, did_cbt = False
file_share, os.path.basename(ds_path)) file_sha = None
) bytes_downloaded_this_file = None
if use_cbt and is_flat_disk and snap_ref:
# Find the device key for the descriptor that corresponds
# to this flat file (descriptor ref = flat_ref without -flat)
descriptor_ref = ref[:-len('-flat.vmdk')] + '.vmdk'
dev = disk_devices.get(descriptor_ref)
if dev:
device_key = dev.key
prior_disk_state = cbt_state.get('disks', {}).get(ref, {})
prior_change_id = prior_disk_state.get('change_id')
disk_size_bytes = (getattr(dev, 'capacityInKB', 0) or 0) * 1024
if prior_change_id:
# ── Incremental: query and download only changes ──
print(f"CBT: Incremental mode for {ref} "
f"(prior changeId: {prior_change_id[:20]}…)")
try:
extents = query_changed_areas(
snap_ref, device_key, prior_change_id
)
if not extents:
print(f"CBT: No changes detected for {ref} — creating empty delta")
os.makedirs(os.path.dirname(local_file), exist_ok=True)
open(local_file, 'wb').close()
file_sha = hashlib.sha256(b'').hexdigest()
bytes_downloaded_this_file = 0
did_cbt = True
else:
total_extent_bytes = sum(e['length'] for e in extents)
cbt_total_changed_bytes += total_extent_bytes
cbt_total_disk_bytes += disk_size_bytes
file_sha, bytes_downloaded_this_file = download_disk_changed_ranges(
host, dc_name, ds_name, ds_path, extents,
local_file, session_cookie,
total_disk_size=disk_size_bytes,
verify_ssl=not no_verify_ssl,
progress_cb=make_dl_cb(
file_idx, total_files,
file_base_pct, file_share,
f"[CBT] {os.path.basename(ds_path)}"
)
)
did_cbt = True
except Exception as cbt_err:
print(f"CBT: Incremental download failed ({cbt_err}), "
f"falling back to full download for {ref}")
did_cbt = False
else:
# No prior state: this is the seeding full backup
print(f"CBT: No prior changeId for {ref}"
f"performing FULL download to seed CBT")
cbt_total_disk_bytes += disk_size_bytes
# After snapshot, get the new changeId for next run
new_cid = get_disk_change_id(snap_ref, device_key)
new_cbt_disk_state[ref] = {
'change_id': new_cid or '*',
'backup_type': 'incremental' if (did_cbt and prior_change_id) else 'full',
'last_snapshot': snap_name,
}
if not did_cbt:
# ── Full download path (also used for descriptors & VMX) ──
file_sha = download_datastore_file(
host, dc_name, ds_name, ds_path, local_file, session_cookie,
verify_ssl=not no_verify_ssl,
progress_cb=make_dl_cb(file_idx, total_files, file_base_pct,
file_share, os.path.basename(ds_path))
)
downloaded_files.append(local_file) downloaded_files.append(local_file)
# Checksum was computed on-the-fly during download
file_size = os.path.getsize(local_file) file_size = os.path.getsize(local_file)
print(f"SHA-256 (computed on-the-fly): {file_sha} (size: {file_size} bytes)") print(f"SHA-256: {file_sha} (size: {file_size} bytes)")
# Relative path from dest directory using forward slashes (e.g. "datastore1/Nakivo/Nakivo.vmdk")
rel_path = os.path.relpath(local_file, dest).replace(os.sep, '/') rel_path = os.path.relpath(local_file, dest).replace(os.sep, '/')
files_manifest_info.append({ manifest_entry = {
"path": rel_path, "path": rel_path,
"size_bytes": file_size, "size_bytes": file_size,
"sha256": file_sha "sha256": file_sha,
}) }
if use_cbt and is_flat_disk and ref in new_cbt_disk_state:
disk_st = new_cbt_disk_state[ref]
manifest_entry['backup_type'] = disk_st.get('backup_type', 'full')
if bytes_downloaded_this_file is not None:
manifest_entry['changed_bytes'] = bytes_downloaded_this_file
files_manifest_info.append(manifest_entry)
_prog('compressing', 90, 'Downloads complete. Creating manifest…') _prog('compressing', 90, 'Downloads complete. Creating manifest…')
if is_cancelled_cb and is_cancelled_cb(): if is_cancelled_cb and is_cancelled_cb():
@ -507,6 +844,14 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
# Write manifest.json # Write manifest.json
finished_iso = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') finished_iso = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
# Determine overall backup type label
has_incremental = any(
e.get('backup_type') == 'incremental'
for e in files_manifest_info
)
overall_type = 'incremental' if has_incremental else 'full'
manifest_data = { manifest_data = {
"job_id": job_id or "...", "job_id": job_id or "...",
"vm_name": vm_name, "vm_name": vm_name,
@ -514,13 +859,36 @@ def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ss
"finished": finished_iso, "finished": finished_iso,
"vcenter": host, "vcenter": host,
"snapshot": snap_name, "snapshot": snap_name,
"backup_type": overall_type,
"cbt_enabled": use_cbt,
"files": files_manifest_info "files": files_manifest_info
} }
if use_cbt and cbt_total_disk_bytes > 0:
savings_pct = round(
(1 - cbt_total_changed_bytes / cbt_total_disk_bytes) * 100, 1
)
manifest_data['cbt_transfer_savings_pct'] = savings_pct
manifest_data['cbt_changed_bytes'] = cbt_total_changed_bytes
manifest_data['cbt_total_disk_bytes'] = cbt_total_disk_bytes
print(f"CBT summary: {savings_pct}% transfer savings "
f"({cbt_total_changed_bytes // (1024*1024)} MB transferred of "
f"{cbt_total_disk_bytes // (1024*1024)} MB total)")
manifest_path = os.path.join(dest, 'manifest.json') manifest_path = os.path.join(dest, 'manifest.json')
with open(manifest_path, 'w', encoding='utf-8') as f: with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest_data, f, indent=2) json.dump(manifest_data, f, indent=2)
print(f"Backup manifest created at {manifest_path}") print(f"Backup manifest created at {manifest_path}")
# Persist CBT state for next incremental run
if use_cbt and new_cbt_disk_state:
updated_state = {
'last_backup_ts': finished_iso,
'backup_type': overall_type,
'disks': new_cbt_disk_state,
}
save_cbt_state(vm_base_dir, updated_state)
final_files = [] final_files = []
for f in downloaded_files: for f in downloaded_files:
if is_cancelled_cb and is_cancelled_cb(): if is_cancelled_cb and is_cancelled_cb():

View File

@ -334,6 +334,7 @@ def job_to_display(jid, info):
'monthly_day': info.get('monthly_day'), 'monthly_day': info.get('monthly_day'),
'weekly_day': info.get('weekly_day'), 'weekly_day': info.get('weekly_day'),
'vm_names': vm_names, 'vm_names': vm_names,
'use_cbt': info.get('use_cbt', False),
} }
@ -503,6 +504,7 @@ def run_job_thread(jid):
disk_filter=disk_filter, disk_filter=disk_filter,
job_id=jid, job_id=jid,
is_cancelled_cb=is_cancelled, is_cancelled_cb=is_cancelled,
use_cbt=info.get('use_cbt', False),
) )
success_vms.append(vm) success_vms.append(vm)
except Exception as e: except Exception as e:
@ -569,6 +571,7 @@ def run_job_thread(jid):
disk_filter=info.get('disk_filter'), # None = all disks disk_filter=info.get('disk_filter'), # None = all disks
job_id=jid, job_id=jid,
is_cancelled_cb=is_cancelled, is_cancelled_cb=is_cancelled,
use_cbt=info.get('use_cbt', False),
) )
info['status'] = 'finished' info['status'] = 'finished'
info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'} info['progress'] = {'pct': 100, 'phase': 'done', 'detail': 'Backup completed successfully'}
@ -591,11 +594,12 @@ def create_and_start_job(
schedule_type, schedule_time, weekly_day, interval_hours, schedule_type, schedule_time, weekly_day, interval_hours,
label='', disk_filter=None, monthly_day=1, label='', disk_filter=None, monthly_day=1,
retention_type='keep_all', retention_value=5, retention_type='keep_all', retention_value=5,
vm_names=None, disk_filter_map=None vm_names=None, disk_filter_map=None, use_cbt=False
): ):
"""Create a job entry and either run immediately or register schedule. """Create a job entry and either run immediately or register schedule.
disk_filter: list of VMDK path strings to include, or None for all. disk_filter: list of VMDK path strings to include, or None for all.
monthly_day: day of month (1-28) for monthly schedule. monthly_day: day of month (1-28) for monthly schedule.
use_cbt: if True enable Changed Block Tracking incremental backup.
""" """
jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6] jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6]
job_dir = JOBS_DIR / jid job_dir = JOBS_DIR / jid
@ -627,6 +631,7 @@ def create_and_start_job(
'interval_hours': interval_hours, 'interval_hours': interval_hours,
'retention_type': retention_type, 'retention_type': retention_type,
'retention_value': retention_value, 'retention_value': retention_value,
'use_cbt': use_cbt,
} }
jobs[jid] = info jobs[jid] = info
@ -795,6 +800,8 @@ def create_job():
except ValueError: except ValueError:
retention_value = 5 retention_value = 5
use_cbt = 'use_cbt' in request.form
jid = create_and_start_job( jid = create_and_start_job(
vm_name=vm_name, vm_name=vm_name,
dest=dest, dest=dest,
@ -812,6 +819,7 @@ def create_job():
monthly_day=monthly_day, monthly_day=monthly_day,
retention_type=retention_type, retention_type=retention_type,
retention_value=retention_value, retention_value=retention_value,
use_cbt=use_cbt,
) )
n_disks = len(disk_filter) if disk_filter is not None else 'all' n_disks = len(disk_filter) if disk_filter is not None else 'all'
flash(f'Job created — {n_disks} disk(s) selected.', 'success') flash(f'Job created — {n_disks} disk(s) selected.', 'success')
@ -904,6 +912,8 @@ def batch_jobs():
except ValueError: except ValueError:
retention_value = 5 retention_value = 5
use_cbt = 'use_cbt' in request.form
label = label_prefix if label_prefix else f"Batch Backup — {len(vm_names)} VMs" label = label_prefix if label_prefix else f"Batch Backup — {len(vm_names)} VMs"
jid = create_and_start_job( jid = create_and_start_job(
@ -925,6 +935,7 @@ def batch_jobs():
retention_value=retention_value, retention_value=retention_value,
vm_names=vm_names, vm_names=vm_names,
disk_filter_map=disk_filter_map, disk_filter_map=disk_filter_map,
use_cbt=use_cbt,
) )
strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy) strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy)

View File

@ -111,6 +111,42 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 20px; margin-bottom: 20px;
} }
.bkp-strategy-options {
display: grid; grid-template-columns: 1fr 1fr;
gap: 12px; margin-bottom: 4px;
}
.bkp-strategy-opt {
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; align-items: flex-start; gap: 14px;
background: rgba(255, 255, 255, 0.01);
}
.bkp-strategy-opt:hover { border-color: var(--border-bright); background: var(--bg-card-hover); transform: translateY(-1px); }
.bkp-strategy-opt.selected { border-color: var(--accent); background: rgba(99, 102, 241, 0.08); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.05); }
.bkp-strategy-opt input[type=radio] { display: none; }
.bkp-strategy-opt-icon { font-size: 26px; flex-shrink: 0; margin-top: 2px; }
.bkp-strategy-opt-title { font-size: 14.5px; font-weight: 700; display: flex; align-items: center; gap: 7px; }
.bkp-strategy-opt-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; font-weight: 500; line-height: 1.5; }
.bkp-strategy-badge {
font-size: 10px; font-weight: 800; letter-spacing: 0.04em;
padding: 2px 7px; border-radius: 100px;
background: linear-gradient(135deg, #6366f1, #06b6d4);
color: #fff; text-transform: uppercase;
}
.cbt-info-banner {
margin-top: 14px; padding: 12px 16px;
background: rgba(99,102,241,0.06);
border: 1px solid rgba(99,102,241,0.18);
border-radius: var(--radius-sm);
font-size: 13px; color: var(--text-secondary);
display: none;
}
.cbt-info-banner.visible { display: block; }
.cbt-info-banner strong { color: var(--text-primary); }
</style> </style>
{% endblock %} {% endblock %}
@ -257,6 +293,43 @@
</div> </div>
</div> </div>
<!-- Backup Strategy (CBT) -->
<div class="section-card">
<div class="section-card-header">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Backup Strategy
</div>
<div class="section-card-body">
<div class="bkp-strategy-options">
<label class="bkp-strategy-opt selected" id="bkp-full" onclick="selectBkpStrategy('full')">
<input type="radio" name="use_cbt" value="" checked id="bkp_radio_full" />
<div class="bkp-strategy-opt-icon">💾</div>
<div>
<div class="bkp-strategy-opt-title">Full Backup</div>
<div class="bkp-strategy-opt-desc">Download the entire disk image every run. Best for initial backups or infrequent schedules.</div>
</div>
</label>
<label class="bkp-strategy-opt" id="bkp-incremental" onclick="selectBkpStrategy('incremental')">
<input type="radio" name="use_cbt" value="1" id="bkp_radio_incremental" />
<div class="bkp-strategy-opt-icon"></div>
<div>
<div class="bkp-strategy-opt-title">
Incremental (CBT)
<span class="bkp-strategy-badge">⭐ Enterprise</span>
</div>
<div class="bkp-strategy-opt-desc">Transfer only <strong>changed blocks</strong> via VMware CBT. Dramatic daily transfer reduction.</div>
</div>
</label>
</div>
<div class="cbt-info-banner" id="cbtInfoBanner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:6px;color:var(--accent);"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<strong>8099% less data transferred on daily runs.</strong>
The first run is a full backup that seeds the CBT state. All subsequent runs transfer only changed blocks.
CBT requires ESXi 4.0+ and VM hardware version 7+.
</div>
</div>
</div>
<!-- Retention Policy --> <!-- Retention Policy -->
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
@ -490,6 +563,19 @@
document.getElementById('stratHint').textContent = stratHints[type]; document.getElementById('stratHint').textContent = stratHints[type];
} }
function selectBkpStrategy(type) {
document.getElementById('bkp-full').classList.remove('selected');
document.getElementById('bkp-incremental').classList.remove('selected');
document.getElementById('bkp-' + type).classList.add('selected');
document.getElementById('bkp_radio_' + type).checked = true;
const banner = document.getElementById('cbtInfoBanner');
if (type === 'incremental') {
banner.classList.add('visible');
} else {
banner.classList.remove('visible');
}
}
document.getElementById('batchForm').addEventListener('submit', function() { document.getElementById('batchForm').addEventListener('submit', function() {
document.getElementById('submitText').style.display = 'none'; document.getElementById('submitText').style.display = 'none';
document.getElementById('submitSpinner').style.display = 'inline-block'; document.getElementById('submitSpinner').style.display = 'inline-block';

View File

@ -94,6 +94,43 @@
.sftp-section { display: none; margin-top: 20px; } .sftp-section { display: none; margin-top: 20px; }
.sftp-section.visible { display: block; } .sftp-section.visible { display: block; }
.strategy-options {
display: grid; grid-template-columns: 1fr 1fr;
gap: 12px; margin-bottom: 4px;
}
.strategy-opt {
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; align-items: flex-start; gap: 14px;
background: rgba(255, 255, 255, 0.01);
position: relative;
}
.strategy-opt:hover { border-color: var(--border-bright); background: var(--bg-card-hover); transform: translateY(-1px); }
.strategy-opt.selected { border-color: var(--accent); background: rgba(99, 102, 241, 0.08); box-shadow: 0 4px 12px rgba(99, 102, 241, 0.05); }
.strategy-opt input[type=radio] { display: none; }
.strategy-opt-icon { font-size: 26px; flex-shrink: 0; margin-top: 2px; }
.strategy-opt-title { font-size: 14.5px; font-weight: 700; display: flex; align-items: center; gap: 7px; }
.strategy-opt-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; font-weight: 500; line-height: 1.5; }
.strategy-badge {
font-size: 10px; font-weight: 800; letter-spacing: 0.04em;
padding: 2px 7px; border-radius: 100px;
background: linear-gradient(135deg, #6366f1, #06b6d4);
color: #fff; text-transform: uppercase;
}
.cbt-savings-banner {
margin-top: 14px; padding: 12px 16px;
background: rgba(99,102,241,0.06);
border: 1px solid rgba(99,102,241,0.18);
border-radius: var(--radius-sm);
font-size: 13px; color: var(--text-secondary);
display: none;
}
.cbt-savings-banner.visible { display: block; }
.cbt-savings-banner strong { color: var(--text-primary); }
.action-bar { .action-bar {
display: flex; gap: 14px; align-items: center; display: flex; gap: 14px; align-items: center;
padding: 24px 0 0; padding: 24px 0 0;
@ -232,6 +269,47 @@
</div> </div>
</div> </div>
<!-- Backup Strategy (CBT) -->
<div class="section-card">
<div class="section-card-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Backup Strategy
</div>
<div class="section-card-body">
<div class="strategy-options">
<label class="strategy-opt selected" id="strat-full" onclick="selectStrategy('full')">
<input type="radio" name="use_cbt" value="" checked id="strat_radio_full" />
<div class="strategy-opt-icon">💾</div>
<div>
<div class="strategy-opt-title">Full Backup</div>
<div class="strategy-opt-desc">Download the entire disk image every run. Simple and always complete — best for first-time setups or infrequent backups.</div>
</div>
</label>
<label class="strategy-opt" id="strat-incremental" onclick="selectStrategy('incremental')">
<input type="radio" name="use_cbt" value="1" id="strat_radio_incremental" />
<div class="strategy-opt-icon"></div>
<div>
<div class="strategy-opt-title">
Incremental (CBT)
<span class="strategy-badge">⭐ Enterprise</span>
</div>
<div class="strategy-opt-desc">Transfer only <strong>changed blocks</strong> using VMware Changed Block Tracking. Dramatically reduces daily backup size.</div>
</div>
</label>
</div>
<div class="cbt-savings-banner" id="cbtBanner">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:6px;color:var(--accent);"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<strong>8099% less data transferred on daily runs.</strong>
The first run performs a full backup to seed the CBT state. All subsequent runs download only the blocks that changed since the last backup.
CBT requires ESXi 4.0+ and VM hardware version 7+.
</div>
</div>
</div>
<!-- Retention Policy --> <!-- Retention Policy -->
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
@ -463,6 +541,19 @@
if (detail) detail.classList.add('visible'); if (detail) detail.classList.add('visible');
} }
function selectStrategy(type) {
document.getElementById('strat-full').classList.remove('selected');
document.getElementById('strat-incremental').classList.remove('selected');
document.getElementById('strat-' + type).classList.add('selected');
document.getElementById('strat_radio_' + type).checked = true;
const banner = document.getElementById('cbtBanner');
if (type === 'incremental') {
banner.classList.add('visible');
} else {
banner.classList.remove('visible');
}
}
function toggleSFTP() { function toggleSFTP() {
const sec = document.getElementById('sftpSection'); const sec = document.getElementById('sftpSection');
const ico = document.getElementById('sftpToggleIcon'); const ico = document.getElementById('sftpToggleIcon');

View File

@ -273,6 +273,21 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="detail-item">
<div class="detail-item-label">Backup Strategy</div>
<div class="detail-item-val" style="font-size:13px; display:flex; align-items:center; gap:8px;">
{% if job.use_cbt %}
<span style="font-size:14px;"></span>
Incremental (CBT)
<span style="font-size:10px; font-weight:800; padding:2px 7px; border-radius:100px;
background:linear-gradient(135deg,#6366f1,#06b6d4); color:#fff;
text-transform:uppercase; letter-spacing:0.04em;">Enterprise</span>
{% else %}
<span style="font-size:14px;">💾</span>
Full Backup
{% endif %}
</div>
</div>
</div> </div>
{% if job.schedule_id %} {% if job.schedule_id %}