Implement backup upload and deletion functionality in eMMC provisioning dashboard: add API endpoints for uploading image files and deleting backups, enhance UI with upload button and delete options, and improve error handling for file operations. Update documentation to reflect new features.

This commit is contained in:
nearxos
2026-02-19 14:36:17 +02:00
parent 41b7e95c96
commit 01a9f61ca5
4 changed files with 143 additions and 15 deletions

View File

@@ -335,9 +335,44 @@ def api_backups():
return jsonify({"backups": list_backups(), "backups_dir": str(BACKUPS_DIR)})
@app.route("/api/backups/upload", methods=["POST"])
def api_backups_upload():
"""Upload an image file from the dashboard (multipart form)."""
if "file" not in request.files and "image" not in request.files:
return jsonify({"ok": False, "error": "no file in request (use field 'file' or 'image')"}), 400
f = request.files.get("file") or request.files.get("image")
if not f or not f.filename:
return jsonify({"ok": False, "error": "no file selected"}), 400
base = (f.filename.rsplit(".", 1)[0] if "." in f.filename else f.filename).strip() or "upload"
safe_base = re.sub(r"[^\w\-.]", "_", base)[:80]
if not safe_base:
safe_base = "upload"
ext = ""
if f.filename.lower().endswith(".img.xz"):
ext = ".img.xz"
elif f.filename.lower().endswith(".img.gz"):
ext = ".img.gz"
elif f.filename.lower().endswith(".img"):
ext = ".img"
else:
ext = ".img"
name = f"{safe_base}-{int(time.time())}{ext}"
if not _safe_backup_name(name):
name = f"upload-{int(time.time())}.img"
path = BACKUPS_DIR / name
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
f.save(str(path))
return jsonify({"ok": True, "name": name, "message": f"Uploaded {name}"})
except (OSError, IOError) as e:
if path.exists():
path.unlink(missing_ok=True)
return jsonify({"ok": False, "error": str(e)}), 500
@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."""
"""Use this backup as the golden image (symlink when in backups dir to avoid copying)."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name
@@ -345,7 +380,17 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": "backup not found"}), 404
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, GOLDEN_IMAGE)
GOLDEN_IMAGE.parent.mkdir(parents=True, exist_ok=True)
if GOLDEN_IMAGE.exists():
GOLDEN_IMAGE.unlink()
# Symlink to the backup so we use the same file instead of duplicating
path_resolved = path.resolve()
try:
path_resolved.relative_to(BACKUPS_DIR.resolve())
os.symlink(path_resolved, GOLDEN_IMAGE)
except ValueError:
# Backup not under BACKUPS_DIR (shouldn't happen for list); fall back to copy
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
@@ -458,6 +503,30 @@ def api_backup_update(name):
return jsonify({"ok": True, "name": name})
@app.route("/api/backups/<path:name>", methods=["DELETE"])
def api_backup_delete(name):
"""Delete a backup file. If it is the current golden image (symlink), the golden link is removed."""
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:
if GOLDEN_IMAGE.exists() and GOLDEN_IMAGE.is_symlink():
try:
if (GOLDEN_IMAGE.resolve() == path.resolve()):
GOLDEN_IMAGE.unlink()
except OSError:
pass
path.unlink()
meta = _load_backups_meta()
meta.pop(name, None)
_save_backups_meta(meta)
return jsonify({"ok": True, "message": f"Deleted {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>", methods=["GET"])
def api_backup_download(name):
if not _safe_backup_name(name):