Skip to content

Commit d054a9c

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 0aff249 commit d054a9c

6 files changed

Lines changed: 419 additions & 116 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: 223 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."""
@@ -46,38 +51,227 @@ def _is_ubuntu() -> bool:
4651
return False
4752

4853

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

82276

83277
def _setup_user_environment():
@@ -92,12 +286,9 @@ def _setup_user_environment():
92286
# Compute the bazel rcfile snippet. This is always created, but only needs
93287
# content for Drake Developers on Linux.
94288
bazelrc_content = ""
95-
if sys.platform == "linux":
96-
os_release = platform.freedesktop_os_release()
289+
if _is_ubuntu():
97290
developer_txt = (
98-
_MY_DIR
99-
/ "ubuntu"
100-
/ f"packages-{os_release['VERSION_CODENAME']}-developer.txt"
291+
_MY_DIR / "ubuntu" / f"packages-{_os_codename()}-developer.txt"
101292
)
102293
clang_re = re.compile("^clang-([0-9]+)$")
103294
for line in developer_txt.read_text(encoding="utf-8").splitlines():
@@ -191,12 +382,17 @@ def main():
191382
"but don't install any system-wide packages."
192383
),
193384
)
194-
for name in ("--without-update", "-y"):
195-
parser.add_argument(
196-
name,
197-
action="store_true",
198-
help="Ignored for forwards compatibility.",
199-
)
385+
parser.add_argument(
386+
"--without-update",
387+
action="store_true",
388+
help="Ignored for forwards compatibility.",
389+
)
390+
parser.add_argument(
391+
"-y",
392+
action="store_true",
393+
dest="yes",
394+
help="Install without prompting for confirmation.",
395+
)
200396
parser.add_argument(
201397
"--verbose",
202398
action="store_true",
@@ -208,6 +404,8 @@ def main():
208404

209405
# We are in the process of migrating our bash setup code into this file.
210406
# Anything not set up here was already setup by install_prereqs.sh.
407+
if _is_ubuntu() and args.developer:
408+
_install_downloaded_debs(yes=args.yes)
211409
if args.developer or args.user_environment_only:
212410
_setup_user_environment()
213411
if args.developer:

0 commit comments

Comments
 (0)