Merge lp:~abentley/juju-core/sign-metadata into lp:~juju-qa/juju-core/cd-release-juju
- sign-metadata
- Merge into 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 |
Related bugs: |
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.
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) |
Thank you.