Merge lp:~abentley/juju-core/sign-metadata into lp:~juju-qa/juju-core/cd-release-juju

Proposed by Aaron Bentley
Status: Merged
Merged at revision: 262
Proposed branch: lp:~abentley/juju-core/sign-metadata
Merge into: lp:~juju-qa/juju-core/cd-release-juju
Diff against target: 356 lines (+331/-0)
5 files modified
Makefile (+11/-0)
sign_metadata.py (+94/-0)
tests/__init__.py (+14/-0)
tests/test_sign_metadata.py (+200/-0)
utility.py (+12/-0)
To merge this branch: bzr merge lp:~abentley/juju-core/sign-metadata
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+284781@code.launchpad.net

Commit message

Add sign_metadata script.

Description of the change

This branch copies the sign_metadata script from juju-release-tools to cd-release-juju.

fake_gpg and its associated functions were extracted so that they could be reused.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'Makefile'
2--- Makefile 1970-01-01 00:00:00 +0000
3+++ Makefile 2016-02-02 19:14:21 +0000
4@@ -0,0 +1,11 @@
5+p=*.py
6+test:
7+ TMPDIR=/tmp python -m unittest discover -vv ./tests -p "$(p)"
8+lint:
9+ flake8 --max-line-length=80 $$(find . -name '*.py')
10+cover:
11+ python -m coverage run --source="./" --omit "./tests/*" -m unittest discover -vv ./tests
12+ python -m coverage report
13+clean:
14+ find . -name '*.pyc' -delete
15+.PHONY: lint test cover clean
16
17=== added file 'sign_metadata.py'
18--- sign_metadata.py 1970-01-01 00:00:00 +0000
19+++ sign_metadata.py 2016-02-02 19:14:21 +0000
20@@ -0,0 +1,94 @@
21+from __future__ import print_function
22+
23+from argparse import ArgumentParser
24+from os import listdir
25+from os.path import (
26+ basename,
27+ join,
28+ isdir,
29+ isfile,
30+)
31+import subprocess
32+from tempfile import NamedTemporaryFile
33+
34+
35+def run(command, verbose=False, dry_run=False):
36+ """Run command list and ensure stdout and error are available."""
37+ if verbose:
38+ print(command)
39+ try:
40+ subprocess.check_output(command, stderr=subprocess.STDOUT)
41+ except subprocess.CalledProcessError as e:
42+ print('FAIL: {} - Returncode: {}'.format(e.output, e.returncode))
43+ raise
44+
45+
46+def sign_metadata(signing_key, meta_dir, signing_passphrase_file=None):
47+ key_option, gpg_options = get_gpg_options(
48+ signing_key, signing_passphrase_file)
49+ for meta_file in get_meta_files(meta_dir):
50+ with NamedTemporaryFile() as temp_file:
51+ meta_file_path = join(meta_dir, meta_file)
52+ update_file_content(meta_file_path, temp_file.name)
53+ meta_basename = basename(meta_file).replace('.json', '.sjson')
54+ output_file = join(meta_dir, meta_basename)
55+ ensure_no_file(output_file)
56+ cmd = 'gpg {} --clearsign {} -o {} {}'.format(
57+ gpg_options, key_option, output_file, temp_file.name)
58+ run(cmd.split())
59+ output_file = join(meta_dir, '{}.gpg'.format(meta_file))
60+ ensure_no_file(output_file)
61+ cmd = 'gpg {} --detach-sign {} -o {} {}'.format(
62+ gpg_options, key_option, output_file, meta_file_path)
63+ run(cmd.split())
64+
65+
66+def update_file_content(mete_file, dst_file):
67+ with open(mete_file) as m:
68+ with open(dst_file, 'w') as d:
69+ d.write(m.read().replace('.json', '.sjson'))
70+
71+
72+def get_gpg_options(signing_key, signing_passphrase_file=None):
73+ key_option = "--default-key {}".format(signing_key)
74+ gpg_options = "--no-tty"
75+ if signing_passphrase_file:
76+ gpg_options = "--no-use-agent --no-tty --passphrase-file {}".format(
77+ signing_passphrase_file)
78+ return key_option, gpg_options
79+
80+
81+def get_meta_files(meta_dir):
82+ meta_files = [f for f in listdir(meta_dir) if f.endswith('.json')]
83+ if not meta_files:
84+ print('Warning! no meta files found in {}'.format(meta_dir))
85+ return meta_files
86+
87+
88+def ensure_no_file(file_path):
89+ if isfile(file_path):
90+ raise ValueError('FAIL! file already exists: {}'.format(file_path))
91+
92+
93+def parse_args(argv=None):
94+ parser = ArgumentParser("Sign streams' meta files.")
95+ parser.add_argument('metadata_dir', help='Metadata directory.')
96+ parser.add_argument('signing_key', help='Key to sign with.')
97+ parser.add_argument(
98+ '-p', '--signing-passphrase-file', help='Signing passphrase file path.')
99+ args = parser.parse_args(argv)
100+ if not isdir(args.metadata_dir):
101+ parser.error(
102+ 'Invalid metadata directory path {}'.format(args.metadata_dir))
103+ if (args.signing_passphrase_file and
104+ not isfile(args.signing_passphrase_file)):
105+ parser.error(
106+ 'Invalid passphrase file path {}'.format(
107+ args.signing_passphrase_file))
108+ return args
109+
110+
111+if __name__ == '__main__':
112+ args = parse_args()
113+ sign_metadata(args.signing_key, args.metadata_dir,
114+ args.signing_passphrase_file)
115
116=== added directory 'tests'
117=== added file 'tests/__init__.py'
118--- tests/__init__.py 1970-01-01 00:00:00 +0000
119+++ tests/__init__.py 2016-02-02 19:14:21 +0000
120@@ -0,0 +1,14 @@
121+from StringIO import StringIO
122+from unittest import TestCase
123+
124+from mock import patch
125+
126+
127+class QuietTestCase(TestCase):
128+
129+ def setUp(self):
130+ super(QuietTestCase, self).setUp()
131+ self.stdout = StringIO()
132+ patcher = patch('sys.stdout', self.stdout)
133+ patcher.start()
134+ self.addCleanup(patcher.stop)
135
136=== added file 'tests/test_sign_metadata.py'
137--- tests/test_sign_metadata.py 1970-01-01 00:00:00 +0000
138+++ tests/test_sign_metadata.py 2016-02-02 19:14:21 +0000
139@@ -0,0 +1,200 @@
140+from argparse import Namespace
141+from contextlib import contextmanager
142+from os.path import(
143+ join,
144+ isfile,
145+)
146+import json
147+from StringIO import StringIO
148+from tempfile import (
149+ NamedTemporaryFile,
150+ )
151+from unittest import TestCase
152+
153+from mock import (
154+ patch,
155+ call,
156+)
157+
158+from sign_metadata import (
159+ get_gpg_options,
160+ get_meta_files,
161+ sign_metadata,
162+ parse_args,
163+ update_file_content,
164+)
165+from utility import temp_dir
166+
167+
168+__metaclass__ = type
169+
170+
171+def write_file(path, contents):
172+ with open(path, 'w') as f:
173+ f.write(contents)
174+
175+
176+def gpg_header():
177+ return '-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA1\n'
178+
179+
180+def gpg_footer():
181+ return ('\n-----BEGIN PGP SIGNATURE-----\nblah blah\n'
182+ '-----END PGP SIGNATURE-----')
183+
184+
185+def fake_gpg(args):
186+ output_file = args[6]
187+ input_file = args[7]
188+ if '--passphrase-file' in args:
189+ output_file = args[9]
190+ input_file = args[10]
191+ if '--clearsign' in args:
192+ with open(input_file) as in_file:
193+ with open(output_file, 'w') as out_file:
194+ content = in_file.read()
195+ out_file.write('{}{}{}'.format(
196+ gpg_header(), content, gpg_footer()))
197+ if '--detach-sign' in args:
198+ open(output_file, 'w').close()
199+
200+
201+class TestSignMetaData(TestCase):
202+
203+ content = json.dumps(
204+ {'index':
205+ {'com.ubuntu.juju:released:tools':
206+ {'path': "streams/v1/com.ubuntu.juju-released-tools.json",
207+ 'format': 'products:1.0'}}
208+ })
209+ expected = json.dumps(
210+ {'index':
211+ {'com.ubuntu.juju:released:tools':
212+ {'path': "streams/v1/com.ubuntu.juju-released-tools.sjson",
213+ "format": "products:1.0"}}
214+ })
215+
216+ def test_get_meta_files(self):
217+ with temp_dir() as meta_dir:
218+ file1 = join(meta_dir, "index.json")
219+ file2 = join(meta_dir, "proposed-tools.json")
220+ file3 = join(meta_dir, "random.txt")
221+ open(file1, 'w').close()
222+ open(file2, 'w').close()
223+ open(file3, 'w').close()
224+ meta_files = get_meta_files(meta_dir)
225+ self.assertItemsEqual(
226+ meta_files, ['index.json', 'proposed-tools.json'])
227+
228+ def test_gpg_options(self):
229+ output = get_gpg_options('thedude@example.com')
230+ expected = ("--default-key thedude@example.com", '--no-tty')
231+ self.assertEqual(output, expected)
232+
233+ def test_gpg_options_signing_passphrase_file(self):
234+ output = get_gpg_options('thedude@example.com', '/tmp')
235+ expected = ("--default-key thedude@example.com",
236+ '--no-use-agent --no-tty --passphrase-file /tmp')
237+ self.assertEqual(output, expected)
238+
239+ def test_update_file_content(self):
240+ with NamedTemporaryFile() as meta_file:
241+ with NamedTemporaryFile() as dst_file:
242+ write_file(meta_file.name, self.content)
243+ update_file_content(meta_file.name, dst_file.name)
244+ with open(dst_file.name) as dst:
245+ dst_file_content = dst.read()
246+ self.assertEqual(dst_file_content, self.expected)
247+
248+ def test_sign_metadata(self):
249+ with patch('sign_metadata.run', autospec=True,
250+ side_effect=fake_gpg) as smr:
251+ with temp_dir() as meta_dir:
252+ meta_file = join(meta_dir, 'index.json')
253+ write_file(meta_file, self.content)
254+ with NamedTemporaryFile() as temp_file:
255+ with patch('sign_metadata.NamedTemporaryFile',
256+ autospec=True, return_value=temp_file) as ntf:
257+ sign_metadata('thedude@example.com', meta_dir)
258+ self.verify_signed_content(meta_dir)
259+ signed_file = meta_file.replace('.json', '.sjson')
260+ gpg_file = '{}.gpg'.format(meta_file)
261+ calls = [
262+ call(['gpg', '--no-tty', '--clearsign', '--default-key',
263+ 'thedude@example.com', '-o', signed_file, temp_file.name]),
264+ call(['gpg', '--no-tty', '--detach-sign', '--default-key',
265+ 'thedude@example.com', '-o', gpg_file, meta_file])]
266+ self.assertEqual(smr.mock_calls, calls)
267+ ntf.assert_called_once_with()
268+
269+ def test_sign_metadata_signing_passphrase_file(self):
270+ with patch('sign_metadata.run', autospec=True,
271+ side_effect=fake_gpg) as smr:
272+ with temp_dir() as meta_dir:
273+ meta_file = join(meta_dir, 'index.json')
274+ write_file(meta_file, self.content)
275+ with NamedTemporaryFile() as temp_file:
276+ with patch('sign_metadata.NamedTemporaryFile',
277+ autospec=True, return_value=temp_file) as ntf:
278+ sign_metadata(
279+ 'thedude@example.com', meta_dir, 'passphrase_file')
280+ self.verify_signed_content(meta_dir)
281+ signed_file = meta_file.replace('.json', '.sjson')
282+ gpg_file = '{}.gpg'.format(meta_file)
283+ calls = [
284+ call(['gpg', '--no-use-agent', '--no-tty', '--passphrase-file',
285+ 'passphrase_file', '--clearsign', '--default-key',
286+ 'thedude@example.com', '-o', signed_file, temp_file.name]),
287+ call(['gpg', '--no-use-agent', '--no-tty', '--passphrase-file',
288+ 'passphrase_file', '--detach-sign', '--default-key',
289+ 'thedude@example.com', '-o', gpg_file, meta_file])]
290+ self.assertEqual(smr.mock_calls, calls)
291+ ntf.assert_called_once_with()
292+
293+ def verify_signed_content(self, meta_dir):
294+ file_path = join(meta_dir, 'index.sjson')
295+ with open(file_path) as i:
296+ signed_content = i.read()
297+ self.assertEqual(signed_content, '{}{}{}'.format(
298+ gpg_header(), self.expected, gpg_footer()))
299+ self.assertTrue(
300+ isfile(join(meta_dir, 'index.json.gpg')))
301+
302+ def test_parse_args_default(self):
303+ with temp_dir() as metadata_dir:
304+ args = parse_args([metadata_dir, 's_key'])
305+ self.assertEqual(args, Namespace(
306+ signing_key='s_key', signing_passphrase_file=None,
307+ metadata_dir=metadata_dir))
308+
309+ def test_parse_args_signing_passphrase_file(self):
310+ with temp_dir() as metadata_dir:
311+ with NamedTemporaryFile() as pass_file:
312+ args = parse_args([metadata_dir, 's_key',
313+ '--signing-passphrase-file', pass_file.name])
314+ self.assertEqual(args, Namespace(
315+ signing_key='s_key', signing_passphrase_file=pass_file.name,
316+ metadata_dir=metadata_dir))
317+
318+ def test_parse_args_error(self):
319+ with parse_error(self) as stderr:
320+ parse_args([])
321+ self.assertIn("error: too few arguments", stderr.getvalue())
322+
323+ with parse_error(self) as stderr:
324+ parse_args(['metadata_dir', 'signing_key'])
325+ self.assertIn("Invalid metadata directory path", stderr.getvalue())
326+
327+ with parse_error(self) as stderr:
328+ with temp_dir() as metadata_dir:
329+ parse_args([metadata_dir, 's_key', '--signing-passphrase-file',
330+ 'fake/file/'])
331+ self.assertIn("Invalid passphrase file path ", stderr.getvalue())
332+
333+
334+@contextmanager
335+def parse_error(test_case):
336+ stderr = StringIO()
337+ with test_case.assertRaises(SystemExit):
338+ with patch('sys.stderr', stderr):
339+ yield stderr
340
341=== added file 'utility.py'
342--- utility.py 1970-01-01 00:00:00 +0000
343+++ utility.py 2016-02-02 19:14:21 +0000
344@@ -0,0 +1,12 @@
345+from contextlib import contextmanager
346+import shutil
347+from tempfile import mkdtemp
348+
349+
350+@contextmanager
351+def temp_dir():
352+ dirname = mkdtemp()
353+ try:
354+ yield dirname
355+ finally:
356+ shutil.rmtree(dirname)

Subscribers

People subscribed via source and target branches