Merge lp:~cjohnston/ubuntu-ci-services-itself/get-swift-image into lp:ubuntu-ci-services-itself

Proposed by Chris Johnston on 2014-03-16
Status: Merged
Approved by: Chris Johnston on 2014-03-17
Approved revision: 405
Merged at revision: 410
Proposed branch: lp:~cjohnston/ubuntu-ci-services-itself/get-swift-image
Merge into: lp:ubuntu-ci-services-itself
Diff against target: 442 lines (+300/-25)
6 files modified
ci-utils/ci_utils/data_store/__init__.py (+2/-2)
cli/ci_libs/image.py (+93/-0)
cli/ci_libs/ticket.py (+2/-1)
cli/ci_libs/utils.py (+6/-1)
cli/tests/test_image.py (+168/-0)
cli/ubuntu-ci (+29/-21)
To merge this branch: bzr merge lp:~cjohnston/ubuntu-ci-services-itself/get-swift-image
Reviewer Review Type Date Requested Status
PS Jenkins bot (community) continuous-integration 2014-03-16 Approve on 2014-03-17
Chris Johnston (community) Resubmit on 2014-03-17
Andy Doan (community) Approve on 2014-03-17
Review via email: mp+211221@code.launchpad.net

Commit message

Add the ability to get_image to the CLI

Description of the change

The CI Engine builds a release candidate image that includes all of the tested and 'approved' packages included in it.

Currently the built images are stored in glance and swift. The images stored in glance are not always available for users to download due to different provider policies. Because of this we had to start storing the images in swift. They are presently stored in a container called ticket-#-image (where # is the ticket number). This container is a private container to avoid the images being available without credentials (NOTE: we should probably make the public/private availability a choice either at deployment time or at ticket creation time).

Because the image containers are presently set to be private, even though the artifact reference can be seen on the WebUI, it isn't possible for a user to download the image via the WebUI since they are missing the proper credentials. Because of this, we needed a way for the user to be able to download the image with the proper credentials.

Also note that Ursula is working on some additional tests for this MP which will follow soon.

To post a comment you must log in.
PS Jenkins bot (ps-jenkins) wrote :

PASSED: Continuous integration, rev:404
http://s-jenkins.ubuntu-ci:8080/job/uci-engine-ci/440/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/uci-engine-ci/440/rebuild

review: Approve (continuous-integration)
Ursula Junque (ursinha) wrote :

+class GetTicketImage(TestCaseWithGnupg):

I changed this in my branch earlier today and I think it got lost in the tons of changes after that: these tests don't check anything gnupg related, so I think we can use unittest.TestCase here.

402. By Chris Johnston on 2014-03-17

Update per review - use unittest

Evan (ev) wrote :

> Because the image containers are presently set to be private, even though the
> artifact reference can be seen on the WebUI, it isn't possible for a user to
> download the image via the WebUI since they are missing the proper
> credentials. Because of this, we needed a way for the user to be able to
> download the image with the proper credentials.

Just to throw support behind this, I think it's entirely reasonable that we do not provide access to the images via the webui in phase 0 (and I think we discussed as much on IRC when planning where this feature would get implemented).

Once we're past phase 0, I want to see us put data store object creation behind a new intermediary service that manages the credentials. The CLI should just send the signed GPG content and this intermediary should validate the signature. No cloud credentials should change hands.

This is bug 1288710.

Andy Doan (doanac) wrote :

On 03/16/2014 06:33 PM, Chris Johnston wrote:
> +def get_image(args):
> + if args.ticket:
> + ticket = args.ticket
> + else:
> + ticket_status_base = utils.CI_URL + utils.TICKET_STATUS_BASE
> + url = ticket_status_base + '?current_workflow_step={}'.format(
> + ticket_states.TicketWorkflowStep.COMPLETED.value)
> + data = utils.get(url)

> + tickets = []
> + for o in data['objects']:
> + tickets.append(o[u'id'])
> + tickets = sorted(tickets, reverse=True)
> + ticket = tickets.pop(0)

You could do this in one line with:
   ticket = max([x['id'] for x in data['objects']])

If there are no completed tickets, does data['objects'] exist? Maybe to
make this super safe you do:
      ticket = max([x['id'] for x in data.get('objects', [])])

