Clonezilla back up / restore by machine number

Boot menu: "Clonezilla: back up this PC" and "restore this PC" ask for the
machine number and boot Clonezilla (64-bit with Debian shim, or 32-bit
i686 by cpuid) with ocs_repository=smb3://clonezilla:<pw>@server/clonezilla/,
ocs_prerun fetching clonezilla-scripts/pxe-cz.sh and ocs_live_run running
it. The script validates the machine number, saves
<machine>-<YYYY-MM-DD-HHMM> (server time), writes pxe-meta.txt, runs
ocs-chkimg, and restores only that machine's images with -k1 -r after a
typed YES. The old entry is "Clonezilla: manual".

Playbook: clonezilla Samba user for the [clonezilla] share only, password
from clonezilla_smb_password (validated, filled into the menu
placeholder), scripts installed to the web root.

Webapp: Clonezilla backups page lists image folders and zips grouped by
machine number with serial/model/version/check, serial mismatch flag,
delete, move to another machine, streamed tar download, disk space bar
and an explicit keep-last-N retention with preview. Tests and a VM test
plan added.
This commit is contained in:
cproudlock
2026-10-15 15:26:30 -04:00
parent dde1aacccb
commit 9d56a5369e
11 changed files with 1991 additions and 67 deletions

View File

@@ -44,6 +44,7 @@ from werkzeug.utils import secure_filename
import config
from services import (
blancco_evidence,
clonezilla_images,
hardware_coverage,
blancco_report,
dell_updates,
@@ -551,28 +552,110 @@ def images_rename(image_type):
# Routes - Clonezilla Backups
# ---------------------------------------------------------------------------
@app.route("/backups")
def clonezilla_backups():
backups = []
if os.path.isdir(config.CLONEZILLA_SHARE):
for f in sorted(os.listdir(config.CLONEZILLA_SHARE)):
fpath = os.path.join(config.CLONEZILLA_SHARE, f)
if os.path.isfile(fpath) and f.lower().endswith(".zip"):
stat = os.stat(fpath)
backups.append({
"filename": f,
"machine": os.path.splitext(f)[0],
"size": stat.st_size,
"modified": stat.st_mtime,
})
def _backups_page(plan=None, plan_keep=None):
images = clonezilla_images.list_images()
zips = clonezilla_images.list_zips()
groups = clonezilla_images.grouped(images, zips)
st = clonezilla_images.settings()
return render_template(
"backups.html",
backups=backups,
groups=groups,
backups=zips,
image_count=len(images),
mismatches=sum(1 for g in groups if g["mismatch"]),
space=clonezilla_images.disk_space(st["warn_percent"]),
cz_settings=st,
plan=plan,
plan_keep=plan_keep,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
@app.route("/backups")
def clonezilla_backups():
"""Clonezilla image directories and machine-number zips on the share,
grouped by machine number (services/clonezilla_images.py)."""
return _backups_page()
@app.route("/backups/image/<name>/delete", methods=["POST"])
def clonezilla_image_delete(name):
try:
clonezilla_images.delete_image(name)
audit("BACKUP_IMAGE_DELETE", name)
flash(f"Deleted image {name}.", "success")
except (clonezilla_images.ImageError, OSError) as ex:
flash(str(ex), "danger")
return redirect(url_for("clonezilla_backups"))
@app.route("/backups/image/<name>/move", methods=["POST"])
def clonezilla_image_move(name):
try:
new = clonezilla_images.move_image(name, request.form.get("machine", ""))
audit("BACKUP_IMAGE_MOVE", f"{name} -> {new}")
flash(f"Moved {name} to machine {clonezilla_images.norm_machine(request.form.get('machine'))}: now {new}.", "success")
except (clonezilla_images.ImageError, OSError) as ex:
flash(str(ex), "danger")
return redirect(url_for("clonezilla_backups"))
@app.route("/backups/image/<name>/tar")
def clonezilla_image_tar(name):
"""The image directory as a tar, streamed file by file (no temp copy)."""
try:
gen = clonezilla_images.tar_stream(name)
except clonezilla_images.ImageError as ex:
flash(str(ex), "danger")
return redirect(url_for("clonezilla_backups"))
audit("BACKUP_IMAGE_DOWNLOAD", name)
resp = Response(stream_with_context(gen), mimetype="application/x-tar")
resp.headers["Content-Disposition"] = f'attachment; filename="{secure_filename(name) or "image"}.tar"'
resp.headers["X-Accel-Buffering"] = "no"
return resp
@app.route("/backups/settings", methods=["POST"])
def clonezilla_settings():
try:
st = clonezilla_images.save_settings(request.form.get("keep_last", ""), request.form.get("warn_percent", ""))
audit("BACKUP_SETTINGS", f"keep_last={st['keep_last']} warn_percent={st['warn_percent']}")
flash("Clonezilla backup settings saved." + ("" if not st["keep_last"] else
" Nothing is deleted until you press Apply retention and confirm."), "success")
except clonezilla_images.ImageError as ex:
flash(str(ex), "danger")
return redirect(url_for("clonezilla_backups"))
@app.route("/backups/retention")
def clonezilla_retention_preview():
"""What 'keep last N per machine' would delete. Shows only; the apply
is a separate POST of exactly these names."""
keep = clonezilla_images.settings()["keep_last"]
if not keep:
flash("Set Keep last N images per machine first.", "warning")
return redirect(url_for("clonezilla_backups"))
return _backups_page(plan=clonezilla_images.retention_plan(keep), plan_keep=keep)
@app.route("/backups/retention/apply", methods=["POST"])
def clonezilla_retention_apply():
keep = clonezilla_images.settings()["keep_last"]
names = request.form.getlist("name")
if not keep or not names:
flash("Nothing to delete.", "warning")
return redirect(url_for("clonezilla_backups"))
deleted, skipped = clonezilla_images.apply_retention(names, keep)
for n in deleted:
audit("BACKUP_RETENTION_DELETE", f"{n} (keep last {keep})")
msg = f"Retention (keep last {keep}): deleted {len(deleted)} image{'s' if len(deleted) != 1 else ''}."
if skipped:
msg += f" Kept {len(skipped)} that are no longer due for deletion: {', '.join(skipped[:5])}{'...' if len(skipped) > 5 else ''}."
flash(msg, "success" if not skipped else "warning")
return redirect(url_for("clonezilla_backups"))
@app.route("/backups/upload", methods=["POST"])
def clonezilla_upload():
if "backup_file" not in request.files: