Merge ~mpontillo/maas:rack-version-utils into maas:master

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: f660088482f7947bda6654935deb03c382b7f5df
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~mpontillo/maas:rack-version-utils
Merge into: maas:master
Diff against target: 1137 lines (+461/-159)
17 files modified
dev/null (+0/-142)
src/maasserver/__init__.py (+3/-1)
src/maasserver/api/tests/test_version.py (+1/-1)
src/maasserver/api/version.py (+1/-1)
src/maasserver/bootresources.py (+4/-4)
src/maasserver/bootsources.py (+1/-1)
src/maasserver/context_processors.py (+1/-1)
src/maasserver/tests/test_bootresources.py (+1/-1)
src/maasserver/tests/test_bootsources.py (+1/-1)
src/maasserver/websockets/handlers/bootresource.py (+1/-1)
src/maasserver/websockets/handlers/general.py (+1/-1)
src/maasserver/websockets/handlers/tests/test_bootresource.py (+1/-1)
src/metadataserver/builtin_scripts/__init__.py (+1/-1)
src/metadataserver/builtin_scripts/tests/test_builtin_scripts.py (+1/-1)
src/provisioningserver/utils/tests/test_version.py (+293/-0)
src/provisioningserver/utils/version.py (+149/-0)
utilities/check-imports (+1/-1)
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Newell Jensen Pending
Review via email: mp+327333@code.launchpad.net

This proposal supersedes a proposal from 2017-07-12.

Commit message

Make version check utilities work for the rack.

This allows rack-only installations to determine their version.

Also moves code to determine MAAS version to the src/provisioningserver.

To post a comment you must log in.
Revision history for this message
Newell Jensen (newell-jensen) wrote : Posted in a previous version of this proposal

Looks good. Just one small suggestion.

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Self-approving previously approved branch. (I resubmitted to land two related branches together.)

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
~mpontillo/maas:rack-version-utils updated
f660088... by Mike Pontillo

