88
99import argparse
1010import functools
11+ import hashlib
12+ import json
1113import logging
1214import os
1315from pathlib import Path
1618import shlex
1719import subprocess
1820import sys
21+ import tempfile
1922import 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+
4166def _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 setup/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
75270def _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