Merge ~ltrager/maas:pass_script_parameters into maas:master

Proposed by Lee Trager
Status: Merged
Approved by: Lee Trager
Approved revision: a541906923d31a5ac762a75b7dd2dace4324153c
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~ltrager/maas:pass_script_parameters
Merge into: maas:master
Diff against target: 350 lines (+245/-13)
2 files modified
src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py (+84/-7)
src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py (+161/-6)
Reviewer Review Type Date Requested Status
Данило Шеган (community) Approve
Review via email: mp+329983@code.launchpad.net

Commit message

Pass parameters to scripts.

Paramaters are now passed to commissioning and testing scripts. If the
parameter is a storage type maas_run_remote_scripts will attempt to lookup
the storage device by model and serial to get the block device path.

To post a comment you must log in.
Revision history for this message
Данило Шеган (danilo) wrote :

Looks good, a few minor nits inline (mostly typos and test improvement suggestions), so approving.

review: Approve
Revision history for this message
Данило Шеган (danilo) wrote :

You've also got a typo in your commit message ("ParamAters" :)

Revision history for this message
Lee Trager (ltrager) wrote :

Thanks for the review and catching those late night spelling errors ;) All corrections have been made.

Revision history for this message
Andres Rodriguez (andreserl) wrote :

This branch has been reverted as it broke testing in MAAS. http://<maas-ci>:8080/job/maas-xenial-master-git/142/ . I've re-opened this MP so that when it gets fixed it can be re-loanded.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py b/src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py
2index b0237a5..d938174 100644
3--- a/src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py
4+++ b/src/metadataserver/user_data/templates/snippets/maas_run_remote_scripts.py
5@@ -26,7 +26,9 @@ from io import BytesIO
6 import json
7 import os
8 import re
9+import shlex
10 from subprocess import (
11+ check_output,
12 PIPE,
13 Popen,
14 TimeoutExpired,
15@@ -210,6 +212,58 @@ def install_dependencies(
16 return True
17
18
19+# Cache the block devices so we only have to query once.
20+_block_devices = None
21+
22+
23+def get_block_devices():
24+ """If needed, query lsblk for all known block devices and store."""
25+ global _block_devices
26+ if _block_devices is None:
27+ _block_devices = []
28+ block_list = check_output([
29+ 'lsblk', '--exclude', '1,2,7', '-d', '-P', '-o',
30+ 'NAME,MODEL,SERIAL']).decode('utf-8')
31+ for blockdev in block_list.splitlines():
32+ tokens = shlex.split(blockdev)
33+ current_block = {}
34+ for token in tokens:
35+ k, v = token.split("=", 1)
36+ current_block[k] = v.strip()
37+ _block_devices.append(current_block)
38+
39+ return _block_devices
40+
41+
42+def parse_parameters(script, scripts_dir):
43+ """Return a list containg script path and parameters to be passed to it."""
44+ ret = [os.path.join(scripts_dir, script['path'])]
45+ for param in script.get('parameters', {}).values():
46+ param_type = param.get('type')
47+ if param_type == 'runtime':
48+ argument_format = param.get('argument_format', '--runtime={input}')
49+ ret += argument_format.format(input=param['value']).split()
50+ elif param_type == 'storage':
51+ value = param['value']
52+ if not (value.get('model') and value.get('serial')):
53+ # If no model or serial were included trust that id_path
54+ # is correct. This is needed for VirtIO devices.
55+ value['path'] = value['input'] = value['id_path']
56+ else:
57+ # Map the current path of the device to what it currently is
58+ # for the device model and serial. This is needed as the
59+ # the device name may have changed since commissioning.
60+ for blockdev in get_block_devices():
61+ if (value['model'] == blockdev['MODEL'] and
62+ value['serial'] == blockdev['SERIAL']):
63+ value['path'] = value['input'] = "/dev/%s" % blockdev[
64+ 'NAME']
65+ argument_format = param.get(
66+ 'argument_format', '--storage={path}')
67+ ret += argument_format.format(**value).split()
68+ return ret
69+
70+
71 def run_scripts(url, creds, scripts_dir, out_dir, scripts):
72 """Run and report results for the given scripts."""
73 total_scripts = len(scripts)
74@@ -227,15 +281,23 @@ def run_scripts(url, creds, scripts_dir, out_dir, scripts):
75 if script_version_id is not None:
76 args['script_version_id'] = script_version_id
77 timeout_seconds = script.get('timeout_seconds')
78-
79- script_path = os.path.join(scripts_dir, script['path'])
80- combined_path = os.path.join(out_dir, script['name'])
81+ for param in script.get('parameters', {}).values():
82+ if param.get('type') == 'runtime':
83+ timeout_seconds = param['value']
84+ break
85+
86+ # Create a seperate output directory for each script being run as
87+ # multiple scripts with the same name may be run.
88+ script_out_dir = os.path.join(out_dir, '%s.%s' % (
89+ script['name'], script['script_result_id']))
90+ os.makedirs(script_out_dir, exist_ok=True)
91+ combined_path = os.path.join(script_out_dir, script['name'])
92 stdout_name = '%s.out' % script['name']
93- stdout_path = os.path.join(out_dir, stdout_name)
94+ stdout_path = os.path.join(script_out_dir, stdout_name)
95 stderr_name = '%s.err' % script['name']
96- stderr_path = os.path.join(out_dir, stderr_name)
97+ stderr_path = os.path.join(script_out_dir, stderr_name)
98 result_name = '%s.yaml' % script['name']
99- result_path = os.path.join(out_dir, result_name)
100+ result_path = os.path.join(script_out_dir, result_name)
101 download_path = os.path.join(scripts_dir, 'downloads', script['name'])
102
103 if not install_dependencies(
104@@ -257,6 +319,21 @@ def run_scripts(url, creds, scripts_dir, out_dir, scripts):
105 env['DOWNLOAD_PATH'] = download_path
106
107 try:
108+ script_arguments = parse_parameters(script, scripts_dir)
109+ except KeyError:
110+ # 2 is the return code bash gives when it can't execute.
111+ args['exit_status'] = 2
112+ args['files'] = {
113+ script['name']: b'Unable to map parameters',
114+ stderr_name: b'Unable to map parameters',
115+ }
116+ signal_wrapper(
117+ error='Failed to execute %s [%d/%d]: %d' % (
118+ script['name'], i, total_scripts, args['exit_status']),
119+ **args)
120+ continue
121+
122+ try:
123 # This script sets its own niceness value to the highest(-20) below
124 # to help ensure the heartbeat keeps running. When launching the
125 # script we need to lower the nice value as a child process
126@@ -266,7 +343,7 @@ def run_scripts(url, creds, scripts_dir, out_dir, scripts):
127 # to the provided value. Since the runner uses a nice value of -20
128 # setting it to 40 gives the actual nice value of 20.
129 proc = Popen(
130- script_path, stdout=PIPE, stderr=PIPE, env=env,
131+ script_arguments, stdout=PIPE, stderr=PIPE, env=env,
132 preexec_fn=lambda: os.nice(40))
133 capture_script_output(
134 proc, combined_path, stdout_path, stderr_path, timeout_seconds)
135diff --git a/src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py b/src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py
136index 0e25501..90c858a 100644
137--- a/src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py
138+++ b/src/metadataserver/user_data/templates/snippets/tests/test_maas_run_remote_scripts.py
139@@ -28,7 +28,9 @@ from maastesting.testcase import MAASTestCase
140 from snippets import maas_run_remote_scripts
141 from snippets.maas_run_remote_scripts import (
142 download_and_extract_tar,
143+ get_block_devices,
144 install_dependencies,
145+ parse_parameters,
146 run_and_check,
147 run_scripts,
148 run_scripts_from_metadata,
149@@ -52,19 +54,23 @@ class TestMaasRunRemoteScripts(MAASTestCase):
150
151 def make_script_output(self, scripts, scripts_dir, with_result=False):
152 for script in scripts:
153+ script_out_dir = os.path.join(scripts_dir, '%s.%s' % (
154+ script['name'], script['script_result_id']))
155+ os.makedirs(script_out_dir)
156 output = factory.make_string()
157 stdout = factory.make_string()
158 stderr = factory.make_string()
159 script['output'] = output.encode()
160 script['stdout'] = stdout.encode()
161 script['stderr'] = stderr.encode()
162- script['output_path'] = os.path.join(scripts_dir, script['name'])
163+ script['output_path'] = os.path.join(
164+ script_out_dir, script['name'])
165 script['stdout_path'] = os.path.join(
166- scripts_dir, '%s.out' % script['name'])
167+ script_out_dir, '%s.out' % script['name'])
168 script['stderr_path'] = os.path.join(
169- scripts_dir, '%s.err' % script['name'])
170+ script_out_dir, '%s.err' % script['name'])
171 script['download_path'] = os.path.join(
172- scripts_dir, 'downloads', script['name'])
173+ script_out_dir, 'downloads', script['name'])
174 os.makedirs(script['download_path'], exist_ok=True)
175 open(script['output_path'], 'w').write(output)
176 open(script['stdout_path'], 'w').write(stdout)
177@@ -73,7 +79,7 @@ class TestMaasRunRemoteScripts(MAASTestCase):
178 result = factory.make_string()
179 script['result'] = result.encode()
180 script['result_path'] = os.path.join(
181- scripts_dir, '%s.yaml' % script['name'])
182+ script_out_dir, '%s.yaml' % script['name'])
183 open(script['result_path'], 'w').write(result)
184
185 def make_index_json(
186@@ -732,6 +738,152 @@ class TestMaasRunRemoteScripts(MAASTestCase):
187 args['error'] = 'Finished %s [2/2]: 1' % scripts[1]['name']
188 self.assertThat(mock_signal, MockAnyCall(**args))
189
190+ def test_get_block_devices(self):
191+ expected_blockdevs = [
192+ {
193+ 'NAME': factory.make_name('NAME'),
194+ 'MODEL': factory.make_name('MODEL'),
195+ 'SERIAL': factory.make_name('SERIAL'),
196+ } for _ in range(3)
197+ ]
198+ mock_check_output = self.patch(maas_run_remote_scripts, 'check_output')
199+ mock_check_output.return_value = ''.join([
200+ 'NAME="{NAME}" MODEL="{MODEL}" SERIAL="{SERIAL}"\n'.format(
201+ **blockdev) for blockdev in expected_blockdevs]).encode()
202+ maas_run_remote_scripts._block_devices = None
203+
204+ self.assertItemsEqual(expected_blockdevs, get_block_devices())
205+
206+ def test_get_block_devices_cached(self):
207+ block_devices = factory.make_name('block_devices')
208+ mock_check_output = self.patch(maas_run_remote_scripts, 'check_output')
209+ maas_run_remote_scripts._block_devices = block_devices
210+
211+ self.assertItemsEqual(block_devices, get_block_devices())
212+ self.assertThat(mock_check_output, MockNotCalled())
213+
214+ def test_parse_parameters(self):
215+ scripts_dir = factory.make_name('scripts_dir')
216+ script = {
217+ 'path': os.path.join('path_to', factory.make_name('script_name')),
218+ 'parameters': {
219+ 'runtime': {
220+ 'type': 'runtime',
221+ 'value': random.randint(0, 1000),
222+ },
223+ 'storage_virtio': {
224+ 'type': 'storage',
225+ 'value': {
226+ 'name': factory.make_name('name'),
227+ 'model': '',
228+ 'serial': '',
229+ 'id_path': '/dev/%s' % factory.make_name('id_path'),
230+ },
231+ },
232+ 'storage': {
233+ 'type': 'storage',
234+ 'value': {
235+ 'name': factory.make_name('name'),
236+ 'model': factory.make_name('model'),
237+ 'serial': factory.make_name('serial'),
238+ 'id_path': '/dev/%s' % factory.make_name('id_path'),
239+ },
240+ },
241+ },
242+ }
243+ mock_check_output = self.patch(maas_run_remote_scripts, 'check_output')
244+ mock_check_output.return_value = ''.join([
245+ 'NAME="{name}" MODEL="{model}" SERIAL="{serial}"\n'.format(
246+ **param['value'])
247+ for param_name, param in script['parameters'].items()
248+ if 'storage' in param_name]).encode()
249+ maas_run_remote_scripts._block_devices = None
250+
251+ self.assertItemsEqual(
252+ [
253+ os.path.join(scripts_dir, script['path']),
254+ '--runtime=%s' % script['parameters']['runtime']['value'],
255+ '--storage=%s' % script['parameters']['storage_virtio'][
256+ 'value']['id_path'],
257+ '--storage=/dev/%s' % script['parameters']['storage']['value'][
258+ 'name'],
259+ ], parse_parameters(script, scripts_dir))
260+
261+ def test_parse_parameters_argument_format(self):
262+ scripts_dir = factory.make_name('scripts_dir')
263+ script = {
264+ 'path': os.path.join('path_to', factory.make_name('script_name')),
265+ 'parameters': {
266+ 'runtime': {
267+ 'type': 'runtime',
268+ 'value': random.randint(0, 1000),
269+ 'argument_format': '--foo --timeout {input}',
270+ },
271+ 'storage': {
272+ 'type': 'storage',
273+ 'value': {
274+ 'name': factory.make_name('name'),
275+ 'model': factory.make_name('model'),
276+ 'serial': factory.make_name('serial'),
277+ 'id_path': '/dev/%s' % factory.make_name('id_path'),
278+ },
279+ 'argument_format': (
280+ '--bar {name} {model} {serial} {path} {input}'),
281+ },
282+ },
283+ }
284+ mock_check_output = self.patch(maas_run_remote_scripts, 'check_output')
285+ mock_check_output.return_value = ''.join([
286+ 'NAME="{name}" MODEL="{model}" SERIAL="{serial}"\n'.format(
287+ **param['value'])
288+ for param_name, param in script['parameters'].items()
289+ if 'storage' in param_name]).encode()
290+ maas_run_remote_scripts._block_devices = None
291+
292+ self.assertItemsEqual(
293+ [
294+ os.path.join(scripts_dir, script['path']),
295+ '--foo', '--timeout',
296+ str(script['parameters']['runtime']['value']),
297+ '--bar', script['parameters']['storage']['value']['name'],
298+ script['parameters']['storage']['value']['model'],
299+ script['parameters']['storage']['value']['serial'],
300+ '/dev/%s' % script['parameters']['storage']['value']['name'],
301+ '/dev/%s' % script['parameters']['storage']['value']['name'],
302+ ], parse_parameters(script, scripts_dir))
303+
304+ def test_run_scripts_errors_with_bad_param(self):
305+ scripts_dir = self.useFixture(TempDirectory()).path
306+ mock_signal = self.patch(maas_run_remote_scripts, 'signal')
307+ mock_popen = self.patch(maas_run_remote_scripts, 'Popen')
308+ self.patch(maas_run_remote_scripts, 'install_dependencies')
309+ scripts = self.make_scripts()
310+ scripts[0]['parameters'] = {'storage': {
311+ 'type': 'storage',
312+ 'argument_format': '{bad}',
313+ }}
314+ self.make_script_output(scripts, scripts_dir, with_result=True)
315+
316+ self.assertEquals(
317+ 0, run_scripts(None, None, scripts_dir, scripts_dir, scripts))
318+
319+ args = {
320+ 'url': None, 'creds': None, 'status': 'WORKING',
321+ 'script_result_id': scripts[0]['script_result_id'],
322+ 'script_version_id': scripts[0]['script_version_id'],
323+ 'error': 'Starting %s [1/1]' % scripts[0]['name'],
324+ }
325+ self.assertThat(mock_signal, MockAnyCall(**args))
326+ self.assertThat(mock_popen, MockNotCalled())
327+ # This is a MagicMock
328+ args['exit_status'] = 2
329+ args['files'] = {
330+ scripts[0]['name']: b'Unable to map parameters',
331+ '%s.err' % scripts[0]['name']: b'Unable to map parameters',
332+ }
333+ args['error'] = 'Failed to execute %s [1/1]: 2' % scripts[0]['name']
334+ self.assertThat(mock_signal, MockAnyCall(**args))
335+
336 def test_run_scripts_sends_result_when_available(self):
337 scripts_dir = self.useFixture(TempDirectory()).path
338 mock_signal = self.patch(maas_run_remote_scripts, 'signal')
339@@ -788,7 +940,10 @@ class TestMaasRunRemoteScripts(MAASTestCase):
340 1, run_scripts(None, None, scripts_dir, scripts_dir, scripts))
341
342 env = mock_popen.call_args[1]['env']
343- base = os.path.join(scripts_dir, scripts[0]['name'])
344+ base = os.path.join(
345+ scripts_dir, '%s.%s' % (
346+ scripts[0]['name'], scripts[0]['script_result_id']),
347+ scripts[0]['name'])
348 self.assertEquals(base, env['OUTPUT_COMBINED_PATH'])
349 self.assertEquals('%s.out' % base, env['OUTPUT_STDOUT_PATH'])
350 self.assertEquals('%s.err' % base, env['OUTPUT_STDERR_PATH'])

Subscribers

People subscribed via source and target branches