Fix imports.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/src/maasserver/__init__.py b/src/maasserver/__init__.py
index cd6b4d6..7d85981 100644
--- a/src/maasserver/__init__.py
+++ b/src/maasserver/__init__.py
@@ -13,8 +13,10 @@ __all__ = [
13import logging13import logging
14from os import environ14from os import environ
1515
16from provisioningserver.utils import version
1617
17__version__ = '2.3.0'18
19__version__ = version.DEFAULT_VERSION
1820
19default_app_config = 'maasserver.apps.MAASConfig'21default_app_config = 'maasserver.apps.MAASConfig'
2022
diff --git a/src/maasserver/api/tests/test_version.py b/src/maasserver/api/tests/test_version.py
index b9204d2..defa1fb 100644
--- a/src/maasserver/api/tests/test_version.py
+++ b/src/maasserver/api/tests/test_version.py
@@ -13,7 +13,7 @@ from django.conf import settings
13from django.core.urlresolvers import reverse13from django.core.urlresolvers import reverse
14from maasserver.api.version import API_CAPABILITIES_LIST14from maasserver.api.version import API_CAPABILITIES_LIST
15from maasserver.testing.api import APITestCase15from maasserver.testing.api import APITestCase
16from maasserver.utils import version as version_module16from provisioningserver.utils import version as version_module
1717
1818
19class TestVersionAPIBasics(APITestCase.ForAnonymousAndUserAndAdmin):19class TestVersionAPIBasics(APITestCase.ForAnonymousAndUserAndAdmin):
diff --git a/src/maasserver/api/version.py b/src/maasserver/api/version.py
index 3fc557b..77bb317 100644
--- a/src/maasserver/api/version.py
+++ b/src/maasserver/api/version.py
@@ -12,7 +12,7 @@ import json
1212
13from django.http import HttpResponse13from django.http import HttpResponse
14from maasserver.api.support import AnonymousOperationsHandler14from maasserver.api.support import AnonymousOperationsHandler
15from maasserver.utils.version import get_maas_version_subversion15from provisioningserver.utils.version import get_maas_version_subversion
1616
17# MAAS capabilities. See docs/version.rst for documentation.17# MAAS capabilities. See docs/version.rst for documentation.
18CAP_NETWORKS_MANAGEMENT = 'networks-management'18CAP_NETWORKS_MANAGEMENT = 'networks-management'
diff --git a/src/maasserver/bootresources.py b/src/maasserver/bootresources.py
index 996a6a0..fd7ede4 100644
--- a/src/maasserver/bootresources.py
+++ b/src/maasserver/bootresources.py
@@ -77,10 +77,6 @@ from maasserver.utils.orm import (
77 with_connection,77 with_connection,
78)78)
79from maasserver.utils.threads import deferToDatabase79from maasserver.utils.threads import deferToDatabase
80from maasserver.utils.version import (
81 get_maas_version_tuple,
82 get_maas_version_user_agent,
83)
84from provisioningserver.config import is_dev_environment80from provisioningserver.config import is_dev_environment
85from provisioningserver.events import EVENT_TYPES81from provisioningserver.events import EVENT_TYPES
86from provisioningserver.import_images.download_descriptions import (82from provisioningserver.import_images.download_descriptions import (
@@ -111,6 +107,10 @@ from provisioningserver.utils.twisted import (
111 pause,107 pause,
112 synchronous,108 synchronous,
113)109)
110from provisioningserver.utils.version import (
111 get_maas_version_tuple,
112 get_maas_version_user_agent,
113)
114from simplestreams import util as sutil114from simplestreams import util as sutil
115from simplestreams.mirrors import (115from simplestreams.mirrors import (
116 BasicMirrorWriter,116 BasicMirrorWriter,
diff --git a/src/maasserver/bootsources.py b/src/maasserver/bootsources.py
index 999a6d8..2cd5ab2 100644
--- a/src/maasserver/bootsources.py
+++ b/src/maasserver/bootsources.py
@@ -27,7 +27,6 @@ from maasserver.models import (
27)27)
28from maasserver.utils.orm import transactional28from maasserver.utils.orm import transactional
29from maasserver.utils.threads import deferToDatabase29from maasserver.utils.threads import deferToDatabase
30from maasserver.utils.version import get_maas_version_user_agent
31from provisioningserver.auth import get_maas_user_gpghome30from provisioningserver.auth import get_maas_user_gpghome
32from provisioningserver.config import (31from provisioningserver.config import (
33 DEFAULT_IMAGES_URL,32 DEFAULT_IMAGES_URL,
@@ -44,6 +43,7 @@ from provisioningserver.utils.twisted import (
44 asynchronous,43 asynchronous,
45 FOREVER,44 FOREVER,
46)45)
46from provisioningserver.utils.version import get_maas_version_user_agent
47from requests.exceptions import ConnectionError47from requests.exceptions import ConnectionError
48from twisted.internet.defer import inlineCallbacks48from twisted.internet.defer import inlineCallbacks
4949
diff --git a/src/maasserver/context_processors.py b/src/maasserver/context_processors.py
index da8e817..697ec15 100644
--- a/src/maasserver/context_processors.py
+++ b/src/maasserver/context_processors.py
@@ -11,7 +11,7 @@ __all__ = [
11from django.conf import settings11from django.conf import settings
12from maasserver.config import RegionConfiguration12from maasserver.config import RegionConfiguration
13from maasserver.models import Config13from maasserver.models import Config
14from maasserver.utils.version import (14from provisioningserver.utils.version import (
15 get_maas_doc_version,15 get_maas_doc_version,
16 get_maas_version_ui,16 get_maas_version_ui,
17)17)
diff --git a/src/maasserver/tests/test_bootresources.py b/src/maasserver/tests/test_bootresources.py
index 135715b..b3fd049 100644
--- a/src/maasserver/tests/test_bootresources.py
+++ b/src/maasserver/tests/test_bootresources.py
@@ -92,7 +92,6 @@ from maasserver.utils.orm import (
92 transactional,92 transactional,
93)93)
94from maasserver.utils.threads import deferToDatabase94from maasserver.utils.threads import deferToDatabase
95from maasserver.utils.version import get_maas_version_user_agent
96from maastesting.matchers import (95from maastesting.matchers import (
97 MockCalledOnce,96 MockCalledOnce,
98 MockCalledOnceWith,97 MockCalledOnceWith,
@@ -115,6 +114,7 @@ from provisioningserver.utils.twisted import (
115 asynchronous,114 asynchronous,
116 DeferredValue,115 DeferredValue,
117)116)
117from provisioningserver.utils.version import get_maas_version_user_agent
118from testtools.matchers import (118from testtools.matchers import (
119 Contains,119 Contains,
120 ContainsAll,120 ContainsAll,
diff --git a/src/maasserver/tests/test_bootsources.py b/src/maasserver/tests/test_bootsources.py
index ecba7ce..d7f8ce8 100644
--- a/src/maasserver/tests/test_bootsources.py
+++ b/src/maasserver/tests/test_bootsources.py
@@ -42,7 +42,6 @@ from maasserver.testing.testcase import (
42 MAASTransactionServerTestCase,42 MAASTransactionServerTestCase,
43)43)
44from maasserver.tests.test_bootresources import SimplestreamsEnvFixture44from maasserver.tests.test_bootresources import SimplestreamsEnvFixture
45from maasserver.utils.version import get_maas_version_user_agent
46from maastesting.matchers import MockCalledOnceWith45from maastesting.matchers import MockCalledOnceWith
47from provisioningserver.config import DEFAULT_IMAGES_URL46from provisioningserver.config import DEFAULT_IMAGES_URL
48from provisioningserver.import_images import (47from provisioningserver.import_images import (
@@ -52,6 +51,7 @@ from provisioningserver.import_images.boot_image_mapping import (
52 BootImageMapping,51 BootImageMapping,
53)52)
54from provisioningserver.import_images.helpers import ImageSpec53from provisioningserver.import_images.helpers import ImageSpec
54from provisioningserver.utils.version import get_maas_version_user_agent
55from requests.exceptions import ConnectionError55from requests.exceptions import ConnectionError
56from testtools.matchers import HasLength56from testtools.matchers import HasLength
5757
diff --git a/src/maasserver/utils/tests/test_version.py b/src/maasserver/utils/tests/test_version.py
58deleted file mode 10064458deleted file mode 100644
index 92b31dd..0000000
--- a/src/maasserver/utils/tests/test_version.py
+++ /dev/null
@@ -1,278 +0,0 @@
1# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test version utilities."""
5
6__all__ = []
7
8import os.path
9import random
10from unittest import skipUnless
11from unittest.mock import (
12 MagicMock,
13 sentinel,
14)
15
16from maasserver import __version__ as old_version
17from maasserver.utils import version
18from maastesting import root
19from maastesting.matchers import MockCalledOnceWith
20from maastesting.testcase import MAASTestCase
21from provisioningserver.utils import (
22 shell,
23 snappy,
24)
25from testtools.matchers import (
26 GreaterThan,
27 Is,
28 IsInstance,
29)
30
31
32class TestGetVersionFromAPT(MAASTestCase):
33
34 def test__creates_cache_with_None_progress(self):
35 mock_Cache = self.patch(version.apt_pkg, "Cache")
36 version.get_version_from_apt(version.REGION_PACKAGE_NAME)
37 self.assertThat(mock_Cache, MockCalledOnceWith(None))
38
39 def test__returns_empty_string_if_package_not_in_cache(self):
40 self.patch(version.apt_pkg, "Cache")
41 self.assertEqual(
42 "",
43 version.get_version_from_apt(version.REGION_PACKAGE_NAME))
44
45 def test__returns_empty_string_if_not_current_ver_from_package(self):
46 package = MagicMock()
47 package.current_ver = None
48 mock_cache = {
49 version.REGION_PACKAGE_NAME: package,
50 }
51 self.patch(version.apt_pkg, "Cache").return_value = mock_cache
52 self.assertEqual(
53 "",
54 version.get_version_from_apt(version.REGION_PACKAGE_NAME))
55
56 def test__returns_ver_str_from_package(self):
57 package = MagicMock()
58 package.current_ver.ver_str = sentinel.ver_str
59 mock_cache = {
60 version.REGION_PACKAGE_NAME: package,
61 }
62 self.patch(version.apt_pkg, "Cache").return_value = mock_cache
63 self.assertIs(
64 sentinel.ver_str,
65 version.get_version_from_apt(version.REGION_PACKAGE_NAME))
66
67
68class TestGetMAASBranchVersion(MAASTestCase):
69
70 def test__returns_None_if_this_is_not_a_branch(self):
71 self.patch(version, "__file__", "/")
72 self.assertIsNone(version.get_maas_branch_version())
73
74 def test__returns_None_if_bzr_crashes(self):
75 call_and_check = self.patch(shell, "call_and_check")
76 call_and_check.side_effect = shell.ExternalProcessError(2, "cmd")
77 self.assertIsNone(version.get_maas_branch_version())
78
79 def test__returns_None_if_bzr_not_found(self):
80 call_and_check = self.patch(shell, "call_and_check")
81 call_and_check.side_effect = FileNotFoundError()
82 self.assertIsNone(version.get_maas_branch_version())
83
84 def test__returns_None_if_bzr_emits_something_thats_not_a_number(self):
85 call_and_check = self.patch(shell, "call_and_check")
86 call_and_check.return_value = b"???"
87 self.assertIsNone(version.get_maas_branch_version())
88
89 @skipUnless(os.path.isdir(os.path.join(root, ".bzr")), "Not a branch")
90 def test__returns_revno_for_this_branch(self):
91 revno = version.get_maas_branch_version()
92 self.assertThat(revno, IsInstance(int))
93 self.assertThat(revno, GreaterThan(0))
94
95
96class TestExtractVersionSubversion(MAASTestCase):
97
98 scenarios = [
99 ("with ~", {
100 "version": "2.2.0~beta4+bzr5856-0ubuntu1",
101 "output": ("2.2.0~beta4", "bzr5856-0ubuntu1"),
102 }),
103 ("without ~", {
104 "version": "2.1.0+bzr5480-0ubuntu1",
105 "output": ("2.1.0", "bzr5480-0ubuntu1"),
106 }),
107 ("without ~ or +", {
108 "version": "2.1.0-0ubuntu1",
109 "output": ("2.1.0", "0ubuntu1"),
110 }),
111 ]
112
113 def test__returns_version_subversion(self):
114 self.assertEqual(
115 self.output, version.extract_version_subversion(self.version))
116
117
118class TestVersionTestCase(MAASTestCase):
119 """MAASTestCase that resets the cache used by utility methods."""
120
121 def setUp(self):
122 super(TestVersionTestCase, self).setUp()
123 for attribute in vars(version).values():
124 if hasattr(attribute, "cache_clear"):
125 attribute.cache_clear()
126
127
128class TestGetMAASVersion(TestVersionTestCase):
129
130 def test__calls_get_version_from_apt(self):
131 mock_apt = self.patch(version, "get_version_from_apt")
132 mock_apt.return_value = sentinel.version
133 self.expectThat(
134 version.get_maas_version(), Is(sentinel.version))
135 self.expectThat(
136 mock_apt, MockCalledOnceWith(version.REGION_PACKAGE_NAME))
137
138 def test__uses_snappy_get_snap_version(self):
139 self.patch(snappy, 'running_in_snap').return_value = True
140 self.patch(snappy, 'get_snap_version').return_value = sentinel.version
141 self.assertEqual(sentinel.version, version.get_maas_version())
142
143
144class TestGetMAASVersionSubversion(TestVersionTestCase):
145
146 def test__returns_package_version(self):
147 mock_apt = self.patch(version, "get_version_from_apt")
148 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
149 self.assertEqual(
150 ("1.8.0~alpha4", "bzr356-0ubuntu1"),
151 version.get_maas_version_subversion())
152
153 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
154 mock_version = self.patch(version, "get_version_from_apt")
155 mock_version.return_value = ""
156 mock_branch_version = self.patch(version, "get_maas_branch_version")
157 mock_branch_version.return_value = None
158 self.assertEqual(
159 (old_version, "unknown"),
160 version.get_maas_version_subversion())
161
162 def test__returns_from_source_and_revno_from_branch(self):
163 mock_version = self.patch(version, "get_version_from_apt")
164 mock_version.return_value = ""
165 revno = random.randint(1, 5000)
166 mock_branch_version = self.patch(version, "get_maas_branch_version")
167 mock_branch_version.return_value = revno
168 self.assertEqual(
169 ("%s from source" % old_version, "bzr%d" % revno),
170 version.get_maas_version_subversion())
171
172
173class TestGetMAASVersionUI(TestVersionTestCase):
174
175 def test__returns_package_version(self):
176 mock_apt = self.patch(version, "get_version_from_apt")
177 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
178 self.assertEqual(
179 "1.8.0~alpha4 (bzr356-0ubuntu1)", version.get_maas_version_ui())
180
181 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
182 mock_version = self.patch(version, "get_version_from_apt")
183 mock_version.return_value = ""
184 mock_branch_version = self.patch(version, "get_maas_branch_version")
185 mock_branch_version.return_value = None
186 self.assertEqual(
187 "%s (unknown)" % old_version,
188 version.get_maas_version_ui())
189
190 def test__returns_from_source_and_revno_from_branch(self):
191 mock_version = self.patch(version, "get_version_from_apt")
192 mock_version.return_value = ""
193 revno = random.randint(1, 5000)
194 mock_branch_version = self.patch(version, "get_maas_branch_version")
195 mock_branch_version.return_value = revno
196 self.assertEqual(
197 "%s from source (bzr%d)" % (old_version, revno),
198 version.get_maas_version_ui())
199
200
201class TestGetMAASVersionUserAgent(TestVersionTestCase):
202
203 def test__returns_package_version(self):
204 mock_apt = self.patch(version, "get_version_from_apt")
205 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
206 self.assertEqual(
207 "maas/1.8.0~alpha4/bzr356-0ubuntu1",
208 version.get_maas_version_user_agent())
209
210 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
211 mock_version = self.patch(version, "get_version_from_apt")
212 mock_version.return_value = ""
213 mock_branch_version = self.patch(version, "get_maas_branch_version")
214 mock_branch_version.return_value = None
215 self.assertEqual(
216 "maas/%s/unknown" % old_version,
217 version.get_maas_version_user_agent())
218
219 def test__returns_from_source_and_revno_from_branch(self):
220 mock_version = self.patch(version, "get_version_from_apt")
221 mock_version.return_value = ""
222 revno = random.randint(1, 5000)
223 mock_branch_version = self.patch(version, "get_maas_branch_version")
224 mock_branch_version.return_value = revno
225 self.assertEqual(
226 "maas/%s from source/bzr%d" % (old_version, revno),
227 version.get_maas_version_user_agent())
228
229
230class TestGetMAASDocVersion(TestVersionTestCase):
231
232 def test__returns_doc_version_with_greater_than_1_decimals(self):
233 mock_apt = self.patch(version, "get_version_from_apt")
234 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
235 self.assertEqual("1.8", version.get_maas_doc_version())
236
237 def test__returns_doc_version_with_equal_to_1_decimals(self):
238 mock_apt = self.patch(version, "get_version_from_apt")
239 mock_apt.return_value = "1.8~alpha4+bzr356-0ubuntu1"
240 self.assertEqual("1.8", version.get_maas_doc_version())
241
242 def test__returns_empty_if_version_is_empty(self):
243 mock_apt = self.patch(version, "get_version_from_apt")
244 mock_apt.return_value = ""
245 self.assertEqual("", version.get_maas_doc_version())
246
247
248class TestVersionMethodsCached(TestVersionTestCase):
249
250 scenarios = [
251 ("get_maas_version", dict(method="get_maas_version")),
252 ("get_maas_version_subversion", dict(
253 method="get_maas_version_subversion")),
254 ("get_maas_version_ui", dict(method="get_maas_version_ui")),
255 ("get_maas_doc_version", dict(method="get_maas_doc_version")),
256 ]
257
258 def test_method_is_cached(self):
259 mock_apt = self.patch(version, "get_version_from_apt")
260 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
261 cached_method = getattr(version, self.method)
262 first_return_value = cached_method()
263 second_return_value = cached_method()
264 # The return value is not empty (full unit tests have been performed
265 # earlier).
266 self.assertNotIn(first_return_value, [b'', '', None])
267 self.assertEqual(first_return_value, second_return_value)
268 # Apt has only been called once.
269 self.expectThat(
270 mock_apt, MockCalledOnceWith(version.REGION_PACKAGE_NAME))
271
272
273class TestGetMAASVersionTuple(MAASTestCase):
274
275 def test_get_maas_version_tuple(self):
276 self.assertEquals(
277 '.'.join([str(i) for i in version.get_maas_version_tuple()]),
278 version.get_maas_version_subversion()[0])
diff --git a/src/maasserver/utils/version.py b/src/maasserver/utils/version.py
279deleted file mode 1006440deleted file mode 100644
index bdd6424..0000000
--- a/src/maasserver/utils/version.py
+++ /dev/null
@@ -1,142 +0,0 @@
1# Copyright 2015-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Version utilities."""
5
6__all__ = [
7 "get_maas_doc_version",
8 "get_maas_version_subversion",
9 "get_maas_version_ui",
10 ]
11
12from functools import lru_cache
13import re
14
15from maasserver import __version__ as old_version
16from maasserver.api.logger import maaslog
17from provisioningserver.utils import (
18 shell,
19 snappy,
20)
21
22# Only import apt_pkg and initialize when not running in a snap.
23if not snappy.running_in_snap():
24 import apt_pkg
25 apt_pkg.init()
26
27# Name of maas package to get version from.
28REGION_PACKAGE_NAME = "maas-region-api"
29
30
31def get_version_from_apt(package):
32 """Return the version output from `apt_pkg.Cache` for the given package or
33 an error message if the package data is not valid."""
34 try:
35 cache = apt_pkg.Cache(None)
36 except SystemError:
37 maaslog.error(
38 'Installed version could not be determined. Ensure '
39 '/var/lib/dpkg/status is valid.')
40 return ""
41
42 version = None
43 if package in cache:
44 apt_package = cache[package]
45 version = apt_package.current_ver
46
47 return version.ver_str if version is not None else ""
48
49
50def extract_version_subversion(version):
51 """Return a tuple (version, subversion) from the given apt version."""
52 main_version, subversion = re.split('[+|-]', version, 1)
53 return main_version, subversion
54
55
56def get_maas_branch_version():
57 """Return the Bazaar revision for this running MAAS.
58
59 :return: An integer if MAAS is running from a Bazaar working tree, else
60 `None`. The revision number is only representative of the BRANCH, not
61 the working tree.
62 """
63 try:
64 revno = shell.call_and_check(("bzr", "revno", __file__))
65 except shell.ExternalProcessError:
66 # We may not be in a Bazaar working tree, or any manner of other
67 # errors. For the purposes of this function we don't care; simply say
68 # we don't know.
69 return None
70 except FileNotFoundError:
71 # Bazaar is not installed. We don't care and simply say we don't know.
72 return None
73 else:
74 # `bzr revno` can return '???' when it can't find the working tree's
75 # current revision in the branch. Hopefully a fairly unlikely thing to
76 # happen, but we guard against it, and other ills, here.
77 try:
78 return int(revno)
79 except ValueError:
80 return None
81
82
83@lru_cache(maxsize=1)
84def get_maas_version():
85 """Return the apt or snap version for the main MAAS package."""
86 if snappy.running_in_snap():
87 return snappy.get_snap_version()
88 else:
89 return get_version_from_apt(REGION_PACKAGE_NAME)
90
91
92@lru_cache(maxsize=1)
93def get_maas_version_subversion():
94 """Return a tuple with the MAAS version and the MAAS subversion."""
95 version = get_maas_version()
96 if version:
97 return extract_version_subversion(version)
98 else:
99 # Get the branch information
100 branch_version = get_maas_branch_version()
101 if branch_version is None:
102 # Not installed not in branch, then no way to identify. This should
103 # not happen, but just in case.
104 return old_version, "unknown"
105 else:
106 return "%s from source" % old_version, "bzr%d" % branch_version
107
108
109@lru_cache(maxsize=1)
110def get_maas_version_ui():
111 """Return the version string for the running MAAS region.
112
113 The returned string is suitable to display in the UI.
114 """
115 version, subversion = get_maas_version_subversion()
116 return "%s (%s)" % (version, subversion) if subversion else version
117
118
119@lru_cache(maxsize=1)
120def get_maas_version_user_agent():
121 """Return the version string for the running MAAS region.
122
123 The returned string is suitable to set the user agent.
124 """
125 version, subversion = get_maas_version_subversion()
126 return "maas/%s/%s" % (version, subversion)
127
128
129@lru_cache(maxsize=1)
130def get_maas_doc_version():
131 """Return the doc version for the running MAAS region."""
132 apt_version = get_maas_version()
133 if apt_version:
134 version, _ = extract_version_subversion(apt_version)
135 return '.'.join(version.split('~')[0].split('.')[:2])
136 else:
137 return ''
138
139
140def get_maas_version_tuple():
141 """Returns a tuple of the MAAS version without the svn rev."""
142 return tuple(int(x) for x in old_version.split('.'))
diff --git a/src/maasserver/websockets/handlers/bootresource.py b/src/maasserver/websockets/handlers/bootresource.py
index 1df2ecd..68742d9 100644
--- a/src/maasserver/websockets/handlers/bootresource.py
+++ b/src/maasserver/websockets/handlers/bootresource.py
@@ -43,7 +43,6 @@ from maasserver.models import (
43from maasserver.utils.converters import human_readable_bytes43from maasserver.utils.converters import human_readable_bytes
44from maasserver.utils.orm import transactional44from maasserver.utils.orm import transactional
45from maasserver.utils.threads import deferToDatabase45from maasserver.utils.threads import deferToDatabase
46from maasserver.utils.version import get_maas_version_user_agent
47from maasserver.websockets.base import (46from maasserver.websockets.base import (
48 Handler,47 Handler,
49 HandlerError,48 HandlerError,
@@ -65,6 +64,7 @@ from provisioningserver.utils.twisted import (
65 callOut,64 callOut,
66 FOREVER,65 FOREVER,
67)66)
67from provisioningserver.utils.version import get_maas_version_user_agent
68from twisted.internet.defer import Deferred68from twisted.internet.defer import Deferred
6969
7070
diff --git a/src/maasserver/websockets/handlers/general.py b/src/maasserver/websockets/handlers/general.py
index b15300a..e5df4f0 100644
--- a/src/maasserver/websockets/handlers/general.py
+++ b/src/maasserver/websockets/handlers/general.py
@@ -33,9 +33,9 @@ from maasserver.utils.osystems import (
33 list_osystem_choices,33 list_osystem_choices,
34 list_release_choices,34 list_release_choices,
35)35)
36from maasserver.utils.version import get_maas_version_ui
37from maasserver.websockets.base import Handler36from maasserver.websockets.base import Handler
38import petname37import petname
38from provisioningserver.utils.version import get_maas_version_ui
3939
4040
41class GeneralHandler(Handler):41class GeneralHandler(Handler):
diff --git a/src/maasserver/websockets/handlers/tests/test_bootresource.py b/src/maasserver/websockets/handlers/tests/test_bootresource.py
index 57940a7..87438ea 100644
--- a/src/maasserver/websockets/handlers/tests/test_bootresource.py
+++ b/src/maasserver/websockets/handlers/tests/test_bootresource.py
@@ -32,7 +32,6 @@ from maasserver.utils.orm import (
32 get_one,32 get_one,
33 reload_object,33 reload_object,
34)34)
35from maasserver.utils.version import get_maas_version_user_agent
36from maasserver.websockets.base import (35from maasserver.websockets.base import (
37 HandlerError,36 HandlerError,
38 HandlerValidationError,37 HandlerValidationError,
@@ -54,6 +53,7 @@ from provisioningserver.import_images.testing.factory import (
54 make_image_spec,53 make_image_spec,
55 set_resource,54 set_resource,
56)55)
56from provisioningserver.utils.version import get_maas_version_user_agent
57from testtools.matchers import (57from testtools.matchers import (
58 ContainsAll,58 ContainsAll,
59 HasLength,59 HasLength,
diff --git a/src/metadataserver/builtin_scripts/__init__.py b/src/metadataserver/builtin_scripts/__init__.py
index 934a3a1..8cff96f 100644
--- a/src/metadataserver/builtin_scripts/__init__.py
+++ b/src/metadataserver/builtin_scripts/__init__.py
@@ -15,10 +15,10 @@ from attr.validators import (
15 instance_of,15 instance_of,
16 optional,16 optional,
17)17)
18from maasserver.utils.version import get_maas_version
19from metadataserver.enum import SCRIPT_TYPE18from metadataserver.enum import SCRIPT_TYPE
20from metadataserver.models import Script19from metadataserver.models import Script
21from provisioningserver.utils.fs import read_text_file20from provisioningserver.utils.fs import read_text_file
21from provisioningserver.utils.version import get_maas_version
22from zope.interface import (22from zope.interface import (
23 Attribute,23 Attribute,
24 implementer,24 implementer,
diff --git a/src/metadataserver/builtin_scripts/tests/test_builtin_scripts.py b/src/metadataserver/builtin_scripts/tests/test_builtin_scripts.py
index 004560b..703ba94 100644
--- a/src/metadataserver/builtin_scripts/tests/test_builtin_scripts.py
+++ b/src/metadataserver/builtin_scripts/tests/test_builtin_scripts.py
@@ -11,13 +11,13 @@ from maasserver.models import VersionedTextFile
11from maasserver.testing.factory import factory11from maasserver.testing.factory import factory
12from maasserver.testing.testcase import MAASServerTestCase12from maasserver.testing.testcase import MAASServerTestCase
13from maasserver.utils.orm import reload_object13from maasserver.utils.orm import reload_object
14from maasserver.utils.version import get_maas_version
15from metadataserver.builtin_scripts import (14from metadataserver.builtin_scripts import (
16 BUILTIN_SCRIPTS,15 BUILTIN_SCRIPTS,
17 load_builtin_scripts,16 load_builtin_scripts,
18)17)
19from metadataserver.enum import SCRIPT_TYPE_CHOICES18from metadataserver.enum import SCRIPT_TYPE_CHOICES
20from metadataserver.models import Script19from metadataserver.models import Script
20from provisioningserver.utils.version import get_maas_version
2121
2222
23class TestBuiltinScripts(MAASServerTestCase):23class TestBuiltinScripts(MAASServerTestCase):
diff --git a/src/provisioningserver/utils/tests/test_version.py b/src/provisioningserver/utils/tests/test_version.py
24new file mode 10064424new file mode 100644
index 0000000..410369d
--- /dev/null
+++ b/src/provisioningserver/utils/tests/test_version.py
@@ -0,0 +1,293 @@
1# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test version utilities."""
5
6__all__ = []
7
8
9import os.path
10import random
11from unittest import skipUnless
12from unittest.mock import (
13 MagicMock,
14 sentinel,
15)
16
17from maastesting import root
18from maastesting.matchers import MockCalledOnceWith
19from maastesting.testcase import MAASTestCase
20from provisioningserver.utils import (
21 shell,
22 snappy,
23 version,
24)
25from provisioningserver.utils.version import DEFAULT_VERSION as old_version
26from testtools.matchers import (
27 GreaterThan,
28 Is,
29 IsInstance,
30)
31
32
33class TestGetVersionFromAPT(MAASTestCase):
34
35 def test__creates_cache_with_None_progress(self):
36 mock_Cache = self.patch(version.apt_pkg, "Cache")
37 version.get_version_from_apt(version.REGION_PACKAGE_NAME)
38 self.assertThat(mock_Cache, MockCalledOnceWith(None))
39
40 def test__returns_empty_string_if_package_not_in_cache(self):
41 self.patch(version.apt_pkg, "Cache")
42 self.assertEqual(
43 "",
44 version.get_version_from_apt(version.REGION_PACKAGE_NAME))
45
46 def test__returns_empty_string_if_not_current_ver_from_package(self):
47 package = MagicMock()
48 package.current_ver = None
49 mock_cache = {
50 version.REGION_PACKAGE_NAME: package,
51 }
52 self.patch(version.apt_pkg, "Cache").return_value = mock_cache
53 self.assertEqual(
54 "",
55 version.get_version_from_apt(version.REGION_PACKAGE_NAME))
56
57 def test__returns_ver_str_from_package(self):
58 package = MagicMock()
59 package.current_ver.ver_str = sentinel.ver_str
60 mock_cache = {
61 version.RACK_PACKAGE_NAME: package
62 }
63 self.patch(version.apt_pkg, "Cache").return_value = mock_cache
64 self.assertIs(
65 sentinel.ver_str,
66 version.get_version_from_apt(version.RACK_PACKAGE_NAME))
67
68 def test__returns_ver_str_from_second_package_if_first_not_found(self):
69 package = MagicMock()
70 package.current_ver.ver_str = sentinel.ver_str
71 mock_cache = {
72 version.REGION_PACKAGE_NAME: package,
73 }
74 self.patch(version.apt_pkg, "Cache").return_value = mock_cache
75 self.assertIs(
76 sentinel.ver_str,
77 version.get_version_from_apt(
78 version.RACK_PACKAGE_NAME, version.REGION_PACKAGE_NAME))
79
80
81class TestGetMAASBranchVersion(MAASTestCase):
82
83 def test__returns_None_if_this_is_not_a_branch(self):
84 self.patch(version, "__file__", "/")
85 self.assertIsNone(version.get_maas_branch_version())
86
87 def test__returns_None_if_bzr_crashes(self):
88 call_and_check = self.patch(shell, "call_and_check")
89 call_and_check.side_effect = shell.ExternalProcessError(2, "cmd")
90 self.assertIsNone(version.get_maas_branch_version())
91
92 def test__returns_None_if_bzr_not_found(self):
93 call_and_check = self.patch(shell, "call_and_check")
94 call_and_check.side_effect = FileNotFoundError()
95 self.assertIsNone(version.get_maas_branch_version())
96
97 def test__returns_None_if_bzr_emits_something_thats_not_a_number(self):
98 call_and_check = self.patch(shell, "call_and_check")
99 call_and_check.return_value = b"???"
100 self.assertIsNone(version.get_maas_branch_version())
101
102 @skipUnless(os.path.isdir(os.path.join(root, ".bzr")), "Not a branch")
103 def test__returns_revno_for_this_branch(self):
104 revno = version.get_maas_branch_version()
105 self.assertThat(revno, IsInstance(int))
106 self.assertThat(revno, GreaterThan(0))
107
108
109class TestExtractVersionSubversion(MAASTestCase):
110
111 scenarios = [
112 ("with ~", {
113 "version": "2.2.0~beta4+bzr5856-0ubuntu1",
114 "output": ("2.2.0~beta4", "bzr5856-0ubuntu1"),
115 }),
116 ("without ~", {
117 "version": "2.1.0+bzr5480-0ubuntu1",
118 "output": ("2.1.0", "bzr5480-0ubuntu1"),
119 }),
120 ("without ~ or +", {
121 "version": "2.1.0-0ubuntu1",
122 "output": ("2.1.0", "0ubuntu1"),
123 }),
124 ]
125
126 def test__returns_version_subversion(self):
127 self.assertEqual(
128 self.output, version.extract_version_subversion(self.version))
129
130
131class TestVersionTestCase(MAASTestCase):
132 """MAASTestCase that resets the cache used by utility methods."""
133
134 def setUp(self):
135 super(TestVersionTestCase, self).setUp()
136 for attribute in vars(version).values():
137 if hasattr(attribute, "cache_clear"):
138 attribute.cache_clear()
139
140
141class TestGetMAASVersion(TestVersionTestCase):
142
143 def test__calls_get_version_from_apt(self):
144 mock_apt = self.patch(version, "get_version_from_apt")
145 mock_apt.return_value = sentinel.version
146 self.expectThat(
147 version.get_maas_version(), Is(sentinel.version))
148 self.expectThat(
149 mock_apt, MockCalledOnceWith(
150 version.RACK_PACKAGE_NAME, version.REGION_PACKAGE_NAME))
151
152 def test__uses_snappy_get_snap_version(self):
153 self.patch(snappy, 'running_in_snap').return_value = True
154 self.patch(snappy, 'get_snap_version').return_value = sentinel.version
155 self.assertEqual(sentinel.version, version.get_maas_version())
156
157
158class TestGetMAASVersionSubversion(TestVersionTestCase):
159
160 def test__returns_package_version(self):
161 mock_apt = self.patch(version, "get_version_from_apt")
162 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
163 self.assertEqual(
164 ("1.8.0~alpha4", "bzr356-0ubuntu1"),
165 version.get_maas_version_subversion())
166
167 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
168 mock_version = self.patch(version, "get_version_from_apt")
169 mock_version.return_value = ""
170 mock_branch_version = self.patch(version, "get_maas_branch_version")
171 mock_branch_version.return_value = None
172 self.assertEqual(
173 (old_version, "unknown"),
174 version.get_maas_version_subversion())
175
176 def test__returns_from_source_and_revno_from_branch(self):
177 mock_version = self.patch(version, "get_version_from_apt")
178 mock_version.return_value = ""
179 revno = random.randint(1, 5000)
180 mock_branch_version = self.patch(version, "get_maas_branch_version")
181 mock_branch_version.return_value = revno
182 self.assertEqual(
183 ("%s from source" % old_version, "bzr%d" % revno),
184 version.get_maas_version_subversion())
185
186
187class TestGetMAASVersionUI(TestVersionTestCase):
188
189 def test__returns_package_version(self):
190 mock_apt = self.patch(version, "get_version_from_apt")
191 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
192 self.assertEqual(
193 "1.8.0~alpha4 (bzr356-0ubuntu1)", version.get_maas_version_ui())
194
195 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
196 mock_version = self.patch(version, "get_version_from_apt")
197 mock_version.return_value = ""
198 mock_branch_version = self.patch(version, "get_maas_branch_version")
199 mock_branch_version.return_value = None
200 self.assertEqual(
201 "%s (unknown)" % old_version,
202 version.get_maas_version_ui())
203
204 def test__returns_from_source_and_revno_from_branch(self):
205 mock_version = self.patch(version, "get_version_from_apt")
206 mock_version.return_value = ""
207 revno = random.randint(1, 5000)
208 mock_branch_version = self.patch(version, "get_maas_branch_version")
209 mock_branch_version.return_value = revno
210 self.assertEqual(
211 "%s from source (bzr%d)" % (old_version, revno),
212 version.get_maas_version_ui())
213
214
215class TestGetMAASVersionUserAgent(TestVersionTestCase):
216
217 def test__returns_package_version(self):
218 mock_apt = self.patch(version, "get_version_from_apt")
219 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
220 self.assertEqual(
221 "maas/1.8.0~alpha4/bzr356-0ubuntu1",
222 version.get_maas_version_user_agent())
223
224 def test__returns_unknown_if_version_is_empty_and_not_bzr_branch(self):
225 mock_version = self.patch(version, "get_version_from_apt")
226 mock_version.return_value = ""
227 mock_branch_version = self.patch(version, "get_maas_branch_version")
228 mock_branch_version.return_value = None
229 self.assertEqual(
230 "maas/%s/unknown" % old_version,
231 version.get_maas_version_user_agent())
232
233 def test__returns_from_source_and_revno_from_branch(self):
234 mock_version = self.patch(version, "get_version_from_apt")
235 mock_version.return_value = ""
236 revno = random.randint(1, 5000)
237 mock_branch_version = self.patch(version, "get_maas_branch_version")
238 mock_branch_version.return_value = revno
239 self.assertEqual(
240 "maas/%s from source/bzr%d" % (old_version, revno),
241 version.get_maas_version_user_agent())
242
243
244class TestGetMAASDocVersion(TestVersionTestCase):
245
246 def test__returns_doc_version_with_greater_than_1_decimals(self):
247 mock_apt = self.patch(version, "get_version_from_apt")
248 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
249 self.assertEqual("1.8", version.get_maas_doc_version())
250
251 def test__returns_doc_version_with_equal_to_1_decimals(self):
252 mock_apt = self.patch(version, "get_version_from_apt")
253 mock_apt.return_value = "1.8~alpha4+bzr356-0ubuntu1"
254 self.assertEqual("1.8", version.get_maas_doc_version())
255
256 def test__returns_empty_if_version_is_empty(self):
257 mock_apt = self.patch(version, "get_version_from_apt")
258 mock_apt.return_value = ""
259 self.assertEqual("", version.get_maas_doc_version())
260
261
262class TestVersionMethodsCached(TestVersionTestCase):
263
264 scenarios = [
265 ("get_maas_version", dict(method="get_maas_version")),
266 ("get_maas_version_subversion", dict(
267 method="get_maas_version_subversion")),
268 ("get_maas_version_ui", dict(method="get_maas_version_ui")),
269 ("get_maas_doc_version", dict(method="get_maas_doc_version")),
270 ]
271
272 def test_method_is_cached(self):
273 mock_apt = self.patch(version, "get_version_from_apt")
274 mock_apt.return_value = "1.8.0~alpha4+bzr356-0ubuntu1"
275 cached_method = getattr(version, self.method)
276 first_return_value = cached_method()
277 second_return_value = cached_method()
278 # The return value is not empty (full unit tests have been performed
279 # earlier).
280 self.assertNotIn(first_return_value, [b'', '', None])
281 self.assertEqual(first_return_value, second_return_value)
282 # Apt has only been called once.
283 self.expectThat(
284 mock_apt, MockCalledOnceWith(
285 version.RACK_PACKAGE_NAME, version.REGION_PACKAGE_NAME))
286
287
288class TestGetMAASVersionTuple(MAASTestCase):
289
290 def test_get_maas_version_tuple(self):
291 self.assertEquals(
292 '.'.join([str(i) for i in version.get_maas_version_tuple()]),
293 version.get_maas_version_subversion()[0])
diff --git a/src/provisioningserver/utils/version.py b/src/provisioningserver/utils/version.py
0new file mode 100644294new file mode 100644
index 0000000..f372b0d
--- /dev/null
+++ b/src/provisioningserver/utils/version.py
@@ -0,0 +1,149 @@
1# Copyright 2015-2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Version utilities."""
5
6__all__ = [
7 "get_maas_doc_version",
8 "get_maas_version_subversion",
9 "get_maas_version_ui",
10 ]
11
12from functools import lru_cache
13import re
14
15from provisioningserver.logger import get_maas_logger
16from provisioningserver.utils import (
17 shell,
18 snappy,
19)
20
21
22maaslog = get_maas_logger('version')
23
24DEFAULT_VERSION = "2.3.0"
25
26# Only import apt_pkg and initialize when not running in a snap.
27if not snappy.running_in_snap():
28 import apt_pkg
29 apt_pkg.init()
30
31# Name of maas package to get version from.
32REGION_PACKAGE_NAME = "maas-region-api"
33RACK_PACKAGE_NAME = "maas-rack-controller"
34
35
36def get_version_from_apt(*packages):
37 """Return the version output from `apt_pkg.Cache` for the given package(s),
38 or log an error message if the package data is not valid."""
39 try:
40 cache = apt_pkg.Cache(None)
41 except SystemError:
42 maaslog.error(
43 'Installed version could not be determined. Ensure '
44 '/var/lib/dpkg/status is valid.')
45 return ""
46
47 version = None
48 for package in packages:
49 if package in cache:
50 apt_package = cache[package]
51 version = apt_package.current_ver
52 break
53
54 return version.ver_str if version is not None else ""
55
56
57def extract_version_subversion(version):
58 """Return a tuple (version, subversion) from the given apt version."""
59 main_version, subversion = re.split('[+|-]', version, 1)
60 return main_version, subversion
61
62
63def get_maas_branch_version():
64 """Return the Bazaar revision for this running MAAS.
65
66 :return: An integer if MAAS is running from a Bazaar working tree, else
67 `None`. The revision number is only representative of the BRANCH, not
68 the working tree.
69 """
70 try:
71 revno = shell.call_and_check(("bzr", "revno", __file__))
72 except shell.ExternalProcessError:
73 # We may not be in a Bazaar working tree, or any manner of other
74 # errors. For the purposes of this function we don't care; simply say
75 # we don't know.
76 return None
77 except FileNotFoundError:
78 # Bazaar is not installed. We don't care and simply say we don't know.
79 return None
80 else:
81 # `bzr revno` can return '???' when it can't find the working tree's
82 # current revision in the branch. Hopefully a fairly unlikely thing to
83 # happen, but we guard against it, and other ills, here.
84 try:
85 return int(revno)
86 except ValueError:
87 return None
88
89
90@lru_cache(maxsize=1)
91def get_maas_version():
92 """Return the apt or snap version for the main MAAS package."""
93 if snappy.running_in_snap():
94 return snappy.get_snap_version()
95 else:
96 return get_version_from_apt(RACK_PACKAGE_NAME, REGION_PACKAGE_NAME)
97
98
99@lru_cache(maxsize=1)
100def get_maas_version_subversion():
101 """Return a tuple with the MAAS version and the MAAS subversion."""
102 version = get_maas_version()
103 if version:
104 return extract_version_subversion(version)
105 else:
106 # Get the branch information
107 branch_version = get_maas_branch_version()
108 if branch_version is None:
109 # Not installed not in branch, then no way to identify. This should
110 # not happen, but just in case.
111 return DEFAULT_VERSION, "unknown"
112 else:
113 return "%s from source" % DEFAULT_VERSION, "bzr%d" % branch_version
114
115
116@lru_cache(maxsize=1)
117def get_maas_version_ui():
118 """Return the version string for the running MAAS region.
119
120 The returned string is suitable to display in the UI.
121 """
122 version, subversion = get_maas_version_subversion()
123 return "%s (%s)" % (version, subversion) if subversion else version
124
125
126@lru_cache(maxsize=1)
127def get_maas_version_user_agent():
128 """Return the version string for the running MAAS region.
129
130 The returned string is suitable to set the user agent.
131 """
132 version, subversion = get_maas_version_subversion()
133 return "maas/%s/%s" % (version, subversion)
134
135
136@lru_cache(maxsize=1)
137def get_maas_doc_version():
138 """Return the doc version for the running MAAS region."""
139 apt_version = get_maas_version()
140 if apt_version:
141 version, _ = extract_version_subversion(apt_version)
142 return '.'.join(version.split('~')[0].split('.')[:2])
143 else:
144 return ''
145
146
147def get_maas_version_tuple():
148 """Returns a tuple of the MAAS version without the svn rev."""
149 return tuple(int(x) for x in DEFAULT_VERSION.split('.'))
diff --git a/utilities/check-imports b/utilities/check-imports
index 405ca8d..d047e70 100755
--- a/utilities/check-imports
+++ b/utilities/check-imports
@@ -195,6 +195,7 @@ RackControllerRule = Rule(
195 Allow("apiclient.creds.*"),195 Allow("apiclient.creds.*"),
196 Allow("apiclient.maas_client.*"),196 Allow("apiclient.maas_client.*"),
197 Allow("apiclient.utils.*"),197 Allow("apiclient.utils.*"),
198 Allow("apt_pkg"),
198 Allow("attr|attr.**"),199 Allow("attr|attr.**"),
199 Allow("bson|bson.**"),200 Allow("bson|bson.**"),
200 Allow("crochet|crochet.**"),201 Allow("crochet|crochet.**"),
@@ -245,7 +246,6 @@ RegionControllerRule = Rule(
245 Allow("apiclient.creds.*"),246 Allow("apiclient.creds.*"),
246 Allow("apiclient.multipart.*"),247 Allow("apiclient.multipart.*"),
247 Allow("apiclient.utils.*"),248 Allow("apiclient.utils.*"),
248 Allow("apt_pkg"),
249 Allow("attr|attr.**"),249 Allow("attr|attr.**"),
250 Allow("bson"),250 Allow("bson"),
251 Allow("convoy|convoy.**"),251 Allow("convoy|convoy.**"),

Subscribers

People subscribed via source and target branches