diff --git a/README.md b/README.md index d015d52..380ebba 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,115 @@ python vsphere_backup.py --host vc.example.com --user administrator@vsphere.loca --- +## Manual Restore & Clone + +Backups are stored in **native VMware format** (VMDK + VMX), so they can be restored directly to vCenter/ESXi without any conversion. + +### Backup File Structure + +``` +backups//backup-YYYYMMDDHHMMSS/ +├── manifest.json ← SHA-256 checksums + metadata +├── .vmx ← VM configuration (CPU, RAM, network, etc.) +└── / + └── / + ├── .vmdk ← Disk descriptor (~500 bytes, plain text) + └── -flat.vmdk ← Actual disk data (full size) +``` + +With compression enabled, files are stored as `.vmdk.zst` / `-flat.vmdk.zst`. + +### Restoring a VM (In-Place) + +#### Step 1 — Decompress (if compressed) + +```bash +zstd -d .vmdk.zst +zstd -d -flat.vmdk.zst +``` + +#### Step 2 — Verify Checksum + +```bash +# Compare the output with the value in manifest.json +sha256sum -flat.vmdk +``` + +#### Step 3 — Upload to Datastore + +**Option A — vSphere Web Client** (easiest) + +1. Navigate to **Storage** → select the target datastore +2. Create or navigate to the VM folder +3. Upload the `.vmx`, `.vmdk`, and `-flat.vmdk` files + +**Option B — SCP to ESXi host** + +```bash +# Enable SSH on the ESXi host first, then: +scp -r ./backup-20260623020000/// \ + root@esxi-host:/vmfs/volumes/// +``` + +**Option C — PowerCLI** + +```powershell +# Copy files to ESXi datastore via datastore browser +Copy-DatastoreItem -Item ".\*.vmdk" -Destination "[datastore1] /" +``` + +#### Step 4 — Register the VM + +Right-click the `.vmx` file in the datastore browser → **Register VM**, or use PowerCLI: + +```powershell +New-VM -VMFilePath "[datastore1] /.vmx" -VMHost "esxi-host" +``` + +#### Step 5 — Power On + +```powershell +Start-VM "" +``` + +### Cloning from Backup (New VM) + +To restore a backup as a **separate new VM** without affecting the original: + +1. Upload files to a **new folder** on the datastore (e.g. `-clone/`) + +2. Edit the `.vmx` file — change these lines to avoid UUID/MAC conflicts: + + ``` + displayName = "-clone" + uuid.bios = "generate a new UUID" + ethernet0.generateAddress = "00:0c:29:xx:xx:xx" + ``` + +3. Remove any snapshot references if present: + + ``` + # Delete or comment out lines starting with: + snapshot.redoNotWithParent = + ``` + +4. Register and power on: + + ```powershell + New-VM -VMFilePath "[datastore1] -clone/.vmx" + Start-VM "-clone" + ``` + +### Best Practices + +- **Keep a copy** — never restore over your only backup copy +- **Test restore quarterly** — verify backups actually work before you need them +- **Isolated network first** — always boot cloned VMs on an isolated port group to check for IP conflicts before connecting to production +- **CBT resets on clone** — the first backup of a cloned VM will be a full backup (CBT state does not carry over) +- **Snapshot cleanup** — if the backup was taken with snapshots still active, remove orphaned snapshots after restore + +--- + ## Safety & Architecture 1. **Snapshot Isolation**: The backup engine creates a temporary snapshot on the target VM, downloads the locked base files (such as `.vmdk` descriptors, `-flat.vmdk` disk data, and `.vmx` configurations) directly from the Datastore HTTP gateway, and deletes the snapshot immediately afterwards. diff --git a/gui_app.py b/gui_app.py index 56f6973..f0a5b10 100644 --- a/gui_app.py +++ b/gui_app.py @@ -207,6 +207,37 @@ def register_scheduler_job(info): day=day_val, hour=int(hour), minute=int(minute) ) + elif schedule_type == '3_monthly': + hour, minute = (schedule_time.split(':') + ['00'])[:2] + day_val = monthly_day + if str(day_val).isdigit(): + day_val = max(1, min(28, int(day_val))) + trigger = CronTrigger( + month='*/3', + day=day_val, + hour=int(hour), minute=int(minute) + ) + elif schedule_type == '6_monthly': + hour, minute = (schedule_time.split(':') + ['00'])[:2] + day_val = monthly_day + if str(day_val).isdigit(): + day_val = max(1, min(28, int(day_val))) + trigger = CronTrigger( + month='*/6', + day=day_val, + hour=int(hour), minute=int(minute) + ) + elif schedule_type == 'yearly': + hour, minute = (schedule_time.split(':') + ['00'])[:2] + day_val = monthly_day + if str(day_val).isdigit(): + day_val = max(1, min(28, int(day_val))) + yearly_month = info.get('yearly_month', '1') + trigger = CronTrigger( + month=int(yearly_month) if str(yearly_month).isdigit() else 1, + day=day_val, + hour=int(hour), minute=int(minute) + ) elif schedule_type == 'interval': trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24))) @@ -432,6 +463,7 @@ def job_to_display(jid, info): 'retention_type': info.get('retention_type', 'keep_all'), 'retention_value': info.get('retention_value', 5), 'monthly_day': info.get('monthly_day'), + 'yearly_month': info.get('yearly_month'), 'weekly_day': info.get('weekly_day'), 'vm_names': vm_names, 'use_cbt': info.get('use_cbt', False), @@ -708,7 +740,7 @@ def create_and_start_job( vm_name, dest, compress, no_verify_ssl, sftp_host, sftp_user, sftp_password, schedule_type, schedule_time, weekly_day, interval_hours, - label='', disk_filter=None, monthly_day=1, + label='', disk_filter=None, monthly_day=1, yearly_month=1, retention_type='keep_all', retention_value=5, vm_names=None, disk_filter_map=None, use_cbt=False ): @@ -744,6 +776,7 @@ def create_and_start_job( 'disk_filter': disk_filter, # None = back up all disks 'weekly_day': weekly_day, 'monthly_day': monthly_day, + 'yearly_month': yearly_month, 'interval_hours': interval_hours, 'retention_type': retention_type, 'retention_value': retention_value, @@ -886,6 +919,7 @@ def create_job(): monthly_day = request.form.get('monthly_day', '1') monthly_time = request.form.get('monthly_time_1', '02:00') + yearly_month = request.form.get('yearly_month', '1') interval_hrs = request.form.get('interval_hours', '24') label = request.form.get('job_label', '').strip() @@ -898,7 +932,7 @@ def create_job(): sched_time = daily_time elif schedule_type == 'weekly': sched_time = weekly_time - elif schedule_type == 'monthly': + elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'): sched_time = monthly_time else: sched_time = '' @@ -934,6 +968,7 @@ def create_job(): label=label, disk_filter=disk_filter, monthly_day=monthly_day, + yearly_month=yearly_month, retention_type=retention_type, retention_value=retention_value, use_cbt=use_cbt, @@ -955,11 +990,13 @@ def create_job(): # Sort alphabetically for the dropdown vm_list = sorted(vm_list, key=lambda v: v['name'].lower()) + from datetime import datetime return render_template( 'create_job.html', vms=vm_list, selected_vm=selected_vm, show_schedule=show_schedule, + current_month=datetime.now().month, ) @@ -995,6 +1032,7 @@ def batch_jobs(): monthly_day = request.form.get('monthly_day', '1') monthly_time = request.form.get('monthly_time_1', '02:00') + yearly_month = request.form.get('yearly_month', '1') interval_hrs = request.form.get('interval_hours', '24') label_prefix = request.form.get('job_label', '').strip() @@ -1002,7 +1040,7 @@ def batch_jobs(): sched_time = daily_time elif schedule_type == 'weekly': sched_time = weekly_time - elif schedule_type == 'monthly': + elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'): sched_time = monthly_time else: sched_time = '' @@ -1048,6 +1086,7 @@ def batch_jobs(): label=label, disk_filter=None, monthly_day=monthly_day, + yearly_month=yearly_month, retention_type=retention_type, retention_value=retention_value, vm_names=vm_names, @@ -1066,10 +1105,12 @@ def batch_jobs(): flash('No VMs specified for batch backup.', 'danger') return redirect(url_for('vms')) + from datetime import datetime return render_template( 'batch_job.html', vm_names=vm_names, vms_by_name=vms_by_name, + current_month=datetime.now().month, ) @@ -1279,6 +1320,7 @@ def edit_job(jobid): monthly_day = request.form.get('monthly_day', '1') monthly_time = request.form.get('monthly_time_1', '02:00') + yearly_month = request.form.get('yearly_month', '1') interval_hrs = request.form.get('interval_hours', '24') label = request.form.get('job_label', '').strip() @@ -1286,7 +1328,7 @@ def edit_job(jobid): sched_time = daily_time elif schedule_type == 'weekly': sched_time = weekly_time - elif schedule_type == 'monthly': + elif schedule_type in ('monthly', '3_monthly', '6_monthly', 'yearly'): sched_time = monthly_time else: sched_time = '' @@ -1319,6 +1361,7 @@ def edit_job(jobid): info['schedule_time'] = sched_time info['weekly_day'] = weekly_day info['monthly_day'] = monthly_day + info['yearly_month'] = yearly_month info['interval_hours'] = interval_hrs # Register new schedule if applicable @@ -1338,7 +1381,8 @@ def edit_job(jobid): # GET: Display edit form job_disp = job_to_display(jobid, info) - return render_template('edit_job.html', job=job_disp, raw_job=info) + from datetime import datetime + return render_template('edit_job.html', job=job_disp, raw_job=info, current_month=datetime.now().month) # ── Template filter ─────────────────────────────────────────────────────────── diff --git a/templates/batch_job.html b/templates/batch_job.html index d03e079..b8fc0c5 100644 --- a/templates/batch_job.html +++ b/templates/batch_job.html @@ -78,7 +78,7 @@ .strategy-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; } .schedule-options { - display: grid; grid-template-columns: repeat(2, 1fr); + display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; margin-bottom: 16px; } .schedule-opt { @@ -366,7 +366,7 @@ Schedule
-
+
+ + +
+
@@ -539,17 +581,34 @@ {% block scripts %}