Merge lp:~canonical-platform-qa/ubuntu-ota-tests/thinkingoutloud-wrapper-reflash-script into lp:ubuntu-ota-tests

Proposed by Christopher Lee
Status: Merged
Approved by: Leo Arias
Approved revision: 34
Merged at revision: 13
Proposed branch: lp:~canonical-platform-qa/ubuntu-ota-tests/thinkingoutloud-wrapper-reflash-script
Merge into: lp:ubuntu-ota-tests
Diff against target: 546 lines (+509/-1)
5 files modified
run-ota-tests (+79/-0)
ubuntu_ota_tests/command_line.py (+290/-0)
ubuntu_ota_tests/hooks.py (+0/-1)
ubuntu_ota_tests/selftests/test_commandline.py (+138/-0)
ubuntu_ota_tests/selftests/test_services.py (+2/-0)
To merge this branch: bzr merge lp:~canonical-platform-qa/ubuntu-ota-tests/thinkingoutloud-wrapper-reflash-script
Reviewer Review Type Date Requested Status
PS Jenkins bot continuous-integration Approve
Federico Gimenez (community) Approve
Christopher Lee (community) Needs Fixing
Brendan Donegan (community) Needs Fixing
Review via email: mp+253018@code.launchpad.net

Commit message

Wrapper script to reflash the device to an old revision prior to running each ota update test with adt-run.

Description of the change

WIP thoughts around how to flash the device between test runs.
Currently it's really thinking out load:

 A script that runs the individual dep8 tests and reflashes the device in-between.

Some issues incl. the --testname argument not working as I expected it to and it still runs all the tests in d/t/control :-)

To post a comment you must log in.
Revision history for this message
Brendan Donegan (brendan-donegan) wrote :

Some inline comments. Also the assumption of test files starting with a particular prefix is a little brittle. Is there any way we can parse the test names from debian/tests/control instead? If not that's ok

review: Needs Fixing
Revision history for this message
Christopher Lee (veebers) wrote :

> Some inline comments. Also the assumption of test files starting with a
> particular prefix is a little brittle. Is there any way we can parse the test
> names from debian/tests/control instead? If not that's ok

For a first pass this was quick and hacky. I agree that it's brittle, I'll update the branch with a solution if I can get it done in time. Otherwise we'll use what's here and iterate on it.

Revision history for this message
Christopher Lee (veebers) wrote :

Not sure what to do about the phablet-network setting as it requires sudo perms. Running the whole script is dumb as adt attempts to do ssh stuff and the perms are all screwy (and will change the perms of .ssh/known_hosts for instance).
At the moment it'll prompt you for the password, and it's likely that the test will take long enough that the next time it's needed you'll get prompted again.

review: Needs Fixing
Revision history for this message
Richard Huddie (rhuddie) wrote :

I've made updates to now use nmcli to setup the wifi connection. This is called using adt-run --setup-command option. During testing I found that if the network connection already exists, adt-run would fail becaue nmcli reported a failure, although it was not an error that would cause any issues. So that is why the nmcli command always reports true. But under normal use this should never be an issue as the device is just reflashed. I also set the default revision to -1 and added a 10 minute timeout for each test execution.

33. By Richard Huddie

Fix self tests and add new ones for wifi and timeout parameters.

Revision history for this message
Christopher Lee (veebers) wrote :

Code looks good to me, currently trying to make a run on the device but it's playing up. If someone can confirm this run on a device this turns into an Approve from me.

Revision history for this message
Federico Gimenez (fgimenez) wrote :

Works great, only two issues:

* The test_apply_update_noop is triggering the update because of it has an update available after reflashing to rev-1; we should add a guard as in [1], something like:

  @unittest.skipIf(is_update_available(),
                  'Update available, would be applied')

* There's a flake8 error in ./ubuntu_ota_tests/hooks.py:31, a file that hasn't been modified by this mp, don't know how it got there :)

Thanks,

[1] https://code.launchpad.net/~canonical-platform-qa/ubuntu-ota-tests/dbus-upgrade/+merge/253423

review: Needs Fixing
34. By Richard Huddie

Fix review comments: skip test_apply_update_noop if update available and fix flake8. Revert logging to use print which was not previously working.

