Merge ~cjwatson/launchpad:git-xmlrpc-log-request-id into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 9ceac6cba62b9792ff04c8e056e36649fca3328e
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:git-xmlrpc-log-request-id
Merge into: launchpad:master
Diff against target: 479 lines (+168/-65)
2 files modified
lib/lp/code/xmlrpc/git.py (+73/-10)
lib/lp/code/xmlrpc/tests/test_git.py (+95/-55)
Reviewer Review Type Date Requested Status
Tom Wardill (community) Approve
Review via email: mp+374221@code.launchpad.net

Commit message

Log requests and responses in Git XML-RPC endpoint

LP: #1846313

Description of the change

I don't love the test changes here, but I've tried to make them as clean as possible.

To post a comment you must log in.
Revision history for this message
Tom Wardill (twom) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/code/xmlrpc/git.py b/lib/lp/code/xmlrpc/git.py
2index d85aae7..cbf9205 100644
3--- a/lib/lp/code/xmlrpc/git.py
4+++ b/lib/lp/code/xmlrpc/git.py
5@@ -8,7 +8,9 @@ __all__ = [
6 'GitAPI',
7 ]
8
9+import logging
10 import sys
11+import uuid
12
13 from pymacaroons import Macaroon
14 import six
15@@ -109,6 +111,15 @@ def _can_internal_issuer_write(verified):
16 return verified.issuer_name == "code-import-job"
17
18
19+class GitLoggerAdapter(logging.LoggerAdapter):
20+ """A logger adapter that adds request-id information."""
21+
22+ def process(self, msg, kwargs):
23+ if self.extra is not None and self.extra.get("request-id"):
24+ msg = "[request-id=%s] %s" % (self.extra["request-id"], msg)
25+ return msg, kwargs
26+
27+
28 @implementer(IGitAPI)
29 class GitAPI(LaunchpadXMLRPCView):
30 """See `IGitAPI`."""
31@@ -300,6 +311,14 @@ class GitAPI(LaunchpadXMLRPCView):
32 getUtility(IErrorReportingUtility).raising(sys.exc_info(), request)
33 raise faults.OopsOccurred("creating a Git repository", request.oopsid)
34
35+ def _getLogger(self, request_id=None):
36+ # XXX cjwatson 2019-10-16: Ideally we'd always have a request-id,
37+ # but since that isn't yet the case, generate one if necessary.
38+ if request_id is None:
39+ request_id = uuid.uuid4()
40+ return GitLoggerAdapter(
41+ logging.getLogger(__name__), {"request-id": request_id})
42+
43 def _createRepository(self, requester, path, clone_from=None):
44 try:
45 namespace, repository_name, target_default, owner_default = (
46@@ -376,18 +395,36 @@ class GitAPI(LaunchpadXMLRPCView):
47
48 def translatePath(self, path, permission, auth_params):
49 """See `IGitAPI`."""
50- return run_with_login(
51- _get_requester_id(auth_params), self._translatePath,
52+ logger = self._getLogger(auth_params.get("request-id"))
53+ requester_id = _get_requester_id(auth_params)
54+ logger.info(
55+ "Request received: translatePath('%s', '%s') for %s",
56+ path, permission, requester_id)
57+ result = run_with_login(
58+ requester_id, self._translatePath,
59 six.ensure_text(path).strip("/"), permission, auth_params)
60+ if isinstance(result, xmlrpc_client.Fault):
61+ logger.error("translatePath failed: %r", result)
62+ else:
63+ # The results of path translation are not sensitive for logging
64+ # purposes (they may refer to private artifacts, but contain no
65+ # credentials).
66+ logger.info("translatePath succeeded: %s", result)
67+ return result
68
69 def notify(self, translated_path):
70 """See `IGitAPI`."""
71+ logger = self._getLogger()
72+ logger.info("Request received: notify('%s')", translated_path)
73 repository = getUtility(IGitLookup).getByHostingPath(translated_path)
74 if repository is None:
75- return faults.NotFound(
76+ fault = faults.NotFound(
77 "No repository found for '%s'." % translated_path)
78+ logger.error("notify failed: %r", fault)
79+ return fault
80 getUtility(IGitRefScanJobSource).create(
81 removeSecurityProxy(repository))
82+ logger.info("notify succeeded")
83
84 @return_fault
85 def _authenticateWithPassword(self, username, password):
86@@ -411,7 +448,19 @@ class GitAPI(LaunchpadXMLRPCView):
87
88 def authenticateWithPassword(self, username, password):
89 """See `IGitAPI`."""
90- return self._authenticateWithPassword(username, password)
91+ logger = self._getLogger()
92+ logger.info(
93+ "Request received: authenticateWithPassword('%s')", username)
94+ result = self._authenticateWithPassword(username, password)
95+ if isinstance(result, xmlrpc_client.Fault):
96+ logger.error("authenticateWithPassword failed: %r", result)
97+ else:
98+ # The results of authentication may be sensitive, but we can at
99+ # least log the authenticated user.
100+ logger.info(
101+ "authenticateWithPassword succeeded: %s",
102+ result.get("uid", result.get("user")))
103+ return result
104
105 def _renderPermissions(self, set_of_permissions):
106 """Render a set of permission strings for XML-RPC output."""
107@@ -470,9 +519,23 @@ class GitAPI(LaunchpadXMLRPCView):
108
109 def checkRefPermissions(self, translated_path, ref_paths, auth_params):
110 """See `IGitAPI`."""
111- return run_with_login(
112- _get_requester_id(auth_params),
113- self._checkRefPermissions,
114- translated_path,
115- ref_paths,
116- auth_params)
117+ logger = self._getLogger(auth_params.get("request-id"))
118+ requester_id = _get_requester_id(auth_params)
119+ logger.info(
120+ "Request received: checkRefPermissions('%s', %s) for %s",
121+ translated_path, [ref_path.data for ref_path in ref_paths],
122+ requester_id)
123+ result = run_with_login(
124+ requester_id, self._checkRefPermissions,
125+ translated_path, ref_paths, auth_params)
126+ if isinstance(result, xmlrpc_client.Fault):
127+ logger.error("checkRefPermissions failed: %r", result)
128+ else:
129+ # The results of ref permission checks are not sensitive for
130+ # logging purposes (they may refer to private artifacts, but
131+ # contain no credentials).
132+ logger.info(
133+ "checkRefPermissions succeeded: %s",
134+ [(ref_path.data, permissions)
135+ for ref_path, permissions in result])
136+ return result
137diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py
138index 757443a..f1846e7 100644
139--- a/lib/lp/code/xmlrpc/tests/test_git.py
140+++ b/lib/lp/code/xmlrpc/tests/test_git.py
141@@ -5,6 +5,9 @@
142
143 __metaclass__ = type
144
145+import uuid
146+
147+from fixtures import FakeLogger
148 from pymacaroons import Macaroon
149 import six
150 from six.moves import xmlrpc_client
151@@ -13,6 +16,7 @@ from testtools.matchers import (
152 IsInstance,
153 MatchesAll,
154 MatchesListwise,
155+ MatchesRegex,
156 MatchesSetwise,
157 MatchesStructure,
158 )
159@@ -67,7 +71,10 @@ from lp.xmlrpc import faults
160
161
162 def _make_auth_params(requester, can_authenticate=False, macaroon_raw=None):
163- auth_params = {"can-authenticate": can_authenticate}
164+ auth_params = {
165+ "can-authenticate": can_authenticate,
166+ "request-id": str(uuid.uuid4()),
167+ }
168 if requester == LAUNCHPAD_SERVICES:
169 auth_params["user"] = LAUNCHPAD_SERVICES
170 elif requester is not None:
171@@ -146,21 +153,42 @@ class TestGitAPIMixin:
172 self.hosting_fixture = self.useFixture(GitHostingFixture())
173 self.repository_set = getUtility(IGitRepositorySet)
174
175- def assertFault(self, expected_fault, func, *args, **kwargs):
176+ def assertFault(self, expected_fault, request_id, func_name,
177+ *args, **kwargs):
178 """Assert that a call raises the expected fault."""
179- fault = self.assertRaises(xmlrpc_client.Fault, func, *args, **kwargs)
180- self.assertThat(fault, MatchesFault(expected_fault))
181+ with FakeLogger() as logger:
182+ fault = self.assertRaises(
183+ xmlrpc_client.Fault, getattr(self.git_api, func_name),
184+ *args, **kwargs)
185+ self.assertThat(fault, MatchesFault(expected_fault))
186+ self.assertThat(logger.output, MatchesRegex(
187+ r"\[request-id=%s\] Request received: %s.*\n"
188+ r"\[request-id=%s\] %s failed: .* %s:.*" % (
189+ request_id or ".*", func_name,
190+ request_id or ".*", func_name, fault.faultCode)))
191 return fault
192
193+ def assertDoesNotFault(self, request_id, func_name, *args, **kwargs):
194+ """Assert that a call does not raise a fault."""
195+ with FakeLogger() as logger:
196+ results = getattr(self.git_api, func_name)(*args, **kwargs)
197+ self.assertThat(logger.output, MatchesRegex(
198+ r"\[request-id=%s\] Request received: %s.*\n"
199+ r"\[request-id=%s\] %s succeeded.*" % (
200+ request_id or ".*", func_name,
201+ request_id or ".*", func_name)))
202+ return results
203+
204 def assertGitRepositoryNotFound(self, requester, path, permission="read",
205 can_authenticate=False, macaroon_raw=None):
206 """Assert that the given path cannot be translated."""
207 auth_params = _make_auth_params(
208 requester, can_authenticate=can_authenticate,
209 macaroon_raw=macaroon_raw)
210+ request_id = auth_params["request-id"]
211 self.assertFault(
212- faults.GitRepositoryNotFound(path.strip("/")),
213- self.git_api.translatePath, path, permission, auth_params)
214+ faults.GitRepositoryNotFound(path.strip("/")), request_id,
215+ "translatePath", path, permission, auth_params)
216
217 def assertPermissionDenied(self, requester, path,
218 message="Permission denied.",
219@@ -170,9 +198,10 @@ class TestGitAPIMixin:
220 auth_params = _make_auth_params(
221 requester, can_authenticate=can_authenticate,
222 macaroon_raw=macaroon_raw)
223+ request_id = auth_params["request-id"]
224 self.assertFault(
225- faults.PermissionDenied(message),
226- self.git_api.translatePath, path, permission, auth_params)
227+ faults.PermissionDenied(message), request_id,
228+ "translatePath", path, permission, auth_params)
229
230 def assertUnauthorized(self, requester, path,
231 message="Authorisation required.",
232@@ -182,18 +211,20 @@ class TestGitAPIMixin:
233 auth_params = _make_auth_params(
234 requester, can_authenticate=can_authenticate,
235 macaroon_raw=macaroon_raw)
236+ request_id = auth_params["request-id"]
237 self.assertFault(
238- faults.Unauthorized(message),
239- self.git_api.translatePath, path, permission, auth_params)
240+ faults.Unauthorized(message), request_id,
241+ "translatePath", path, permission, auth_params)
242
243 def assertNotFound(self, requester, path, message, permission="read",
244 can_authenticate=False):
245 """Assert that looking at the given path returns NotFound."""
246 auth_params = _make_auth_params(
247 requester, can_authenticate=can_authenticate)
248+ request_id = auth_params["request-id"]
249 self.assertFault(
250- faults.NotFound(message),
251- self.git_api.translatePath, path, permission, auth_params)
252+ faults.NotFound(message), request_id,
253+ "translatePath", path, permission, auth_params)
254
255 def assertInvalidSourcePackageName(self, requester, path, name,
256 permission="read",
257@@ -202,27 +233,30 @@ class TestGitAPIMixin:
258 InvalidSourcePackageName."""
259 auth_params = _make_auth_params(
260 requester, can_authenticate=can_authenticate)
261+ request_id = auth_params["request-id"]
262 self.assertFault(
263- faults.InvalidSourcePackageName(name),
264- self.git_api.translatePath, path, permission, auth_params)
265+ faults.InvalidSourcePackageName(name), request_id,
266+ "translatePath", path, permission, auth_params)
267
268 def assertInvalidBranchName(self, requester, path, message,
269 permission="read", can_authenticate=False):
270 """Assert that looking at the given path returns InvalidBranchName."""
271 auth_params = _make_auth_params(
272 requester, can_authenticate=can_authenticate)
273+ request_id = auth_params["request-id"]
274 self.assertFault(
275- faults.InvalidBranchName(Exception(message)),
276- self.git_api.translatePath, path, permission, auth_params)
277+ faults.InvalidBranchName(Exception(message)), request_id,
278+ "translatePath", path, permission, auth_params)
279
280 def assertOopsOccurred(self, requester, path,
281 permission="read", can_authenticate=False):
282 """Assert that looking at the given path OOPSes."""
283 auth_params = _make_auth_params(
284 requester, can_authenticate=can_authenticate)
285+ request_id = auth_params["request-id"]
286 fault = self.assertFault(
287- faults.OopsOccurred,
288- self.git_api.translatePath, path, permission, auth_params)
289+ faults.OopsOccurred, request_id,
290+ "translatePath", path, permission, auth_params)
291 prefix = (
292 "An unexpected error has occurred while creating a Git "
293 "repository. Please report a Launchpad bug and quote: ")
294@@ -235,7 +269,9 @@ class TestGitAPIMixin:
295 auth_params = _make_auth_params(
296 requester, can_authenticate=can_authenticate,
297 macaroon_raw=macaroon_raw)
298- translation = self.git_api.translatePath(path, permission, auth_params)
299+ request_id = auth_params["request-id"]
300+ translation = self.assertDoesNotFault(
301+ request_id, "translatePath", path, permission, auth_params)
302 login(ANONYMOUS)
303 self.assertEqual(
304 {"path": removeSecurityProxy(repository).getInternalPath(),
305@@ -246,7 +282,9 @@ class TestGitAPIMixin:
306 private=False):
307 auth_params = _make_auth_params(
308 requester, can_authenticate=can_authenticate)
309- translation = self.git_api.translatePath(path, "write", auth_params)
310+ request_id = auth_params["request-id"]
311+ translation = self.assertDoesNotFault(
312+ request_id, "translatePath", path, "write", auth_params)
313 login(ANONYMOUS)
314 repository = getUtility(IGitRepositorySet).getByPath(
315 requester, path.lstrip("/"))
316@@ -272,10 +310,12 @@ class TestGitAPIMixin:
317 def assertHasRefPermissions(self, requester, repository, ref_paths,
318 permissions, macaroon_raw=None):
319 auth_params = _make_auth_params(requester, macaroon_raw=macaroon_raw)
320+ request_id = auth_params["request-id"]
321 translated_path = removeSecurityProxy(repository).getInternalPath()
322 ref_paths = [
323 xmlrpc_client.Binary(ref_path) for ref_path in ref_paths]
324- results = self.git_api.checkRefPermissions(
325+ results = self.assertDoesNotFault(
326+ request_id, "checkRefPermissions",
327 translated_path, ref_paths, auth_params)
328 self.assertThat(results, MatchesSetwise(*(
329 MatchesListwise([
330@@ -593,9 +633,8 @@ class TestGitAPIMixin:
331 def test_checkRefPermissions_nonexistent_repository(self):
332 requester = self.factory.makePerson()
333 self.assertFault(
334- faults.GitRepositoryNotFound("nonexistent"),
335- self.git_api.checkRefPermissions,
336- "nonexistent", [], {"uid": requester.id})
337+ faults.GitRepositoryNotFound("nonexistent"), None,
338+ "checkRefPermissions", "nonexistent", [], {"uid": requester.id})
339
340
341 class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
342@@ -1169,7 +1208,8 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
343 def test_notify(self):
344 # The notify call creates a GitRefScanJob.
345 repository = self.factory.makeGitRepository()
346- self.assertIsNone(self.git_api.notify(repository.getInternalPath()))
347+ self.assertIsNone(self.assertDoesNotFault(
348+ None, "notify", repository.getInternalPath()))
349 job_source = getUtility(IGitRefScanJobSource)
350 [job] = list(job_source.iterReady())
351 self.assertEqual(repository, job.repository)
352@@ -1177,7 +1217,7 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
353 def test_notify_missing_repository(self):
354 # A notify call on a non-existent repository returns a fault and
355 # does not create a job.
356- self.assertFault(faults.NotFound, self.git_api.notify, "10000")
357+ self.assertFault(faults.NotFound, None, "notify", "10000")
358 job_source = getUtility(IGitRefScanJobSource)
359 self.assertEqual([], list(job_source.iterReady()))
360
361@@ -1187,15 +1227,15 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
362 repository = self.factory.makeGitRepository(
363 information_type=InformationType.PRIVATESECURITY)
364 path = repository.getInternalPath()
365- self.assertIsNone(self.git_api.notify(path))
366+ self.assertIsNone(self.assertDoesNotFault(None, "notify", path))
367 job_source = getUtility(IGitRefScanJobSource)
368 [job] = list(job_source.iterReady())
369 self.assertEqual(repository, job.repository)
370
371 def test_authenticateWithPassword(self):
372 self.assertFault(
373- faults.Unauthorized,
374- self.git_api.authenticateWithPassword, "foo", "bar")
375+ faults.Unauthorized, None,
376+ "authenticateWithPassword", "foo", "bar")
377
378 def test_authenticateWithPassword_code_import(self):
379 self.pushConfig(
380@@ -1208,15 +1248,15 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
381 macaroon = removeSecurityProxy(issuer).issueMacaroon(job)
382 self.assertEqual(
383 {"macaroon": macaroon.serialize(), "user": "+launchpad-services"},
384- self.git_api.authenticateWithPassword("", macaroon.serialize()))
385+ self.assertDoesNotFault(
386+ None, "authenticateWithPassword", "", macaroon.serialize()))
387 other_macaroon = Macaroon(identifier="another", key="another-secret")
388 self.assertFault(
389- faults.Unauthorized,
390- self.git_api.authenticateWithPassword,
391- "", other_macaroon.serialize())
392+ faults.Unauthorized, None,
393+ "authenticateWithPassword", "", other_macaroon.serialize())
394 self.assertFault(
395- faults.Unauthorized,
396- self.git_api.authenticateWithPassword, "", "nonsense")
397+ faults.Unauthorized, None,
398+ "authenticateWithPassword", "", "nonsense")
399
400 def test_authenticateWithPassword_private_snap_build(self):
401 self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
402@@ -1231,15 +1271,15 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
403 macaroon = removeSecurityProxy(issuer).issueMacaroon(build)
404 self.assertEqual(
405 {"macaroon": macaroon.serialize(), "user": "+launchpad-services"},
406- self.git_api.authenticateWithPassword("", macaroon.serialize()))
407+ self.assertDoesNotFault(
408+ None, "authenticateWithPassword", "", macaroon.serialize()))
409 other_macaroon = Macaroon(identifier="another", key="another-secret")
410 self.assertFault(
411- faults.Unauthorized,
412- self.git_api.authenticateWithPassword,
413- "", other_macaroon.serialize())
414+ faults.Unauthorized, None,
415+ "authenticateWithPassword", "", other_macaroon.serialize())
416 self.assertFault(
417- faults.Unauthorized,
418- self.git_api.authenticateWithPassword, "", "nonsense")
419+ faults.Unauthorized, None,
420+ "authenticateWithPassword", "", "nonsense")
421
422 def test_authenticateWithPassword_user_macaroon(self):
423 # A user with a suitable macaroon can authenticate using it, in
424@@ -1252,23 +1292,23 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
425 self.factory.makeGitRepository(owner=requester), user=requester)
426 self.assertEqual(
427 {"macaroon": macaroon.serialize(), "uid": requester.id},
428- self.git_api.authenticateWithPassword(
429+ self.assertDoesNotFault(
430+ None, "authenticateWithPassword",
431 requester.name, macaroon.serialize()))
432 self.assertFault(
433- faults.Unauthorized,
434- self.git_api.authenticateWithPassword, "", macaroon.serialize())
435+ faults.Unauthorized, None,
436+ "authenticateWithPassword", "", macaroon.serialize())
437 self.assertFault(
438- faults.Unauthorized,
439- self.git_api.authenticateWithPassword,
440- "nonexistent", macaroon.serialize())
441+ faults.Unauthorized, None,
442+ "authenticateWithPassword", "nonexistent", macaroon.serialize())
443 other_macaroon = Macaroon(identifier="another", key="another-secret")
444 self.assertFault(
445- faults.Unauthorized,
446- self.git_api.authenticateWithPassword,
447+ faults.Unauthorized, None,
448+ "authenticateWithPassword",
449 requester.name, other_macaroon.serialize())
450 self.assertFault(
451- faults.Unauthorized,
452- self.git_api.authenticateWithPassword, requester.name, "nonsense")
453+ faults.Unauthorized, None,
454+ "authenticateWithPassword", requester.name, "nonsense")
455
456 def test_authenticateWithPassword_user_mismatch(self):
457 # authenticateWithPassword refuses macaroons in the case where the
458@@ -1300,7 +1340,8 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
459 expected_auth_params["uid"] = requester.id
460 self.assertEqual(
461 expected_auth_params,
462- self.git_api.authenticateWithPassword(
463+ self.assertDoesNotFault(
464+ None, "authenticateWithPassword",
465 name, macaroon.serialize()))
466 for requester in unauthorized:
467 login(ANONYMOUS)
468@@ -1308,9 +1349,8 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
469 requester if requester == LAUNCHPAD_SERVICES
470 else requester.name)
471 self.assertFault(
472- faults.Unauthorized,
473- self.git_api.authenticateWithPassword,
474- name, macaroon.serialize())
475+ faults.Unauthorized, None,
476+ "authenticateWithPassword", name, macaroon.serialize())
477
478 def test_checkRefPermissions_code_import(self):
479 # A code import worker with a suitable macaroon has repository owner

Subscribers

People subscribed via source and target branches

to status/vote changes: