Merge ~cjwatson/launchpad:gpg-handle-preload into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: f7c5413a48179f688334c6f997c12fa6f2e303b9
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:gpg-handle-preload
Merge into: launchpad:master
Diff against target: 306 lines (+91/-60)
4 files modified
lib/lp/services/gpg/handler.py (+11/-47)
lib/lp/services/gpg/interfaces.py (+50/-1)
lib/lp/services/gpg/tests/test_gpghandler.py (+15/-8)
lib/lp/soyuz/tests/fakepackager.py (+15/-4)
Reviewer Review Type Date Requested Status
Jürgen Gmach Approve
Review via email: mp+441142@code.launchpad.net

Commit message

Defer GnuPG home directory setup to support app preloading

Description of the change

The `launchpad-appserver` charm uses Gunicorn's `preload_app` setting as part of its arrangements to support rolling restart during upgrades. However, this causes a problem with the GnuPG handler. Because it sets up its temporary GnuPG home directory in `GPGHandler.__init__`, this is done during ZCML initialization, and when application preloading is enabled this happens in the Gunicorn parent ("arbiter") process and is shared between all the child ("worker") processes. Unfortunately, this means that when a worker process exits, which can happen during normal operation, the `atexit` handler that's registered to clean up the temporary GnuPG home directory is called, thus leaving all other running workers without a GnuPG home directory. At this point all GnuPG operations will fail until the entire service is restarted.

There's certainly work we can do to make the behaviour of our `atexit` handlers less surprising with child processes in general, but in the meantime I think it makes sense to ensure that we have a separate GnuPG home directory for each child process even when using application preloading; I doubt GnuPG is completely robust against having multiple processes writing to the same home directory in parallel anyway. We achieve this by deferring setup of the GnuPG home directory until we need to perform an actual GnuPG operation.

I've always been uncomfortable with the way that `GPGHandler` sets `GNUPGHOME` in the process environment; it seems too much like implicit action at a distance. Most of the relevant tools now support passing this in some other way, either via a GPGME engine parameter or via a command-line option, so I've switched to that where possible. There are a couple of cases where this isn't possible, and in those cases I've arranged to set the environment variable in a more localized way.

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

LGTM with one question