Revision history for this message
Richard Huddie (rhuddie) wrote :

Thanks Federico, I have made those updates.

Revision history for this message
Federico Gimenez (fgimenez) wrote :

Ok thanks, +1

review: Approve
Revision history for this message
Richard Huddie (rhuddie) wrote :

Hi Chris, I confirm it works. There was an issue with test_apply_update_noop, which was causing an actual reboot and update in the selftests because the version was flashed to -1. But this test is now skipped if there is a real update available.
Thanks.

Revision history for this message
Leo Arias (elopio) wrote :

federico + veebers, that's +2. Top-approving...

Revision history for this message
PS Jenkins bot (ps-jenkins) :
review: Approve (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'run-ota-tests'
2--- run-ota-tests 1970-01-01 00:00:00 +0000
3+++ run-ota-tests 2015-03-24 11:32:34 +0000
4@@ -0,0 +1,79 @@
5+#!/usr/bin/python3
6+
7+#
8+# Ubuntu OTA Tests
9+# Copyright (C) 2015 Canonical
10+#
11+# This program is free software: you can redistribute it and/or modify
12+# it under the terms of the GNU General Public License as published by
13+# the Free Software Foundation, either version 3 of the License, or
14+# (at your option) any later version.
15+#
16+# This program is distributed in the hope that it will be useful,
17+# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+# GNU General Public License for more details.
20+#
21+# You should have received a copy of the GNU General Public License
22+# along with this program. If not, see <http://www.gnu.org/licenses/>.
23+#
24+
25+import sys
26+
27+from ubuntu_ota_tests import command_line
28+
29+
30+def main(argv):
31+ """
32+ Individually run each test found in debian/tests/control file.
33+ For each test:
34+ 1) Flash the device to the required starting revision.
35+ 2) Run each individual ota upgrade test with adt-run and store results.
36+ 3) Amalgamate results into a single directory.
37+
38+ Usage:
39+ run-ota-tests --wifi-ssid <SSID> --wifi-password <PASSWORD> [options]
40+ Required:
41+ --wifi-ssid SSID of wifi network to connect device to
42+ --wifi-password Password of wifi network to connect device to
43+ Optional:
44+ --revision Image revision, default: latest -1
45+ --channel Channel, default: ubuntu-touch/devel-proposed
46+ --password Device password, default: 0000
47+ --output_dir Results directory, default: /tmp/otatests-{ID}
48+ --ssh-overwrite-script SSH virt script
49+ adb or adb-reboot-to-recovery
50+ default adb
51+ [-h] Display help message
52+
53+ """
54+ args = command_line.parse_cmdline_args(argv)
55+ adt_script = args.ssh_overwrite_script or 'adb'
56+ test_names = command_line.find_adt_test_file_names()
57+
58+ if not test_names:
59+ print('No tests found')
60+ raise RuntimeError('No tests found')
61+
62+ print('Found tests: {}'.format(', '.join(test_names)))
63+ output_dir_path = command_line.get_output_dir(args.output_dir)
64+
65+ for test in test_names:
66+ print('{ui}Flashing device for test: {test}'.format(
67+ ui='*** ', test=test))
68+ command_line.flash_device(args.revision, args.channel, args.password)
69+ print('{ui}Running test: {test}'.format(ui='*** ', test=test))
70+ command_line.adt_run_test(
71+ test, output_dir_path, adt_script, args.password,
72+ args.wifi_ssid, args.wifi_password)
73+ print('Test completed.')
74+
75+ command_line.amalgamate_results(output_dir_path)
76+
77+ print('{ui}\nResults can be found: {path}\n{ui}'.format(
78+ path=output_dir_path,
79+ ui='='*80))
80+
81+
82+if __name__ == '__main__':
83+ main(sys.argv[1:])
84
85=== added file 'ubuntu_ota_tests/command_line.py'
86--- ubuntu_ota_tests/command_line.py 1970-01-01 00:00:00 +0000
87+++ ubuntu_ota_tests/command_line.py 2015-03-24 11:32:34 +0000
88@@ -0,0 +1,290 @@
89+#
90+# Ubuntu OTA Tests
91+# Copyright (C) 2015 Canonical
92+#
93+# This program is free software: you can redistribute it and/or modify
94+# it under the terms of the GNU General Public License as published by
95+# the Free Software Foundation, either version 3 of the License, or
96+# (at your option) any later version.
97+#
98+# This program is distributed in the hope that it will be useful,
99+# but WITHOUT ANY WARRANTY; without even the implied warranty of
100+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
101+# GNU General Public License for more details.
102+#
103+# You should have received a copy of the GNU General Public License
104+# along with this program. If not, see <http://www.gnu.org/licenses/>.
105+#
106+
107+import debian.deb822
108+import fixtures
109+import glob
110+import os
111+import subprocess
112+
113+from argparse import ArgumentParser
114+from datetime import datetime
115+
116+
117+ONE_MIN_TIMEOUT = 60
118+FIVE_MIN_TIMEOUT = 300
119+TEN_MIN_TIMEOUT = 600
120+THIRTY_MIN_TIMEOUT = 1800
121+
122+
123+def find_adt_test_file_names():
124+ """Return list of test names for test files.
125+
126+ :return: List of strings of the file name of each test that is defined in
127+ debian/tests/control
128+
129+ """
130+ test_dir = get_test_directory(os.getcwd())
131+ test_control = os.path.join(test_dir, 'control')
132+ test_paras = get_test_stanza_strings(test_control)
133+
134+ all_tests = []
135+ for test_string in test_paras:
136+ all_tests.extend(test_string.replace(',', ' ').split())
137+
138+ # Perhaps we want to filter on name so we can pick what gets run now and
139+ # later (i.e. self tests etc.)
140+ return all_tests
141+
142+
143+def get_test_stanza_strings(test_control_file):
144+ """Parse debian/tests/control file and read out names of tests.
145+
146+ :param test_control_file: Path to debian/tests/control file
147+ :return: List of test names to run
148+
149+ """
150+ with open(test_control_file, 'r') as control_file:
151+ control_paras = debian.deb822.Deb822.iter_paragraphs(
152+ control_file.readlines(),
153+ fields=['Tests']
154+ )
155+ # Reduce all test paragraphs into one string.
156+ # and split out into individual names
157+ return [p['Tests'] for p in control_paras]
158+
159+
160+def get_test_directory(base_dir):
161+ """Return the path to debian/tests folder from root directory.
162+
163+ :param base_dir: Root directory of project
164+ :return: Path to debian/tests folder from root directory
165+
166+ """
167+ test_dir = os.path.join(base_dir, 'debian/tests/')
168+ if not os.path.exists(test_dir):
169+ raise RuntimeError(
170+ 'Cannot find test dir. This script is designed to be run from the '
171+ 'branch root.')
172+ return test_dir
173+
174+
175+def get_output_dir(output_dir):
176+ """Create unique output directory path and ensure it exists.
177+
178+ :param output_dir: Directory to use
179+ :return: output_dir or unique path
180+
181+ """
182+ path = output_dir or os.path.join(
183+ '/tmp/',
184+ 'otatests-{date}'.format(
185+ date=datetime.now().strftime('%H%M%S')))
186+ if not os.path.exists(path):
187+ os.makedirs(path)
188+ return path
189+
190+
191+def amalgamate_results(output_dir_path):
192+ """Moves all the results from the per-run dirs into the main.
193+
194+ :param output_dir_path: Directory to store results
195+
196+ """
197+ summary_files = glob.glob('{}/*/summary'.format(output_dir_path))
198+ main_summary_file = os.path.join(output_dir_path, 'ota_summary')
199+ with open(main_summary_file, 'w') as summary_file:
200+ for summary in summary_files:
201+ with open(summary, 'r') as f:
202+ summary_file.write(f.read())
203+
204+
205+def adt_run_test(test_name, output_dir, adt_script, device_password,
206+ wifi_ssid, wifi_password, timeout=TEN_MIN_TIMEOUT):
207+ """Run the singular test storing the results in output directory.
208+
209+ :param test_name: Name of test to run
210+ :param output_dir: Directory to store results
211+ :param adt_script: Script for ssh connection, or 'adb'
212+ :param password: Password for device
213+ :param wifi_ssid: SSID of wifi network to connect device to
214+ :param wifi_password: Password for wifi network to connect device to
215+
216+ """
217+ # Results for each test in their own dir.
218+ run_output_dir = os.path.join(output_dir, test_name)
219+ os.mkdir(run_output_dir)
220+
221+ adt_command = get_adtrun_commands(
222+ test_name,
223+ run_output_dir,
224+ adt_script,
225+ device_password,
226+ wifi_ssid,
227+ wifi_password,
228+ timeout)
229+
230+ return subprocess.call(adt_command)
231+
232+
233+def get_adtrun_commands(test_name, output_dir, adt_script, password,
234+ wifi_ssid, wifi_password, timeout):
235+ """Return adt-run command using specified parameters.
236+
237+ :param test_name: Name of test to run
238+ :param output_dir: Directory to store results
239+ :param adt_script: Script for ssh connection, or 'adb'
240+ :param password: Password for device
241+ :param wifi_ssid: SSID of wifi network to connect device to
242+ :param wifi_password: Password for wifi network to connect device to
243+ :param timeout: Timeout value for test exeuction
244+ :return: List containing adt-run command and options
245+
246+ """
247+ adt_run_cmds = [
248+ 'adt-run',
249+ '-B',
250+ '--testname',
251+ test_name,
252+ '--unbuilt-tree={}'.format(os.getcwd()),
253+ '--output-dir',
254+ output_dir,
255+ '--setup-commands={}'.format(get_device_network_command(
256+ wifi_ssid, wifi_password)),
257+ '--timeout-test={}'.format(timeout)
258+ ]
259+
260+ virt_cmds = ['ssh', '-s', adt_script, '--', '-p', password]
261+
262+ return adt_run_cmds + ['---'] + virt_cmds
263+
264+
265+def flash_device(revision, channel, password):
266+ """Flash the connected device with the passed revision.
267+
268+ :param revision: Revision of image to flash
269+ :param channel: Name of channel
270+ :param password: Password for device
271+
272+ """
273+ flash_cmd = get_udflash_command(revision, channel, password)
274+ print('{ui}\nFlashing device to revno: {rev} of {channel} . . .\n'.format(
275+ rev=revision, channel=channel, ui='-' * 80))
276+ timed_command_runner(flash_cmd, THIRTY_MIN_TIMEOUT)
277+
278+ print('*** Waiting for device to boot . . .')
279+ wait_for_boot()
280+ print('*** Device booted.')
281+
282+
283+def get_udflash_command(revision, channel, password):
284+ """Return command to flash specified revision and channel to device.
285+
286+ :param revision: Revision of image to flash
287+ :param channel: Name of channel
288+ :param password: Password for device
289+ :return: List of commands
290+
291+ """
292+ return [
293+ 'ubuntu-device-flash',
294+ '--revision={}'.format(revision),
295+ 'touch',
296+ '--developer-mode',
297+ '--password',
298+ password,
299+ '--channel={}'.format(channel),
300+ '--wipe'
301+ ]
302+
303+
304+def wait_for_boot():
305+ """Wait for device to boot up."""
306+ adb_wait = ['adb', 'wait-for-device']
307+ timed_command_runner(adb_wait, timeout=TEN_MIN_TIMEOUT)
308+
309+
310+def get_device_network_command(wifi_ssid, wifi_password):
311+ """Return command to configure wifi network on device.
312+
313+ :param wifi_ssid: SSID of required wifi network
314+ :param wifi_password: Password of required wifi network
315+
316+ """
317+ cmd_fmt = ('nmcli device wifi connect '
318+ '\'{ssid}\' password \'{password}\' 2>/dev/null || true')
319+ return cmd_fmt.format(ssid=wifi_ssid, password=wifi_password)
320+
321+
322+def timed_command_runner(cmd_list, timeout=ONE_MIN_TIMEOUT):
323+ """Return command return code if successfully executes.
324+
325+ :param cmd_list: List of commands to execute
326+ :param timeout: Timeout period for command to execute
327+
328+ :raises RuntimeError: if the command takes longer than timeout seconds to
329+ complete
330+
331+ """
332+ try:
333+ with fixtures.Timeout(timeout, gentle=True):
334+ return subprocess.call(cmd_list)
335+ except fixtures.TimeoutException:
336+ raise RuntimeError('Command:\n$ {command}\ntook longer than {timeout} '
337+ 'seconds to run.'.format(
338+ command=' '.join(cmd_list),
339+ timeout=timeout))
340+
341+
342+def parse_cmdline_args(argv):
343+ """Return options and values read from command line."""
344+ parser = _get_parser()
345+ return parser.parse_args(args=argv)
346+
347+
348+def _get_parser():
349+ """Specify parameter options for running the tests."""
350+ main_args = ArgumentParser()
351+ main_args.add_argument(
352+ '--wifi-ssid',
353+ required=True,
354+ help='SSID of the wi-fi network to connect device to.')
355+ main_args.add_argument(
356+ '--wifi-password',
357+ required=True,
358+ help='Password of the wi-fi network to connect device to.')
359+ main_args.add_argument(
360+ '--revision',
361+ default='-1',
362+ help='Revision of the image to flash, default is latest revision -1.')
363+ main_args.add_argument(
364+ '--channel',
365+ default='ubuntu-touch/devel-proposed',
366+ help='Channel to flash.')
367+ main_args.add_argument(
368+ '--password', default='0000',
369+ help='Device password.')
370+ main_args.add_argument(
371+ '--output_dir',
372+ help='Results will be placed here')
373+ main_args.add_argument(
374+ '--ssh-overwrite-script',
375+ default=None,
376+ help='Specific ssh virt script to run (i.e. adb-reboot-to-recovery)')
377+
378+ return main_args
379
380=== modified file 'ubuntu_ota_tests/hooks.py'
381--- ubuntu_ota_tests/hooks.py 2015-03-18 12:05:27 +0000
382+++ ubuntu_ota_tests/hooks.py 2015-03-24 11:32:34 +0000
383@@ -27,7 +27,6 @@
384 from systemimage.reboot import BaseReboot as Base
385
386
387-
388 log = logging.getLogger('systemimage')
389
390
391
392=== added file 'ubuntu_ota_tests/selftests/test_commandline.py'
393--- ubuntu_ota_tests/selftests/test_commandline.py 1970-01-01 00:00:00 +0000
394+++ ubuntu_ota_tests/selftests/test_commandline.py 2015-03-24 11:32:34 +0000
395@@ -0,0 +1,138 @@
396+#
397+# Ubuntu OTA Tests
398+# Copyright (C) 2015 Canonical
399+#
400+# This program is free software: you can redistribute it and/or modify
401+# it under the terms of the GNU General Public License as published by
402+# the Free Software Foundation, either version 3 of the License, or
403+# (at your option) any later version.
404+#
405+# This program is distributed in the hope that it will be useful,
406+# but WITHOUT ANY WARRANTY; without even the implied warranty of
407+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
408+# GNU General Public License for more details.
409+#
410+# You should have received a copy of the GNU General Public License
411+# along with this program. If not, see <http://www.gnu.org/licenses/>.
412+#
413+
414+import os
415+import unittest
416+import testtools
417+import tempfile
418+
419+from unittest.mock import patch
420+from testtools import matchers
421+from ubuntu_ota_tests import command_line as cmdline
422+
423+
424+class CommandLineGetTestDirectoryTests(unittest.TestCase):
425+
426+ def test_raises_runtimeerror_when_dir_doesnt_exist(self):
427+ base_dir = '/tmp'
428+ self.assertRaises(RuntimeError, cmdline.get_test_directory, base_dir)
429+
430+ @patch.object(cmdline.os.path, 'exists', new=lambda x: True)
431+ def test_returns_correct_path(self):
432+ base_dir = '/tmp'
433+ expected_output = os.path.join(base_dir, 'debian/tests/')
434+ self.assertEqual(expected_output, cmdline.get_test_directory(base_dir))
435+
436+
437+class CommandLineGetOutputDirTests(unittest.TestCase):
438+
439+ def test_returns_same_path(self):
440+ with tempfile.TemporaryDirectory() as tmp_dir:
441+ self.assertEqual(tmp_dir, cmdline.get_output_dir(tmp_dir))
442+
443+ def test_creates_dir_if_nonexistent(self):
444+ with tempfile.TemporaryDirectory() as tmp_dir:
445+ non_dir = os.path.join(tmp_dir, 'testing')
446+ self.assertFalse(os.path.exists(non_dir))
447+
448+ cmdline.get_output_dir(non_dir)
449+
450+ self.assertTrue(os.path.exists(non_dir))
451+
452+ @patch.object(cmdline.os.path, 'exists', new=lambda x: True)
453+ def test_create_custom_dir_if_passed_None(self):
454+ output_path = cmdline.get_output_dir(None)
455+ self.assertTrue(output_path.startswith('/tmp/otatests-'))
456+
457+
458+class CommandLineGetFlashCommand(testtools.TestCase):
459+
460+ def test_revision_argument_uses_passed_value(self):
461+ revision = 'revision-test'
462+ channel = 'channel-test'
463+ password = 'password-test'
464+ command_list = cmdline.get_udflash_command(revision, channel, password)
465+ self.assertThat(
466+ command_list,
467+ matchers.Contains('--revision=revision-test'))
468+
469+ self.assertThat(
470+ command_list,
471+ matchers.Contains('--channel=channel-test'))
472+
473+ self.assertThat(
474+ command_list,
475+ matchers.ContainsAll(['--password', 'password-test']))
476+
477+
478+class CommandLineGetAdtrunCommand(testtools.TestCase):
479+
480+ def test_ssh_commands_contain_password(self):
481+ adt_commands = cmdline.get_adtrun_commands(
482+ '', '', '', 'test-password', '', '', '')
483+
484+ ssh_command_list = adt_commands[adt_commands.index('---'):]
485+
486+ self.assertThat(
487+ ssh_command_list,
488+ matchers.ContainsAll(['-p', 'test-password']))
489+
490+ def test_testname_argument_used(self):
491+ adt_commands = cmdline.get_adtrun_commands(
492+ 'test-name', '', '', '', '', '', '')
493+
494+ self.assertThat(
495+ adt_commands,
496+ matchers.ContainsAll(['--testname', 'test-name']))
497+
498+ def test_wifi_ssid_argument_used(self):
499+ adt_commands = cmdline.get_adtrun_commands(
500+ '', '', '', '', 'wifi-ssid-name', '', '')
501+ command_str = ' '.join(adt_commands)
502+ self.assertThat(
503+ command_str,
504+ matchers.Contains('wifi-ssid-name'))
505+
506+ def test_wifi_password_argument_used(self):
507+ adt_commands = cmdline.get_adtrun_commands(
508+ '', '', '', '', '', 'wifi-password-value', '')
509+ command_str = ' '.join(adt_commands)
510+ self.assertThat(
511+ command_str,
512+ matchers.Contains('wifi-password-value'))
513+
514+ def test_timeout_test_argument_used(self):
515+ adt_commands = cmdline.get_adtrun_commands(
516+ '', '', '', '', '', '', '111')
517+ self.assertThat(
518+ adt_commands,
519+ matchers.ContainsAll(['--timeout-test=111']))
520+
521+
522+class CommandLineTimedCommandRunTests(testtools.TestCase):
523+
524+ def test_raises_no_exception_executed_within_timeout(self):
525+ self.assertEqual(0, cmdline.timed_command_runner(['true'], timeout=1))
526+
527+ def test_raises_exception_executed_goes_over_timeout(self):
528+ # Not sure if there is a better way to make this test quicker because
529+ # it takes at least 1 sec. so we've gone from 0.004 seconds to 1.004s
530+ self.assertRaises(
531+ RuntimeError,
532+ cmdline.timed_command_runner,
533+ ['sleep', '2'], timeout=1)
534
535=== modified file 'ubuntu_ota_tests/selftests/test_services.py'
536--- ubuntu_ota_tests/selftests/test_services.py 2015-03-18 13:54:26 +0000
537+++ ubuntu_ota_tests/selftests/test_services.py 2015-03-24 11:32:34 +0000
538@@ -64,6 +64,8 @@
539 utcnow = datetime.utcnow().replace(microsecond=0).isoformat(' ')
540 self.assertLessEqual(status.last_update_date, utcnow)
541
542+ @unittest.skipIf(is_update_available(),
543+ 'Update available, would be applied')
544 def test_apply_update_noop(self):
545 # make sure that this will be a noop by cancelling any previous update
546 sys_image_iface = services.get_system_image_interface()

Subscribers

People subscribed via source and target branches

to all changes: