VulpesOS/tools/update-gecko.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

288 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Download, qualify and optionally activate an isolated Gecko release."""
import argparse
from contextlib import contextmanager
import datetime
import fcntl
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ACTIVE = ROOT / "engine-lock.json"
HISTORY = ROOT / "logs/updates/last-activation.json"
RESULTS = [
"engine-capabilities.json",
"services-results.json",
"host-results.json",
"host-restart-results.json",
"feedback-results.json",
"feedback-restart-results.json",
"apps-results.json",
"apps-restart-results.json",
"languages-results.json",
"languages-restart-results.json",
]
def atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False) as output:
temporary = Path(output.name)
json.dump(value, output, ensure_ascii=False, indent=2)
output.write("\n")
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
def require_idle():
for port in [8765, 2856, 2857]:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind(("127.0.0.1", port))
except OSError:
raise RuntimeError(
f"Port {port} occupé. Fermer l’émulateur et les tests avant la mise à jour."
)
@contextmanager
def idle_host():
# Share the launcher's lock during profile/engine changes only. Tests need
# to acquire it themselves while qualifying the candidate.
with (ROOT / "logs/host.lock").open("w") as guard:
try:
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Un émulateur est encore actif; aucun basculement.")
require_idle()
yield
def validate_runtime(lock):
runtime = (ROOT / lock["runtime"]).resolve()
if (
not runtime.is_relative_to((ROOT / "engines").resolve())
or not (runtime / "firefox").is_file()
):
raise RuntimeError("Moteur absent ou chemin hors du dossier engines.")
return runtime
@contextmanager
def closed_profile(profile):
if not profile.exists():
yield
return
# Match nsProfileLock::LockWithFcntl (whole file, exclusive, nonblocking).
# Do not follow an unexpected symlink for the native lock file.
descriptor = os.open(profile / ".parentlock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(descriptor, "r+") as guard:
try:
fcntl.lockf(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Le profil source est encore ouvert : " + str(profile))
for name in ["lock", "parent.lock"]:
marker = profile / name
if marker.is_symlink() and ":+" not in os.readlink(marker):
raise RuntimeError("Verrou de profil ancien ou inconnu : " + str(marker))
yield
def activate(candidate, old, report_dir):
require_idle()
validate_runtime(candidate)
if json.loads(ACTIVE.read_text()) != old:
raise RuntimeError("Le moteur actif a changé pendant les tests; basculement annulé.")
source = ROOT / "profiles" / ("desktop-" + old["version"])
target = ROOT / "profiles" / ("desktop-" + candidate["version"])
if target.exists():
raise RuntimeError("Profil cible déjà présent; conservé sans écrasement : " + str(target))
# Firefox on Linux uses a POSIX record lock on .parentlock. A symbolic
# `lock` marker can survive a reboot and is not proof of a live process.
with closed_profile(source):
temporary = Path(tempfile.mkdtemp(prefix=".upgrade-", dir=ROOT / "profiles"))
try:
if source.exists():
shutil.copytree(
source,
temporary,
dirs_exist_ok=True,
symlinks=True,
ignore=shutil.ignore_patterns(
".parentlock",
"parent.lock",
"lock",
"cache2",
"startupCache",
"shader-cache",
),
)
temporary.rename(target)
finally:
if temporary.exists():
shutil.rmtree(temporary)
# Save the recovery record before the single atomic activation write.
recovery = {
"previous": old,
"activated": candidate,
"report": str(report_dir.relative_to(ROOT)),
"oldProfile": str(source.relative_to(ROOT)),
"newProfile": str(target.relative_to(ROOT)),
}
atomic_json(report_dir / "activation.json", recovery)
atomic_json(HISTORY, recovery)
atomic_json(ACTIVE, candidate)
def rollback():
require_idle()
recovery = json.loads(HISTORY.read_text())
current = json.loads(ACTIVE.read_text())
if current == recovery["previous"]:
print("Ancien moteur déjà actif.")
return
if current != recovery["activated"]:
raise RuntimeError(
"Le moteur actif ne correspond pas au dernier basculement; aucun changement."
)
validate_runtime(recovery["previous"])
atomic_json(ACTIVE, recovery["previous"])
print("Retour au moteur " + recovery["previous"]["version"] + " et à son profil conservé.")
print("Les données créées dans le nouveau profil restent dans " + recovery["newProfile"])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--retest", action="store_true", help="Retester même si la version est déjà active"
)
parser.add_argument("--version", help="Version stable exacte; sinon dernière stable Mozilla")
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--apply", action="store_true", help="Activer après tous les tests, en copiant le profil"
)
mode.add_argument(
"--rollback",
action="store_true",
help="Revenir au moteur/profil précédents sans supprimer le nouveau",
)
args = parser.parse_args()
(ROOT / "logs/updates").mkdir(parents=True, exist_ok=True)
with (ROOT / "logs/updates/update.lock").open("w") as guard:
try:
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Une mise à jour est déjà en cours.")
if args.rollback:
if args.version or args.retest:
raise RuntimeError("--version/--retest ne s’appliquent pas au retour arrière.")
with idle_host():
rollback()
return
require_idle()
for command in ["Xvfb", "xdotool", "import"]:
if not shutil.which(command):
raise RuntimeError("Dépendance absente : " + command)
old = json.loads(ACTIVE.read_text())
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
report_dir = ROOT / "logs/updates" / stamp
report_dir.mkdir()
atomic_json(report_dir / "previous-engine.json", old)
report = {
"status": "running",
"previousVersion": old["version"],
"steps": [],
"scope": "desktop automation; full Firefox OS parity and mobile hardware not certified",
}
def save():
atomic_json(report_dir / "report.json", report)
def step(name, script, *arguments):
print("→ " + name, flush=True)
record = {"name": name, "log": name + ".log", "status": "running"}
report["steps"].append(record)
save()
with (report_dir / record["log"]).open("w") as log:
result = subprocess.run(
[sys.executable, str(ROOT / "tools" / script), *arguments],
cwd=ROOT,
stdout=log,
stderr=subprocess.STDOUT,
)
record.update(
status="passed" if result.returncode == 0 else "failed", exitCode=result.returncode
)
save()
if result.returncode:
raise RuntimeError("Échec de " + name + " : " + str(report_dir / record["log"]))
print(" OK", flush=True)
try:
step(
"download",
"fetch-engine.py",
*(["--version", args.version] if args.version else []),
)
candidate = json.loads((ROOT / "candidate-engine.json").read_text())
report["candidateVersion"] = candidate["version"]
save()
print(f"Gecko {old['version']} → candidat {candidate['version']}", flush=True)
if candidate["version"] == old["version"] and not args.retest:
report["status"] = "up-to-date"
print(
"Cette version est déjà active. Utiliser --retest pour refaire les contrôles.",
flush=True,
)
return
# Old result files must never qualify a failed or incomplete run.
for name in RESULTS:
path = ROOT / "logs" / name
if path.exists():
shutil.copy2(path, report_dir / ("before-" + name))
path.unlink()
step("capabilities", "probe-engine.py", "--candidate")
step("services", "test-services.py", "--candidate")
step("desktop", "check-desktop.py", "--candidate")
for name in RESULTS:
path = ROOT / "logs" / name
if not path.is_file():
raise RuntimeError("Rapport de test absent : " + name)
shutil.copy2(path, report_dir / name)
candidate.update(
gaiaBoots=True,
gaiaPorted=False,
qualificationReport=str((report_dir / "report.json").relative_to(ROOT)),
)
if args.apply and candidate["version"] != old["version"]:
with idle_host():
activate(candidate, old, report_dir)
report["status"] = "activated"
print("Moteur activé; ancien moteur et ancien profil conservés.", flush=True)
else:
report["status"] = "qualified"
print("Tests réussis. Moteur actif conservé.", flush=True)
except BaseException as error:
report.update(status="failed", error=str(error))
save()
raise
finally:
save()
print("Rapport : " + str(report_dir / "report.json"), flush=True)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError, KeyError) as error:
sys.exit(str(error))
except KeyboardInterrupt:
sys.exit("Mise à jour interrompue; consulter le rapport.")