> +
> + data = _get_image_artifact(ticket)
> + ticket_id = str(ticket) + '-image'
> + ds = data_store.create_for_ticket(ticket_id=ticket_id,
> + auth_config=utils.AUTH_CONFIG,
> + public=False)
> + image = data['objects'][0][u'name']
> + local_path = os.path.join(utils.HOME, image)

I'm not a fan of this fixed download location. I'd want to specify via
the CLI and not have this throwing stuff under my $HOME. This might just
be me.

> + if os.path.exists(local_path):
> + print("{} already exists.".format(local_path))
> + else:
> + try:
> + download = ds.get_file(image)
> + fp = open(local_path, 'wb')
> + fp.write(download)

Is there a way we can have swift stream this to the file descriptor
rather than loading the ~200M into memory and writing it all to disk?

Francis Ginther (fginther) wrote :

The mechanics of this look good and I was able to test that it works (from a ticket I submitted earlier today). I would prefer that it drop the image into my current dir or support an option to specify the location.

If there are significant issues with addressing Andy's comments, then I would strongly consider approving this as it does fill a functionality gap.

Joe Talbott (joetalbott) wrote :

L60 I think the preferred syntax is; except Blah as b: rather than; except Blah, b:

L235 I prefer to name my test classes as TextXYZ so they stand out visually to me as tests.

I agree with @Andy regarding the download location and avoiding reading the entire file into memory.

Joe Talbott (joetalbott) wrote :

Hrm, after a bit of research, swiftclient.client.get_object doesn't support streaming. So the data-store will need to be adjusted to handle streaming perhaps by using the url and using http streaming. I'd say that's for a later MP.

403. By Chris Johnston on 2014-03-17

merge trunk

404. By Chris Johnston on 2014-03-17

Change the way to get the newest ticket per review

Andy Doan (doanac) wrote :

lets get this merged. and then fix the saving logic separately but soon.

review: Approve
405. By Chris Johnston on 2014-03-17

Require a user to specify where they would like to download their RC image to

Chris Johnston (cjohnston) wrote :

r404 changes how we get the 'latest' completed ticket
r405 changes to require the user to specify where they want to download their image to

review: Resubmit
PS Jenkins bot (ps-jenkins) wrote :

PASSED: Continuous integration, rev:405
http://s-jenkins.ubuntu-ci:8080/job/uci-engine-ci/448/
Executed test runs:

Click here to trigger a rebuild:
http://s-jenkins.ubuntu-ci:8080/job/uci-engine-ci/448/rebuild

review: Approve (continuous-integration)
Andy Doan (doanac) wrote :

looks good. we should just get this merged i think. one note:

246 + self.image_path = os.path.join(
247 + utils.HOME, 'd62d3d9a-ac3d-11e3-847d-fa163eaf5928.img')
248 + self.addCleanup(self._remove_image)

you could use tempfile for that now so the test doesn't mess with $HOME

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ci-utils/ci_utils/data_store/__init__.py'
2--- ci-utils/ci_utils/data_store/__init__.py 2014-03-10 22:25:00 +0000
3+++ ci-utils/ci_utils/data_store/__init__.py 2014-03-17 17:17:40 +0000
4@@ -23,8 +23,8 @@
5 pass
6
7
8-def create_for_ticket(ticket_id, auth_config):
9- return DataStore('ticket-{}'.format(ticket_id), auth_config, public=True)
10+def create_for_ticket(ticket_id, auth_config, public=True):
11+ return DataStore('ticket-{}'.format(ticket_id), auth_config, public=public)
12
13
14 def _get_file_name(filename):
15
16=== added file 'cli/ci_libs/image.py'
17--- cli/ci_libs/image.py 1970-01-01 00:00:00 +0000
18+++ cli/ci_libs/image.py 2014-03-17 17:17:40 +0000
19@@ -0,0 +1,93 @@
20+# Ubuntu Continuous Integration Engine
21+# Copyright 2014 Canonical Ltd.
22+#
23+# This program is free software: you can redistribute it and/or modify it under
24+# the terms of the GNU General Public License version 3, as published by the
25+# Free Software Foundation.
26+#
27+# This program is distributed in the hope that it will be useful, but WITHOUT
28+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
29+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
30+# General Public License for more details.
31+#
32+# You should have received a copy of the GNU General Public License along
33+# with this program. If not, see <http://www.gnu.org/licenses/>.
34+
35+import os
36+import sys
37+import urllib2
38+
39+from ci_utils import data_store, ticket_states
40+from ci_libs import utils
41+
42+
43+class ImageObjectNotFound(Exception):
44+ """Raised when image object cannot be found."""
45+ def __init__(self, message):
46+ self.filename = message.split(",")[0].split(": ")[1]
47+ self.url = message.split("Error: Object GET failed: ")[1].split(
48+ " 404 Not Found")[0]
49+
50+ def __str__(self):
51+ return "404 Not Found: {}".format(self.url)
52+
53+
54+def _get_image_artifact(ticket):
55+ artifact_base = utils.CI_URL + utils.TICKET_ARTIFACT_BASE
56+ try:
57+ url = artifact_base + '?type__exact=IMAGE&ticket__exact={}'.format(
58+ ticket)
59+ return utils.get(url)
60+ except urllib2.HTTPError, exc:
61+ if exc.code == 404 and ticket:
62+ sys.exit("Ticket number {} not found.".format(ticket))
63+ raise
64+ except urllib2.URLError, exc:
65+ raise
66+
67+
68+def _download(ticket, name, args_ticket):
69+ data = _get_image_artifact(ticket)
70+ ticket_id = str(ticket) + '-image'
71+ ds = data_store.create_for_ticket(ticket_id=ticket_id,
72+ auth_config=utils.AUTH_CONFIG,
73+ public=False)
74+ image = data['objects'][0][u'name']
75+ try:
76+ download = ds.get_file(image)
77+ fp = open(name, 'wb')
78+ fp.write(download)
79+ if args_ticket:
80+ print("Successfully downloaded image for ticket #{}.\n"
81+ "Image file can be found at: {}".format(ticket, name))
82+ else:
83+ print("Successfully downloaded latest release candidate image.\n"
84+ "Image file can be found at: {}".format(name))
85+ except data_store.DataStoreException as exc:
86+ if "404 Not Found" in exc.args[0]:
87+ raise ImageObjectNotFound(exc.message)
88+ raise
89+ except IOError:
90+ raise
91+
92+
93+def get_image(args):
94+ if args.ticket:
95+ ticket = args.ticket
96+ else:
97+ ticket_status_base = utils.CI_URL + utils.TICKET_STATUS_BASE
98+ url = ticket_status_base + '?current_workflow_step={}'.format(
99+ ticket_states.TicketWorkflowStep.COMPLETED.value)
100+ data = utils.get(url)
101+ try:
102+ ticket = max([x['id'] for x in data.get('objects', [])])
103+ except ValueError:
104+ sys.exit("No completed tickets found.")
105+
106+ if os.path.exists(args.name):
107+ print("{} already exists.".format(args.name))
108+ elif not os.path.isdir(os.path.dirname(args.name)):
109+ os.makedirs(os.path.dirname(args.name))
110+ _download(ticket, args.name, args.ticket)
111+ else:
112+ _download(ticket, args.name, args.ticket)
113
114=== modified file 'cli/ci_libs/ticket.py'
115--- cli/ci_libs/ticket.py 2014-01-29 16:51:27 +0000
116+++ cli/ci_libs/ticket.py 2014-03-17 17:17:40 +0000
117@@ -98,7 +98,8 @@
118 "name": file,
119 "subticket": self.subticket_uri,
120 }
121- location_ = utils.post(utils.CI_URL + utils.ARTIFACT_BASE, data=data)
122+ location_ = utils.post(utils.CI_URL + utils.SUBTICKET_ARTIFACT_BASE,
123+ data=data)
124 log.info("Created artifact: %s" % location_)
125
126
127
128=== modified file 'cli/ci_libs/utils.py'
129--- cli/ci_libs/utils.py 2014-03-10 22:25:00 +0000
130+++ cli/ci_libs/utils.py 2014-03-17 17:17:40 +0000
131@@ -13,6 +13,7 @@
132 # You should have received a copy of the GNU General Public License along
133 # with this program. If not, see <http://www.gnu.org/licenses/>.
134
135+import os
136 import json
137 import logging
138 import sys
139@@ -29,8 +30,12 @@
140 TICKET_BASE = API_URL + 'ticket/'
141 SPU_BASE = API_URL + 'spu/'
142 SOURCEPACKAGE_BASE = API_URL + 'sourcepackage/'
143-ARTIFACT_BASE = API_URL + 'subticketartifact/'
144+TICKET_ARTIFACT_BASE = API_URL + 'ticketartifact/'
145+SUBTICKET_ARTIFACT_BASE = API_URL + 'subticketartifact/'
146 SUBTICKET_BASE = API_URL + 'subticket/'
147+TICKET_STATUS_BASE = API_URL + 'ticketstatus/'
148+HOME = os.environ["HOME"]
149+DEF_CFG = os.path.join(HOME, '.ubuntu-ci')
150
151 CI_URL = None
152 AUTH_CONFIG = None
153
154=== added file 'cli/tests/test_image.py'
155--- cli/tests/test_image.py 1970-01-01 00:00:00 +0000
156+++ cli/tests/test_image.py 2014-03-17 17:17:40 +0000
157@@ -0,0 +1,168 @@
158+#!/usr/bin/env python
159+# -*- coding: utf-8 -*-
160+# Ubuntu Continuous Integration Engine
161+# Copyright 2014 Canonical Ltd.
162+#
163+# This program is free software: you can redistribute it and/or modify it under
164+# the terms of the GNU General Public License version 3, as published by the
165+# Free Software Foundation.
166+#
167+# This program is distributed in the hope that it will be useful, but WITHOUT
168+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
169+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
170+# General Public License for more details.
171+#
172+# You should have received a copy of the GNU General Public License along
173+# with this program. If not, see <http://www.gnu.org/licenses/>.
174+
175+import imp
176+import json
177+import mock
178+import os
179+import unittest
180+
181+from ci_utils import data_store
182+
183+from ci_libs import utils
184+from ci_libs.image import ImageObjectNotFound
185+from tests import capture_stdout
186+
187+import urllib2
188+urllib2.urlopen = mock.Mock()
189+
190+
191+class FakeUrlOpenReturn:
192+ """Object to mock the return of urllib2.urlopen."""
193+
194+ def __init__(self, code, content):
195+ self.code = code
196+ self.content = content
197+
198+ def __repr__(self):
199+ return self.content
200+
201+ def __iter__(self):
202+ return (line for line in self.content.split("\n"))
203+
204+ def read(self):
205+ return self.content
206+
207+
208+artifact_data = {
209+ "objects": [
210+ {
211+ "id": 6,
212+ "name": "d62d3d9a-ac3d-11e3-847d-fa163eaf5928.img",
213+ "reference": "http://www.example.com",
214+ "resource_uri": "/api/v1/ticketartifact/6/",
215+ "ticket": {
216+ "id": 4,
217+ },
218+ "type": "IMAGE"
219+ },
220+ ]
221+}
222+
223+ticket_data = {
224+ "objects": [
225+ {
226+ "id": 1,
227+ },
228+ {
229+ "id": 4,
230+ },
231+ {
232+ "id": 2,
233+ },
234+ ],
235+}
236+
237+
238+class TestGetTicketImage(unittest.TestCase):
239+
240+ def setUp(self):
241+ super(TestGetTicketImage, self).setUp()
242+ self.cli = imp.load_source(
243+ "ubuntu-ci",
244+ os.path.join(os.path.dirname(__file__), "../ubuntu-ci")
245+ )
246+ self.image_path = os.path.join(
247+ utils.HOME, 'd62d3d9a-ac3d-11e3-847d-fa163eaf5928.img')
248+ self.addCleanup(self._remove_image)
249+
250+ def _remove_image(self):
251+ if os.path.exists(self.image_path):
252+ os.remove(self.image_path)
253+
254+ @mock.patch('ci_utils.data_store.create_for_ticket')
255+ def test_get_ticket_image(self, mock_create_datastore):
256+ urllib2.urlopen.side_effect = [
257+ FakeUrlOpenReturn(200, json.dumps(artifact_data))]
258+ mock_datastore = mock.Mock()
259+ mock_datastore.get_file.return_value = buffer(" ")
260+ mock_create_datastore.return_value = mock_datastore
261+
262+ args = self.cli.parse_arguments(['get_image', '-t', '4', '-n',
263+ self.image_path])
264+ with capture_stdout(args.func, args) as cm:
265+ self.assertEqual(
266+ cm,
267+ "Successfully downloaded image for ticket #4.\n"
268+ "Image file can be found at: {}\n".format(self.image_path))
269+ mock_datastore.assert_called_once()
270+
271+ @mock.patch('ci_utils.data_store.create_for_ticket')
272+ def test_get_ticket_image_not_found(self, mock_create_datastore):
273+ urllib2.urlopen.side_effect = [
274+ FakeUrlOpenReturn(200, json.dumps(artifact_data))]
275+ mock_datastore = mock.Mock()
276+ mock_datastore.get_file.side_effect = data_store.DataStoreException(
277+ "Failed to get file: d62d3d9a-ac3d-11e3-847d-fa163eaf5928.img, "
278+ " Error: Object GET failed: https://swift.canonistack.canonical."
279+ "com:443/v1/AUTH_2de6e093b42c44b58a09758c347535ff/ticket-1-image/"
280+ "d62d3d9a-ac3d-11e3-847d-fa163eaf5928.img 404 Not Found")
281+ mock_create_datastore.return_value = mock_datastore
282+
283+ args = self.cli.parse_arguments(['get_image', '-t', '4', '-n',
284+ self.image_path])
285+ with self.assertRaises(ImageObjectNotFound):
286+ args.func(args)
287+
288+ @mock.patch('ci_utils.data_store.DataStore')
289+ def test_get_ticket_image_already_exists(self, mock_data_store):
290+ urllib2.urlopen.side_effect = [
291+ FakeUrlOpenReturn(200, json.dumps(artifact_data))]
292+ open(self.image_path, 'a').close()
293+ args = self.cli.parse_arguments(['get_image', '-t', '4', '-n',
294+ self.image_path])
295+ with capture_stdout(args.func, args) as cm:
296+ self.assertEqual(
297+ "{} already exists.\n".format(self.image_path),
298+ cm)
299+
300+ @mock.patch('ci_utils.data_store.create_for_ticket')
301+ def test_get_last_completed_ticket_image(self, mock_create_datastore):
302+ urllib2.urlopen.side_effect = [
303+ FakeUrlOpenReturn(200, json.dumps(ticket_data)),
304+ FakeUrlOpenReturn(200, json.dumps(artifact_data))
305+ ]
306+ mock_datastore = mock.Mock()
307+ mock_datastore.get_file.return_value = buffer(" ")
308+ mock_create_datastore.return_value = mock_datastore
309+
310+ args = self.cli.parse_arguments(['get_image', '-n', self.image_path])
311+ with capture_stdout(args.func, args) as cm:
312+ self.assertEqual(
313+ cm,
314+ "Successfully downloaded latest release candidate image.\n"
315+ "Image file can be found at: {}\n".format(self.image_path))
316+ mock_datastore.assert_called_once()
317+
318+ def test_get_last_completed_ticket_none_complete(self):
319+ data = {'objects': []}
320+ urllib2.urlopen.side_effect = [
321+ FakeUrlOpenReturn(200, json.dumps(data))]
322+ args = self.cli.parse_arguments(['get_image', '-n', self.image_path])
323+ with self.assertRaises(SystemExit) as cm:
324+ args.func(args)
325+ self.assertEqual("No completed tickets found.", cm.exception.message)
326
327=== modified file 'cli/ubuntu-ci'
328--- cli/ubuntu-ci 2014-03-10 22:25:00 +0000
329+++ cli/ubuntu-ci 2014-03-17 17:17:40 +0000
330@@ -22,19 +22,13 @@
331 import urllib2
332
333 from ci_libs import (
334+ ticket,
335 file_handler,
336 status,
337 utils,
338+ image,
339 )
340-from ci_libs.file_handler import (ChangesFileNotFound, ChangesFileException,
341- ChangesParseError, ChangesValidationError,
342- FileToUploadNotFound, NotAChangesFileError,
343- UploadDirNotFound)
344-from ci_libs.ticket import new_ticket
345-from ci_utils.data_store import DataStoreException
346-from ci_utils import dump_stack
347-
348-DEF_CFG = os.path.join(os.environ["HOME"], '.ubuntu-ci')
349+from ci_utils import dump_stack, data_store
350
351
352 def parse_arguments(args=None):
353@@ -65,7 +59,7 @@
354 'in the same directory as their respective '
355 'source.changes.', required=True)
356
357- ticket_parser.set_defaults(func=new_ticket)
358+ ticket_parser.set_defaults(func=ticket.new_ticket)
359 status_parser = subparsers.add_parser('status',
360 help='Get ticket status. Use no '
361 'flags for all tickets')
362@@ -73,6 +67,16 @@
363 help='Ticket to display status of. Leave off '
364 'for all tickets')
365 status_parser.set_defaults(func=status.ticket_status)
366+ image_parser = subparsers.add_parser('get_image',
367+ help='Retrieve the image produced by '
368+ 'a ticket.')
369+ image_parser.add_argument('-t', '--ticket',
370+ help='Ticket to display status of. Leave off '
371+ 'for last successful ticket')
372+ image_parser.add_argument('-n', '--name',
373+ help='Desired file name (and path) for the '
374+ 'downloaded image', required=True)
375+ image_parser.set_defaults(func=image.get_image)
376 return parser.parse_args(args)
377
378
379@@ -105,9 +109,9 @@
380 # files are found.
381 for source in args.sources:
382 if not os.path.exists(source):
383- raise ChangesFileNotFound(source)
384+ raise file_handler.ChangesFileNotFound(source)
385 if not source.endswith(".changes"):
386- raise NotAChangesFileError(source)
387+ raise file_handler.NotAChangesFileError(source)
388 # Validating -a option is properly formatted.
389 utils.assert_valid_package_list(args.add)
390 # Validating -r option is properly formatted.
391@@ -117,29 +121,33 @@
392 # We're not in create_ticket context, moving on.
393 pass
394
395- utils.load_config(DEF_CFG)
396+ utils.load_config(utils.DEF_CFG)
397 args.func(args)
398 return 0
399 except utils.InputError as exc:
400 log.error(exc)
401- except ChangesFileNotFound as exc:
402+ except file_handler.ChangesFileNotFound as exc:
403 log.error("Changes file not found: {}".format(exc))
404- except NotAChangesFileError as exc:
405+ except file_handler.NotAChangesFileError as exc:
406 log.error("Upload file must be a .changes; you provided "
407 "'{}'".format(exc))
408- except (ChangesFileException, ChangesParseError) as exc:
409+ except (file_handler.ChangesFileException,
410+ file_handler.ChangesParseError) as exc:
411 log.error("Problem parsing .changes file, are you sure it's a valid "
412 ".changes? ({})".format(str(exc)))
413- except ChangesValidationError as exc:
414+ except file_handler.ChangesValidationError as exc:
415 log.error("Problem validating .changes file: {}".format(exc))
416- except UploadDirNotFound as exc:
417+ except file_handler.UploadDirNotFound as exc:
418 log.error("Directory with files to upload not found: {}".format(exc))
419- except FileToUploadNotFound as exc:
420+ except file_handler.FileToUploadNotFound as exc:
421 log.error("File to upload not found: {}. Maybe a wrong or missing "
422 "-f?".format(exc))
423 except httplib.BadStatusLine as exc:
424 log.error("Server at {} replied with an empty response. Is 'ci_url' "
425 "pointing to the correct service?".format(utils.CI_URL))
426+ except image.ImageObjectNotFound as exc:
427+ log.error("Image cannot be downloaded: {} ({})".format(exc.filename,
428+ str(exc)))
429 except urllib2.URLError as exc:
430 if isinstance(exc, urllib2.HTTPError):
431 reason = exc.reason
432@@ -157,8 +165,8 @@
433 log.error("Cannot reach the server at {}. Please, check your "
434 "configuration file ({}): is 'ci_url' correctly set? "
435 "Is the server up and reachable from this machine? "
436- "({}).".format(utils.CI_URL, DEF_CFG, reason))
437- except DataStoreException as exc:
438+ "({}).".format(utils.CI_URL, utils.DEF_CFG, reason))
439+ except data_store.DataStoreException as exc:
440 log.error("Data Store Error: {}".format(exc))
441 except Exception:
442 log.exception('Unexpected exception')

Subscribers

People subscribed via source and target branches