chore: checkpoint classroom 0.1.5 stable
This commit is contained in:
Executable
+210
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
CONFIG = "/etc/ekklesia-classroom/device.conf"
|
||||
LOCKFILE = "/run/ekklesia-classroom-update.lock"
|
||||
|
||||
|
||||
def log(message):
|
||||
print(f"[ekklesia-classroom-update] {message}", flush=True)
|
||||
|
||||
|
||||
def read_config():
|
||||
config = {
|
||||
"UPDATE_URL": "https://updates.riverofglorymn.org",
|
||||
"CHANNEL": "stable",
|
||||
}
|
||||
|
||||
if os.path.exists(CONFIG):
|
||||
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
config[key.strip()] = value.strip()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def installed_version():
|
||||
result = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Version}", "ekklesia-classroom"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return "0.0.0"
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def version_is_newer(candidate, current):
|
||||
result = subprocess.run(
|
||||
["dpkg", "--compare-versions", candidate, "gt", current]
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def download(url, destination):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
with open(destination, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--no-reboot",
|
||||
action="store_true",
|
||||
help="Install the update but do not reboot automatically",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check for an update without installing it",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(LOCKFILE, "w", encoding="utf-8") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
log("Another update is already running.")
|
||||
return 0
|
||||
|
||||
config = read_config()
|
||||
|
||||
base_url = config["UPDATE_URL"].rstrip("/") + "/"
|
||||
channel = config["CHANNEL"]
|
||||
manifest_url = urljoin(base_url, f"{channel}/manifest.json")
|
||||
|
||||
current = installed_version()
|
||||
|
||||
log(f"Current version: {current}")
|
||||
log(f"Channel: {channel}")
|
||||
log(f"Checking: {manifest_url}")
|
||||
|
||||
try:
|
||||
manifest = fetch_json(manifest_url)
|
||||
except Exception as exc:
|
||||
log(f"Unable to fetch update manifest: {exc}")
|
||||
return 1
|
||||
|
||||
candidate = manifest["version"]
|
||||
|
||||
log(f"Available version: {candidate}")
|
||||
|
||||
if not version_is_newer(candidate, current):
|
||||
log("Already up to date.")
|
||||
return 0
|
||||
|
||||
if args.check:
|
||||
log(f"Update available: {current} -> {candidate}")
|
||||
return 0
|
||||
|
||||
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
||||
expected_hash = manifest["sha256"].lower()
|
||||
|
||||
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
|
||||
|
||||
package_path = os.path.join(
|
||||
"/var/cache/ekklesia-classroom",
|
||||
os.path.basename(manifest["package"]),
|
||||
)
|
||||
|
||||
temporary_path = package_path + ".download"
|
||||
|
||||
log(f"Downloading {package_url}")
|
||||
|
||||
try:
|
||||
download(package_url, temporary_path)
|
||||
except Exception as exc:
|
||||
log(f"Download failed: {exc}")
|
||||
return 1
|
||||
|
||||
actual_hash = sha256(temporary_path)
|
||||
|
||||
if actual_hash != expected_hash:
|
||||
log("SHA-256 verification FAILED.")
|
||||
log(f"Expected: {expected_hash}")
|
||||
log(f"Received: {actual_hash}")
|
||||
os.unlink(temporary_path)
|
||||
return 1
|
||||
|
||||
log("SHA-256 verification passed.")
|
||||
|
||||
os.replace(temporary_path, package_path)
|
||||
|
||||
log(f"Installing {candidate}")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
package_path,
|
||||
]
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log("Package installation failed.")
|
||||
return result.returncode
|
||||
|
||||
log(f"Update to {candidate} installed successfully.")
|
||||
|
||||
if manifest.get("requires_reboot", False):
|
||||
if args.no_reboot:
|
||||
log("Update requires reboot, but --no-reboot was specified.")
|
||||
else:
|
||||
log("Update requires reboot. Rebooting now.")
|
||||
subprocess.run(["systemctl", "reboot"])
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user