Merge lp:~cjwatson/launchpad/branch-hosting-client into lp:launchpad

Proposed by Colin Watson on 2018-05-16
Status: Merged
Merged at revision: 18706
Proposed branch: lp:~cjwatson/launchpad/branch-hosting-client
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/private-loggerhead
Diff against target: 573 lines (+498/-2)
7 files modified
configs/development/launchpad-lazr.conf (+1/-0)
lib/lp/code/configure.zcml (+8/-1)
lib/lp/code/errors.py (+31/-1)
lib/lp/code/interfaces/branchhosting.py (+59/-0)
lib/lp/code/model/branchhosting.py (+166/-0)
lib/lp/code/model/tests/test_branchhosting.py (+230/-0)
lib/lp/services/config/schema-lazr.conf (+3/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/branch-hosting-client
Reviewer Review Type Date Requested Status
William Grant code 2018-05-16 Approve on 2018-06-21
Review via email: mp+345704@code.launchpad.net

Commit message

Add a client for the loggerhead API.

Description of the change

This can't be merged until private-loggerhead is deployed and suitably configured on production, including sorting out haproxy and firewall issues.

To post a comment you must log in.
18664. By Colin Watson on 2018-05-17

Remove unused import.

18665. By Colin Watson on 2018-06-14

Merge private-loggerhead.

William Grant (wgrant) :
review: Approve (code)
18666. By Colin Watson on 2018-06-21

Convert BranchHostingClient to use +branch-id paths.

18667. By Colin Watson on 2018-06-21

Do some simple validation of revision IDs. Bazaar doesn't give us a lot of scope here.

Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'configs/development/launchpad-lazr.conf'
2--- configs/development/launchpad-lazr.conf 2018-05-21 20:30:16 +0000
3+++ configs/development/launchpad-lazr.conf 2018-06-21 14:57:15 +0000
4@@ -47,6 +47,7 @@
5 access_log: /var/tmp/bazaar.launchpad.dev/codehosting-access.log
6 blacklisted_hostnames:
7 use_forking_daemon: True
8+internal_bzr_api_endpoint: http://bazaar.launchpad.dev:8090/
9 internal_git_api_endpoint: http://git.launchpad.dev:19417/
10 git_browse_root: https://git.launchpad.dev/
11 git_anon_root: git://git.launchpad.dev/
12
13=== modified file 'lib/lp/code/configure.zcml'
14--- lib/lp/code/configure.zcml 2017-06-16 10:02:47 +0000
15+++ lib/lp/code/configure.zcml 2018-06-21 14:57:15 +0000
16@@ -1,4 +1,4 @@
17-<!-- Copyright 2009-2017 Canonical Ltd. This software is licensed under the
18+<!-- Copyright 2009-2018 Canonical Ltd. This software is licensed under the
19 GNU Affero General Public License version 3 (see the file LICENSE).
20 -->
21
22@@ -538,6 +538,13 @@
23 <allow interface="lp.code.interfaces.branchrevision.IBranchRevision"/>
24 </class>
25
26+ <!-- Branch hosting -->
27+ <securedutility
28+ class="lp.code.model.branchhosting.BranchHostingClient"
29+ provides="lp.code.interfaces.branchhosting.IBranchHostingClient">
30+ <allow interface="lp.code.interfaces.branchhosting.IBranchHostingClient" />
31+ </securedutility>
32+
33 <!-- CodeReviewComment -->
34
35 <class class="lp.code.model.codereviewcomment.CodeReviewComment">
36
37=== modified file 'lib/lp/code/errors.py'
38--- lib/lp/code/errors.py 2017-01-16 22:27:56 +0000
39+++ lib/lp/code/errors.py 2018-06-21 14:57:15 +0000
40@@ -1,4 +1,4 @@
41-# Copyright 2009-2017 Canonical Ltd. This software is licensed under the
42+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
43 # GNU Affero General Public License version 3 (see the file LICENSE).
44
45 """Errors used in the lp/code modules."""
46@@ -13,7 +13,9 @@
47 'BranchCreatorNotMemberOfOwnerTeam',
48 'BranchCreatorNotOwner',
49 'BranchExists',
50+ 'BranchFileNotFound',
51 'BranchHasPendingWrites',
52+ 'BranchHostingFault',
53 'BranchTargetError',
54 'BranchTypeError',
55 'BuildAlreadyPending',
56@@ -348,6 +350,34 @@
57 """Raised when the user specifies an unrecognized branch type."""
58
59
60+class BranchHostingFault(Exception):
61+ """Raised when there is a fault fetching information about a branch."""
62+
63+
64+class BranchFileNotFound(BranchHostingFault):
65+ """Raised when a file does not exist in a branch."""
66+
67+ def __init__(self, branch_id, filename=None, file_id=None, rev=None):
68+ super(BranchFileNotFound, self).__init__()
69+ if (filename is None) == (file_id is None):
70+ raise AssertionError(
71+ "Exactly one of filename and file_id must be given.")
72+ self.branch_id = branch_id
73+ self.filename = filename
74+ self.file_id = file_id
75+ self.rev = rev
76+
77+ def __str__(self):
78+ message = "Branch ID %s has no file " % self.branch_id
79+ if self.filename is not None:
80+ message += self.filename
81+ else:
82+ message += "with ID %s" % self.file_id
83+ if self.rev is not None:
84+ message += " at revision %s" % self.rev
85+ return message
86+
87+
88 class GitRepositoryCreationException(Exception):
89 """Base class for Git repository creation exceptions."""
90
91
92=== added file 'lib/lp/code/interfaces/branchhosting.py'
93--- lib/lp/code/interfaces/branchhosting.py 1970-01-01 00:00:00 +0000
94+++ lib/lp/code/interfaces/branchhosting.py 2018-06-21 14:57:15 +0000
95@@ -0,0 +1,59 @@
96+# Copyright 2018 Canonical Ltd. This software is licensed under the
97+# GNU Affero General Public License version 3 (see the file LICENSE).
98+
99+"""Interface for communication with the Loggerhead API."""
100+
101+__metaclass__ = type
102+__all__ = [
103+ 'IBranchHostingClient',
104+ ]
105+
106+from zope.interface import Interface
107+
108+
109+class IBranchHostingClient(Interface):
110+ """Interface for the internal API provided by Loggerhead."""
111+
112+ def getDiff(branch_id, new, old=None, context_lines=None, logger=None):
113+ """Get the diff between two revisions.
114+
115+ :param branch_id: The ID of the branch.
116+ :param new: The new revno or revision ID.
117+ :param old: The old revno or revision ID. Defaults to the parent
118+ revision of `new`.
119+ :param context_lines: Include this number of lines of context around
120+ each hunk.
121+ :param logger: An optional logger.
122+ :raises ValueError: if `old` or `new` is ill-formed.
123+ :raises BranchHostingFault: if the API returned an error.
124+ :return: The diff between `old` and `new` as a byte string.
125+ """
126+
127+ def getInventory(branch_id, dirname, rev=None, logger=None):
128+ """Get information on files in a directory.
129+
130+ :param branch_id: The ID of the branch.
131+ :param dirname: The name of the directory, relative to the root of
132+ the branch.
133+ :param rev: An optional revno or revision ID. Defaults to 'head:'.
134+ :param logger: An optional logger.
135+ :raises ValueError: if `rev` is ill-formed.
136+ :raises BranchFileNotFound: if the directory does not exist.
137+ :raises BranchHostingFault: if the API returned some other error.
138+ :return: The directory inventory as a dict: see
139+ `loggerhead.controllers.inventory_ui` for details.
140+ """
141+
142+ def getBlob(branch_id, file_id, rev=None, logger=None):
143+ """Get a blob by file name from a branch.
144+
145+ :param branch_id: The ID of the branch.
146+ :param file_id: The file ID of the file. (`getInventory` may be
147+ useful to retrieve this.)
148+ :param rev: An optional revno or revision ID. Defaults to 'head:'.
149+ :param logger: An optional logger.
150+ :raises ValueError: if `rev` is ill-formed.
151+ :raises BranchFileNotFound: if the directory does not exist.
152+ :raises BranchHostingFault: if the API returned some other error.
153+ :return: The blob content as a byte string.
154+ """
155
156=== added file 'lib/lp/code/model/branchhosting.py'
157--- lib/lp/code/model/branchhosting.py 1970-01-01 00:00:00 +0000
158+++ lib/lp/code/model/branchhosting.py 2018-06-21 14:57:15 +0000
159@@ -0,0 +1,166 @@
160+# Copyright 2018 Canonical Ltd. This software is licensed under the
161+# GNU Affero General Public License version 3 (see the file LICENSE).
162+
163+"""Communication with the Loggerhead API for Bazaar code hosting."""
164+
165+from __future__ import absolute_import, print_function, unicode_literals
166+
167+__metaclass__ = type
168+__all__ = [
169+ 'BranchHostingClient',
170+ ]
171+
172+import json
173+import sys
174+
175+from lazr.restful.utils import get_current_browser_request
176+import requests
177+from six import reraise
178+from six.moves.urllib_parse import (
179+ quote,
180+ urljoin,
181+ )
182+from zope.interface import implementer
183+
184+from lp.code.errors import (
185+ BranchFileNotFound,
186+ BranchHostingFault,
187+ )
188+from lp.code.interfaces.branchhosting import IBranchHostingClient
189+from lp.code.interfaces.codehosting import BRANCH_ID_ALIAS_PREFIX
190+from lp.services.config import config
191+from lp.services.timeline.requesttimeline import get_request_timeline
192+from lp.services.timeout import (
193+ get_default_timeout_function,
194+ TimeoutError,
195+ urlfetch,
196+ )
197+
198+
199+class RequestExceptionWrapper(requests.RequestException):
200+ """A non-requests exception that occurred during a request."""
201+
202+
203+@implementer(IBranchHostingClient)
204+class BranchHostingClient:
205+ """A client for the Bazaar Loggerhead API."""
206+
207+ def __init__(self):
208+ self.endpoint = config.codehosting.internal_bzr_api_endpoint
209+
210+ def _request(self, method, branch_id, quoted_tail, as_json=False,
211+ **kwargs):
212+ """Make a request to the Loggerhead API."""
213+ # Fetch the current timeout before starting the timeline action,
214+ # since making a database query inside this action will result in an
215+ # OverlappingActionError.
216+ get_default_timeout_function()()
217+ timeline = get_request_timeline(get_current_browser_request())
218+ components = [BRANCH_ID_ALIAS_PREFIX, str(branch_id)]
219+ if as_json:
220+ components.append("+json")
221+ components.append(quoted_tail)
222+ path = "/" + "/".join(components)
223+ action = timeline.start(
224+ "branch-hosting-%s" % method, "%s %s" % (path, json.dumps(kwargs)))
225+ try:
226+ response = urlfetch(
227+ urljoin(self.endpoint, path), trust_env=False, method=method,
228+ **kwargs)
229+ except TimeoutError:
230+ # Re-raise this directly so that it can be handled specially by
231+ # callers.
232+ raise
233+ except requests.RequestException:
234+ raise
235+ except Exception:
236+ _, val, tb = sys.exc_info()
237+ reraise(
238+ RequestExceptionWrapper, RequestExceptionWrapper(*val.args),
239+ tb)
240+ finally:
241+ action.finish()
242+ if as_json:
243+ if response.content:
244+ return response.json()
245+ else:
246+ return None
247+ else:
248+ return response.content
249+
250+ def _get(self, branch_id, tail, **kwargs):
251+ return self._request("get", branch_id, tail, **kwargs)
252+
253+ def _checkRevision(self, rev):
254+ """Check that a revision is well-formed.
255+
256+ We don't have a lot of scope for validation here, since Bazaar
257+ allows revision IDs to be basically anything; but let's at least
258+ exclude / as an extra layer of defence against path traversal
259+ attacks.
260+ """
261+ if rev is not None and "/" in rev:
262+ raise ValueError("Revision ID '%s' is not well-formed." % rev)
263+
264+ def getDiff(self, branch_id, new, old=None, context_lines=None,
265+ logger=None):
266+ """See `IBranchHostingClient`."""
267+ self._checkRevision(old)
268+ self._checkRevision(new)
269+ try:
270+ if logger is not None:
271+ if old is None:
272+ logger.info(
273+ "Requesting diff for %s from parent of %s to %s" %
274+ (branch_id, new, new))
275+ else:
276+ logger.info(
277+ "Requesting diff for %s from %s to %s" %
278+ (branch_id, old, new))
279+ quoted_tail = "diff/%s" % quote(new, safe="")
280+ if old is not None:
281+ quoted_tail += "/%s" % quote(old, safe="")
282+ return self._get(
283+ branch_id, quoted_tail, as_json=False,
284+ params={"context_lines": context_lines})
285+ except requests.RequestException as e:
286+ raise BranchHostingFault(
287+ "Failed to get diff from Bazaar branch: %s" % e)
288+
289+ def getInventory(self, branch_id, dirname, rev=None, logger=None):
290+ """See `IBranchHostingClient`."""
291+ self._checkRevision(rev)
292+ try:
293+ if logger is not None:
294+ logger.info(
295+ "Requesting inventory at %s from branch %s" %
296+ (dirname, branch_id))
297+ quoted_tail = "files/%s/%s" % (
298+ quote(rev or "head:", safe=""), quote(dirname.lstrip("/")))
299+ return self._get(branch_id, quoted_tail, as_json=True)
300+ except requests.RequestException as e:
301+ if e.response.status_code == requests.codes.NOT_FOUND:
302+ raise BranchFileNotFound(branch_id, filename=dirname, rev=rev)
303+ else:
304+ raise BranchHostingFault(
305+ "Failed to get inventory from Bazaar branch: %s" % e)
306+
307+ def getBlob(self, branch_id, file_id, rev=None, logger=None):
308+ """See `IBranchHostingClient`."""
309+ self._checkRevision(rev)
310+ try:
311+ if logger is not None:
312+ logger.info(
313+ "Fetching file ID %s from branch %s" %
314+ (file_id, branch_id))
315+ return self._get(
316+ branch_id,
317+ "download/%s/%s" % (
318+ quote(rev or "head:", safe=""), quote(file_id, safe="")),
319+ as_json=False)
320+ except requests.RequestException as e:
321+ if e.response.status_code == requests.codes.NOT_FOUND:
322+ raise BranchFileNotFound(branch_id, file_id=file_id, rev=rev)
323+ else:
324+ raise BranchHostingFault(
325+ "Failed to get file from Bazaar branch: %s" % e)
326
327=== added file 'lib/lp/code/model/tests/test_branchhosting.py'
328--- lib/lp/code/model/tests/test_branchhosting.py 1970-01-01 00:00:00 +0000
329+++ lib/lp/code/model/tests/test_branchhosting.py 2018-06-21 14:57:15 +0000
330@@ -0,0 +1,230 @@
331+# Copyright 2018 Canonical Ltd. This software is licensed under the
332+# GNU Affero General Public License version 3 (see the file LICENSE).
333+
334+"""Unit tests for `BranchHostingClient`.
335+
336+We don't currently do integration testing against a real hosting service,
337+but we at least check that we're sending the right requests.
338+"""
339+
340+from __future__ import absolute_import, print_function, unicode_literals
341+
342+__metaclass__ = type
343+
344+from contextlib import contextmanager
345+import re
346+
347+from lazr.restful.utils import get_current_browser_request
348+import responses
349+from testtools.matchers import MatchesStructure
350+from zope.component import getUtility
351+from zope.interface import implementer
352+from zope.security.proxy import removeSecurityProxy
353+
354+from lp.code.errors import (
355+ BranchFileNotFound,
356+ BranchHostingFault,
357+ )
358+from lp.code.interfaces.branchhosting import IBranchHostingClient
359+from lp.services.job.interfaces.job import (
360+ IRunnableJob,
361+ JobStatus,
362+ )
363+from lp.services.job.model.job import Job
364+from lp.services.job.runner import (
365+ BaseRunnableJob,
366+ JobRunner,
367+ )
368+from lp.services.timeline.requesttimeline import get_request_timeline
369+from lp.services.timeout import (
370+ get_default_timeout_function,
371+ set_default_timeout_function,
372+ )
373+from lp.services.webapp.url import urlappend
374+from lp.testing import TestCase
375+from lp.testing.layers import ZopelessDatabaseLayer
376+
377+
378+class TestBranchHostingClient(TestCase):
379+
380+ layer = ZopelessDatabaseLayer
381+
382+ def setUp(self):
383+ super(TestBranchHostingClient, self).setUp()
384+ self.client = getUtility(IBranchHostingClient)
385+ self.endpoint = removeSecurityProxy(self.client).endpoint
386+ self.requests = []
387+
388+ @contextmanager
389+ def mockRequests(self, method, set_default_timeout=True, **kwargs):
390+ with responses.RequestsMock() as requests_mock:
391+ requests_mock.add(method, re.compile(r".*"), **kwargs)
392+ original_timeout_function = get_default_timeout_function()
393+ if set_default_timeout:
394+ set_default_timeout_function(lambda: 60.0)
395+ try:
396+ yield
397+ finally:
398+ set_default_timeout_function(original_timeout_function)
399+ self.requests = [call.request for call in requests_mock.calls]
400+
401+ def assertRequest(self, url_suffix, **kwargs):
402+ [request] = self.requests
403+ self.assertThat(request, MatchesStructure.byEquality(
404+ url=urlappend(self.endpoint, url_suffix), method="GET", **kwargs))
405+ timeline = get_request_timeline(get_current_browser_request())
406+ action = timeline.actions[-1]
407+ self.assertEqual("branch-hosting-get", action.category)
408+ self.assertEqual(
409+ "/" + url_suffix.split("?", 1)[0], action.detail.split(" ", 1)[0])
410+
411+ def test_getDiff(self):
412+ with self.mockRequests("GET", body="---\n+++\n"):
413+ diff = self.client.getDiff(123, "2", "1")
414+ self.assertEqual(b"---\n+++\n", diff)
415+ self.assertRequest("+branch-id/123/diff/2/1")
416+
417+ def test_getDiff_no_old_revision(self):
418+ with self.mockRequests("GET", body="---\n+++\n"):
419+ diff = self.client.getDiff(123, "2")
420+ self.assertEqual(b"---\n+++\n", diff)
421+ self.assertRequest("+branch-id/123/diff/2")
422+
423+ def test_getDiff_context_lines(self):
424+ with self.mockRequests("GET", body="---\n+++\n"):
425+ diff = self.client.getDiff(123, "2", "1", context_lines=4)
426+ self.assertEqual(b"---\n+++\n", diff)
427+ self.assertRequest("+branch-id/123/diff/2/1?context_lines=4")
428+
429+ def test_getDiff_bad_old_revision(self):
430+ self.assertRaises(ValueError, self.client.getDiff, 123, "x/y", "1")
431+
432+ def test_getDiff_bad_new_revision(self):
433+ self.assertRaises(ValueError, self.client.getDiff, 123, "1", "x/y")
434+
435+ def test_getDiff_failure(self):
436+ with self.mockRequests("GET", status=400):
437+ self.assertRaisesWithContent(
438+ BranchHostingFault,
439+ "Failed to get diff from Bazaar branch: "
440+ "400 Client Error: Bad Request",
441+ self.client.getDiff, 123, "2", "1")
442+
443+ def test_getInventory(self):
444+ with self.mockRequests("GET", json={"filelist": []}):
445+ response = self.client.getInventory(123, "dir/path/file/name")
446+ self.assertEqual({"filelist": []}, response)
447+ self.assertRequest(
448+ "+branch-id/123/+json/files/head%3A/dir/path/file/name")
449+
450+ def test_getInventory_revision(self):
451+ with self.mockRequests("GET", json={"filelist": []}):
452+ response = self.client.getInventory(
453+ 123, "dir/path/file/name", rev="a")
454+ self.assertEqual({"filelist": []}, response)
455+ self.assertRequest("+branch-id/123/+json/files/a/dir/path/file/name")
456+
457+ def test_getInventory_not_found(self):
458+ with self.mockRequests("GET", status=404):
459+ self.assertRaisesWithContent(
460+ BranchFileNotFound,
461+ "Branch ID 123 has no file dir/path/file/name",
462+ self.client.getInventory, 123, "dir/path/file/name")
463+
464+ def test_getInventory_revision_not_found(self):
465+ with self.mockRequests("GET", status=404):
466+ self.assertRaisesWithContent(
467+ BranchFileNotFound,
468+ "Branch ID 123 has no file dir/path/file/name at revision a",
469+ self.client.getInventory, 123, "dir/path/file/name", rev="a")
470+
471+ def test_getInventory_bad_revision(self):
472+ self.assertRaises(
473+ ValueError, self.client.getInventory,
474+ 123, "dir/path/file/name", rev="x/y")
475+
476+ def test_getInventory_failure(self):
477+ with self.mockRequests("GET", status=400):
478+ self.assertRaisesWithContent(
479+ BranchHostingFault,
480+ "Failed to get inventory from Bazaar branch: "
481+ "400 Client Error: Bad Request",
482+ self.client.getInventory, 123, "dir/path/file/name")
483+
484+ def test_getInventory_url_quoting(self):
485+ with self.mockRequests("GET", json={"filelist": []}):
486+ self.client.getInventory(123, "+file/ name?", rev="+rev id?")
487+ self.assertRequest(
488+ "+branch-id/123/+json/files/%2Brev%20id%3F/%2Bfile/%20name%3F")
489+
490+ def test_getBlob(self):
491+ blob = b"".join(chr(i) for i in range(256))
492+ with self.mockRequests("GET", body=blob):
493+ response = self.client.getBlob(123, "file-id")
494+ self.assertEqual(blob, response)
495+ self.assertRequest("+branch-id/123/download/head%3A/file-id")
496+
497+ def test_getBlob_revision(self):
498+ blob = b"".join(chr(i) for i in range(256))
499+ with self.mockRequests("GET", body=blob):
500+ response = self.client.getBlob(123, "file-id", rev="a")
501+ self.assertEqual(blob, response)
502+ self.assertRequest("+branch-id/123/download/a/file-id")
503+
504+ def test_getBlob_not_found(self):
505+ with self.mockRequests("GET", status=404):
506+ self.assertRaisesWithContent(
507+ BranchFileNotFound,
508+ "Branch ID 123 has no file with ID file-id",
509+ self.client.getBlob, 123, "file-id")
510+
511+ def test_getBlob_revision_not_found(self):
512+ with self.mockRequests("GET", status=404):
513+ self.assertRaisesWithContent(
514+ BranchFileNotFound,
515+ "Branch ID 123 has no file with ID file-id at revision a",
516+ self.client.getBlob, 123, "file-id", rev="a")
517+
518+ def test_getBlob_bad_revision(self):
519+ self.assertRaises(
520+ ValueError, self.client.getBlob, 123, "file-id", rev="x/y")
521+
522+ def test_getBlob_failure(self):
523+ with self.mockRequests("GET", status=400):
524+ self.assertRaisesWithContent(
525+ BranchHostingFault,
526+ "Failed to get file from Bazaar branch: "
527+ "400 Client Error: Bad Request",
528+ self.client.getBlob, 123, "file-id")
529+
530+ def test_getBlob_url_quoting(self):
531+ blob = b"".join(chr(i) for i in range(256))
532+ with self.mockRequests("GET", body=blob):
533+ self.client.getBlob(123, "+file/ id?", rev="+rev id?")
534+ self.assertRequest(
535+ "+branch-id/123/download/%2Brev%20id%3F/%2Bfile%2F%20id%3F")
536+
537+ def test_works_in_job(self):
538+ # `BranchHostingClient` is usable from a running job.
539+ blob = b"".join(chr(i) for i in range(256))
540+
541+ @implementer(IRunnableJob)
542+ class GetBlobJob(BaseRunnableJob):
543+ def __init__(self, testcase):
544+ super(GetBlobJob, self).__init__()
545+ self.job = Job()
546+ self.testcase = testcase
547+
548+ def run(self):
549+ with self.testcase.mockRequests(
550+ "GET", body=blob, set_default_timeout=False):
551+ self.blob = self.testcase.client.getBlob(123, "file-id")
552+ # We must make this assertion inside the job, since the job
553+ # runner creates a separate timeline.
554+ self.testcase.assertRequest(
555+ "+branch-id/123/download/head%3A/file-id")
556+
557+ job = GetBlobJob(self)
558+ JobRunner([job]).runAll()
559+ self.assertEqual(JobStatus.COMPLETED, job.job.status)
560+ self.assertEqual(blob, job.blob)
561
562=== modified file 'lib/lp/services/config/schema-lazr.conf'
563--- lib/lp/services/config/schema-lazr.conf 2018-06-21 14:57:15 +0000
564+++ lib/lp/services/config/schema-lazr.conf 2018-06-21 14:57:15 +0000
565@@ -352,6 +352,9 @@
566 # of shutting down and so should not receive any more connections.
567 web_status_port = tcp:8022
568
569+# The URL of the internal Bazaar hosting API endpoint.
570+internal_bzr_api_endpoint: none
571+
572 # The URL of the internal Git hosting API endpoint.
573 internal_git_api_endpoint: none
574