review: Approve
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/services/gpg/handler.py b/lib/lp/services/gpg/handler.py
2index a7a1a20..bfd0393 100644
3--- a/lib/lp/services/gpg/handler.py
4+++ b/lib/lp/services/gpg/handler.py
5@@ -8,13 +8,11 @@ __all__ = [
6 "PymeUserId",
7 ]
8
9-import atexit
10 import http.client
11 import os
12 import shutil
13 import subprocess
14 import sys
15-import tempfile
16 from contextlib import contextmanager
17 from datetime import datetime, timezone
18 from io import BytesIO
19@@ -47,6 +45,7 @@ from lp.services.gpg.interfaces import (
20 IPymeUserId,
21 MoreThanOneGPGKeyFound,
22 SecretGPGKeyImportDetected,
23+ get_gpg_home_directory,
24 get_gpg_path,
25 get_gpgme_context,
26 valid_fingerprint,
27@@ -83,49 +82,6 @@ def gpgme_timeline(name, detail):
28 class GPGHandler:
29 """See IGPGHandler."""
30
31- def __init__(self):
32- """Initialize environment variable."""
33- self._setNewHome()
34- os.environ["GNUPGHOME"] = self.home
35-
36- def _setNewHome(self):
37- """Create a new directory containing the required configuration.
38-
39- This method is called inside the class constructor and generates
40- a new directory (name randomly generated with the 'gpg-' prefix)
41- containing the proper file configuration and options.
42-
43- Also installs an atexit handler to remove the directory on normal
44- process termination.
45- """
46- self.home = tempfile.mkdtemp(prefix="gpg-")
47- confpath = os.path.join(self.home, "gpg.conf")
48- with open(confpath, "w") as conf:
49- # Avoid wasting time verifying the local keyring's consistency.
50- conf.write("no-auto-check-trustdb\n")
51- # Use the loopback mode to allow using password callbacks.
52- conf.write("pinentry-mode loopback\n")
53- # Assume "yes" on most questions; this is needed to allow
54- # deletion of secret keys via GPGME.
55- conf.write("yes\n")
56- # Prefer a SHA-2 hash where possible, otherwise GPG will fall
57- # back to a hash it can use.
58- conf.write(
59- "personal-digest-preferences SHA512 SHA384 SHA256 SHA224\n"
60- )
61- agentconfpath = os.path.join(self.home, "gpg-agent.conf")
62- with open(agentconfpath, "w") as agentconf:
63- agentconf.write("allow-loopback-pinentry\n")
64- # create a local atexit handler to remove the configuration directory
65- # on normal termination.
66-
67- def removeHome(home):
68- """Remove GNUPGHOME directory."""
69- if os.path.exists(home):
70- shutil.rmtree(home)
71-
72- atexit.register(removeHome, self.home)
73-
74 def sanitizeFingerprint(self, fingerprint):
75 """See IGPGHandler."""
76 return sanitize_fingerprint(fingerprint)
77@@ -133,6 +89,7 @@ class GPGHandler:
78 def resetLocalState(self):
79 """See IGPGHandler."""
80 # Remove the public keyring, private keyring and the trust DB.
81+ home = get_gpg_home_directory()
82 for filename in (
83 "pubring.gpg",
84 "pubring.kbx",
85@@ -140,7 +97,7 @@ class GPGHandler:
86 "private-keys-v1.d",
87 "trustdb.gpg",
88 ):
89- filename = os.path.join(self.home, filename)
90+ filename = os.path.join(home, filename)
91 if os.path.exists(filename):
92 if os.path.isdir(filename):
93 shutil.rmtree(filename)
94@@ -148,7 +105,12 @@ class GPGHandler:
95 os.remove(filename)
96 # Kill any running gpg-agent for GnuPG 2
97 if shutil.which("gpgconf"):
98- subprocess.check_call(["gpgconf", "--kill", "gpg-agent"])
99+ # XXX cjwatson 2023-04-16: Ubuntu 16.04's gpgconf doesn't
100+ # support --homedir, so we have to set an environment variable.
101+ # This can be simplified once we require Ubuntu 20.04.
102+ env = dict(os.environ)
103+ env["GNUPGHOME"] = home
104+ subprocess.check_call(["gpgconf", "--kill", "gpg-agent"], env=env)
105
106 def getVerifiedSignatureResilient(self, content, signature=None):
107 """See IGPGHandler."""
108@@ -717,6 +679,8 @@ class PymeKey:
109 return subprocess.run(
110 [
111 get_gpg_path(),
112+ "--homedir",
113+ get_gpg_home_directory(),
114 "--export-secret-keys",
115 "-a",
116 "--passphrase-fd",
117diff --git a/lib/lp/services/gpg/interfaces.py b/lib/lp/services/gpg/interfaces.py
118index 0302da0..6dec53a 100644
119--- a/lib/lp/services/gpg/interfaces.py
120+++ b/lib/lp/services/gpg/interfaces.py
121@@ -2,6 +2,7 @@
122 # GNU Affero General Public License version 3 (see the file LICENSE).
123
124 __all__ = [
125+ "get_gpg_home_directory",
126 "get_gpg_path",
127 "get_gpgme_context",
128 "GPG_INJECT",
129@@ -24,8 +25,12 @@ __all__ = [
130 "valid_keyid",
131 ]
132
133+import atexit
134 import http.client
135+import os.path
136 import re
137+import shutil
138+import tempfile
139
140 from lazr.enum import DBEnumeratedType, DBItem
141 from lazr.restful.declarations import error_status
142@@ -60,12 +65,56 @@ def get_gpg_path():
143 return "/usr/bin/gpg2"
144
145
146+_gpg_home = None
147+
148+
149+def get_gpg_home_directory():
150+ """Create a new GnuPG home directory for this process.
151+
152+ This also installs an atexit handler to remove the directory on normal
153+ process termination.
154+ """
155+ global _gpg_home
156+
157+ if _gpg_home is not None and os.path.exists(_gpg_home):
158+ return _gpg_home
159+
160+ _gpg_home = tempfile.mkdtemp(prefix="gpg-")
161+ confpath = os.path.join(_gpg_home, "gpg.conf")
162+ with open(confpath, "w") as conf:
163+ # Avoid wasting time verifying the local keyring's consistency.
164+ conf.write("no-auto-check-trustdb\n")
165+ # Use the loopback mode to allow using password callbacks.
166+ conf.write("pinentry-mode loopback\n")
167+ # Assume "yes" on most questions; this is needed to allow
168+ # deletion of secret keys via GPGME.
169+ conf.write("yes\n")
170+ # Prefer a SHA-2 hash where possible, otherwise GPG will fall
171+ # back to a hash it can use.
172+ conf.write("personal-digest-preferences SHA512 SHA384 SHA256 SHA224\n")
173+ agentconfpath = os.path.join(_gpg_home, "gpg-agent.conf")
174+ with open(agentconfpath, "w") as agentconf:
175+ agentconf.write("allow-loopback-pinentry\n")
176+
177+ def removeHome(home):
178+ """Remove GnuPG home directory."""
179+ if os.path.exists(home):
180+ shutil.rmtree(home)
181+
182+ # Remove the configuration directory on normal termination.
183+ atexit.register(removeHome, _gpg_home)
184+
185+ return _gpg_home
186+
187+
188 def get_gpgme_context():
189 """Return a new appropriately-configured GPGME context."""
190 import gpgme
191
192 context = gpgme.Context()
193- context.set_engine_info(gpgme.PROTOCOL_OpenPGP, get_gpg_path(), None)
194+ context.set_engine_info(
195+ gpgme.PROTOCOL_OpenPGP, get_gpg_path(), get_gpg_home_directory()
196+ )
197 context.armor = True
198 return context
199
200diff --git a/lib/lp/services/gpg/tests/test_gpghandler.py b/lib/lp/services/gpg/tests/test_gpghandler.py
201index ec2da7e..1199ea0 100644
202--- a/lib/lp/services/gpg/tests/test_gpghandler.py
203+++ b/lib/lp/services/gpg/tests/test_gpghandler.py
204@@ -23,6 +23,7 @@ from lp.services.gpg.interfaces import (
205 GPGKeyMismatchOnServer,
206 GPGKeyTemporarilyNotFoundError,
207 IGPGHandler,
208+ get_gpg_home_directory,
209 get_gpg_path,
210 get_gpgme_context,
211 )
212@@ -87,22 +88,26 @@ class TestGPGHandler(TestCase):
213 """Get a gpghandler and login"""
214 super().setUp()
215 login(ANONYMOUS)
216+ self.addCleanup(logout)
217 self.gpg_handler = getUtility(IGPGHandler)
218 self.gpg_handler.resetLocalState()
219-
220- def tearDown(self):
221- """Zero out the gpg database"""
222- # XXX Stuart Bishop 2005-10-27:
223- # This should be a zope test cleanup thing per SteveA.
224- self.gpg_handler.resetLocalState()
225- logout()
226- super().tearDown()
227+ self.addCleanup(self.gpg_handler.resetLocalState)
228
229 def populateKeyring(self):
230 for email in iter_test_key_emails():
231 pubkey = test_pubkey_from_email(email)
232 self.gpg_handler.importPublicKey(pubkey)
233
234+ def test_first_call_creates_home_directory(self):
235+ original_home = get_gpg_home_directory()
236+ shutil.rmtree(original_home)
237+ list(self.gpg_handler.localKeys())
238+ home = get_gpg_home_directory()
239+ self.assertNotEqual(original_home, home)
240+ self.assertTrue(os.path.exists(os.path.join(home, "gpg.conf")))
241+ list(self.gpg_handler.localKeys())
242+ self.assertEqual(home, get_gpg_home_directory())
243+
244 # This sequence might fit better as a doctest. Hmm.
245 def testEmptyGetKeys(self):
246 """The initial local key list should be empty."""
247@@ -541,6 +546,8 @@ class TestGPGHandler(TestCase):
248 gpg_proc = subprocess.Popen(
249 [
250 get_gpg_path(),
251+ "--homedir",
252+ get_gpg_home_directory(),
253 "--quiet",
254 "--status-fd",
255 "1",
256diff --git a/lib/lp/soyuz/tests/fakepackager.py b/lib/lp/soyuz/tests/fakepackager.py
257index a42cefe..963b94d 100644
258--- a/lib/lp/soyuz/tests/fakepackager.py
259+++ b/lib/lp/soyuz/tests/fakepackager.py
260@@ -22,7 +22,11 @@ from zope.component import getUtility
261 from lp.archiveuploader.nascentupload import NascentUpload
262 from lp.archiveuploader.uploadpolicy import findPolicyByName
263 from lp.registry.interfaces.distribution import IDistributionSet
264-from lp.services.gpg.interfaces import IGPGHandler, get_gpg_path
265+from lp.services.gpg.interfaces import (
266+ IGPGHandler,
267+ get_gpg_home_directory,
268+ get_gpg_path,
269+)
270 from lp.services.log.logger import BufferLogger
271 from lp.soyuz.enums import PackageUploadStatus
272 from lp.testing.gpgkeys import import_secret_test_key
273@@ -219,7 +223,7 @@ class FakePackager:
274 changelog_file.write(previous_content)
275 changelog_file.close()
276
277- def _runSubProcess(self, script, extra_args=None):
278+ def _runSubProcess(self, script, extra_args=None, extra_env=None):
279 """Run the given script and collects STDOUT and STDERR.
280
281 :raises AssertionError: If the script returns a non-Zero value.
282@@ -228,8 +232,11 @@ class FakePackager:
283 extra_args = []
284 args = [script]
285 args.extend(extra_args)
286+ env = dict(os.environ)
287+ if extra_env:
288+ env.update(extra_env)
289 process = subprocess.Popen(
290- args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
291+ args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env
292 )
293 stdout, stderr = process.communicate()
294
295@@ -403,7 +410,11 @@ class FakePackager:
296 current_path = os.getcwd()
297 os.chdir(self.upstream_directory)
298
299- self._runSubProcess("dpkg-buildpackage", dpkg_buildpackage_options)
300+ self._runSubProcess(
301+ "dpkg-buildpackage",
302+ extra_args=dpkg_buildpackage_options,
303+ extra_env={"GNUPGHOME": get_gpg_home_directory()},
304+ )
305
306 os.chdir(current_path)
307

Subscribers

People subscribed via source and target branches

to status/vote changes: