mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
399 lines
15 KiB
Python
Executable File
399 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerate cargo-sources.json for the from-source Flatpak manifest.
|
|
|
|
The upstream flatpak-cargo-generator is downloaded from a pinned commit and
|
|
verified before use; the full script is intentionally not vendored here. Two
|
|
small, reviewed patches are applied in memory:
|
|
|
|
1. Key cargo source replacements by the full source URL (including ?rev=...)
|
|
instead of only the repository URL, so two different revisions of the same
|
|
repository stay distinct.
|
|
2. Include Cargo.lock's [patch.unused] entries, so crates such as egui_demo_lib
|
|
are vendored even though they are not in the resolved package graph.
|
|
|
|
This only produces and validates the source inventory; a full Flatpak SDK
|
|
build is still required. See README.md in this directory for details.
|
|
|
|
The generator runs with stdin connected to /dev/null and is capped at
|
|
GENERATOR_TIMEOUT_SECONDS. On POSIX (the Flatpak build path) it starts in its
|
|
own session, so a timeout, Ctrl-C (SIGINT) or SIGTERM terminates the generator
|
|
and its git descendants without touching any other process; the recorded signal
|
|
is replayed with the handler that was installed before generation started, so
|
|
the wrapper keeps its normal cancellation behaviour. On Windows only the direct
|
|
generator process is terminated; descendants are not tree-killed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from urllib.request import urlopen
|
|
|
|
UPSTREAM_COMMIT = "9d86d336a39b9af38b86f114b6ce5820fe260f91"
|
|
UPSTREAM_URL = (
|
|
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/"
|
|
f"{UPSTREAM_COMMIT}/cargo/flatpak-cargo-generator.py"
|
|
)
|
|
UPSTREAM_SHA256 = "0a2db6be87d75910facef28ab46d4d6460802e8419ab850d0caa6a364d26b380"
|
|
PATCHED_SHA256 = "1e7c61d98d18e88156fb8cd4b91f526e04a6c504b185ae7b12f775ec1dd48512"
|
|
|
|
# Allow cold git caches while bounding stalled inventory regeneration.
|
|
GENERATOR_TIMEOUT_SECONDS = 30 * 60
|
|
|
|
# Signals that cancel regeneration without an exception of their own.
|
|
CANCELLATION_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
|
|
PATCHES = (
|
|
(
|
|
"preserve pinned source identities",
|
|
''' elif branch:
|
|
assert len(branch) == 1
|
|
cargo_vendored_entry[repo_url]["branch"] = branch[0]
|
|
|
|
logging.info("Adding package %s from %s", name, repo_url)
|
|
''',
|
|
''' elif branch:
|
|
assert len(branch) == 1
|
|
cargo_vendored_entry[repo_url]["branch"] = branch[0]
|
|
|
|
# Preserve distinct pinned source identities from the same repository.
|
|
if rev or tag or branch:
|
|
source_key = source.removeprefix("git+").split("#", 1)[0]
|
|
cargo_vendored_entry = {source_key: cargo_vendored_entry[repo_url]}
|
|
|
|
logging.info("Adding package %s from %s", name, repo_url)
|
|
''',
|
|
),
|
|
(
|
|
"include Cargo.lock patch.unused entries",
|
|
''' pkg_coros = [
|
|
get_package_sources(p, cargo_lock, git_repos) for p in cargo_lock["package"]
|
|
]
|
|
''',
|
|
''' pkg_coros = [
|
|
get_package_sources(p, cargo_lock, git_repos)
|
|
for p in cargo_lock["package"] + cargo_lock.get("patch", {}).get("unused", [])
|
|
]
|
|
''',
|
|
),
|
|
)
|
|
|
|
|
|
def sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def download_upstream() -> bytes:
|
|
with urlopen(UPSTREAM_URL, timeout=60) as response:
|
|
data = response.read(1_000_001)
|
|
if len(data) > 1_000_000:
|
|
raise SystemExit("error: upstream generator is unexpectedly large")
|
|
actual = sha256(data)
|
|
if actual != UPSTREAM_SHA256:
|
|
raise SystemExit(
|
|
"error: upstream generator checksum mismatch\n"
|
|
f" expected {UPSTREAM_SHA256}\n"
|
|
f" actual {actual}\n"
|
|
f" url {UPSTREAM_URL}"
|
|
)
|
|
return data
|
|
|
|
|
|
def patched_upstream(data: bytes) -> bytes:
|
|
text = data.decode("utf-8")
|
|
for name, old, new in PATCHES:
|
|
count = text.count(old)
|
|
if count != 1:
|
|
raise SystemExit(
|
|
f"error: patch {name!r} matched {count} times, expected exactly once; "
|
|
"the pinned upstream script or patch set changed"
|
|
)
|
|
text = text.replace(old, new, 1)
|
|
patched = text.encode("utf-8")
|
|
actual = sha256(patched)
|
|
if actual != PATCHED_SHA256:
|
|
raise SystemExit(
|
|
"error: patched generator checksum mismatch\n"
|
|
f" expected {PATCHED_SHA256}\n"
|
|
f" actual {actual}"
|
|
)
|
|
return patched
|
|
|
|
|
|
def _kill_generator(process: subprocess.Popen) -> None:
|
|
"""Signal only our generator session; never waits or reaps.
|
|
|
|
The reaped check is a plain ``returncode`` read: calling ``Popen.poll`` or
|
|
``Popen.kill`` would wait for the child, which is not safe inside a POSIX
|
|
signal handler. On POSIX the generator is a session leader, so its PID is
|
|
also the ID of a process group that contains no process we do not own.
|
|
Killing that group stops git clones/fetches the generator started. On
|
|
Windows only the direct generator process is killed; its descendants may
|
|
survive.
|
|
"""
|
|
if process.returncode is not None:
|
|
return
|
|
if os.name == "posix":
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
# The group is already gone; still try the direct child in case it
|
|
# was not a group leader.
|
|
try:
|
|
os.kill(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
else:
|
|
try:
|
|
process.kill()
|
|
except OSError:
|
|
# The child exited between the timeout and the kill.
|
|
pass
|
|
|
|
|
|
def _terminate_generator(process: subprocess.Popen) -> None:
|
|
"""Stop only our generator session and reap its leader.
|
|
|
|
Only called outside signal handlers; the follow-up ``wait`` reaps the
|
|
session leader so no zombie or live generator outlives the wrapper.
|
|
``_kill_generator`` skips a child that was already reaped, so this cannot
|
|
signal a recycled PID.
|
|
"""
|
|
_kill_generator(process)
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
print(
|
|
f"warning: generator process {process.pid} did not exit after SIGKILL",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
class _GeneratorRun:
|
|
"""State shared with the temporary cancellation signal handlers.
|
|
|
|
Because the generator runs in its own session, Ctrl-C in the terminal or a
|
|
SIGTERM sent to the wrapper no longer reaches it. These handlers record the
|
|
signal and stop exactly that session without waiting; ``run_generator``
|
|
then restores the previous handlers and reports the cancellation, so
|
|
``main`` can clean up before the signal is replayed with the caller's own
|
|
behaviour. The wait that follows always reaps the leader.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.process = None
|
|
self.received = None
|
|
self.killed = False
|
|
|
|
def cancel(self, signum, _frame) -> None:
|
|
"""Record the first SIGINT/SIGTERM and stop the generator's session."""
|
|
if self.received is None:
|
|
self.received = signum
|
|
process = self.process
|
|
# ``_kill_generator`` reads only ``returncode`` and uses raw POSIX
|
|
# signals, so it is safe in a handler. The first signal does the kill;
|
|
# later signals must not overwrite the recorded cancellation.
|
|
if process is not None and not self.killed:
|
|
self.killed = True
|
|
_kill_generator(process)
|
|
|
|
|
|
class _Cancelled(Exception):
|
|
"""A cancellation signal arrived while the generator was running."""
|
|
|
|
def __init__(self, signum: int) -> None:
|
|
super().__init__(signum)
|
|
self.signum = signum
|
|
|
|
|
|
def _replay_cancellation(signum: int) -> None:
|
|
"""Re-deliver a recorded cancellation through the restored handler.
|
|
|
|
Called once the temporary handlers are restored and the temporary file and
|
|
directory are gone. ``raise_signal`` invokes exactly the handler currently
|
|
installed (``KeyboardInterrupt`` for the default SIGINT action, the default
|
|
terminating action for SIGTERM, or ``SIG_IGN``), so no handler-kind
|
|
branching is needed.
|
|
"""
|
|
signal.raise_signal(int(signum))
|
|
|
|
|
|
def run_generator(
|
|
*, script: Path, cargo_lock: Path, temp_output: str, cwd: Path, environment: dict
|
|
) -> int:
|
|
"""Run the upstream generator in its own session; return its exit status.
|
|
|
|
A cancellation or any other error stops the generator's whole session
|
|
before it is reported, so no detached generator or git process survives.
|
|
"""
|
|
run = _GeneratorRun()
|
|
previous_handlers = {}
|
|
try:
|
|
# Install the handlers before Popen: a signal that arrives while the
|
|
# child is being spawned (or between Popen returning and the assignment
|
|
# below) must not leave an unmanaged session behind. It is recorded,
|
|
# and the newly created process is stopped as soon as its PID is known.
|
|
# Installing inside the outer try means a partial installation is
|
|
# restored even if a later signal.signal() call fails unexpectedly.
|
|
for signum in CANCELLATION_SIGNALS:
|
|
try:
|
|
previous_handlers[signum] = signal.signal(signum, run.cancel)
|
|
except (OSError, ValueError):
|
|
# signal.signal() only works in the main thread of the main
|
|
# interpreter; the exception cleanup below still stops the
|
|
# detached generator on KeyboardInterrupt elsewhere.
|
|
pass
|
|
process = subprocess.Popen(
|
|
[sys.executable, str(script), str(cargo_lock), "-o", temp_output],
|
|
cwd=str(cwd),
|
|
env=environment,
|
|
stdin=subprocess.DEVNULL,
|
|
start_new_session=os.name == "posix",
|
|
)
|
|
run.process = process
|
|
if run.received is not None:
|
|
_terminate_generator(process)
|
|
try:
|
|
process.wait(timeout=GENERATOR_TIMEOUT_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
_terminate_generator(process)
|
|
if run.received is None:
|
|
raise
|
|
# A cancellation arrived while reaping the timed-out session; it
|
|
# is reported below as the cancellation it is.
|
|
except BaseException:
|
|
# KeyboardInterrupt without a temporary handler (non-main thread)
|
|
# or any other error: never leave the generator detached.
|
|
_terminate_generator(process)
|
|
raise
|
|
returncode = process.returncode
|
|
finally:
|
|
for signum, handler in previous_handlers.items():
|
|
signal.signal(signum, handler)
|
|
# Read the recorded signal after restoring the handlers: a signal that
|
|
# arrives between the child being reaped and the restoration above is
|
|
# recorded by run.cancel and must still be reported, not silently dropped.
|
|
received = run.received
|
|
if received is not None:
|
|
raise _Cancelled(received)
|
|
return returncode
|
|
|
|
|
|
def default_cache_dir() -> Path:
|
|
base = os.environ.get("XDG_CACHE_HOME")
|
|
if base:
|
|
return Path(base) / "lightcraft-flatpak-cargo"
|
|
return Path.home() / ".cache" / "lightcraft-flatpak-cargo"
|
|
|
|
|
|
def ensure_outside_source(cache: Path, repo_root: Path) -> None:
|
|
cache = cache.resolve()
|
|
if cache == repo_root or repo_root in cache.parents:
|
|
raise SystemExit(f"error: cache directory must be outside the source checkout: {cache}")
|
|
target = os.environ.get("CARGO_TARGET_DIR")
|
|
if target:
|
|
target_path = Path(target)
|
|
# Cargo runs in repo_root, even when this wrapper was invoked elsewhere.
|
|
if not target_path.is_absolute():
|
|
target_path = repo_root / target_path
|
|
target_path = target_path.resolve()
|
|
if cache == target_path or target_path in cache.parents:
|
|
raise SystemExit(f"error: cache directory must be outside CARGO_TARGET_DIR: {cache}")
|
|
|
|
|
|
def main() -> int:
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"cargo_lock",
|
|
nargs="?",
|
|
default=str(repo_root / "Cargo.lock"),
|
|
help="Cargo.lock to read (default: repository Cargo.lock)",
|
|
)
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
default=str(repo_root / "packaging/linux/flatpak/cargo-sources.json"),
|
|
help="output path (default: packaging/linux/flatpak/cargo-sources.json)",
|
|
)
|
|
parser.add_argument(
|
|
"--cache-dir",
|
|
default=None,
|
|
help=(
|
|
"git clone cache root (default: $XDG_CACHE_HOME/lightcraft-flatpak-cargo, "
|
|
"or ~/.cache/lightcraft-flatpak-cargo)"
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
cargo_lock = Path(args.cargo_lock).resolve()
|
|
output = Path(args.output).resolve()
|
|
cache = Path(args.cache_dir).resolve() if args.cache_dir else default_cache_dir().resolve()
|
|
if not cargo_lock.is_file():
|
|
raise SystemExit(f"error: Cargo.lock not found: {cargo_lock}")
|
|
ensure_outside_source(cache, repo_root)
|
|
cache.mkdir(parents=True, exist_ok=True)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
generator = patched_upstream(download_upstream())
|
|
|
|
cancelled = None
|
|
with tempfile.TemporaryDirectory(prefix="lightcraft-flatpak-generator-") as tmpdir:
|
|
script = Path(tmpdir) / "flatpak-cargo-generator.py"
|
|
script.write_bytes(generator)
|
|
|
|
descriptor, temp_output = tempfile.mkstemp(
|
|
prefix=f".{output.name}.", suffix=".tmp", dir=output.parent
|
|
)
|
|
os.close(descriptor)
|
|
try:
|
|
environment = os.environ.copy()
|
|
environment["XDG_CACHE_HOME"] = str(cache)
|
|
returncode = run_generator(
|
|
script=script,
|
|
cargo_lock=cargo_lock,
|
|
temp_output=temp_output,
|
|
cwd=repo_root,
|
|
environment=environment,
|
|
)
|
|
if returncode != 0:
|
|
return returncode
|
|
if os.path.getsize(temp_output) == 0:
|
|
raise SystemExit("error: generator produced an empty file")
|
|
with open(temp_output, "r", encoding="utf-8") as handle:
|
|
sources = json.load(handle)
|
|
if not isinstance(sources, list) or not sources:
|
|
raise SystemExit("error: generated sources are not a non-empty list")
|
|
# Keep the owner-only mkstemp mode through the atomic replacement.
|
|
os.replace(temp_output, output)
|
|
except subprocess.TimeoutExpired as error:
|
|
raise SystemExit(
|
|
f"error: inventory generation exceeded {GENERATOR_TIMEOUT_SECONDS} seconds; "
|
|
"the existing inventory was not changed"
|
|
) from error
|
|
except _Cancelled as event:
|
|
# Replayed below, after the temporary file and script directory are
|
|
# removed, so cancellation leaves no leftovers.
|
|
cancelled = event
|
|
finally:
|
|
if os.path.exists(temp_output):
|
|
os.unlink(temp_output)
|
|
|
|
if cancelled is not None:
|
|
_replay_cancellation(cancelled.signum)
|
|
raise SystemExit(f"error: generator cancelled by signal {cancelled.signum}")
|
|
|
|
print(f"wrote {output}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|