Skip to content

Commit 5c7c6d2

Browse files
committed
[setup] Rewrite dpkg_install_from_wget in Python
This removes the special case logic for "remove bazel first", but (1) now that we're installing with apt-get instead of dpkg, hopefully that doesn't matter in practice, and (2) upstream bazel hasn't shipped a new deb in several years and their apt site only goes up to 22.04, so hopefully this isn't a problem anymore.
1 parent 6763e71 commit 5c7c6d2

6 files changed

Lines changed: 388 additions & 108 deletions

File tree

setup/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package(default_visibility = ["//visibility:private"])
99
PREREQUISITES_DATA = glob([
1010
"**/Brewfile*",
1111
"**/*.lock",
12+
"**/*.json",
1213
"**/*.toml",
1314
"**/*.txt",
1415
])

setup/install_prereqs.py

Lines changed: 224 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import argparse
1010
import functools
11+
import hashlib
12+
import json
1113
import logging
1214
import os
1315
from pathlib import Path
@@ -16,7 +18,10 @@
1618
import shlex
1719
import subprocess
1820
import sys
21+
import tempfile
1922
import textwrap
23+
import urllib.parse
24+
import urllib.request
2025

2126
_MY_DIR: Path = Path(__file__).parent
2227
"""The directory containing this script, used to locate our data assets."""
@@ -38,38 +43,228 @@ def _workspace_dir() -> Path:
3843
return _MY_DIR.parent
3944

4045

46+
@functools.cache
47+
def _is_ubuntu() -> bool:
48+
if platform.system() == "Linux":
49+
os_release = platform.freedesktop_os_release()["ID"]
50+
return os_release == "ubuntu"
51+
return False
52+
53+
54+
@functools.cache
55+
def _os_codename() -> str:
56+
if platform.system() == "Linux":
57+
return platform.freedesktop_os_release()["VERSION_CODENAME"]
58+
raise NotImplementedError(platform.system())
59+
60+
61+
def _check_sudo() -> None:
62+
"""Checks that 'sudo' has sufficient credentials."""
63+
subprocess.check_call(["sudo", "-v"])
64+
65+
4166
def _run(
4267
*,
4368
args: list,
4469
cwd: Path | None = None,
70+
check: bool = True,
71+
superuser: bool = False,
4572
quiet: bool = False,
46-
) -> None:
47-
"""Runs a subprocess command given by `args`. Failure of the command is an
48-
`_error`. When `quiet` is true, the command line will not be printed by
49-
default.
50-
"""
73+
interactive: bool = False,
74+
) -> subprocess.CompletedProcess:
75+
"""Runs a subprocess command given by `args`. When `check` is true, failure
76+
of the command is an `_error`. When `superuser` is true, the command will
77+
be run under 'sudo' unless the euid is already root. When `quiet` is
78+
true, the command line will not be printed by default. When `interactive`
79+
is true, input is allowed and output is unbuffered. Returns the completed
80+
process object."""
5181
command = args[0]
82+
if superuser and os.geteuid() != 0:
83+
_check_sudo()
84+
args = ["sudo"] + args
5285
logging.log(
5386
msg=f"Running: {shlex.join(args)} ...",
5487
level=logging.DEBUG if quiet else logging.INFO,
5588
)
5689
process = subprocess.run(
5790
args,
5891
cwd=cwd,
59-
stdin=subprocess.DEVNULL,
60-
stdout=subprocess.PIPE,
61-
stderr=subprocess.STDOUT,
92+
stdin=subprocess.DEVNULL if not interactive else None,
93+
stdout=subprocess.PIPE if not interactive else None,
94+
stderr=subprocess.STDOUT if not interactive else None,
6295
text=True,
6396
)
64-
problem = process.returncode != 0
65-
for line in process.stdout.splitlines():
66-
logging.log(
67-
msg=f"... from {command}: {line}",
68-
level=logging.INFO if problem else logging.DEBUG,
69-
)
97+
problem = check and (process.returncode != 0)
98+
if process.stdout is not None:
99+
for line in process.stdout.splitlines():
100+
logging.log(
101+
msg=f"... from {command}: {line}",
102+
level=logging.INFO if problem else logging.DEBUG,
103+
)
70104
logging.debug(f"... finished {command}.")
71105
if problem:
72106
_error(f"{command} failed with returncode {process.returncode}")
107+
return process
108+
109+
110+
def _get_dpkg_versions(package_names: list[str]) -> dict[str, str]:
111+
"""Returns the installed version of packages. The input is a list of
112+
package names, and the return value is a dict mapping all of those
113+
names to their installed version (or None, if not installed)."""
114+
assert package_names
115+
result = {}
116+
for name in package_names:
117+
result[name] = None
118+
process = _run(
119+
args=[
120+
"dpkg-query",
121+
"--show",
122+
"--showformat=${Package} ${db:Status-Abbrev} ${Version}\n",
123+
]
124+
+ package_names,
125+
check=False,
126+
quiet=True,
127+
)
128+
for line in process.stdout.splitlines():
129+
tokens = line.split()
130+
if len(tokens) != 3:
131+
continue
132+
name, status, version = tokens
133+
if status == "ii":
134+
result[name] = version
135+
logging.debug(f"dpkg_versions = {result!r}")
136+
return result
137+
138+
139+
def _apt_install(*, package_names: list[str], yes: bool) -> None:
140+
"""Installs the given packages using 'apt-get'.
141+
The `yes` flag is passed along to apt as `--yes`."""
142+
assert package_names
143+
args = [
144+
"apt-get",
145+
"install",
146+
"--no-install-recommends",
147+
]
148+
if yes:
149+
args.append("--yes")
150+
args.extend(package_names)
151+
process = _run(args=args, superuser=True, check=yes)
152+
if process.returncode == 0:
153+
return
154+
# We can only reach here when yes=False (i.e., check=False). The apt-get
155+
# command didn't work, and the most likely reason is it needs Y/n input
156+
# from the user, so we'll try it again allowing for user input.
157+
_run(args=args, superuser=True, interactive=True, quiet=True)
158+
159+
160+
def _download(*, temp_dir: Path, package: dict) -> Path:
161+
"""Downloads a `*.deb` package a denoted by the given `package` entry loaded
162+
from the setupo/ubuntu/packages.json file. Returns its path inside temp_dir.
163+
"""
164+
name = package["name"]
165+
version = package["version"]
166+
urls = package["urls"]
167+
sha256 = package["sha256"]
168+
169+
logging.info(f"Downloading {name} {version} ...")
170+
171+
# Try each url in turn.
172+
errors = []
173+
for url in urls:
174+
logging.debug(f"Trying {url} ...")
175+
basename = urllib.parse.urlparse(url).path.split("/")[-1]
176+
temp_filename = temp_dir / basename
177+
hasher = hashlib.sha256()
178+
with temp_filename.open("wb") as f:
179+
try:
180+
with urllib.request.urlopen(url=url, timeout=30) as response:
181+
while True:
182+
data = response.read(4096)
183+
if not data:
184+
break
185+
hasher.update(data)
186+
f.write(data)
187+
except OSError as e:
188+
errors.append(f"Candidate {url} failed:\n{e}")
189+
continue
190+
download_sha256 = hasher.hexdigest()
191+
if download_sha256 == sha256:
192+
return temp_filename
193+
errors.append(
194+
f"Candidate {url} failed:\n"
195+
f"Checksum mismatch; was {download_sha256} but wanted {sha256}."
196+
)
197+
198+
# No downloads succeeded.
199+
messages = "\n\n".join(errors)
200+
_error(f"All downloads failed:\n\n{messages}")
201+
202+
203+
def _install_downloaded_debs(*, yes: bool) -> None:
204+
"""Downloads and installs required debs for --developer that are not
205+
available in Ubuntu's apt site.
206+
The `yes` flag is passed along to apt as `--yes`."""
207+
deb_arch = {
208+
"x86_64": "amd64",
209+
"aarch64": "arm64",
210+
}[platform.machine().lower()]
211+
212+
# Load the list of packages and filter for the relevant ones.
213+
json_filename = _MY_DIR / "ubuntu/packages.json"
214+
packages = {}
215+
for package in json.loads(json_filename.read_text(encoding="utf-8")):
216+
assert package["type"] == "download_deb"
217+
name = package["name"]
218+
if deb_arch not in package["arches"]:
219+
continue
220+
if _os_codename() not in package["codenames"]:
221+
continue
222+
assert name not in packages, name
223+
packages[name] = package
224+
if not packages:
225+
return
226+
227+
# Check what's already installed in case we can skip some.
228+
all_names = list(packages.keys())
229+
for name, installed_version in _get_dpkg_versions(all_names).items():
230+
desired_version = packages[name]["version"]
231+
if installed_version is None:
232+
continue
233+
234+
# Check if already installed at the exact version.
235+
if installed_version == desired_version:
236+
logging.debug(f"{name} already installed at the desired version.")
237+
del packages[name]
238+
continue
239+
240+
# Check if already installed at a newer version.
241+
comparison = _run(
242+
args=[
243+
"dpkg",
244+
"--compare-versions",
245+
installed_version,
246+
"gt",
247+
desired_version,
248+
],
249+
quiet=True,
250+
check=False,
251+
)
252+
if comparison.returncode == 0:
253+
logging.info(
254+
f"Not downgrading {name} from {installed_version=} "
255+
f"to {desired_version=}."
256+
)
257+
del packages[name]
258+
continue
259+
260+
# Download and install the necessary file(s).
261+
if packages:
262+
with tempfile.TemporaryDirectory(prefix="drake_prereqs_") as temp:
263+
paths = [
264+
str(_download(temp_dir=Path(temp), package=package))
265+
for package in packages.values()
266+
]
267+
_apt_install(package_names=paths, yes=yes)
73268

