commit 474192b18679a4d89c4952f6d99181dd6fcf5a64 Author: Rizqi Date: Sun Jun 21 03:27:28 2026 +0700 feat: implement vSphere backup manager web interface with job tracking and dashboard functionality diff --git a/vsphere_backup/README.md b/vsphere_backup/README.md new file mode 100644 index 0000000..b9b3df5 --- /dev/null +++ b/vsphere_backup/README.md @@ -0,0 +1,25 @@ +# vSphere Snapshot Backup Tool + +Simple CLI to automate the snapshot -> copy -> compress -> delete workflow for a VM on vCenter/ESXi. + +Requirements +- Python 3.8+ +- See `requirements.txt` (pyvmomi, requests, paramiko, zstandard) + +Basic usage + +```bash +python vsphere_backup.py --host vc.example.local --user administrator@vsphere.local --vm MyVM --dest /backups/MyVM --compress +``` + +Optional SFTP upload + +```bash +python vsphere_backup.py --host vc.example.local --user admin --vm MyVM --dest /tmp/backups --sftp-host backup.example.com --sftp-user backup --sftp-password secret +``` + +Notes & caveats +- The script creates a snapshot on the VM and downloads the VM's `.vmdk` and `.vmx` files from the datastore while the snapshot exists — do NOT copy `.vmdk` without snapshot. +- The script attempts to use `zstd -19` if available; otherwise it falls back to Python `zstandard`. +- SSL verification is disabled with `--no-verify-ssl` for convenience with self-signed vCenter/ESXi certs. +- Test carefully in dev before using in production. This is a minimal DIY backup tool and does not replace a full backup product. diff --git a/vsphere_backup/__pycache__/backup_core.cpython-310.pyc b/vsphere_backup/__pycache__/backup_core.cpython-310.pyc new file mode 100644 index 0000000..9b3a318 Binary files /dev/null and b/vsphere_backup/__pycache__/backup_core.cpython-310.pyc differ diff --git a/vsphere_backup/__pycache__/gui_app.cpython-310.pyc b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc new file mode 100644 index 0000000..d319e55 Binary files /dev/null and b/vsphere_backup/__pycache__/gui_app.cpython-310.pyc differ diff --git a/vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc b/vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc new file mode 100644 index 0000000..34bf2a5 Binary files /dev/null and b/vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc differ diff --git a/vsphere_backup/backup_core.py b/vsphere_backup/backup_core.py new file mode 100644 index 0000000..2e2c530 --- /dev/null +++ b/vsphere_backup/backup_core.py @@ -0,0 +1,319 @@ +import atexit +import getpass +import os +import re +import ssl +import sys +import time +import urllib.parse +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path + +import requests +from pyVim.connect import SmartConnect, Disconnect +from pyVmomi import vim + +try: + import paramiko +except Exception: + paramiko = None + + +def get_si(host, user, pwd, no_verify_ssl=False): + context = None + if no_verify_ssl: + context = ssl._create_unverified_context() + si = SmartConnect(host=host, user=user, pwd=pwd, sslContext=context) + # Caller is responsible for disconnect via Disconnect(si) + return si + + +def list_vms(host, user, password, no_verify_ssl=False): + """Connect to vCenter/ESXi and return a list of VM info dicts.""" + si = None + try: + si = get_si(host, user, password, no_verify_ssl=no_verify_ssl) + content = si.RetrieveContent() + obj_view = content.viewManager.CreateContainerView( + content.rootFolder, [vim.VirtualMachine], True + ) + vms = [] + for vm in obj_view.view: + try: + summary = vm.summary + config = summary.config + runtime = summary.runtime + guest = summary.guest + storage = summary.storage + + # Power state + power_map = { + vim.VirtualMachinePowerState.poweredOn: 'poweredOn', + vim.VirtualMachinePowerState.poweredOff: 'poweredOff', + vim.VirtualMachinePowerState.suspended: 'suspended', + } + power_state = power_map.get(runtime.powerState, str(runtime.powerState)) + + # Datastore names + ds_names = [] + try: + for ds in vm.datastore: + ds_names.append(ds.info.name) + except Exception: + pass + + vms.append({ + 'name': config.name, + 'power_state': power_state, + 'num_cpu': config.numCpu, + 'memory_mb': config.memorySizeMB, + 'guest_os': config.guestFullName or config.guestId or 'Unknown', + 'ip_address': (guest.ipAddress or '') if guest else '', + 'datastores': ds_names, + 'committed_gb': round((storage.committed or 0) / (1024 ** 3), 2), + 'tools_status': (guest.toolsStatus or 'unknown') if guest else 'unknown', + }) + except Exception as e: + vms.append({'name': getattr(vm, 'name', '?'), 'error': str(e), + 'power_state': 'unknown', 'num_cpu': 0, + 'memory_mb': 0, 'guest_os': '', 'ip_address': '', + 'datastores': [], 'committed_gb': 0, 'tools_status': 'unknown'}) + obj_view.Destroy() + return vms + finally: + if si: + try: + Disconnect(si) + except Exception: + pass + + +def wait_for_task(task, action_name='job'): + while task.info.state == vim.TaskInfo.State.running: + time.sleep(1) + if task.info.state == vim.TaskInfo.State.success: + return task.info.result + else: + raise Exception(f"{action_name} did not complete successfully: {task.info.error}") + + +def create_snapshot(vm, snap_name, desc="backup snapshot", memory=False, quiesce=False): + print(f"Creating snapshot '{snap_name}'") + task = vm.CreateSnapshot_Task(name=snap_name, description=desc, memory=memory, quiesce=quiesce) + wait_for_task(task, 'CreateSnapshot') + print("Snapshot created") + + +def find_datacenter_for_datastore(content, datastore_name): + for dc in content.rootFolder.childEntity: + if isinstance(dc, vim.Datacenter): + for ds in dc.datastore: + if ds.info.name == datastore_name: + return dc + return None + + +def download_datastore_file(host, dc_name, datastore_name, ds_path, local_path, session_cookie, verify_ssl=True): + encoded_path = urllib.parse.quote(ds_path, safe='') + url = f"https://{host}/folder/{encoded_path}?dcPath={urllib.parse.quote(dc_name)}&dsName={urllib.parse.quote(datastore_name)}" + headers = {"Cookie": f"vmware_soap_session={session_cookie}"} + print(f"Downloading {ds_path} from datastore {datastore_name} to {local_path}") + with requests.get(url, headers=headers, stream=True, verify=verify_ssl) as r: + r.raise_for_status() + os.makedirs(os.path.dirname(local_path), exist_ok=True) + with open(local_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=10 * 1024 * 1024): + if chunk: + f.write(chunk) + print("Download completed") + + +def extract_session_cookie(si): + raw = getattr(si._stub, 'cookie', '') + m = re.search(r"vmware_soap_session\s*=\s*\"?([A-Za-z0-9\-_]+)\"?", raw) + if m: + return m.group(1) + return None + + +def vm_disk_vmdk_paths(vm): + files = set() + for dev in vm.config.hardware.device: + if isinstance(dev, vim.vm.device.VirtualDisk): + backing = dev.backing + fn = getattr(backing, 'fileName', None) + if fn: + files.add(fn) + return list(files) + + +def vm_config_vmx_path(vm): + return getattr(vm.config.files, 'vmPathName', None) + + +def parse_datastore_path(ds_file_ref): + m = re.match(r"\[(?P[^\]]+)\]\s*(?P.+)", ds_file_ref) + if not m: + raise ValueError(f"Unexpected datastore file format: {ds_file_ref}") + return m.group('ds'), m.group('path') + + +def find_snapshot_by_name(snapshots, name): + for snap in snapshots: + if snap.name == name: + return snap.snapshot + if snap.childSnapshotList: + found = find_snapshot_by_name(snap.childSnapshotList, name) + if found: + return found + return None + + +def remove_snapshot(snapshot_obj): + print("Removing snapshot") + task = snapshot_obj.RemoveSnapshot_Task(removeChildren=False) + wait_for_task(task, 'RemoveSnapshot') + print("Snapshot removed") + + +def maybe_compress(path): + try: + import subprocess + rc = subprocess.run(['zstd', '-19', path], check=False) + if rc.returncode == 0: + return path + '.zst' + except FileNotFoundError: + pass + try: + import zstandard as zstd + out_path = path + '.zst' + with open(path, 'rb') as ifh, open(out_path, 'wb') as ofh: + cctx = zstd.ZstdCompressor(level=19) + cctx.copy_stream(ifh, ofh) + return out_path + except Exception: + print('Compression not available; skipping') + return path + + +def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir): + if paramiko is None: + raise RuntimeError("paramiko is required for SFTP upload") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if key_filename: + client.connect(hostname=host, username=user, key_filename=key_filename) + else: + client.connect(hostname=host, username=user, password=password) + sftp = client.open_sftp() + try: + try: + sftp.chdir(remote_dir) + except IOError: + sftp.mkdir(remote_dir) + sftp.chdir(remote_dir) + fname = os.path.basename(local_path) + print(f"Uploading {local_path} to {host}:{remote_dir}/{fname}") + sftp.put(local_path, fname) + finally: + sftp.close() + client.close() + + +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): + """Run full backup flow. If log_path is provided, stdout/stderr will be redirected there.""" + if log_path: + logfile = open(log_path, 'ab') + # use binary logfile; redirect prints into it + def _wrap(): + with redirect_stdout(logfile), redirect_stderr(logfile): + return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, + sftp_host, sftp_user, sftp_password, sftp_key) + try: + return _wrap() + finally: + logfile.close() + else: + return _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, + sftp_host, sftp_user, sftp_password, sftp_key) + + +def _run_backup_impl(host, user, password, vm_name, dest, compress, no_verify_ssl, + sftp_host, sftp_user, sftp_password, sftp_key): + si = None + try: + si = get_si(host, user, password, no_verify_ssl=no_verify_ssl) + content = si.RetrieveContent() + # find vm + obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True) + vm = None + for v in obj_view.view: + if v.name == vm_name: + vm = v + break + obj_view.Destroy() + if not vm: + raise Exception(f"VM named {vm_name} not found") + + snap_name = f"backup-{int(time.time())}" + created_snapshot = False + try: + create_snapshot(vm, snap_name, desc="Automated backup snapshot", memory=False, quiesce=False) + created_snapshot = True + + session_cookie = extract_session_cookie(si) + if not session_cookie: + raise Exception('Could not extract session cookie for downloads') + + vmdk_refs = vm_disk_vmdk_paths(vm) + vmx_ref = vm_config_vmx_path(vm) + all_refs = vmdk_refs[:] + if vmx_ref: + all_refs.append(vmx_ref) + + downloaded_files = [] + for ref in all_refs: + ds_name, ds_path = parse_datastore_path(ref) + dc = find_datacenter_for_datastore(content, ds_name) + if not dc: + raise Exception(f"Datacenter for datastore {ds_name} not found") + dc_name = dc.name + safe_path = ds_path.replace('/', os.sep) + local_file = os.path.join(dest, ds_name, safe_path) + download_datastore_file(host, dc_name, ds_name, ds_path, local_file, session_cookie, verify_ssl=not no_verify_ssl) + downloaded_files.append(local_file) + + final_files = [] + for f in downloaded_files: + if compress: + cf = maybe_compress(f) + final_files.append(cf) + else: + final_files.append(f) + + if sftp_host: + if not sftp_user: + raise Exception('SFTP user required') + for f in final_files: + upload_via_sftp(sftp_host, sftp_user, sftp_password, sftp_key, f, os.path.basename(dest)) + + print('Backup completed successfully') + finally: + if created_snapshot: + snap_root = getattr(vm, 'snapshot', None) + if snap_root and snap_root.rootSnapshotList: + snap_obj = find_snapshot_by_name(snap_root.rootSnapshotList, snap_name) + if snap_obj: + try: + remove_snapshot(snap_obj) + except Exception as e: + print(f'Failed to remove snapshot: {e}', file=sys.stderr) + else: + print('Snapshot object not found in tree; may have been removed already') + finally: + if si: + try: + Disconnect(si) + except Exception: + pass diff --git a/vsphere_backup/gui_app.py b/vsphere_backup/gui_app.py new file mode 100644 index 0000000..b9c3737 --- /dev/null +++ b/vsphere_backup/gui_app.py @@ -0,0 +1,412 @@ +""" +gui_app.py — vSphere Backup Manager +Flask web UI: login → VM browser → create jobs → schedule backups +""" +import os +import sys +import uuid +import threading +import time +import json +from datetime import datetime +from functools import wraps +from pathlib import Path + +from flask import ( + Flask, request, redirect, url_for, session, + flash, jsonify, abort, render_template +) + +from backup_core import run_backup, list_vms + +# ── APScheduler (optional graceful degradation) ────────────────────────────── +try: + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.triggers.cron import CronTrigger + from apscheduler.triggers.interval import IntervalTrigger + HAS_SCHEDULER = True +except ImportError: + HAS_SCHEDULER = False + print("WARNING: APScheduler not installed — recurring schedules disabled. " + "Install with: pip install APScheduler", file=sys.stderr) + +# ── App setup ───────────────────────────────────────────────────────────────── +app = Flask(__name__) +app.secret_key = os.environ.get('SECRET_KEY', 'vsphere-backup-dev-key-change-me') + +BASE_DIR = Path(__file__).resolve().parent +JOBS_DIR = BASE_DIR / 'jobs' +JOBS_DIR.mkdir(exist_ok=True) + +# In-memory job store: {job_id: job_dict} +# job_dict keys: id, label, vm_name, status, started, dest, compress, +# no_verify_ssl, sftp_host, sftp_user, sftp_password, +# log, schedule_type, schedule_time, schedule_id +jobs: dict = {} + +# APScheduler instance +scheduler = None +if HAS_SCHEDULER: + scheduler = BackgroundScheduler(daemon=True) + scheduler.start() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('host'): + flash('Please log in first.', 'info') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + + +def fmt_time(ts): + return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') if ts else '—' + + +def job_to_display(jid, info): + """Convert internal job dict to template-friendly dict.""" + return { + 'id': jid, + 'label': info.get('label', ''), + 'vm_name': info.get('vm_name', '—'), + 'status': info.get('status', 'unknown'), + 'started_fmt': fmt_time(info.get('started')), + 'dest': info.get('dest', ''), + 'compress': info.get('compress', False), + 'sftp_host': info.get('sftp_host', ''), + 'schedule_type': info.get('schedule_type', 'now'), + 'schedule_time': info.get('schedule_time', ''), + 'schedule_id': info.get('schedule_id'), + } + + +def run_job_thread(jid): + """Worker executed in a thread (and by APScheduler).""" + info = jobs.get(jid) + if not info: + return + info['status'] = 'running' + info['started'] = time.time() + log_path = str(JOBS_DIR / jid / 'backup.log') + try: + run_backup( + host=info['host'], + user=info['user'], + password=info['password'], + vm_name=info['vm_name'], + dest=info['dest'], + compress=info.get('compress', False), + no_verify_ssl=info.get('no_verify_ssl', False), + sftp_host=info.get('sftp_host') or None, + sftp_user=info.get('sftp_user') or None, + sftp_password=info.get('sftp_password') or None, + sftp_key=None, + log_path=log_path, + ) + info['status'] = 'finished' + except Exception as e: + info['status'] = f'failed ({e})' + + +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='' +): + """Create a job entry and either run immediately or register schedule.""" + jid = datetime.now().strftime('%Y%m%d%H%M%S') + '-' + uuid.uuid4().hex[:6] + job_dir = JOBS_DIR / jid + job_dir.mkdir(parents=True, exist_ok=True) + + info = { + 'id': jid, + 'label': label, + 'host': session['host'], + 'user': session['user'], + 'password': session['password'], + 'vm_name': vm_name, + 'dest': dest, + 'compress': compress, + 'no_verify_ssl': no_verify_ssl, + 'sftp_host': sftp_host, + 'sftp_user': sftp_user, + 'sftp_password': sftp_password, + 'started': time.time(), + 'status': 'queued', + 'schedule_type': schedule_type, + 'schedule_time': schedule_time, + 'schedule_id': None, + } + jobs[jid] = info + + if schedule_type == 'now' or not HAS_SCHEDULER: + t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True) + t.start() + else: + # Build APScheduler trigger + trigger = None + if schedule_type == 'daily': + hour, minute = (schedule_time.split(':') + ['00'])[:2] + trigger = CronTrigger(hour=int(hour), minute=int(minute)) + elif schedule_type == 'weekly': + hour, minute = (schedule_time.split(':') + ['00'])[:2] + trigger = CronTrigger( + day_of_week=int(weekly_day), + hour=int(hour), minute=int(minute) + ) + elif schedule_type == 'interval': + trigger = IntervalTrigger(hours=max(1, int(interval_hours or 24))) + + if trigger: + # Capture jid in closure + def make_runner(j): + def _runner(): + run_job_thread(j) + return _runner + + sched_job = scheduler.add_job( + make_runner(jid), + trigger=trigger, + id=f'backup-{jid}', + name=f'Backup {vm_name} ({label or jid[:8]})', + misfire_grace_time=3600, + max_instances=1, + ) + info['schedule_id'] = sched_job.id + info['status'] = 'scheduled' + else: + # Fallback: run now + t = threading.Thread(target=run_job_thread, args=(jid,), daemon=True) + t.start() + + return jid + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + if session.get('host'): + return redirect(url_for('vms')) + return redirect(url_for('login')) + + +# ── Login / Logout ──────────────────────────────────────────────────────────── + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + host = request.form.get('host', '').strip() + user = request.form.get('user', '').strip() + password = request.form.get('password', '') + no_verify_ssl = 'no_verify_ssl' in request.form + + if not (host and user and password): + flash('Host, username and password are required.', 'danger') + return render_template('login.html') + + # Verify credentials by listing VMs + try: + list_vms(host, user, password, no_verify_ssl=no_verify_ssl) + except Exception as e: + flash(f'Connection failed: {e}', 'danger') + return render_template('login.html') + + session['host'] = host + session['user'] = user + session['password'] = password + session['no_verify_ssl'] = no_verify_ssl + flash(f'Connected to {host} successfully.', 'success') + return redirect(url_for('vms')) + + return render_template('login.html') + + +@app.route('/logout') +def logout(): + session.clear() + flash('Logged out.', 'info') + return redirect(url_for('login')) + + +# ── VM Browser ──────────────────────────────────────────────────────────────── + +@app.route('/vms') +@login_required +def vms(): + error = None + vm_list = [] + try: + vm_list = list_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + # Sort: powered on first, then alphabetical + order = {'poweredOn': 0, 'suspended': 1, 'poweredOff': 2} + vm_list.sort(key=lambda v: (order.get(v['power_state'], 3), v['name'].lower())) + except Exception as e: + error = str(e) + return render_template('vms.html', vms=vm_list, error=error) + + +@app.route('/api/vms') +@login_required +def api_vms(): + try: + vm_list = list_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + return jsonify(vm_list) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +# ── Create Job ──────────────────────────────────────────────────────────────── + +@app.route('/jobs/create', methods=['GET', 'POST']) +@login_required +def create_job(): + if request.method == 'POST': + vm_name = request.form.get('vm_name', '').strip() + dest = request.form.get('dest', './backups').strip() + compress = 'compress' in request.form + no_verify_ssl = 'no_verify_ssl' in request.form + sftp_host = request.form.get('sftp_host', '').strip() or None + sftp_user = request.form.get('sftp_user', '').strip() or None + sftp_password = request.form.get('sftp_password', '') or None + schedule_type = request.form.get('schedule_type', 'now') + daily_time = request.form.get('daily_time', '02:00') + weekly_day = request.form.get('weekly_day', '0') + weekly_time = request.form.get('weekly_time', '02:00') + interval_hrs = request.form.get('interval_hours', '24') + label = request.form.get('job_label', '').strip() + + if not vm_name: + flash('Please select a virtual machine.', 'danger') + return redirect(url_for('create_job')) + + # Determine schedule_time string for display + if schedule_type == 'daily': + sched_time = daily_time + elif schedule_type == 'weekly': + sched_time = weekly_time + else: + sched_time = '' + + jid = create_and_start_job( + vm_name=vm_name, + dest=dest, + compress=compress, + no_verify_ssl=no_verify_ssl, + sftp_host=sftp_host, + sftp_user=sftp_user, + sftp_password=sftp_password, + schedule_type=schedule_type, + schedule_time=sched_time, + weekly_day=weekly_day, + interval_hours=interval_hrs, + label=label, + ) + flash(f'Job created successfully!', 'success') + return redirect(url_for('job_detail', jobid=jid)) + + # GET: load VM list for the dropdown + selected_vm = request.args.get('vm', '') + show_schedule = bool(request.args.get('schedule', '')) + vm_list = [] + try: + vm_list = list_vms( + session['host'], session['user'], session['password'], + no_verify_ssl=session.get('no_verify_ssl', False) + ) + vm_list.sort(key=lambda v: v['name'].lower()) + except Exception as e: + flash(f'Could not load VM list: {e}', 'danger') + + return render_template( + 'create_job.html', + vms=vm_list, + selected_vm=selected_vm, + show_schedule=show_schedule, + ) + + +# ── Jobs Dashboard ──────────────────────────────────────────────────────────── + +@app.route('/jobs') +@login_required +def list_jobs(): + job_list = [ + job_to_display(jid, info) + for jid, info in sorted(jobs.items(), key=lambda x: x[1].get('started', 0), reverse=True) + ] + scheduled_count = sum(1 for j in job_list if j['schedule_id']) + return render_template('jobs.html', jobs=job_list, scheduled_count=scheduled_count) + + +# ── Job Detail ──────────────────────────────────────────────────────────────── + +@app.route('/job/') +@login_required +def job_detail(jobid): + info = jobs.get(jobid) + if not info: + abort(404) + return render_template('job_detail.html', job=job_to_display(jobid, info)) + + +@app.route('/job//log') +@login_required +def job_log(jobid): + info = jobs.get(jobid) + if not info: + abort(404) + log_path = JOBS_DIR / jobid / 'backup.log' + if not log_path.exists(): + return '(No log output yet)', 200 + with open(log_path, 'rb') as f: + lines = f.read().splitlines()[-300:] + return '\n'.join(line.decode('utf-8', errors='replace') for line in lines) + + +@app.route('/api/job//status') +@login_required +def api_job_status(jobid): + info = jobs.get(jobid) + if not info: + return jsonify({'error': 'not found'}), 404 + return jsonify({'status': info.get('status', 'unknown'), 'id': jobid}) + + +@app.route('/job//cancel-schedule', methods=['POST']) +@login_required +def cancel_schedule(jobid): + info = jobs.get(jobid) + if not info: + abort(404) + sched_id = info.get('schedule_id') + if sched_id and scheduler: + try: + scheduler.remove_job(sched_id) + except Exception: + pass + info['schedule_id'] = None + info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status'] + flash('Recurring schedule cancelled.', 'success') + return redirect(url_for('job_detail', jobid=jobid)) + + +# ── Template filter ─────────────────────────────────────────────────────────── +@app.template_filter('startswith') +def startswith_filter(value, prefix): + return str(value).startswith(prefix) + + +# ── Main ────────────────────────────────────────────────────────────────────── +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/vsphere_backup/requirements.txt b/vsphere_backup/requirements.txt new file mode 100644 index 0000000..19c0126 --- /dev/null +++ b/vsphere_backup/requirements.txt @@ -0,0 +1,6 @@ +pyvmomi>=8.0.0 +requests +paramiko +zstandard +APScheduler>=3.10 +Flask>=2.3 diff --git a/vsphere_backup/templates/base.html b/vsphere_backup/templates/base.html new file mode 100644 index 0000000..d3b5da3 --- /dev/null +++ b/vsphere_backup/templates/base.html @@ -0,0 +1,332 @@ + + + + + + {% block title %}vSphere Backup Manager{% endblock %} + + + + + {% block head %}{% endblock %} + + + {% if session.get('host') %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/vsphere_backup/templates/create_job.html b/vsphere_backup/templates/create_job.html new file mode 100644 index 0000000..374fbbf --- /dev/null +++ b/vsphere_backup/templates/create_job.html @@ -0,0 +1,324 @@ +{% extends "base.html" %} +{% set active_page = 'create_job' %} +{% block title %}Create Backup Job — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
Create Backup Job
+
Configure a new backup job for a virtual machine
+
+
+ +
+
+ +
+
+
+
Connected
+
+
+
2
+
Configure Job
+
+
+
3
+
Review & Run
+
+
+ +
+ + +
+
🖥 Virtual Machine
+
+
+ + +
+
+
+ + +
+
📁 Destination
+
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
🕐 Schedule
+
+
+ + + + + + + + + +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+ + +
+
+
+ +
+ + Cancel +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/templates/job_detail.html b/vsphere_backup/templates/job_detail.html new file mode 100644 index 0000000..ee3fe28 --- /dev/null +++ b/vsphere_backup/templates/job_detail.html @@ -0,0 +1,190 @@ +{% extends "base.html" %} +{% set active_page = 'jobs' %} +{% block title %}Job {{ job.id[:8] }} — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+ {{ job.label or 'Backup Job' }} + {% if job.status == 'running' %} + ⏳ Running + {% elif job.status == 'finished' %} + ✓ Finished + {% elif job.status == 'queued' %} + ⏱ Queued + {% elif job.status.startswith('failed') %} + ✕ Failed + {% endif %} +
+
Job ID: {{ job.id }}
+
+ +
+ +
+ +
+
+
Virtual Machine
+
{{ job.vm_name }}
+
+
+
Status
+
{{ job.status }}
+
+
+
Schedule
+
+ {% if job.schedule_type and job.schedule_type != 'now' %} + 🔁 {{ job.schedule_type|capitalize }} + {% if job.schedule_time %}at {{ job.schedule_time }}{% endif %} + {% else %} + One-time (Run Now) + {% endif %} +
+
+
+
Started
+
{{ job.started_fmt }}
+
+
+
Destination
+
{{ job.dest or '—' }}
+
+
+
Options
+
+ {% if job.compress %}🗜 Compressed{% else %}Raw{% endif %} + {% if job.sftp_host %} · 📤 SFTP: {{ job.sftp_host }}{% endif %} +
+
+
+ + {% if job.schedule_id %} +
+ 🔁 This job has an active recurring schedule. Future backups will run automatically. +
+ +
+
+ {% endif %} + + +
+
+
📄 Backup Log
+
+ {% if job.status == 'running' %} + + Auto-refreshing… + {% endif %} + +
+
+
+      
Loading log…
+
+
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/templates/jobs.html b/vsphere_backup/templates/jobs.html new file mode 100644 index 0000000..c3cb904 --- /dev/null +++ b/vsphere_backup/templates/jobs.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% set active_page = 'jobs' %} +{% block title %}Backup Jobs — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
Backup Jobs
+
All scheduled and completed backup jobs
+
+ +
+ +
+ +
+
+
{{ jobs|length }}
+
Total
+
+
+
{{ jobs|selectattr('status','equalto','running')|list|length }}
+
Running
+
+
+
{{ jobs|selectattr('status','equalto','finished')|list|length }}
+
Finished
+
+
+
{{ jobs|selectattr('status','equalto','queued')|list|length }}
+
Queued
+
+
+
{% set fcnt = namespace(n=0) %}{% for j in jobs %}{% if j.status.startswith('failed') %}{% set fcnt.n = fcnt.n + 1 %}{% endif %}{% endfor %}{{ fcnt.n }}
+
Failed
+
+
+
{{ scheduled_count }}
+
Scheduled
+
+
+ + {% if jobs %} +
+ + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
JobVMStatusScheduleStartedActions
+
+ {{ job.label or ('Job #' + job.id[:8]) }} +
+
{{ job.id[:12] }}…
+
+ {{ job.vm_name }} + + {% if job.status == 'running' %} + ⏳ Running + {% elif job.status == 'finished' %} + ✓ Finished + {% elif job.status == 'queued' %} + ⏱ Queued + {% elif job.status.startswith('failed') %} + ✕ Failed + {% else %} + {{ job.status }} + {% endif %} + + {% if job.schedule_type and job.schedule_type != 'now' %} + 🔁 {{ job.schedule_type|capitalize }} + {% else %} + One-time + {% endif %} + + {{ job.started_fmt }} + +
+ View + {% if job.schedule_id %} +
+ +
+ {% endif %} +
+
+
+ + {% else %} +
+
📋
+

No backup jobs yet.

+ ➕ Create your first job +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/templates/login.html b/vsphere_backup/templates/login.html new file mode 100644 index 0000000..bfadd3c --- /dev/null +++ b/vsphere_backup/templates/login.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% block title %}Login — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/templates/vms.html b/vsphere_backup/templates/vms.html new file mode 100644 index 0000000..f51100d --- /dev/null +++ b/vsphere_backup/templates/vms.html @@ -0,0 +1,245 @@ +{% extends "base.html" %} +{% set active_page = 'vms' %} +{% block title %}Virtual Machines — vSphere Backup Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
Virtual Machines
+
{{ vms|length }} VM{% if vms|length != 1 %}s{% endif %} found on {{ session.get('host') }}
+
+ +
+ +
+ {% if error %} +
⚠ {{ error }}
+ {% endif %} + + +
+
+
{{ vms|length }}
+
Total VMs
+
+
+
{{ vms|selectattr('power_state','equalto','poweredOn')|list|length }}
+
Powered On
+
+
+
{{ vms|selectattr('power_state','equalto','poweredOff')|list|length }}
+
Powered Off
+
+
+
{{ vms|selectattr('power_state','equalto','suspended')|list|length }}
+
Suspended
+
+
+ + +
+ + + + + +
+ + +
+ {% for vm in vms %} +
+ +
+
+
{{ vm.name }}
+
{{ vm.guest_os or 'Unknown OS' }}
+
+ {% if vm.power_state == 'poweredOn' %} + On + {% elif vm.power_state == 'poweredOff' %} + Off + {% elif vm.power_state == 'suspended' %} + Suspended + {% else %} + {{ vm.power_state }} + {% endif %} +
+ +
+
+
CPUs
+
{{ vm.num_cpu }}
+
+
+
Memory
+
{{ (vm.memory_mb / 1024)|round(1) }} GB
+
+
+
Disk Used
+
{{ vm.committed_gb }} GB
+
+
+
IP Address
+
{{ vm.ip_address or '—' }}
+
+
+ + {% if vm.datastores %} +
+ 📦 {{ vm.datastores|join(', ') }} +
+ {% endif %} + + +
+ {% else %} +
+
🖥
+

