summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMike Frysinger <vapier@google.com>2021-01-07 22:14:25 -0500
committerMike Frysinger <vapier@google.com>2021-01-19 16:48:21 +0000
commite5670c881225ed025c77e0362a7c7edcc912ef9f (patch)
tree39bff697aee8c2b318e7e10c7a5e92f365b6f439
parent48b2d10d8f7565173ca53bed0d0be15323512de4 (diff)
downloadgit-repo-e5670c881225ed025c77e0362a7c7edcc912ef9f.tar.gz
launcher: add a requirements framework to declare version dependencies
Currently we don't have a way for the checked out repo version to declare the version of tools it needs before we start running it. For somethings, like git, it's not a big deal as it can handle all the asserts itself. But for things like Python, it's impossible to reliably check before executing. We're in this state now: - we've been allowing Python 3.4, so the launcher accepts it - the repo codebase starts using Python 3.6 features - launcher tries to import us but hits syntax errors - user is left confused and assuming new repo is broken because they're seeing syntax errors This scenario is playing out with old launchers that still accept Python 2, and will continue to play out as time goes on and we want to require newer versions of Python 3. Lets create a JSON file to declare all these system requirements. That file format is extremely stable, so loading & parsing from even ancient versions of Python shouldn't be a problem. Then the launcher can read these settings and check the system state before attempting to execute any code. If the tools are too old, it can clearly diagnose & display information to the user as to the real problem (and not emit tracebacks or syntax errors). We have a couple of different tool version checks already (git, python, ssh) and can harmonize them in a single place. This also allows us to assert a reverse dependency if the need ever comes up: force the user to upgrade their `repo` launcher before we'll let them run us. Even though the launcher warns whenever a newer release is available, some users seem to ignore that, or they don't use repo that often (on the scale of years), and their upgrade jump is so dramatic that they fall back into the syntax error pit. Hopefully by the end of the year we can assume enough people have upgraded their launcher such that we can delete all of the duplicate version checks in the codebase. But until then, we'll keep them to maintain coverage. Change-Id: I5c12bbffdfd0a8ce978f39aa7f4674026fe9f4f8 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/293003 Reviewed-by: Michael Mortensen <mmortensen@google.com> Tested-by: Mike Frysinger <vapier@google.com>
-rwxr-xr-xrepo89
-rw-r--r--requirements.json57
-rw-r--r--tests/test_wrapper.py76
3 files changed, 222 insertions, 0 deletions
diff --git a/repo b/repo
index 8f13015f..8936f57b 100755
--- a/repo
+++ b/repo
@@ -246,6 +246,7 @@ GITC_FS_ROOT_DIR = '/gitc/manifest-rw/'
246 246
247import collections 247import collections
248import errno 248import errno
249import json
249import optparse 250import optparse
250import re 251import re
251import shutil 252import shutil
@@ -1035,6 +1036,90 @@ def _ParseArguments(args):
1035 return cmd, opt, arg 1036 return cmd, opt, arg
1036 1037
1037 1038
1039class Requirements(object):
1040 """Helper for checking repo's system requirements."""
1041
1042 REQUIREMENTS_NAME = 'requirements.json'
1043
1044 def __init__(self, requirements):
1045 """Initialize.
1046
1047 Args:
1048 requirements: A dictionary of settings.
1049 """
1050 self.requirements = requirements
1051
1052 @classmethod
1053 def from_dir(cls, path):
1054 return cls.from_file(os.path.join(path, cls.REQUIREMENTS_NAME))
1055
1056 @classmethod
1057 def from_file(cls, path):
1058 try:
1059 with open(path, 'rb') as f:
1060 data = f.read()
1061 except EnvironmentError:
1062 # NB: EnvironmentError is used for Python 2 & 3 compatibility.
1063 # If we couldn't open the file, assume it's an old source tree.
1064 return None
1065
1066 return cls.from_data(data)
1067
1068 @classmethod
1069 def from_data(cls, data):
1070 comment_line = re.compile(br'^ *#')
1071 strip_data = b''.join(x for x in data.splitlines() if not comment_line.match(x))
1072 try:
1073 json_data = json.loads(strip_data)
1074 except Exception: # pylint: disable=broad-except
1075 # If we couldn't parse it, assume it's incompatible.
1076 return None
1077
1078 return cls(json_data)
1079
1080 def _get_soft_ver(self, pkg):
1081 """Return the soft version for |pkg| if it exists."""
1082 return self.requirements.get(pkg, {}).get('soft', ())
1083
1084 def _get_hard_ver(self, pkg):
1085 """Return the hard version for |pkg| if it exists."""
1086 return self.requirements.get(pkg, {}).get('hard', ())
1087
1088 @staticmethod
1089 def _format_ver(ver):
1090 """Return a dotted version from |ver|."""
1091 return '.'.join(str(x) for x in ver)
1092
1093 def assert_ver(self, pkg, curr_ver):
1094 """Verify |pkg|'s |curr_ver| is new enough."""
1095 curr_ver = tuple(curr_ver)
1096 soft_ver = tuple(self._get_soft_ver(pkg))
1097 hard_ver = tuple(self._get_hard_ver(pkg))
1098 if curr_ver < hard_ver:
1099 print('repo: error: Your version of "%s" (%s) is unsupported; '
1100 'Please upgrade to at least version %s to continue.' %
1101 (pkg, self._format_ver(curr_ver), self._format_ver(soft_ver)),
1102 file=sys.stderr)
1103 sys.exit(1)
1104
1105 if curr_ver < soft_ver:
1106 print('repo: warning: Your version of "%s" (%s) is no longer supported; '
1107 'Please upgrade to at least version %s to avoid breakage.' %
1108 (pkg, self._format_ver(curr_ver), self._format_ver(soft_ver)),
1109 file=sys.stderr)
1110
1111 def assert_all(self):
1112 """Assert all of the requirements are satisified."""
1113 # See if we need a repo launcher upgrade first.
1114 self.assert_ver('repo', VERSION)
1115
1116 # Check python before we try to import the repo code.
1117 self.assert_ver('python', sys.version_info)
1118
1119 # Check git while we're at it.
1120 self.assert_ver('git', ParseGitVersion())
1121
1122
1038def _Usage(): 1123def _Usage():
1039 gitc_usage = "" 1124 gitc_usage = ""
1040 if get_gitc_manifest_dir(): 1125 if get_gitc_manifest_dir():
@@ -1192,6 +1277,10 @@ def main(orig_args):
1192 print("fatal: unable to find repo entry point", file=sys.stderr) 1277 print("fatal: unable to find repo entry point", file=sys.stderr)
1193 sys.exit(1) 1278 sys.exit(1)
1194 1279
1280 reqs = Requirements.from_dir(os.path.dirname(repo_main))
1281 if reqs:
1282 reqs.assert_all()
1283
1195 ver_str = '.'.join(map(str, VERSION)) 1284 ver_str = '.'.join(map(str, VERSION))
1196 me = [sys.executable, repo_main, 1285 me = [sys.executable, repo_main,
1197 '--repo-dir=%s' % rel_repo_dir, 1286 '--repo-dir=%s' % rel_repo_dir,
diff --git a/requirements.json b/requirements.json
new file mode 100644
index 00000000..86b9a46c
--- /dev/null
+++ b/requirements.json
@@ -0,0 +1,57 @@
1# This file declares various requirements for this version of repo. The
2# launcher script will load it and check the constraints before trying to run
3# us. This avoids issues of the launcher using an old version of Python (e.g.
4# 3.5) while the codebase has moved on to requiring something much newer (e.g.
5# 3.8). If the launcher tried to import us, it would fail with syntax errors.
6
7# This is a JSON file with line-level comments allowed.
8
9# Always keep backwards compatibility in mine. The launcher script is robust
10# against missing values, but when a field is renamed/removed, it means older
11# versions of the launcher script won't be able to enforce the constraint.
12
13# When requiring versions, always use lists as they are easy to parse & compare
14# in Python. Strings would require futher processing to turn into a list.
15
16# Version constraints should be expressed in pairs: soft & hard. Soft versions
17# are when we start warning users that their software too old and we're planning
18# on dropping support for it, so they need to start planning system upgrades.
19# Hard versions are when we refuse to work the tool. Users will be shown an
20# error message before we abort entirely.
21
22# When deciding whether to upgrade a version requirement, check out the distro
23# lists to see who will be impacted:
24# https://gerrit.googlesource.com/git-repo/+/HEAD/docs/release-process.md#Project-References
25
26{
27 # The repo launcher itself. This allows us to force people to upgrade as some
28 # ignore the warnings about it being out of date, or install ancient versions
29 # to start with for whatever reason.
30 #
31 # NB: Repo launchers started checking this file with repo-2.12, so listing
32 # versions older than that won't make a difference.
33 "repo": {
34 "hard": [2, 11],
35 "soft": [2, 11]
36 },
37
38 # Supported Python versions.
39 #
40 # python-3.6 is in Ubuntu Bionic.
41 # python-3.5 is in Debian Stretch.
42 "python": {
43 "hard": [3, 5],
44 "soft": [3, 6]
45 },
46
47 # Supported git versions.
48 #
49 # git-1.7.2 is in Debian Squeeze.
50 # git-1.7.9 is in Ubuntu Precise.
51 # git-1.9.1 is in Ubuntu Trusty.
52 # git-1.7.10 is in Debian Wheezy.
53 "git": {
54 "hard": [1, 7, 2],
55 "soft": [1, 9, 1]
56 }
57}
diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py
index d8713738..6400faf4 100644
--- a/tests/test_wrapper.py
+++ b/tests/test_wrapper.py
@@ -19,6 +19,7 @@ from io import StringIO
19import os 19import os
20import re 20import re
21import shutil 21import shutil
22import sys
22import tempfile 23import tempfile
23import unittest 24import unittest
24from unittest import mock 25from unittest import mock
@@ -255,6 +256,81 @@ class CheckGitVersion(RepoWrapperTestCase):
255 self.wrapper._CheckGitVersion() 256 self.wrapper._CheckGitVersion()
256 257
257 258
259class Requirements(RepoWrapperTestCase):
260 """Check Requirements handling."""
261
262 def test_missing_file(self):
263 """Don't crash if the file is missing (old version)."""
264 testdir = os.path.dirname(os.path.realpath(__file__))
265 self.assertIsNone(self.wrapper.Requirements.from_dir(testdir))
266 self.assertIsNone(self.wrapper.Requirements.from_file(
267 os.path.join(testdir, 'xxxxxxxxxxxxxxxxxxxxxxxx')))
268
269 def test_corrupt_data(self):
270 """If the file can't be parsed, don't blow up."""
271 self.assertIsNone(self.wrapper.Requirements.from_file(__file__))
272 self.assertIsNone(self.wrapper.Requirements.from_data(b'x'))
273
274 def test_valid_data(self):
275 """Make sure we can parse the file we ship."""
276 self.assertIsNotNone(self.wrapper.Requirements.from_data(b'{}'))
277 rootdir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
278 self.assertIsNotNone(self.wrapper.Requirements.from_dir(rootdir))
279 self.assertIsNotNone(self.wrapper.Requirements.from_file(os.path.join(
280 rootdir, 'requirements.json')))
281
282 def test_format_ver(self):
283 """Check format_ver can format."""
284 self.assertEqual('1.2.3', self.wrapper.Requirements._format_ver((1, 2, 3)))
285 self.assertEqual('1', self.wrapper.Requirements._format_ver([1]))
286
287 def test_assert_all_unknown(self):
288 """Check assert_all works with incompatible file."""
289 reqs = self.wrapper.Requirements({})
290 reqs.assert_all()
291
292 def test_assert_all_new_repo(self):
293 """Check assert_all accepts new enough repo."""
294 reqs = self.wrapper.Requirements({'repo': {'hard': [1, 0]}})
295 reqs.assert_all()
296
297 def test_assert_all_old_repo(self):
298 """Check assert_all rejects old repo."""
299 reqs = self.wrapper.Requirements({'repo': {'hard': [99999, 0]}})
300 with self.assertRaises(SystemExit):
301 reqs.assert_all()
302
303 def test_assert_all_new_python(self):
304 """Check assert_all accepts new enough python."""
305 reqs = self.wrapper.Requirements({'python': {'hard': sys.version_info}})
306 reqs.assert_all()
307
308 def test_assert_all_old_repo(self):
309 """Check assert_all rejects old repo."""
310 reqs = self.wrapper.Requirements({'python': {'hard': [99999, 0]}})
311 with self.assertRaises(SystemExit):
312 reqs.assert_all()
313
314 def test_assert_ver_unknown(self):
315 """Check assert_ver works with incompatible file."""
316 reqs = self.wrapper.Requirements({})
317 reqs.assert_ver('xxx', (1, 0))
318
319 def test_assert_ver_new(self):
320 """Check assert_ver allows new enough versions."""
321 reqs = self.wrapper.Requirements({'git': {'hard': [1, 0], 'soft': [2, 0]}})
322 reqs.assert_ver('git', (1, 0))
323 reqs.assert_ver('git', (1, 5))
324 reqs.assert_ver('git', (2, 0))
325 reqs.assert_ver('git', (2, 5))
326
327 def test_assert_ver_old(self):
328 """Check assert_ver rejects old versions."""
329 reqs = self.wrapper.Requirements({'git': {'hard': [1, 0], 'soft': [2, 0]}})
330 with self.assertRaises(SystemExit):
331 reqs.assert_ver('git', (0, 5))
332
333
258class NeedSetupGnuPG(RepoWrapperTestCase): 334class NeedSetupGnuPG(RepoWrapperTestCase):
259 """Check NeedSetupGnuPG behavior.""" 335 """Check NeedSetupGnuPG behavior."""
260 336