74269

75270
def _setup_user_environment():
@@ -84,12 +279,9 @@ def _setup_user_environment():
84279
# Compute the bazel rcfile snippet. This is always created, but only needs
85280
# content for Drake Developers on Linux.
86281
bazelrc_content = ""
87-
if sys.platform == "linux":
88-
os_release = platform.freedesktop_os_release()
282+
if _is_ubuntu():
89283
developer_txt = (
90-
_MY_DIR
91-
/ "ubuntu"
92-
/ f"packages-{os_release['VERSION_CODENAME']}-developer.txt"
284+
_MY_DIR / "ubuntu" / f"packages-{_os_codename()}-developer.txt"
93285
)
94286
clang_re = re.compile("^clang-([0-9]+)$")
95287
for line in developer_txt.read_text(encoding="utf-8").splitlines():
@@ -155,12 +347,17 @@ def main():
155347
"but don't install any system-wide packages."
156348
),
157349
)
158-
for name in ("--without-update", "-y"):
159-
parser.add_argument(
160-
name,
161-
action="store_true",
162-
help="Ignored for forwards compatibility.",
163-
)
350+
parser.add_argument(
351+
"--without-update",
352+
action="store_true",
353+
help="Ignored for forwards compatibility.",
354+
)
355+
parser.add_argument(
356+
"-y",
357+
action="store_true",
358+
dest="yes",
359+
help="Install without prompting for confirmation.",
360+
)
164361
parser.add_argument(
165362
"--verbose",
166363
action="store_true",
@@ -172,6 +369,8 @@ def main():
172369

173370
# We are in the process of migrating our bash setup code into this file.
174371
# Anything not set up here was already setup by install_prereqs.sh.
372+
if _is_ubuntu() and args.developer:
373+
_install_downloaded_debs(yes=args.yes)
175374
if args.developer or args.user_environment_only:
176375
_setup_user_environment()
177376
if args.developer:

0 commit comments

Comments
 (0)