No virtual machines found.

+
+ {% endfor %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/vsphere_backup/vsphere_backup.py b/vsphere_backup/vsphere_backup.py new file mode 100644 index 0000000..e3b28da --- /dev/null +++ b/vsphere_backup/vsphere_backup.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +vsphere_backup.py + +Automates a simple VMware vSphere VM backup using snapshots: +- create snapshot +- download VM disk files (.vmdk) and .vmx +- optional compression (zstd) locally +- optional upload to backup server via SFTP +- delete snapshot + +Requires: pyvmomi, requests, paramiko, zstandard (optional) +""" + +import argparse +import atexit +import getpass +import os +import re +import ssl +import sys +import time +import urllib.parse +from pathlib import Path + +import requests +from backup_core import run_backup +from pyVim.connect import SmartConnect, Disconnect +from pyVmomi import vim + +try: + import paramiko +except Exception: + paramiko = None + + +def parse_args(): + p = argparse.ArgumentParser(description="vSphere VM snapshot + download backup tool") + p.add_argument("--host", required=True, help="vCenter/ESXi host") + p.add_argument("--user", required=True, help="Username") + p.add_argument("--password", help="Password (prompted if omitted)") + p.add_argument("--vm", required=True, help="VM name to backup") + p.add_argument("--dest", required=True, help="Local destination directory for backups") + p.add_argument("--compress", action="store_true", help="Compress downloaded files with zstd -19 if available") + p.add_argument("--no-verify-ssl", action="store_true", help="Do not verify SSL certs") + p.add_argument("--sftp-host", help="Optional: upload files to SFTP host") + p.add_argument("--sftp-user", help="SFTP username") + p.add_argument("--sftp-password", help="SFTP password (or use key via --sftp-key)") + p.add_argument("--sftp-key", help="Path to private key for SFTP auth") + return p.parse_args() + + +def get_si(host, user, pwd, no_verify_ssl=False): + context = None + if no_verify_ssl: + context = ssl._create_unverified_context() + si = SmartConnect(host=host, user=user, pwd=pwd, sslContext=context) + atexit.register(Disconnect, si) + return si + + +def find_vm_by_name(content, vm_name): + obj_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True) + for vm in obj_view.view: + if vm.name == vm_name: + obj_view.Destroy() + return vm + obj_view.Destroy() + return None + + +def wait_for_task(task, action_name='job'): + while task.info.state == vim.TaskInfo.State.running: + time.sleep(1) + if task.info.state == vim.TaskInfo.State.success: + return task.info.result + else: + raise Exception(f"{action_name} did not complete successfully: {task.info.error}") + + +def create_snapshot(vm, snap_name, desc="backup snapshot", memory=False, quiesce=False): + print(f"Creating snapshot '{snap_name}'") + task = vm.CreateSnapshot_Task(name=snap_name, description=desc, memory=memory, quiesce=quiesce) + wait_for_task(task, 'CreateSnapshot') + print("Snapshot created") + + +def find_datacenter_for_datastore(content, datastore_name): + for dc in content.rootFolder.childEntity: + # childEntity can include folders; ensure it's a Datacenter + if isinstance(dc, vim.Datacenter): + for ds in dc.datastore: + if ds.info.name == datastore_name: + return dc + return None + + +def download_datastore_file(si, host, dc_name, datastore_name, ds_path, local_path, session_cookie, verify_ssl=True): + # ds_path is like "folder/file.vmdk" without leading slash + encoded_path = urllib.parse.quote(ds_path, safe='') + url = f"https://{host}/folder/{encoded_path}?dcPath={urllib.parse.quote(dc_name)}&dsName={urllib.parse.quote(datastore_name)}" + headers = {"Cookie": f"vmware_soap_session={session_cookie}"} + print(f"Downloading {ds_path} from datastore {datastore_name} to {local_path}") + with requests.get(url, headers=headers, stream=True, verify=verify_ssl) as r: + r.raise_for_status() + os.makedirs(os.path.dirname(local_path), exist_ok=True) + with open(local_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=10 * 1024 * 1024): + if chunk: + f.write(chunk) + print("Download completed") + + +def extract_session_cookie(si): + # si._stub.cookie looks like 'vmware_soap_session="xxx"; Path=/' + raw = getattr(si._stub, 'cookie', '') + m = re.search(r"vmware_soap_session\s*=\s*\"?([A-Za-z0-9\-_]+)\"?", raw) + if m: + return m.group(1) + return None + + +def vm_disk_vmdk_paths(vm): + files = set() + for dev in vm.config.hardware.device: + if isinstance(dev, vim.vm.device.VirtualDisk): + backing = dev.backing + fn = getattr(backing, 'fileName', None) + if fn: + files.add(fn) + return list(files) + + +def vm_config_vmx_path(vm): + # vm.config.files.vmPathName e.g. '[datastore1] vmfolder/vm.vmx' + return getattr(vm.config.files, 'vmPathName', None) + + +def parse_datastore_path(ds_file_ref): + # ds_file_ref like "[datastore1] vmfolder/vm.vmdk" + m = re.match(r"\[(?P[^\]]+)\]\s*(?P.+)", ds_file_ref) + if not m: + raise ValueError(f"Unexpected datastore file format: {ds_file_ref}") + return m.group('ds'), m.group('path') + + +def find_snapshot_by_name(snapshots, name): + for snap in snapshots: + if snap.name == name: + return snap.snapshot + if snap.childSnapshotList: + found = find_snapshot_by_name(snap.childSnapshotList, name) + if found: + return found + return None + + +def remove_snapshot(snapshot_obj): + print("Removing snapshot") + task = snapshot_obj.RemoveSnapshot_Task(removeChildren=False) + wait_for_task(task, 'RemoveSnapshot') + print("Snapshot removed") + + +def upload_via_sftp(host, user, password, key_filename, local_path, remote_dir): + if paramiko is None: + raise RuntimeError("paramiko is required for SFTP upload") + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + if key_filename: + client.connect(hostname=host, username=user, key_filename=key_filename) + else: + client.connect(hostname=host, username=user, password=password) + sftp = client.open_sftp() + try: + try: + sftp.chdir(remote_dir) + except IOError: + sftp.mkdir(remote_dir) + sftp.chdir(remote_dir) + fname = os.path.basename(local_path) + print(f"Uploading {local_path} to {host}:{remote_dir}/{fname}") + sftp.put(local_path, fname) + finally: + sftp.close() + client.close() + + +def maybe_compress(path): + # Try system zstd first + try: + import subprocess + rc = subprocess.run(['zstd', '-19', path], check=False) + if rc.returncode == 0: + return path + '.zst' + except FileNotFoundError: + pass + # fallback to python zstandard + try: + import zstandard as zstd + out_path = path + '.zst' + with open(path, 'rb') as ifh, open(out_path, 'wb') as ofh: + cctx = zstd.ZstdCompressor(level=19) + cctx.copy_stream(ifh, ofh) + return out_path + except Exception: + print('Compression not available; skipping') + return path + + +def main(): + args = parse_args() + password = args.password or getpass.getpass('Password: ') + dest = os.path.abspath(args.dest) + os.makedirs(dest, exist_ok=True) + # Delegate to backup_core.run_backup which handles logging when called by GUI + try: + run_backup( + args.host, + args.user, + password, + args.vm, + dest, + compress=args.compress, + no_verify_ssl=args.no_verify_ssl, + sftp_host=args.sftp_host, + sftp_user=args.sftp_user, + sftp_password=args.sftp_password, + sftp_key=args.sftp_key, + ) + except Exception as e: + print(f'Backup failed: {e}', file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main()