From 474192b18679a4d89c4952f6d99181dd6fcf5a64 Mon Sep 17 00:00:00 2001 From: Rizqi Date: Sun, 21 Jun 2026 03:27:28 +0700 Subject: [PATCH] feat: implement vSphere backup manager web interface with job tracking and dashboard functionality --- vsphere_backup/README.md | 25 ++ .../__pycache__/backup_core.cpython-310.pyc | Bin 0 -> 9694 bytes .../__pycache__/gui_app.cpython-310.pyc | Bin 0 -> 11040 bytes .../vsphere_backup.cpython-310.pyc | Bin 0 -> 7742 bytes vsphere_backup/backup_core.py | 319 ++++++++++++++ vsphere_backup/gui_app.py | 412 ++++++++++++++++++ vsphere_backup/requirements.txt | 6 + vsphere_backup/templates/base.html | 332 ++++++++++++++ vsphere_backup/templates/create_job.html | 324 ++++++++++++++ vsphere_backup/templates/job_detail.html | 190 ++++++++ vsphere_backup/templates/jobs.html | 182 ++++++++ vsphere_backup/templates/login.html | 109 +++++ vsphere_backup/templates/vms.html | 245 +++++++++++ vsphere_backup/vsphere_backup.py | 237 ++++++++++ 14 files changed, 2381 insertions(+) create mode 100644 vsphere_backup/README.md create mode 100644 vsphere_backup/__pycache__/backup_core.cpython-310.pyc create mode 100644 vsphere_backup/__pycache__/gui_app.cpython-310.pyc create mode 100644 vsphere_backup/__pycache__/vsphere_backup.cpython-310.pyc create mode 100644 vsphere_backup/backup_core.py create mode 100644 vsphere_backup/gui_app.py create mode 100644 vsphere_backup/requirements.txt create mode 100644 vsphere_backup/templates/base.html create mode 100644 vsphere_backup/templates/create_job.html create mode 100644 vsphere_backup/templates/job_detail.html create mode 100644 vsphere_backup/templates/jobs.html create mode 100644 vsphere_backup/templates/login.html create mode 100644 vsphere_backup/templates/vms.html create mode 100644 vsphere_backup/vsphere_backup.py 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 0000000000000000000000000000000000000000..9b3a3183c5957dcb7989b3ee4eea54e1db31e086 GIT binary patch literal 9694 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d319e55853ddc5c230f8d159c11add53acfb8084 GIT binary patch literal 11040 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%0D+#Ssu z16vsV3CfrS-Hu(tuOTDI9S1=0j>l%c3eMn10!B;16Z8wT1TzvFp`Y{&%+rhzwiufw z#4dVfM_^)l2B=rh>YJ+eQ8E@iZhF9d8QesVn;~xdRdBdH7&?x5bh%ADkT~(e=@TcEOMLxAl{y0!sUNleFTT30K`Qn!iN146r8{V~Yvw8N>DoED64PQ;ViS%oMR z?7qRUA1SE)1uA<{La(NDc>U#E&BOX?noliT!wdj5}-;f}KG z#lg03Q|Uh;a)-!MG^hFoGKOeQX*?N&KotemcH&R)s{bE>yFt-l%SZxNCGmtGo1rIZ z@}o?ls29-IRzo!gJv`2Om~?aePkzkr4-%}Cw<%*GHozpK_wZDGG|D>=nUZO@6)sr&`oMdVLeJd z3ZCIZMr@vKXrfIa72Hz#GVi%ofuRR8fJ`z9umxekEP#^dt_4sqV%8xZ&Swz zMJ0_fk^E!?Laly?#~4-|Kf{Xg>=P44g2s|eW@Xy}37m2XX{7^EDdy`$D}>X^ytrlwv}&tiA~{OT!@1%m7T1 zo5b9pNc*rq2y&OU4TJ_kb$B3mkodm_!sj{y5pxkTHu82!Y(R1-aN;ZeYy^(Ev7SWK zJ%x=7r-GunT@$A=ka$zOYv@|jI;nXlS1>K~zllpG*R9?LH)(^@sb&el@-NU2bqb^; zg3%+>Ej}!UYu4`g_Nwcb9{dz}j!?6;fBfXpqe#ze8xjdK4a=j8+&>oP3<>ihxbch)wI{w_`DcTx1FRS8MG)gks_vjmjBpvfgd|JRV9+`C*?Z;$EeHU+8rs-ai>+D{;;|_MXHkvM zoZEK$!0K*xdsg%2g3gE1I?G$z!C$Ov|^cd=Da$kXaP83i5)Jw?5@ z$;wddU9;n0klW_dF)+>r$zg?}Oa7QKV<|Pw&QXx2?nn%)3U zYI8fV>+qLU%pMSu{-{8n0u46q^y=1)P{cUZU!hbmzHK8^D~PPtC;M7un*@<69Bhj3 z<6+xC2fLexCpKVOLZK#nLrnxJI%*2RWA8f+2%A_v+!uKIm`alE5{Mqr)EGFhF_&S3 z;wPG@CLdMu@hBaxCS_8l7<#90>;#}L(=qfeWqRA>MRbqn3)6O$(ytJC6(o4(ttY-m zvEyOW_b7}3Sw*OCq4bkdDZzCz3g3_{yBt-$NG)&2L|C7JJoaE?8K=@Vm1M%}GZepd zfVsX-3q)on7Dx{D0F@x-ju5ORoX>WX6V*a379@6k6iPkeqr%fxz%R3xlBGzh+s zU@f9%z`~52$BhpXa5$2{K}QFtA~?vOt37T^qv6dLmGJ=%j)Hzjy(DD<7yNHz|F zQK>b{z zf!%Pjv6lmrsa!t2Wzgyz#Ftooh(9$*c~ew6{zgg-(<}FEE!R7`@TRKxWQq?3oM)bz z-v_aLT%i6C4Mo|j4Vs-x6hny;ZG0o;(?=H+rlD!2fG4KNz$a=^%5B0$!W#(p0RFnb z^nRJougkPDDCj^_d#(C1EheADIoA1eGne>?ngl|9g$Tm}lfDRvhRQfOg1)IxT+}O< z70I^xBO-^0JV%6(V0>O>q5A_ndxA6cfUT)5N0_ zbW_hfLToe%a4xu9l441;rBFYEYgpKskF9IiQWm)5xGaIXR*`5i&*_UyvrJP=b3_Yq NdKk__VoWz5{V$vv2toh= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..34bf2a51f3d3bbbd51d5cdd6258f4bad59b86ae9 GIT binary patch literal 7742 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[^\]]+)\]\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()