Enhance eMMC provisioning dashboard: add backup metadata management, implement backup renaming and setting golden image functionality, and improve UI for backup actions and descriptions.
This commit is contained in:
@@ -7,6 +7,7 @@ Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register,
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -86,15 +87,55 @@ def _save_network_devices(data):
|
||||
return False
|
||||
|
||||
|
||||
BACKUPS_META_FILE = BACKUPS_DIR / "backups_meta.json"
|
||||
|
||||
|
||||
def _load_backups_meta():
|
||||
try:
|
||||
if BACKUPS_META_FILE.is_file():
|
||||
with open(BACKUPS_META_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_backups_meta(data):
|
||||
try:
|
||||
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(BACKUPS_META_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
return True
|
||||
except (PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _safe_backup_name(name):
|
||||
"""Reject path traversal and ensure it's a backup filename we manage."""
|
||||
if not name or ".." in name or "/" in name or "\\" in name:
|
||||
return False
|
||||
if not (name.endswith(".img") or name.endswith(".img.gz")):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_backups():
|
||||
if not BACKUPS_DIR.is_dir():
|
||||
return []
|
||||
meta = _load_backups_meta()
|
||||
out = []
|
||||
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
if p.is_file() and p.suffix in (".img", ".img.gz"):
|
||||
if p.is_file() and p.suffix in (".img", ".img.gz") and p.name != "backups_meta.json":
|
||||
try:
|
||||
st = p.stat()
|
||||
out.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime})
|
||||
m = meta.get(p.name, {})
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"display_name": m.get("name") or p.name,
|
||||
"description": m.get("description") or "",
|
||||
"size": st.st_size,
|
||||
"mtime": st.st_mtime,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
@@ -241,9 +282,65 @@ def api_backups():
|
||||
return jsonify({"backups": list_backups()})
|
||||
|
||||
|
||||
@app.route("/api/backups/<path:name>")
|
||||
@app.route("/api/backups/<path:name>/set-as-golden", methods=["POST"])
|
||||
def api_backup_set_as_golden(name):
|
||||
"""Copy this backup to golden.img so it becomes the image used for Deploy."""
|
||||
if not _safe_backup_name(name):
|
||||
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
||||
path = BACKUPS_DIR / name
|
||||
if not path.is_file():
|
||||
return jsonify({"ok": False, "error": "backup not found"}), 404
|
||||
try:
|
||||
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, GOLDEN_IMAGE)
|
||||
return jsonify({"ok": True, "message": f"Golden image set from {name}"})
|
||||
except (OSError, IOError) as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/backups/<path:name>", methods=["PATCH"])
|
||||
def api_backup_update(name):
|
||||
"""Update backup metadata (display name, description) or rename the file."""
|
||||
if not _safe_backup_name(name):
|
||||
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
||||
path = BACKUPS_DIR / name
|
||||
if not path.is_file():
|
||||
return jsonify({"ok": False, "error": "backup not found"}), 404
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
meta = _load_backups_meta()
|
||||
entry = meta.get(name, {})
|
||||
|
||||
new_filename = (body.get("filename") or "").strip()
|
||||
if new_filename:
|
||||
if not _safe_backup_name(new_filename):
|
||||
return jsonify({"ok": False, "error": "invalid new filename"}), 400
|
||||
new_path = BACKUPS_DIR / new_filename
|
||||
if new_path.exists() and new_path != path:
|
||||
return jsonify({"ok": False, "error": "target filename already exists"}), 409
|
||||
try:
|
||||
path.rename(new_path)
|
||||
except OSError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
meta[new_filename] = {"name": entry.get("name") or name, "description": entry.get("description") or ""}
|
||||
if name in meta:
|
||||
del meta[name]
|
||||
name = new_filename
|
||||
path = BACKUPS_DIR / name
|
||||
else:
|
||||
if "name" in body:
|
||||
entry["name"] = (body.get("name") or "").strip() or path.name
|
||||
if "description" in body:
|
||||
entry["description"] = (body.get("description") or "").strip()
|
||||
meta[name] = entry
|
||||
|
||||
if not _save_backups_meta(meta):
|
||||
return jsonify({"ok": False, "error": "could not save metadata"}), 500
|
||||
return jsonify({"ok": True, "name": name})
|
||||
|
||||
|
||||
@app.route("/api/backups/<path:name>", methods=["GET"])
|
||||
def api_backup_download(name):
|
||||
if ".." in name or "/" in name or "\\" in name:
|
||||
if not _safe_backup_name(name):
|
||||
return jsonify({"error": "invalid name"}), 400
|
||||
path = BACKUPS_DIR / name
|
||||
if not path.is_file():
|
||||
@@ -251,5 +348,17 @@ def api_backup_download(name):
|
||||
return send_file(path, as_attachment=True, download_name=name)
|
||||
|
||||
|
||||
@app.route("/api/golden-info")
|
||||
def api_golden_info():
|
||||
"""Return whether golden image exists and its size/mtime for UI."""
|
||||
if not GOLDEN_IMAGE.is_file():
|
||||
return jsonify({"present": False})
|
||||
try:
|
||||
st = GOLDEN_IMAGE.stat()
|
||||
return jsonify({"present": True, "size": st.st_size, "mtime": st.st_mtime})
|
||||
except OSError:
|
||||
return jsonify({"present": False})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
|
||||
Reference in New Issue
Block a user