feat: implement Changed Block Tracking (CBT) support for incremental VM backups
This commit is contained in:
parent
a0bbe9a3b8
commit
f7dba1d11c
396
backup_core.py
396
backup_core.py
@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
import requests
|
||||
from pyVim.connect import SmartConnect, Disconnect
|
||||
from pyVmomi import vim
|
||||
from pyVmomi import vim, vmodl
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
@ -229,6 +229,206 @@ def remove_snapshot(snapshot_obj):
|
||||
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):
|
||||
"""Compute the SHA-256 hash of a file. Optionally decompress on-the-fly if it is a .zst file."""
|
||||
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,
|
||||
sftp_host=None, sftp_user=None, sftp_password=None, sftp_key=None,
|
||||
log_path=None, progress_cb=None, disk_filter=None, job_id=None,
|
||||
is_cancelled_cb=None):
|
||||
"""Run full backup flow.
|
||||
is_cancelled_cb=None, use_cbt=False):
|
||||
"""Run backup flow (full or CBT incremental).
|
||||
disk_filter: if not None, a set/list of VMDK file-ref strings to include.
|
||||
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:
|
||||
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,
|
||||
sftp_host, sftp_user, sftp_password, sftp_key,
|
||||
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:
|
||||
return _wrap()
|
||||
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,
|
||||
sftp_host, sftp_user, sftp_password, sftp_key,
|
||||
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,
|
||||
sftp_host, sftp_user, sftp_password, sftp_key,
|
||||
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=''):
|
||||
if progress_cb:
|
||||
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())}"
|
||||
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:
|
||||
_prog('snapshot', 3, 'Creating snapshot…')
|
||||
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]
|
||||
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
|
||||
if disk_filter is not None:
|
||||
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:
|
||||
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 = []
|
||||
flat_vmdk_refs = set() # track which refs are flat data disks
|
||||
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'):
|
||||
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:
|
||||
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 = []
|
||||
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):
|
||||
if is_cancelled_cb and is_cancelled_cb():
|
||||
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,
|
||||
f'Starting file {file_idx+1}/{total_files}: {os.path.basename(ds_path)}')
|
||||
|
||||
# ── CBT incremental path for flat VMDK data disks ─────────────
|
||||
is_flat_disk = ref in flat_vmdk_refs
|
||||
did_cbt = False
|
||||
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)
|
||||
|
||||
# Checksum was computed on-the-fly during download
|
||||
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, '/')
|
||||
files_manifest_info.append({
|
||||
manifest_entry = {
|
||||
"path": rel_path,
|
||||
"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…')
|
||||
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
|
||||
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 = {
|
||||
"job_id": job_id or "...",
|
||||
"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,
|
||||
"vcenter": host,
|
||||
"snapshot": snap_name,
|
||||
"backup_type": overall_type,
|
||||
"cbt_enabled": use_cbt,
|
||||
"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')
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest_data, f, indent=2)
|
||||
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 = []
|
||||
for f in downloaded_files:
|
||||
if is_cancelled_cb and is_cancelled_cb():
|
||||
|
||||
13
gui_app.py
13
gui_app.py
@ -334,6 +334,7 @@ def job_to_display(jid, info):
|
||||
'monthly_day': info.get('monthly_day'),
|
||||
'weekly_day': info.get('weekly_day'),
|
||||
'vm_names': vm_names,
|
||||
'use_cbt': info.get('use_cbt', False),
|
||||
}
|
||||
|
||||
|
||||
@ -503,6 +504,7 @@ def run_job_thread(jid):
|
||||
disk_filter=disk_filter,
|
||||
job_id=jid,
|
||||
is_cancelled_cb=is_cancelled,
|
||||
use_cbt=info.get('use_cbt', False),
|
||||
)
|
||||
success_vms.append(vm)
|
||||
except Exception as e:
|
||||
@ -569,6 +571,7 @@ def run_job_thread(jid):
|
||||
disk_filter=info.get('disk_filter'), # None = all disks
|
||||
job_id=jid,
|
||||
is_cancelled_cb=is_cancelled,
|
||||
use_cbt=info.get('use_cbt', False),
|
||||
)
|
||||
info['status'] = 'finished'
|
||||
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,
|
||||
label='', disk_filter=None, monthly_day=1,
|
||||
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.
|
||||
disk_filter: list of VMDK path strings to include, or None for all.
|
||||
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]
|
||||
job_dir = JOBS_DIR / jid
|
||||
@ -627,6 +631,7 @@ def create_and_start_job(
|
||||
'interval_hours': interval_hours,
|
||||
'retention_type': retention_type,
|
||||
'retention_value': retention_value,
|
||||
'use_cbt': use_cbt,
|
||||
}
|
||||
jobs[jid] = info
|
||||
|
||||
@ -795,6 +800,8 @@ def create_job():
|
||||
except ValueError:
|
||||
retention_value = 5
|
||||
|
||||
use_cbt = 'use_cbt' in request.form
|
||||
|
||||
jid = create_and_start_job(
|
||||
vm_name=vm_name,
|
||||
dest=dest,
|
||||
@ -812,6 +819,7 @@ def create_job():
|
||||
monthly_day=monthly_day,
|
||||
retention_type=retention_type,
|
||||
retention_value=retention_value,
|
||||
use_cbt=use_cbt,
|
||||
)
|
||||
n_disks = len(disk_filter) if disk_filter is not None else 'all'
|
||||
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
|
||||
@ -904,6 +912,8 @@ def batch_jobs():
|
||||
except ValueError:
|
||||
retention_value = 5
|
||||
|
||||
use_cbt = 'use_cbt' in request.form
|
||||
|
||||
label = label_prefix if label_prefix else f"Batch Backup — {len(vm_names)} VMs"
|
||||
|
||||
jid = create_and_start_job(
|
||||
@ -925,6 +935,7 @@ def batch_jobs():
|
||||
retention_value=retention_value,
|
||||
vm_names=vm_names,
|
||||
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)
|
||||
|
||||
@ -111,6 +111,42 @@
|
||||
color: var(--text-secondary);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -257,6 +293,43 @@
|
||||
</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>80–99% 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 -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
@ -490,6 +563,19 @@
|
||||
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('submitText').style.display = 'none';
|
||||
document.getElementById('submitSpinner').style.display = 'inline-block';
|
||||
|
||||
@ -94,6 +94,43 @@
|
||||
.sftp-section { display: none; margin-top: 20px; }
|
||||
.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 {
|
||||
display: flex; gap: 14px; align-items: center;
|
||||
padding: 24px 0 0;
|
||||
@ -232,6 +269,47 @@
|
||||
</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>80–99% 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 -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
@ -463,6 +541,19 @@
|
||||
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() {
|
||||
const sec = document.getElementById('sftpSection');
|
||||
const ico = document.getElementById('sftpToggleIcon');
|
||||
|
||||
@ -273,6 +273,21 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
{% if job.schedule_id %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user