from __future__ import annotations
import argparse
import os
import subprocess
import sys
import tempfile
import time
from typing import Callable, Sequence
APT_OPTIONS = (
"-o", "Acquire::Retries=3",
"-o", "Acquire::http::Timeout=30",
"-o", "Acquire::https::Timeout=30",
"-o", "DPkg::Lock::Timeout=120",
)
MIRROR_LIST = "/etc/apt/apt-mirrors.txt"
TIMED_OUT = 124
Runner = Callable[[Sequence[str]], int]
def run(command: Sequence[str]) -> int:
return subprocess.run(command, check=False).returncode
def limited(command: Sequence[str], seconds: int) -> list[str]:
return ["sudo", "timeout", "--kill-after=30", str(seconds), *command]
def drop_first_mirror(path: str, runner: Runner) -> None:
try:
with open(path, encoding="utf-8") as f:
lines = f.readlines()
except OSError:
return
mirrors = [line for line in lines if line.strip()]
if len(mirrors) < 2:
return
print(f"apt_install: no longer using mirror {mirrors[0].split()[0]}", flush=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as f:
f.writelines(mirrors[1:])
try:
runner(["sudo", "cp", f.name, path])
finally:
os.unlink(f.name)
def install(
packages: Sequence[str],
attempts: int,
attempt_timeout: int,
runner: Runner | None = None,
sleep: Callable[[float], None] | None = None,
mirror_list: str | None = None,
) -> bool:
runner = runner or run
sleep = sleep or time.sleep
mirror_list = mirror_list or MIRROR_LIST
commands = {
"update": ["apt-get", *APT_OPTIONS, "update"],
"install": ["apt-get", *APT_OPTIONS, "install", "-y", "--no-install-recommends", *packages],
}
for attempt in range(1, attempts + 1):
if attempt > 1:
pause = 10 * (attempt - 1)
print(f"apt_install: retrying in {pause} s", flush=True)
sleep(pause)
if attempt == 2:
drop_first_mirror(mirror_list, runner)
runner(limited(["dpkg", "--configure", "-a"], attempt_timeout))
print(f"apt_install: attempt {attempt} of {attempts}", flush=True)
for name, command in commands.items():
status = runner(limited(command, attempt_timeout))
if status != 0:
reason = (
f"timed out after {attempt_timeout} s"
if status == TIMED_OUT
else f"exited with status {status}"
)
print(f"apt_install: `apt-get {name}` {reason}", flush=True)
break
else:
print(f"apt_install: installed {' '.join(packages)}", flush=True)
return True
print(f"apt_install: giving up after {attempts} attempts", flush=True)
return False
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--attempts", type=int, default=3)
parser.add_argument(
"--attempt-timeout",
type=int,
default=240,
help="seconds each apt-get command may run before it is killed",
)
parser.add_argument("packages", nargs="+")
args = parser.parse_args(argv)
return 0 if install(args.packages, args.attempts, args.attempt_timeout) else 1
if __name__ == "__main__":
sys.exit(main())