ouroboros/android/bootstrap/update-host

264 lines
13 KiB
Python
Executable file

#!/usr/bin/env python3
"""Build the current native host source with this installation's persistent key.
This is a native artifact installer, not a Git updater. The common source update
and reviewed self-edit paths choose the repository state before calling it.
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import runpy
import subprocess
import sys
import tempfile
PACKAGE = "ai.ouroboros.android"
KEY_ALIAS = "ouroboros-host"
def run(argv, *, binary=False, input=None):
return subprocess.run([str(value) for value in argv], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=not binary, input=input).stdout
def file_hash(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def platform_state(repo, app):
module = runpy.run_path(str(repo / "android/provision/runtime.py"))
module["platform_current"].__globals__["APP"] = app
return module["platform_current"](repo)
def source_identity(repo, sdk):
paths = sorted(path for path in (repo / "android" / "host").rglob("*")
if path.is_file() and "__pycache__" not in path.parts)
paths.append(repo / "assets" / "icon_1024.png")
paths.append(repo / "VERSION")
if not (repo / "android" / "host" / "build.py").is_file():
raise RuntimeError("The selected repository has no Android host compiler.")
inputs = {str(path.relative_to(repo)): file_hash(path) for path in paths}
inputs["sdk_installation"] = file_hash(sdk / "installation.json")
module = runpy.run_path(str(repo / "android/provision/runtime.py"))
inputs["platform_desired"] = module["platform_inputs"](repo)
digest = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
return digest, inputs
def signer(apk, java, sdk):
output = run([java / "bin" / "java", "-jar", sdk / "build-tools" / "36.0.0"
/ "lib" / "apksigner.jar", "verify", "--print-certs", apk])
certs = re.findall(r"^Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)$", output, re.M)
if len(certs) != 1:
raise RuntimeError("Could not establish the APK's single signing certificate.")
return certs[0].lower()
def installed_package(android_exec):
try:
output = run([android_exec, "pm", "path", PACKAGE])
except subprocess.CalledProcessError as error:
# Android pm reports a genuinely missing package as exit 1 with no
# output. Root/transport errors carry diagnostics or another code.
if error.returncode == 1 and not (error.stdout or "").strip() and not (error.stderr or "").strip():
return None
raise
paths = [line.removeprefix("package:") for line in output.splitlines()
if line.startswith("package:")]
if not paths:
raise RuntimeError("Android package lookup returned no package path.")
if len(paths) != 1:
raise RuntimeError("The installed host unexpectedly has multiple APK files.")
details = run([android_exec, "dumpsys", "package", PACKAGE])
match = re.search(r"\bversionCode=(\d+)\b", details)
if not match:
raise RuntimeError("The installed host versionCode could not be read.")
return {"path": paths[0], "version_code": int(match[1])}
def copy_installed(apk_path, destination, android_exec):
with destination.open("wb") as stream:
subprocess.run([str(android_exec), "cat", apk_path], check=True,
stdout=stream, stderr=subprocess.PIPE)
def write_receipt(path, receipt):
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
os.replace(temporary, path)
def restore_host(android_exec):
run([android_exec, "am", "start-foreground-service", "-n", PACKAGE + "/.CoreService", "-a", "status"])
def sync_bootstrap(repo, app, android_exec, *, check=False):
"""Adopt the source-selected entry scripts without moving Git or the seed."""
actual = {}
for name in ("android-exec", "android-call", "build-apk", "update-host", "enter-linux", "core-control"):
source = repo / "android" / "bootstrap" / name
data = source.read_bytes()
expected = hashlib.sha256(data).hexdigest()
if name in {"enter-linux", "core-control"}:
destination = "/data/local/ouroboros-phone/bin/" + name
try:
old = run([android_exec, "cat", destination], binary=True)
except subprocess.CalledProcessError:
old = b""
if old != data and not check:
run([android_exec, "/system/bin/sh", "-c",
'cat > "$1.tmp" && chmod 755 "$1.tmp" && mv "$1.tmp" "$1"',
"ouroboros-bootstrap-install", destination], binary=True, input=data)
old = run([android_exec, "cat", destination], binary=True)
else:
destination = app / "tools" / name
old = destination.read_bytes() if destination.exists() else b""
if old != data and not check:
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(".tmp")
temporary.write_bytes(data)
temporary.chmod(0o755)
os.replace(temporary, destination)
old = destination.read_bytes()
actual[name] = hashlib.sha256(old).hexdigest()
if actual[name] != expected:
return False, actual
return True, actual
def update(args):
app = args.app_root.resolve()
repo = args.repo.resolve() if args.repo else app / "repo"
sdk = args.sdk.resolve() if args.sdk else app / "android-sdk"
key = app / "signing" / "host.keystore"
password = app / "signing" / "host-password"
if not key.is_file() or not password.is_file():
raise RuntimeError("Personal signing key is missing. Restore its backup; updates never create a replacement.")
current_platform, desired_platform = platform_state(repo, app)
if not current_platform:
if args.check:
return {"status": "platform_update_required", "platform_inputs": desired_platform}
run([sys.executable, repo / "android/provision/runtime.py", "--ensure-platform", repo, "--app-root", app])
if not platform_state(repo, app)[0]:
raise RuntimeError("Prepared platform does not match the selected source recipe.")
pin = json.loads((sdk / "installation.json").read_text())
if pin.get("build_tools_version") != "36.0.0" or pin.get("platform") != "android-36":
raise RuntimeError("SDK installation does not match the host compiler's pinned platform.")
java = args.java_home.resolve() if args.java_home else Path(pin["java_home"])
android_exec = app / "tools" / "android-exec"
certificate = run([java / "bin" / "keytool", "-exportcert", "-alias", KEY_ALIAS,
"-keystore", key, "-storepass:file", password], binary=True)
expected_signer = hashlib.sha256(certificate).hexdigest()
input_digest, inputs = source_identity(repo, sdk)
state = app / "data" / "state" / "android_host.json"
previous = json.loads(state.read_text()) if state.exists() else {}
installed = installed_package(android_exec)
with tempfile.TemporaryDirectory(prefix="ouroboros-installed-apk-") as temporary:
installed_apk = Path(temporary) / "app.apk"
if installed:
copy_installed(installed["path"], installed_apk, android_exec)
installed["sha256"] = file_hash(installed_apk)
if signer(installed_apk, java, sdk) != expected_signer:
raise RuntimeError("Installed host uses a different signing key. Restore the matching personal key.")
current = bool(installed and previous.get("input_sha256") == input_digest
and previous.get("apk_sha256") == installed["sha256"]
and previous.get("version_code") == installed["version_code"])
if args.check or current:
bootstrap_ok, bootstrap = sync_bootstrap(repo, app, android_exec, check=args.check)
if current and not args.check:
if not bootstrap_ok:
raise RuntimeError("Bootstrap script readback did not match the selected source.")
restore_host(android_exec)
return {"status": "current" if current and bootstrap_ok else "build_required", "input_sha256": input_digest,
"installed": installed, "signer_sha256": expected_signer,
"bootstrap": bootstrap,
"source_commit": run(["git", "-C", repo, "rev-parse", "HEAD"]).strip()}
version_code = installed["version_code"] + 1 if installed else 1
build_root = app / "data" / "android-builds"
build_root.mkdir(parents=True, exist_ok=True)
output = Path(tempfile.mkdtemp(prefix=f"{version_code}-{input_digest[:12]}-", dir=build_root))
version_name = (repo / "VERSION").read_text().strip()
run([sys.executable, repo / "android" / "host" / "build.py", "--sdk", sdk,
"--java-home", java, "--out", output, "--keystore", key, "--key-alias", KEY_ALIAS,
"--keystore-pass-file", password, "--version-code", version_code,
"--version-name", version_name])
apk = output / "Ouroboros.apk"
if source_identity(repo, sdk)[0] != input_digest:
raise RuntimeError("Android source changed during compilation; the candidate was not installed.")
if signer(apk, java, sdk) != expected_signer:
raise RuntimeError("Built host does not have the installation's signing certificate.")
digest = file_hash(apk)
# Android's mount namespace cannot see this chroot path. pm consumes the
# APK through stdin, while the Linux parent survives host replacement.
with apk.open("rb") as stream:
result = subprocess.run([str(android_exec), "pm", "install", "-r", "-S", str(apk.stat().st_size), "--", "-"],
stdin=stream, capture_output=True, text=True, check=True)
if result.stdout.strip() != "Success":
raise RuntimeError("Android did not confirm APK replacement: " + result.stdout.strip())
observed = installed_package(android_exec)
if not observed or observed["version_code"] != version_code:
raise RuntimeError("Installed version does not match the built APK; inspect before retrying.")
copy_installed(observed["path"], installed_apk, android_exec)
if file_hash(installed_apk) != digest or signer(installed_apk, java, sdk) != expected_signer:
raise RuntimeError("Installed APK readback does not match the built artifact.")
receipt = {"schema_version": 1, "input_sha256": input_digest, "inputs": inputs,
"source_commit": run(["git", "-C", repo, "rev-parse", "HEAD"]).strip(),
"version_name": version_name, "version_code": version_code,
"apk_sha256": digest, "signer_sha256": expected_signer, "artifact": str(apk)}
state.parent.mkdir(parents=True, exist_ok=True)
write_receipt(state, receipt)
bootstrap_ok, bootstrap = sync_bootstrap(repo, app, android_exec)
if not bootstrap_ok:
raise RuntimeError("Bootstrap script readback did not match the selected source.")
receipt["bootstrap"] = bootstrap
write_receipt(state, receipt)
restore_host(android_exec)
return {"status": "installed", **receipt}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--app-root", type=Path, default=Path("/opt/ouroboros"))
parser.add_argument("--repo", type=Path)
parser.add_argument("--sdk", type=Path)
parser.add_argument("--java-home", type=Path)
parser.add_argument("--check", action="store_true", help="Inspect only; do not build, install, or start the host.")
args = parser.parse_args()
try:
if args.check:
result = update(args)
else:
lock_path = args.app_root / "data" / "state" / "android_host.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
result = update(args)
print(json.dumps(result, sort_keys=True))
return 0 if result["status"] in {"current", "installed"} else 1
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
detail = {"status": "failed", "error": str(error)}
if isinstance(error, subprocess.CalledProcessError):
detail = {"status": "failed", "error": "Tool process failed", "returncode": error.returncode}
for name in ("stdout", "stderr"):
value = getattr(error, name, None)
detail[name] = (value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or "").strip()
print(json.dumps(detail))
return 1
if __name__ == "__main__":
sys.exit(main())