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."""
@@ -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+
4972def _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
83277def _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