init
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OTA software update for ButtonTask.
|
||||
|
||||
The web side receives an uploaded package (tar.gz containing the ButtonTask
|
||||
binary + webconfig/ + manifest.json), validates it, then hands the actual
|
||||
swap/health-check/rollback to the privileged, detached helper `bt-update`.
|
||||
Because the web service itself is part of the package being replaced, the
|
||||
helper is launched detached (setsid) so it survives the service restart.
|
||||
Status is reported via run/update-status.json.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, abort, jsonify, request
|
||||
|
||||
from auth import login_required
|
||||
from paths import (UPDATE_STATUS, current_link, install_root, run_dir,
|
||||
scripts_dir)
|
||||
|
||||
updater_bp = Blueprint("updater", __name__)
|
||||
|
||||
STAGING_NAME = "staging"
|
||||
|
||||
|
||||
def _read_manifest(directory):
|
||||
mf = Path(directory) / "manifest.json"
|
||||
if not mf.exists():
|
||||
return {}
|
||||
try:
|
||||
with mf.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def current_version():
|
||||
link = current_link()
|
||||
target = link.resolve() if link.exists() else None
|
||||
manifest = _read_manifest(target) if target else {}
|
||||
return {
|
||||
"version": manifest.get("version", "unknown"),
|
||||
"path": str(target) if target else "",
|
||||
}
|
||||
|
||||
|
||||
def read_status():
|
||||
p = run_dir() / UPDATE_STATUS
|
||||
if not p.exists():
|
||||
return {"state": "idle"}
|
||||
try:
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {"state": "unknown"}
|
||||
|
||||
|
||||
def _staging_dir():
|
||||
p = install_root() / STAGING_NAME
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
@updater_bp.route("/api/update/status", methods=["GET"])
|
||||
@login_required
|
||||
def api_update_status():
|
||||
return jsonify({"current": current_version(), "status": read_status()})
|
||||
|
||||
|
||||
@updater_bp.route("/api/update", methods=["POST"])
|
||||
@login_required
|
||||
def api_update():
|
||||
if "file" not in request.files:
|
||||
abort(400, "package file required")
|
||||
fobj = request.files["file"]
|
||||
fname = Path(fobj.filename or "").name
|
||||
if not (fname.endswith(".tar.gz") or fname.endswith(".tgz")):
|
||||
abort(400, "package must be a .tar.gz")
|
||||
|
||||
staging = _staging_dir()
|
||||
pkg_path = staging / f"upload-{int(time.time())}.tar.gz"
|
||||
fobj.save(str(pkg_path))
|
||||
|
||||
# Optional integrity check against a client-provided sha256.
|
||||
expected = (request.form.get("sha256") or "").strip().lower()
|
||||
if expected:
|
||||
h = hashlib.sha256()
|
||||
with pkg_path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
if h.hexdigest() != expected:
|
||||
pkg_path.unlink(missing_ok=True)
|
||||
abort(400, "sha256 mismatch")
|
||||
|
||||
script = str(scripts_dir() / "bt-update")
|
||||
# start_new_session detaches the child (setsid) so it survives the web
|
||||
# service restart performed mid-update.
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["sudo", "-n", script, "stage", str(pkg_path)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
return jsonify({"ok": True, "state": "started"})
|
||||
|
||||
|
||||
@updater_bp.route("/api/update/rollback", methods=["POST"])
|
||||
@login_required
|
||||
def api_update_rollback():
|
||||
script = str(scripts_dir() / "bt-update")
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["sudo", "-n", script, "rollback"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
return jsonify({"ok": True, "state": "rollback-started"})
|
||||
Reference in New Issue
Block a user