Merge ~sylvain-pineau/checkbox/+git/metabox:configs into ~checkbox-dev/checkbox/+git/metabox:main

Proposed by Sylvain Pineau
Status: Superseded
Proposed branch: ~sylvain-pineau/checkbox/+git/metabox:configs
Merge into: ~checkbox-dev/checkbox/+git/metabox:main
Diff against target: 2668 lines (+2480/-5)
27 files modified
.gitignore (+6/-0)
MANIFEST.in (+2/-0)
config.py (+103/-0)
metabox/__init__.py (+0/-0)
metabox/core/__init__.py (+0/-0)
metabox/core/actions.py (+97/-0)
metabox/core/aggregator.py (+50/-0)
metabox/core/configuration.py (+145/-0)
metabox/core/keys.py (+36/-0)
metabox/core/lxd_execute.py (+236/-0)
metabox/core/lxd_provider.py (+229/-0)
metabox/core/machine.py (+433/-0)
metabox/core/runner.py (+221/-0)
metabox/core/scenario.py (+244/-0)
metabox/core/utils.py (+49/-0)
metabox/lxd_profiles/checkbox.profile (+35/-0)
metabox/lxd_profiles/lowmem.profile (+3/-0)
metabox/lxd_profiles/snap.profile (+24/-0)
metabox/main.py (+65/-1)
metabox/metabox-provider/manage.py (+9/-0)
metabox/metabox-provider/units/basic-jobs.pxu (+224/-0)
metabox/metabox-provider/units/basic-tps.pxu (+39/-0)
metabox/scenarios/__init__.py (+0/-0)
metabox/scenarios/basic/__init__.py (+0/-0)
metabox/scenarios/basic/launcher.py (+100/-0)
metabox/scenarios/basic/run-invocation.py (+111/-0)
setup.py (+19/-4)
Reviewer Review Type Date Requested Status
Sylvain Pineau Pending
Review via email: mp+400331@code.launchpad.net

This proposal supersedes a proposal from 2021-03-29.

Description of the change

First commit (configs now define an URI when they are referring to a file/dir path or a ppa isntead of abusing the origin field)

To post a comment you must log in.
249119a... by Sylvain Pineau

First version

Unmerged commits

249119a... by Sylvain Pineau

First version

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..8d4988b
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,6 @@
7+__pycache__/
8+metabox.egg-info/
9+venv
10+build
11+dist
12+.tox
13diff --git a/MANIFEST.in b/MANIFEST.in
14new file mode 100644
15index 0000000..4511d8e
16--- /dev/null
17+++ b/MANIFEST.in
18@@ -0,0 +1,2 @@
19+recursive-include metabox/metabox-provider/ *
20+recursive-include metabox/lxd_profiles/ *
21diff --git a/config.py b/config.py
22new file mode 100644
23index 0000000..6e70b8c
24--- /dev/null
25+++ b/config.py
26@@ -0,0 +1,103 @@
27+configuration = {
28+ 'local': {
29+ 'origin': 'ppa',
30+ 'uri': 'ppa:checkbox-dev/ppa',
31+ 'releases': ['focal'],
32+ },
33+ 'remote': {
34+ 'origin': 'source',
35+ 'uri': '~/lp/checkbox-ng',
36+ 'releases': ['focal'],
37+ },
38+ 'service': {
39+ 'origin': 'classic_snap',
40+ 'checkbox_core_snap': {'risk': 'beta'},
41+ 'checkbox_snap': {'risk': 'beta'},
42+ 'releases': ['focal'],
43+ },
44+}
45+
46+ # Example running devmode with checkbox_snap sideloaded (currently failing)
47+ # 'service': {
48+ # 'origin': 'snap',
49+ # 'releases': ['focal'],
50+ # 'checkbox_core_snap': {'risk': 'stable'},
51+ # 'checkbox_snap': {'uri': '~/checkbox-snappy_20.17.dev_amd64.snap'},
52+ # # Connect all the content interfaces
53+ # 'setup': [
54+ # 'sudo snap connect checkbox-snappy:checkbox-runtime checkbox20:checkbox-runtime',
55+ # 'sudo snap connect checkbox-snappy:provider-resource checkbox20:provider-resource',
56+ # 'sudo snap connect checkbox-snappy:provider-checkbox checkbox20:provider-checkbox',
57+ # ]
58+ # },
59+
60+ # Example running devmode with checkbox_snap sideloaded (currently failing)
61+ # sudo snap install checkbox-snappy --channel=20/beta --devmode
62+ # error: cannot perform the following tasks:
63+ # - Run configure hook of "checkbox-snappy" snap if present (run hook "configure": ERROR: no /wrapper_common found)
64+ # 'service': {
65+ # 'origin': 'snap',
66+ # 'releases': ['focal'],
67+ # 'checkbox_core_snap': {'risk': 'stable'},
68+ # 'checkbox_snap': {'uri': '~/checkbox-snappy_20.17.dev_amd64.snap'},
69+ # },
70+
71+ # Example running devmode with checkbox_core_snap sideloaded (currently failing)
72+ # sudo snap install checkbox-snappy --channel=20/beta --devmode
73+ # error: cannot perform the following tasks:
74+ # - Run configure hook of "checkbox-snappy" snap if present (run hook "configure": ERROR: no /wrapper_common found)
75+ # 'service': {
76+ # 'origin': 'snap',
77+ # 'releases': ['focal'],
78+ # 'checkbox_core_snap': {'uri': '~/checkbox20_428.snap'},
79+ # 'checkbox_snap': {'risk': 'beta'},
80+ # },
81+
82+ # Example running classic with both snaps sideloaded
83+ # 'service': {
84+ # 'origin': 'classic_snap',
85+ # 'releases': ['focal'],
86+ # 'checkbox_core_snap': {'uri': '~/checkbox20_428.snap'},
87+ # 'checkbox_snap': {'uri': '~/checkbox-snappy-classic_207.snap'},
88+ # },
89+
90+ # Example running classic with checkbox_core_snap sideloaded
91+ # 'service': {
92+ # 'origin': 'classic_snap',
93+ # 'releases': ['focal'],
94+ # 'checkbox_core_snap': {'uri': '~/checkbox20_428.snap'},
95+ # 'checkbox_snap': {'risk': 'stable'},
96+ # },
97+
98+ # Example running with classic checkbox_snap sideloaded
99+ # 'service': {
100+ # 'origin': 'classic_snap',
101+ # 'releases': ['focal'],
102+ # 'checkbox_core_snap': {'risk': 'beta'},
103+ # 'checkbox_snap': {
104+ # 'uri': '~/checkbox-snappy-classic_207.snap',
105+ # },
106+ # },
107+
108+ # Example running with cdts classic tracks
109+ # 'service': {
110+ # 'origin': 'classic_snap',
111+ # 'name': 'cdts',
112+ # 'releases': ['bionic', 'focal'],
113+ # 'checkbox_core_snap': {'risk': 'beta'},
114+ # 'checkbox_snap': {
115+ # 'risk': 'beta',
116+ # 'track_map': {
117+ # 'bionic': '18.04',
118+ # 'focal': '20.04',
119+ # },
120+ # },
121+ # },
122+
123+ # Default to checkbox-snappy (or checkbox-snappy-classic)
124+ # 'service': {
125+ # 'origin': 'classic_snap',
126+ # 'releases': ['bionic', 'focal'],
127+ # 'checkbox_core_snap': {'risk': 'beta'},
128+ # 'checkbox_snap': {'risk': 'beta'},
129+ # },
130diff --git a/metabox/__init__.py b/metabox/__init__.py
131new file mode 100644
132index 0000000..e69de29
133--- /dev/null
134+++ b/metabox/__init__.py
135diff --git a/metabox/core/__init__.py b/metabox/core/__init__.py
136new file mode 100644
137index 0000000..e69de29
138--- /dev/null
139+++ b/metabox/core/__init__.py
140diff --git a/metabox/core/actions.py b/metabox/core/actions.py
141new file mode 100644
142index 0000000..c4f57b1
143--- /dev/null
144+++ b/metabox/core/actions.py
145@@ -0,0 +1,97 @@
146+# This file is part of Checkbox.
147+#
148+# Copyright 2021 Canonical Ltd.
149+# Written by:
150+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
151+# Sylvain Pineau <sylvain.pineau@canonical.com>
152+#
153+# Checkbox is free software: you can redistribute it and/or modify
154+# it under the terms of the GNU General Public License version 3,
155+# as published by the Free Software Foundation.
156+#
157+# Checkbox is distributed in the hope that it will be useful,
158+# but WITHOUT ANY WARRANTY; without even the implied warranty of
159+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
160+# GNU General Public License for more details.
161+#
162+# You should have received a copy of the GNU General Public License
163+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
164+"""
165+This module defines the Actions classes.
166+
167+"""
168+
169+__all__ = [
170+ "Start", "Expect", "Send", "SelectTestPlan",
171+ "AssertPrinted", "AssertNotPrinted", "AssertRetCode",
172+ "AssertServiceActive", "Sleep", "RunCmd", "Signal", "Reboot",
173+ "NetUp", "NetDown",
174+]
175+
176+
177+class ActionBase:
178+ handler = None
179+
180+ def __init__(self, *args, **kwargs):
181+ self.args = args
182+ self.kwargs = kwargs
183+
184+ def __call__(self, scn):
185+ assert(self.handler is not None)
186+ getattr(scn, self.handler)(*self.args, **self.kwargs)
187+
188+
189+class Start(ActionBase):
190+ handler = 'start'
191+
192+
193+class Expect(ActionBase):
194+ handler = 'expect'
195+
196+
197+class Send(ActionBase):
198+ handler = 'send'
199+
200+
201+class SelectTestPlan(ActionBase):
202+ handler = 'select_test_plan'
203+
204+
205+class AssertPrinted(ActionBase):
206+ handler = 'assert_printed'
207+
208+
209+class AssertNotPrinted(ActionBase):
210+ handler = 'assert_not_printed'
211+
212+
213+class AssertRetCode(ActionBase):
214+ handler = 'assert_ret_code'
215+
216+
217+class AssertServiceActive(ActionBase):
218+ handler = 'is_service_active'
219+
220+
221+class Sleep(ActionBase):
222+ handler = 'sleep'
223+
224+
225+class RunCmd(ActionBase):
226+ handler = 'run_cmd'
227+
228+
229+class Signal(ActionBase):
230+ handler = 'signal'
231+
232+
233+class Reboot(ActionBase):
234+ handler = 'reboot'
235+
236+
237+class NetUp(ActionBase):
238+ handler = 'switch_on_networking'
239+
240+
241+class NetDown(ActionBase):
242+ handler = 'switch_off_networking'
243diff --git a/metabox/core/aggregator.py b/metabox/core/aggregator.py
244new file mode 100644
245index 0000000..c8886d1
246--- /dev/null
247+++ b/metabox/core/aggregator.py
248@@ -0,0 +1,50 @@
249+# This file is part of Checkbox.
250+#
251+# Copyright 2021 Canonical Ltd.
252+# Written by:
253+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
254+# Sylvain Pineau <sylvain.pineau@canonical.com>
255+#
256+# Checkbox is free software: you can redistribute it and/or modify
257+# it under the terms of the GNU General Public License version 3,
258+# as published by the Free Software Foundation.
259+#
260+# Checkbox is distributed in the hope that it will be useful,
261+# but WITHOUT ANY WARRANTY; without even the implied warranty of
262+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
263+# GNU General Public License for more details.
264+#
265+# You should have received a copy of the GNU General Public License
266+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
267+"""
268+This module implements a mechanism to auto-register metabox scenarios.
269+"""
270+
271+import pkgutil
272+
273+import metabox.scenarios
274+from loguru import logger
275+
276+
277+class _ScenarioAggregator:
278+ def __init__(self):
279+ self._scenarios = []
280+
281+ def add_scenario(self, scenario_cls):
282+ """Add a scenario to the collection."""
283+ logger.debug("Registering a scenario: {}", scenario_cls.name)
284+ self._scenarios.append(scenario_cls)
285+
286+ @staticmethod
287+ def load_all():
288+ """Import all modules so the scenarios can be auto-loaded."""
289+ path = metabox.scenarios.__path__
290+ for loader, name, _ in pkgutil.walk_packages(path):
291+ loader.find_module(name).load_module(name)
292+
293+ def all_scenarios(self):
294+ """Return all available scenarios."""
295+ return self._scenarios
296+
297+
298+aggregator = _ScenarioAggregator()
299diff --git a/metabox/core/configuration.py b/metabox/core/configuration.py
300new file mode 100644
301index 0000000..6fab580
302--- /dev/null
303+++ b/metabox/core/configuration.py
304@@ -0,0 +1,145 @@
305+# This file is part of Checkbox.
306+#
307+# Copyright 2021 Canonical Ltd.
308+# Written by:
309+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
310+# Sylvain Pineau <sylvain.pineau@canonical.com>
311+#
312+# Checkbox is free software: you can redistribute it and/or modify
313+# it under the terms of the GNU General Public License version 3,
314+# as published by the Free Software Foundation.
315+#
316+# Checkbox is distributed in the hope that it will be useful,
317+# but WITHOUT ANY WARRANTY; without even the implied warranty of
318+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
319+# GNU General Public License for more details.
320+#
321+# You should have received a copy of the GNU General Public License
322+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
323+"""
324+This module implements functions necessary to load metabox configs.
325+"""
326+import importlib.util
327+import subprocess
328+from pathlib import Path
329+
330+from loguru import logger
331+
332+
333+def read_config(filename):
334+ """
335+ Parse a config file from the path `filename` and yield
336+ a configuration object or raise SystemExit on problems.
337+ """
338+
339+ try:
340+ mod_spec = importlib.util.spec_from_file_location(
341+ 'config_file', filename)
342+ module = importlib.util.module_from_spec(mod_spec)
343+ mod_spec.loader.exec_module(module)
344+ config = module.configuration
345+ return config
346+ except (AttributeError, SyntaxError, FileNotFoundError) as exc:
347+ logger.critical(exc)
348+ raise SystemExit()
349+
350+
351+def validate_config(config):
352+ """
353+ Run a sanity check validation of the config.
354+ Raises SystemExit when a problem is found.
355+ """
356+ if not _has_local_or_remote_declaration(config):
357+ logger.critical(
358+ "Configuration has to define at least one way of running checkbox."
359+ "Define 'local' or 'service' and 'remote'.")
360+ raise SystemExit()
361+ for kind in config:
362+ if kind not in ('local', 'service', 'remote'):
363+ logger.critical(
364+ "Configuration has to define at least one way "
365+ "of running checkbox."
366+ "Define 'local' or 'service' and 'remote'.")
367+ raise SystemExit()
368+ for decl in config[kind]:
369+ if not _decl_has_a_valid_origin(config[kind]):
370+ logger.critical(
371+ "Missing or invalid origin for the {} "
372+ "declaration in config!", kind)
373+ raise SystemExit()
374+
375+
376+def _has_local_or_remote_declaration(config):
377+ """
378+ >>> config = {'local': 'something'}
379+ >>> _has_local_or_remote_declaration(config)
380+ True
381+ >>> config = {'local': ['something_else']}
382+ >>> _has_local_or_remote_declaration(config)
383+ True
384+ >>> config = {'local': []}
385+ >>> _has_local_or_remote_declaration(config)
386+ False
387+ >>> config = {'service': 'something'}
388+ >>> _has_local_or_remote_declaration(config)
389+ False
390+ >>> config = {'remote': 'something'}
391+ >>> _has_local_or_remote_declaration(config)
392+ False
393+ >>> config = {'remote': 'something', 'service': 'somethig_else'}
394+ >>> _has_local_or_remote_declaration(config)
395+ True
396+ """
397+
398+ return bool(config.get('local') or (
399+ config.get('service') and config.get('remote')))
400+
401+
402+def _decl_has_a_valid_origin(decl):
403+ """
404+ >>> decl = {'origin': 'ppa'}
405+ >>> _decl_has_a_valid_origin(decl)
406+ True
407+ >>> decl = {'origin': 'source'}
408+ >>> _decl_has_a_valid_origin(decl)
409+ True
410+ >>> decl = {'origin': 'snap'}
411+ >>> _decl_has_a_valid_origin(decl)
412+ True
413+ >>> decl = {'origin': 'flatpak'}
414+ >>> _decl_has_a_valid_origin(decl)
415+ False
416+ >>> decl = {}
417+ >>> _decl_has_a_valid_origin(decl)
418+ False
419+ """
420+ if 'origin' not in decl:
421+ return False
422+ if decl['origin'] == 'snap':
423+ return True
424+ elif decl['origin'] == 'classic_snap':
425+ return True
426+ elif decl['origin'] == 'ppa':
427+ return True
428+ elif decl['origin'] == 'source':
429+ source = Path(decl['uri']).expanduser()
430+ if not source.is_dir():
431+ logger.error("{} doesn't look like a directory", source)
432+ return False
433+ setup_file = source / 'setup.py'
434+ if not setup_file.exists():
435+ logger.error("{} not found", setup_file)
436+ return False
437+ try:
438+ package_name = subprocess.check_output(
439+ [setup_file, '--name'],
440+ stderr=subprocess.DEVNULL).decode('utf-8').rstrip()
441+ except subprocess.CalledProcessError:
442+ logger.error("{} --name failed", setup_file)
443+ return False
444+ if not package_name == 'checkbox-ng':
445+ logger.error("{} must be a lp:checkbox-ng fork", source)
446+ return False
447+ return True
448+ return False
449+
450diff --git a/metabox/core/keys.py b/metabox/core/keys.py
451new file mode 100644
452index 0000000..e2cbc6f
453--- /dev/null
454+++ b/metabox/core/keys.py
455@@ -0,0 +1,36 @@
456+# This file is part of Checkbox.
457+#
458+# Copyright 2021 Canonical Ltd.
459+# Written by:
460+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
461+# Sylvain Pineau <sylvain.pineau@canonical.com>
462+#
463+# Checkbox is free software: you can redistribute it and/or modify
464+# it under the terms of the GNU General Public License version 3,
465+# as published by the Free Software Foundation.
466+#
467+# Checkbox is distributed in the hope that it will be useful,
468+# but WITHOUT ANY WARRANTY; without even the implied warranty of
469+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
470+# GNU General Public License for more details.
471+#
472+# You should have received a copy of the GNU General Public License
473+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
474+"""Key constants and utilities for Scenarios."""
475+
476+import signal
477+
478+KEY_ENTER = "\n"
479+KEY_UP = "\x1b[A"
480+KEY_DOWN = "\x1b[B"
481+KEY_RIGHT = "\x1b[C"
482+KEY_LEFT = "\x1b[D"
483+KEY_PAGEUP = "\x1b[5~"
484+KEY_PAGEDOWN = "\x1b[6~"
485+KEY_HOME = "\x1b[H"
486+KEY_END = "\x1b[F"
487+KEY_SPACE = "\x20"
488+KEY_ESCAPE = "\x1b"
489+
490+SIGINT = signal.SIGINT.value
491+SIGKILL = signal.SIGKILL.value
492diff --git a/metabox/core/lxd_execute.py b/metabox/core/lxd_execute.py
493new file mode 100644
494index 0000000..1dbb0d7
495--- /dev/null
496+++ b/metabox/core/lxd_execute.py
497@@ -0,0 +1,236 @@
498+# This file is part of Checkbox.
499+#
500+# Copyright 2021 Canonical Ltd.
501+# Written by:
502+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
503+# Sylvain Pineau <sylvain.pineau@canonical.com>
504+#
505+# Checkbox is free software: you can redistribute it and/or modify
506+# it under the terms of the GNU General Public License version 3,
507+# as published by the Free Software Foundation.
508+#
509+# Checkbox is distributed in the hope that it will be useful,
510+# but WITHOUT ANY WARRANTY; without even the implied warranty of
511+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
512+# GNU General Public License for more details.
513+#
514+# You should have received a copy of the GNU General Public License
515+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
516+import json
517+import re
518+import shlex
519+import threading
520+import time
521+
522+from loguru import logger
523+import metabox.core.keys as keys
524+from metabox.core.utils import ExecuteResult
525+from ws4py.client.threadedclient import WebSocketClient
526+
527+
528+base_env = {
529+ 'PYTHONUNBUFFERED': '1',
530+ 'DISABLE_URWID_ESCAPE_CODES': '1',
531+ 'XDG_RUNTIME_DIR': '/run/user/1000'
532+}
533+login_shell = ['sudo', '--user', 'ubuntu', '--login']
534+
535+
536+class InteractiveWebsocket(WebSocketClient):
537+
538+ # https://stackoverflow.com/a/14693789/1154487
539+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
540+
541+ def __init__(self, *args, **kwargs):
542+ super().__init__(*args, **kwargs)
543+ self.stdout_data = bytearray()
544+ self.stdout_data_full = bytearray()
545+ self.stdout_lock = threading.Lock()
546+ self._new_data = False
547+ self._lookup_by_id = False
548+
549+ def received_message(self, message):
550+ if len(message.data) == 0:
551+ self.close()
552+ if self.verbose:
553+ raw_msg = self.ansi_escape.sub(
554+ '', message.data.decode('utf-8', errors='ignore'))
555+ logger.trace(raw_msg.rstrip())
556+ with self.stdout_lock:
557+ self.stdout_data += message.data
558+ self.stdout_data_full += message.data
559+ self._new_data = True
560+
561+ def expect(self, data, timeout=0):
562+ not_found = True
563+ start_time = time.time()
564+ while not_found:
565+ time.sleep(0.1)
566+ if type(data) != str:
567+ check = data.search(self.stdout_data)
568+ else:
569+ check = data.encode('utf-8') in self.stdout_data
570+ if check:
571+ with self.stdout_lock:
572+ self.stdout_data = bytearray()
573+ not_found = False
574+ if timeout and time.time() > start_time + timeout:
575+ logger.warning(
576+ "{} not found! Timeout is reached (set to {})",
577+ data, timeout)
578+ raise TimeoutError
579+ return not_found is False
580+
581+ def select_test_plan(self, data, timeout=0):
582+ if not self._lookup_by_id:
583+ self.send(('i' + keys.KEY_HOME).encode('utf-8'), binary=True)
584+ self._lookup_by_id = True
585+ else:
586+ self.send(keys.KEY_HOME.encode('utf-8'), binary=True)
587+ not_found = True
588+ max_attemps = 10
589+ attempt = 0
590+ still_on_first_screen = True
591+ old_stdout_data = b''
592+ if len(data) > 67:
593+ data = data[:67] + ' │\r\n│ ' + data[67:]
594+ while attempt < max_attemps:
595+ if self._new_data and self.stdout_data:
596+ if old_stdout_data == self.stdout_data:
597+ break
598+ check = data.encode('utf-8') in self.stdout_data
599+ if not check:
600+ self._new_data = False
601+ with self.stdout_lock:
602+ old_stdout_data = self.stdout_data
603+ self.stdout_data = bytearray()
604+ stdin_payload = keys.KEY_PAGEDOWN + keys.KEY_SPACE
605+ self.send(stdin_payload.encode('utf-8'), binary=True)
606+ still_on_first_screen = False
607+ attempt = 0
608+ else:
609+ not_found = False
610+ break
611+ else:
612+ time.sleep(0.1)
613+ attempt += 1
614+ if not_found:
615+ return False
616+ data = '(X) ' + data
617+ attempt = 0
618+ if still_on_first_screen:
619+ self.send(keys.KEY_PAGEDOWN.encode('utf-8'), binary=True)
620+ while attempt < max_attemps:
621+ if self._new_data and self.stdout_data:
622+ check = data.encode('utf-8') in self.stdout_data
623+ if not check:
624+ self._new_data = False
625+ with self.stdout_lock:
626+ self.stdout_data = bytearray()
627+ stdin_payload = keys.KEY_UP + keys.KEY_SPACE
628+ self.send(stdin_payload.encode('utf-8'), binary=True)
629+ attempt = 0
630+ else:
631+ not_found = False
632+ with self.stdout_lock:
633+ self.stdout_data = bytearray()
634+ break
635+ else:
636+ time.sleep(0.1)
637+ attempt += 1
638+ return not_found is False
639+
640+ def send_signal(self, signal):
641+ self.ctl.send(json.dumps({'command': 'signal', 'signal': signal}))
642+
643+ @property
644+ def container(self):
645+ return self._container
646+
647+ @container.setter
648+ def container(self, container):
649+ self._container = container
650+
651+ @property
652+ def ctl(self):
653+ return self._ctl
654+
655+ @ctl.setter
656+ def ctl(self, ctl):
657+ self._ctl = ctl
658+
659+ @property
660+ def verbose(self):
661+ return self._verbose
662+
663+ @verbose.setter
664+ def verbose(self, verbose):
665+ self._verbose = verbose
666+
667+
668+def env_wrapper(env):
669+ env_cmd = ['env']
670+ env.update(base_env)
671+ env_cmd += [
672+ "{key}={value}".format(key=key, value=value)
673+ for key, value in sorted(env.items())]
674+ return env_cmd
675+
676+
677+def timeout_wrapper(timeout):
678+ if timeout:
679+ return ['timeout', '--signal=KILL', str(timeout)]
680+ else:
681+ return []
682+
683+
684+def interactive_execute(container, cmd, env={}, verbose=False, timeout=0):
685+ if verbose:
686+ logger.trace(cmd)
687+ ws_urls = container.raw_interactive_execute(
688+ login_shell + env_wrapper(env) + shlex.split(cmd))
689+ base_websocket_url = container.client.websocket_url
690+ ctl = WebSocketClient(base_websocket_url)
691+ ctl.resource = ws_urls['control']
692+ ctl.connect()
693+ pts = InteractiveWebsocket(base_websocket_url)
694+ pts.resource = ws_urls['ws']
695+ pts.verbose = verbose
696+ pts.container = container
697+ pts.ctl = ctl
698+ pts.connect()
699+ return pts
700+
701+
702+def run_or_raise(container, cmd, env={}, verbose=False, timeout=0):
703+ stdout_data = ''
704+ stderr_data = ''
705+
706+ def on_stdout(msg):
707+ nonlocal stdout_data
708+ stdout_data += msg
709+ logger.trace(msg.rstrip())
710+
711+ def on_stderr(msg):
712+ nonlocal stderr_data
713+ stderr_data += msg
714+ logger.trace(msg.rstrip())
715+
716+ if verbose:
717+ logger.trace(cmd)
718+ res = container.execute(
719+ login_shell + env_wrapper(env) + timeout_wrapper(timeout)
720+ + shlex.split(cmd), # noqa 503
721+ stdout_handler=on_stdout if verbose else None,
722+ stderr_handler=on_stderr if verbose else None,
723+ stdin_payload=open(__file__))
724+ if timeout and res.exit_code == 137:
725+ logger.warning("{} Timeout is reached (set to {})", cmd, timeout)
726+ raise TimeoutError
727+ elif res.exit_code:
728+ msg = "Failed to run command in the container! Command: \n"
729+ msg += cmd + ' ' + res.stderr
730+ # raise SystemExit(msg)
731+ if verbose:
732+ return (ExecuteResult(res.exit_code, stdout_data, stderr_data))
733+ return res
734diff --git a/metabox/core/lxd_provider.py b/metabox/core/lxd_provider.py
735new file mode 100644
736index 0000000..df70b8a
737--- /dev/null
738+++ b/metabox/core/lxd_provider.py
739@@ -0,0 +1,229 @@
740+# This file is part of Checkbox.
741+#
742+# Copyright 2021 Canonical Ltd.
743+# Written by:
744+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
745+# Sylvain Pineau <sylvain.pineau@canonical.com>
746+#
747+# Checkbox is free software: you can redistribute it and/or modify
748+# it under the terms of the GNU General Public License version 3,
749+# as published by the Free Software Foundation.
750+#
751+# Checkbox is distributed in the hope that it will be useful,
752+# but WITHOUT ANY WARRANTY; without even the implied warranty of
753+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
754+# GNU General Public License for more details.
755+#
756+# You should have received a copy of the GNU General Public License
757+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
758+
759+"""
760+lxd_provider
761+============
762+
763+This module implements the LXD Machine and LXD Machine Provider.
764+LXD machines are containers that can run metabox scenarios in them.
765+"""
766+import json
767+import os
768+import time
769+import yaml
770+from pathlib import Path
771+
772+import pkg_resources
773+import pylxd
774+from loguru import logger
775+from pylxd.exceptions import ClientConnectionFailed, LXDAPIException
776+
777+from metabox.core.machine import MachineConfig
778+from metabox.core.machine import machine_selector
779+from metabox.core.lxd_execute import run_or_raise
780+
781+
782+class LxdMachineProvider():
783+ """Machine provider that uses container managed by LXD as targets."""
784+
785+ LXD_CREATE_TIMEOUT = 120
786+ LXD_POLL_INTERVAL = 5
787+ LXD_INTERNAL_CONFIG_PATH = '/var/tmp/machine_config.json'
788+
789+ def __init__(self, session_config, effective_machine_config,
790+ debug_machine_setup=False, dispose=False):
791+ self._session_config = session_config
792+ self._machine_config = effective_machine_config
793+ self._debug_machine_setup = debug_machine_setup
794+ self._owned_containers = []
795+ self._dispose = dispose
796+
797+ # TODO: maybe add handlers for more complicated client connections
798+ # like a remote LXD host and/or authenticated access
799+ try:
800+ # TODO: Find a suitable timeout value here
801+ self.client = pylxd.Client(timeout=None)
802+ except ClientConnectionFailed as exc:
803+ raise SystemExit from exc
804+
805+ def setup(self):
806+ self._create_profiles()
807+ self._get_existing_machines()
808+ for config in self._machine_config:
809+ if config in [oc.config for oc in self._owned_containers]:
810+ continue
811+ self._create_machine(config)
812+
813+ def _get_existing_machines(self):
814+ for container in self.client.containers.all():
815+ try:
816+ content = container.files.get(self.LXD_INTERNAL_CONFIG_PATH)
817+ config_dict = json.loads(content)
818+ config = MachineConfig(config_dict['role'], config_dict)
819+ except (KeyError, LXDAPIException):
820+ continue
821+ if config in self._machine_config:
822+ self._owned_containers.append(
823+ machine_selector(config, container))
824+
825+ def _create_profiles(self):
826+ profiles_path = pkg_resources.resource_filename(
827+ 'metabox', 'lxd_profiles')
828+ for profile_file in os.listdir(profiles_path):
829+ profile_name = Path(profile_file).stem
830+ with open(os.path.join(profiles_path, profile_file)) as f:
831+ profile_dict = yaml.load(f, Loader=yaml.FullLoader)
832+ if self.client.profiles.exists(profile_name):
833+ profile = self.client.profiles.get(profile_name)
834+ if 'config' in profile_dict:
835+ profile.config = profile_dict['config']
836+ if 'devices' in profile_dict:
837+ profile.devices = profile_dict['devices']
838+ profile.save()
839+ logger.debug(
840+ '{} LXD profile updated successfully', profile_name)
841+ else:
842+ profile = self.client.profiles.create(
843+ profile_name,
844+ config=profile_dict.get('config', None),
845+ devices=profile_dict.get('devices', None))
846+ logger.debug(
847+ '{} LXD profile created successfully', profile_name)
848+
849+ def _create_machine(self, config):
850+ name = 'metabox-{}'.format(config)
851+ base_profiles = ["default", "checkbox"]
852+ if config.origin == 'snap':
853+ base_profiles.append('snap')
854+ lxd_config = {
855+ "name": name,
856+ "profiles": base_profiles + config.profiles,
857+ "source": {
858+ "type": "image",
859+ "alias": config.alias,
860+ 'protocol': 'simplestreams',
861+ 'server': 'https://cloud-images.ubuntu.com/releases'
862+ }
863+ }
864+ try:
865+ logger.opt(colors=True).debug("[<y>creating</y> ] {}", name)
866+ container = self.client.containers.create(lxd_config, wait=True)
867+ machine = machine_selector(config, container)
868+ container.start(wait=True)
869+ attempt = 0
870+ max_attempt = self.LXD_CREATE_TIMEOUT / self.LXD_POLL_INTERVAL
871+ while attempt < max_attempt:
872+ time.sleep(self.LXD_POLL_INTERVAL)
873+ (ret, out, err) = container.execute(
874+ ['cloud-init', 'status', '--long'])
875+ if 'status: done' in out:
876+ break
877+ elif ret != 0:
878+ logger.error(out)
879+ raise SystemExit(out)
880+ attempt += 1
881+ else:
882+ raise SystemExit("Timeout reached (still running cloud-init)")
883+ container.stop(wait=True)
884+ container.snapshots.create('base', stateful=False, wait=True)
885+ container.start(wait=True)
886+ logger.opt(colors=True).debug(
887+ "[<y>created</y> ] {}", container.name)
888+ logger.opt(colors=True).debug(
889+ "[<y>provisioning</y>] {}", container.name)
890+ self._run_transfer_commands(machine)
891+ time.sleep(5) # FIXME: is it still needed?
892+ self._run_setup_commands(machine)
893+ self._store_config(machine)
894+ container.stop(wait=True)
895+ container.snapshots.create(
896+ 'provisioned', stateful=False, wait=True)
897+ container.start(wait=True)
898+ self._owned_containers.append(machine)
899+ logger.opt(colors=True).debug(
900+ "[<y>provisioned</y> ] {}", container.name)
901+
902+ except LXDAPIException as exc:
903+ error = self._api_exc_to_human(exc)
904+ raise SystemExit(error) from exc
905+
906+ def _run_transfer_commands(self, machine):
907+ provider_path = pkg_resources.resource_filename(
908+ 'metabox', 'metabox-provider')
909+ metabox_dir_transfers = machine.get_early_dir_transfer() + [
910+ (provider_path, '/var/tmp/checkbox-providers/metabox-provider')]
911+ for src, dest in metabox_dir_transfers + machine.config.transfer:
912+ run_or_raise(
913+ machine._container, 'mkdir -p {}'.format(dest),
914+ verbose=self._debug_machine_setup)
915+ machine._container.files.recursive_put(
916+ os.path.expanduser(src), dest)
917+ run_or_raise(
918+ machine._container,
919+ 'sudo chown -R ubuntu:ubuntu {}'.format(dest),
920+ verbose=self._debug_machine_setup)
921+ # FIXME: preserve the +x bit
922+ for src, dest in machine.get_file_transfer():
923+ with open(src, "rb") as f:
924+ machine._container.files.put(dest, f.read())
925+ # FIXME: preserve the +x bit
926+
927+ def _run_setup_commands(self, machine):
928+ pre_cmds = machine.get_early_setup()
929+ post_cmds = machine.get_late_setup()
930+ for cmd in pre_cmds + machine.config.setup + post_cmds:
931+ res = run_or_raise(
932+ machine._container, cmd,
933+ verbose=self._debug_machine_setup)
934+ if res.exit_code:
935+ msg = "Failed to run command in the container! Command: \n"
936+ msg += cmd + '\n' + res.stderr
937+ logger.critical(msg)
938+ raise SystemExit()
939+
940+ def _store_config(self, machine):
941+ machine._container.files.put(
942+ self.LXD_INTERNAL_CONFIG_PATH, json.dumps(machine.config.__dict__))
943+
944+ def get_machine_by_config(self, config):
945+ for machine in self._owned_containers:
946+ if config == machine.config:
947+ return machine
948+
949+ def cleanup(self, dispose=False):
950+ """Stop and delete (on request) all the containers."""
951+ for machine in self._owned_containers:
952+ container = machine._container
953+ if container.status == "Running":
954+ container.stop(wait=True)
955+ logger.opt(colors=True).debug(
956+ "[<y>stopped</y> ] {}", container.name)
957+ if dispose:
958+ container.delete(wait=True)
959+ logger.opt(colors=True).debug(
960+ "[<y>deleted</y> ] {}", container.name)
961+
962+ def _api_exc_to_human(self, exc):
963+ response = json.loads(exc.response.text)
964+ # TODO: wrap in try/except and on wrong fields dump all info we have
965+ return response.get('error') or response['metadata']['err']
966+
967+ def __del__(self):
968+ self.cleanup(self._dispose)
969diff --git a/metabox/core/machine.py b/metabox/core/machine.py
970new file mode 100644
971index 0000000..2004097
972--- /dev/null
973+++ b/metabox/core/machine.py
974@@ -0,0 +1,433 @@
975+# This file is part of Checkbox.
976+#
977+# Copyright 2021 Canonical Ltd.
978+# Written by:
979+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
980+# Sylvain Pineau <sylvain.pineau@canonical.com>
981+#
982+# Checkbox is free software: you can redistribute it and/or modify
983+# it under the terms of the GNU General Public License version 3,
984+# as published by the Free Software Foundation.
985+#
986+# Checkbox is distributed in the hope that it will be useful,
987+# but WITHOUT ANY WARRANTY; without even the implied warranty of
988+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
989+# GNU General Public License for more details.
990+#
991+# You should have received a copy of the GNU General Public License
992+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
993+import itertools
994+import signal
995+import time
996+from pathlib import Path
997+
998+from loguru import logger
999+from metabox.core.lxd_execute import interactive_execute
1000+from metabox.core.lxd_execute import run_or_raise
1001+
1002+
1003+class MachineConfig:
1004+
1005+ def __init__(self, role, config):
1006+ self.role = role
1007+ self.alias = config['alias']
1008+ self.origin = config['origin']
1009+ self.uri = config.get("uri", "")
1010+ self.profiles = config.get("profiles", [])
1011+ self.transfer = config.get("transfer", [])
1012+ self.setup = config.get("setup", [])
1013+ self.checkbox_core_snap = config.get("checkbox_core_snap", {})
1014+ self.checkbox_snap = config.get("checkbox_snap", {})
1015+ self.snap_name = config.get("name", "")
1016+ if not self.snap_name:
1017+ if self.origin == 'snap':
1018+ self.snap_name = 'checkbox-snappy'
1019+ elif self.origin == 'classic_snap':
1020+ self.snap_name = 'checkbox-snappy-classic'
1021+
1022+ def __members(self):
1023+ return (self.role, self.alias, self.origin, self.snap_name, self.uri,
1024+ ' '.join(self.profiles),
1025+ ' '.join(itertools.chain(*self.transfer)),
1026+ ' '.join(self.setup))
1027+
1028+ def __repr__(self):
1029+ return "<{} alias:{!r} origin:{!r}>".format(
1030+ self.role, self.alias, self.origin)
1031+
1032+ def __str__(self):
1033+ return "{}-{}".format(self.role, self.alias)
1034+
1035+ def __hash__(self):
1036+ return hash(self.__members())
1037+
1038+ def __eq__(self, other):
1039+ if type(other) is type(self):
1040+ return self.__members() == other.__members()
1041+ else:
1042+ return False
1043+
1044+
1045+class ContainerBaseMachine():
1046+ """Base implementation of Machine using LXD container as the backend."""
1047+
1048+ CHECKBOX = 'checkbox-cli ' # mind the trailing space !
1049+
1050+ def __init__(self, config, container):
1051+ self.config = config
1052+ self._container = container
1053+ self._checkbox_wrapper = self.CHECKBOX
1054+
1055+ def execute(self, cmd, env={}, verbose=False, timeout=0):
1056+ return run_or_raise(
1057+ self._container, self._checkbox_wrapper + cmd, env, verbose,
1058+ timeout)
1059+
1060+ def interactive_execute(self, cmd, env={}, verbose=False, timeout=0):
1061+ return interactive_execute(
1062+ self._container, self._checkbox_wrapper + cmd, env, verbose,
1063+ timeout)
1064+
1065+ def rollback_to(self, savepoint):
1066+ if self._container.status != 'Stopped':
1067+ self._container.stop(wait=True)
1068+ self._container.restore_snapshot(savepoint, wait=True)
1069+ self._container.start(wait=True)
1070+ logger.opt(colors=True).debug(
1071+ "[<y>restored</y> ] {}", self._container.name)
1072+ attempt = 0
1073+ # FIXME: in case containers are restarted after reboot, sometime we hit
1074+ # the degraded state.
1075+ # So be sure to wait long enough to claim it started.
1076+ # https://discuss.linuxcontainers.org/t/snap-lxd-activate-service-hanging-after-system-reboot/8374/16
1077+ max_attempt = 60
1078+ old_out = ''
1079+ while attempt < max_attempt:
1080+ time.sleep(1)
1081+ (ret, out, err) = self._container.execute(
1082+ ['systemctl', 'is-system-running'])
1083+ if out != old_out:
1084+ logger.opt(colors=True).debug(
1085+ "[<y>{: <12}</y>] {}", out.rstrip(), self._container.name)
1086+ old_out = out
1087+ if 'running' in out:
1088+ break
1089+ elif 'degraded' in out:
1090+ break
1091+ attempt += 1
1092+ else:
1093+ raise SystemExit(
1094+ "Rollback to {} failed (systemd not in running state)".format(
1095+ savepoint))
1096+
1097+ def put(self, filepath, data, mode=None, uid=1000, gid=1000):
1098+ self._container.files.put(filepath, data, mode, uid, gid)
1099+
1100+ def get_connecting_cmd(self):
1101+ return "lxc exec {} -- sudo --user ubuntu --login".format(
1102+ self._container.name)
1103+
1104+ @property
1105+ def address(self):
1106+ addresses = self._container.state().network['eth0']['addresses']
1107+ return addresses[0]['address']
1108+
1109+ def get_early_dir_transfer(self):
1110+ """
1111+ .. note:: You should override this method in your subclass.
1112+ """
1113+ return []
1114+
1115+ def get_file_transfer(self):
1116+ """
1117+ .. note:: You should override this method in your subclass.
1118+ """
1119+ return []
1120+
1121+ def get_early_setup(self):
1122+ """
1123+ .. note:: You should override this method in your subclass.
1124+ """
1125+ return []
1126+
1127+ def get_late_setup(self):
1128+ """
1129+ .. note:: You should override this method in your subclass.
1130+ """
1131+ return []
1132+
1133+ def start_remote(self, host, launcher, interactive=False, timeout=0):
1134+ assert(self.config.role == 'remote')
1135+
1136+ if interactive:
1137+ # Return a PTS object to interact with
1138+ return self.interactive_execute(
1139+ 'remote {} {}'.format(host, launcher), verbose=True,
1140+ timeout=timeout)
1141+ else:
1142+ # Return an ExecuteResult named tuple
1143+ return self.execute(
1144+ 'remote {} {}'.format(host, launcher), verbose=True,
1145+ timeout=timeout)
1146+
1147+ def start(self, cmd=None, env={}, interactive=False, timeout=0):
1148+ assert(self.config.role == 'local')
1149+ if interactive:
1150+ # Return a PTS object to interact with
1151+ return self.interactive_execute(
1152+ cmd, env=env, verbose=True, timeout=timeout)
1153+ else:
1154+ # Return an ExecuteResult named tuple
1155+ return self.execute(
1156+ cmd, env=env, verbose=True, timeout=timeout)
1157+
1158+ def run_cmd(self, cmd, env={}, interactive=False, timeout=0):
1159+ verbose = True
1160+ if interactive:
1161+ # Return a PTS object to interact with
1162+ return interactive_execute(
1163+ self._container, cmd, env, verbose, timeout)
1164+ else:
1165+ # Return an ExecuteResult named tuple
1166+ return run_or_raise(
1167+ self._container, cmd, env, verbose, timeout)
1168+
1169+ def start_service(self, force=False):
1170+ """
1171+ .. note:: You should override this method in your subclass.
1172+ """
1173+ pass
1174+
1175+ def stop_service(self):
1176+ """
1177+ .. note:: You should override this method in your subclass.
1178+ """
1179+ pass
1180+
1181+ def reboot(self, timeout=0):
1182+ verbose = True
1183+ return run_or_raise(
1184+ self._container, "sudo reboot", verbose, timeout)
1185+
1186+ def is_service_active(self):
1187+ """
1188+ .. note:: You should override this method in your subclass.
1189+ """
1190+ pass
1191+
1192+ def start_user_session(self):
1193+ assert(self.config.role in ('service', 'local'))
1194+ # Start a set of ubuntu-user-owned processes to fake an active GDM user
1195+ # session (A virtual framebuffer and a pulseaudio server with a dummy
1196+ # output)
1197+ interactive_execute(
1198+ self._container, "/usr/bin/Xvfb -screen 0 1280x1024x24")
1199+ # Note: running the following commands as part of standard setup does
1200+ # not make them persistent as after restoring snapshots user/1000
1201+ # is gone from /run
1202+ pulseaudio_setup_cmds = [
1203+ 'sudo mkdir -v -p /run/user/1000/pulse',
1204+ 'sudo chown -R ubuntu:ubuntu /run/user/1000/',
1205+ "pulseaudio --start --exit-idle-time=-1 --disallow-module-loading",
1206+ ]
1207+ env = {'XDG_RUNTIME_DIR': '/run/user/1000'}
1208+ for cmd in pulseaudio_setup_cmds:
1209+ run_or_raise(self._container, cmd, env)
1210+
1211+ def switch_off_networking(self):
1212+ return run_or_raise(self._container, "sudo ip link set eth0 down")
1213+
1214+ def switch_on_networking(self):
1215+ return run_or_raise(self._container, "sudo ip link set eth0 up")
1216+
1217+
1218+class ContainerVenvMachine(ContainerBaseMachine):
1219+ """
1220+ Machine using LXD container as the backend and running checkbox
1221+ in a virtualenv.
1222+ """
1223+
1224+ def __init__(self, config, container):
1225+ super().__init__(config, container)
1226+ if self.config.role == 'service':
1227+ self._checkbox_wrapper = 'sudo /home/ubuntu/run.sh {}'.format(
1228+ self.CHECKBOX)
1229+ else:
1230+ self._checkbox_wrapper = '/home/ubuntu/run.sh {}'.format(
1231+ self.CHECKBOX)
1232+ self._pts = None # Keep a pointer to started pts for easy kill
1233+
1234+ def get_early_dir_transfer(self):
1235+ return [(self.config.uri, '/home/ubuntu/checkbox-ng')]
1236+
1237+ def get_early_setup(self):
1238+ """Virtualenv creation."""
1239+ return [
1240+ '''bash -c "printf '#!/bin/bash\\n. '''
1241+ '''/home/ubuntu/checkbox-ng/venv/bin/activate\\n$@' > '''
1242+ '''/home/ubuntu/run.sh"''',
1243+ 'chmod +x /home/ubuntu/run.sh',
1244+ 'chmod +x /home/ubuntu/checkbox-ng/setup.py',
1245+ 'chmod +x /home/ubuntu/checkbox-ng/mk-venv',
1246+ "bash -c 'pushd /home/ubuntu/checkbox-ng ; ./mk-venv venv'",
1247+ ]
1248+
1249+ def start_service(self, force=False):
1250+ assert(self.config.role == 'service')
1251+ self._pts = self.interactive_execute('service', verbose=True)
1252+ return self._pts
1253+
1254+ def stop_service(self):
1255+ assert(self.config.role == 'service')
1256+ return self._pts.send_signal(signal.SIGINT.value)
1257+
1258+ def reboot_service(self):
1259+ """
1260+ Venv Service is not a systemd service.
1261+ It won't show up after a reboot.
1262+ """
1263+ raise RuntimeError
1264+
1265+ def is_service_active(self):
1266+ assert(self.config.role == 'service')
1267+ return run_or_raise(
1268+ self._container, 'pgrep -f "python3.*checkbox-cli service"')
1269+
1270+
1271+class ContainerPPAMachine(ContainerBaseMachine):
1272+ """
1273+ Machine using LXD container as the backend and running checkbox
1274+ from PPA.
1275+ """
1276+
1277+ def __init__(self, config, container):
1278+ super().__init__(config, container)
1279+
1280+ def get_early_setup(self):
1281+ if self.config.setup:
1282+ return []
1283+ if self.config.role == 'remote':
1284+ deb = 'checkbox-ng'
1285+ else:
1286+ deb = 'canonical-certification-client'
1287+ return [
1288+ 'sudo add-apt-repository {}'.format(self.config.uri),
1289+ 'sudo apt-get update',
1290+ 'sudo apt-get install -y --no-install-recommends {}'.format(deb),
1291+ ]
1292+
1293+ def start_service(self, force=False):
1294+ assert(self.config.role == 'service')
1295+ if force:
1296+ return run_or_raise(
1297+ self._container, "sudo systemctl start checkbox-ng.service")
1298+
1299+ def stop_service(self):
1300+ assert(self.config.role == 'service')
1301+ return run_or_raise(
1302+ self._container, "sudo systemctl stop checkbox-ng.service")
1303+
1304+ def is_service_active(self):
1305+ assert(self.config.role == 'service')
1306+ return run_or_raise(
1307+ self._container,
1308+ "systemctl is-active checkbox-ng.service").stdout == 'active'
1309+
1310+
1311+class ContainerSnapMachine(ContainerBaseMachine):
1312+ """
1313+ Machine using LXD container as the backend and running checkbox
1314+ from a snap.
1315+ """
1316+
1317+ CHECKBOX_CORE_SNAP_MAP = {
1318+ 'xenial': 'checkbox',
1319+ 'bionic': 'checkbox18',
1320+ 'focal': 'checkbox20',
1321+ }
1322+ CHECKBOX_SNAP_TRACK_MAP = {
1323+ 'xenial': '16',
1324+ 'bionic': '18',
1325+ 'focal': '20',
1326+ }
1327+
1328+ def __init__(self, config, container):
1329+ super().__init__(config, container)
1330+ self._snap_name = self.config.snap_name
1331+ self._checkbox_wrapper = '{}.{}'.format(self._snap_name, self.CHECKBOX)
1332+
1333+ def get_file_transfer(self):
1334+ file_tranfer_list = []
1335+ if self.config.checkbox_core_snap.get('uri'):
1336+ core_filename = Path(
1337+ self.config.checkbox_core_snap.get('uri')).expanduser()
1338+ self.core_dest = Path('/home', 'ubuntu', core_filename.name)
1339+ file_tranfer_list.append((core_filename, self.core_dest))
1340+ if self.config.checkbox_snap.get('uri'):
1341+ filename = Path(
1342+ self.config.checkbox_snap.get('uri')).expanduser()
1343+ self.dest = Path('/home', 'ubuntu', filename.name)
1344+ file_tranfer_list.append((filename, self.dest))
1345+ return file_tranfer_list
1346+
1347+ def get_early_setup(self):
1348+ cmds = []
1349+ # First install the checkbox core snap if the related section exists
1350+ if self.config.checkbox_core_snap:
1351+ if self.config.checkbox_core_snap.get('uri'):
1352+ cmds.append(f'sudo snap install {self.core_dest} --dangerous')
1353+ else:
1354+ core_snap = self.CHECKBOX_CORE_SNAP_MAP[self.config.alias]
1355+ channel = f"latest/{self.config.checkbox_core_snap['risk']}"
1356+ cmds.append(
1357+ f'sudo snap install {core_snap} --channel={channel}')
1358+ # Then install the checkbox snap
1359+ confinement = 'devmode'
1360+ if self.config.origin == 'classic_snap':
1361+ confinement = 'classic'
1362+ if self.config.checkbox_snap.get('uri'):
1363+ cmds.append(
1364+ f'sudo snap install {self.dest} --{confinement} --dangerous')
1365+ else:
1366+ try:
1367+ track_map = self.config.checkbox_snap['track_map']
1368+ except KeyError:
1369+ track_map = self.CHECKBOX_SNAP_TRACK_MAP
1370+ channel = "{}/{}".format(
1371+ track_map[self.config.alias],
1372+ self.config.checkbox_snap['risk'])
1373+ cmds.append('sudo snap install {} --channel={} --{}'.format(
1374+ self._snap_name, channel, confinement))
1375+ return cmds
1376+
1377+ def start_service(self, force=False):
1378+ assert(self.config.role == 'service')
1379+ if force:
1380+ return run_or_raise(
1381+ self._container,
1382+ "sudo systemctl start snap.{}.service.service".format(
1383+ self._snap_name))
1384+
1385+ def stop_service(self):
1386+ assert(self.config.role == 'service')
1387+ return run_or_raise(
1388+ self._container,
1389+ "sudo systemctl stop snap.{}.service.service".format(
1390+ self._snap_name))
1391+
1392+ def is_service_active(self):
1393+ assert(self.config.role == 'service')
1394+ return run_or_raise(
1395+ self._container,
1396+ "systemctl is-active snap.{}.service.service".format(
1397+ self._snap_name)
1398+ ).stdout == 'active'
1399+
1400+
1401+def machine_selector(config, container):
1402+ if config.origin in ('snap', 'classic_snap'):
1403+ return(ContainerSnapMachine(config, container))
1404+ elif config.origin == 'ppa':
1405+ return(ContainerPPAMachine(config, container))
1406+ elif config.origin == 'source':
1407+ return(ContainerVenvMachine(config, container))
1408diff --git a/metabox/core/runner.py b/metabox/core/runner.py
1409new file mode 100644
1410index 0000000..57c8530
1411--- /dev/null
1412+++ b/metabox/core/runner.py
1413@@ -0,0 +1,221 @@
1414+# This file is part of Checkbox.
1415+#
1416+# Copyright 2021 Canonical Ltd.
1417+# Written by:
1418+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
1419+# Sylvain Pineau <sylvain.pineau@canonical.com>
1420+#
1421+# Checkbox is free software: you can redistribute it and/or modify
1422+# it under the terms of the GNU General Public License version 3,
1423+# as published by the Free Software Foundation.
1424+#
1425+# Checkbox is distributed in the hope that it will be useful,
1426+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1427+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1428+# GNU General Public License for more details.
1429+#
1430+# You should have received a copy of the GNU General Public License
1431+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
1432+import sys
1433+import time
1434+
1435+from loguru import logger
1436+from metabox.core.aggregator import aggregator
1437+from metabox.core.configuration import read_config
1438+from metabox.core.configuration import validate_config
1439+from metabox.core.lxd_provider import LxdMachineProvider
1440+from metabox.core.machine import MachineConfig
1441+
1442+logger = logger.opt(colors=True)
1443+
1444+
1445+class Runner:
1446+ """Metabox scenario discovery and runner."""
1447+
1448+ def __init__(self, args):
1449+ self.args = args
1450+ # logging
1451+ logger.remove()
1452+ logger.add(
1453+ sys.stdout,
1454+ format=self._formatter,
1455+ level=args.log_level)
1456+ logger.level("TRACE", color="<w><dim>")
1457+ logger.level("DEBUG", color="<w><dim>")
1458+ # session config
1459+ if not args.config.exists():
1460+ raise SystemExit('Config file not found!')
1461+ else:
1462+ self.config = read_config(args.config)
1463+ validate_config(self.config)
1464+ # effective set of machine configs required by scenarios
1465+ self.combo = set()
1466+ self.machine_provider = None
1467+ self.failed = False
1468+ self.tags = set(self.args.tags or [])
1469+ self.exclude_tags = set(self.args.exclude_tags or [])
1470+ self.hold_on_fail = self.args.hold_on_fail
1471+ self.debug_machine_setup = self.args.debug_machine_setup
1472+ self.dispose = not self.args.do_not_dispose
1473+ aggregator.load_all()
1474+
1475+ def _formatter(self, record):
1476+ if record["level"].no < 10:
1477+ return "<level>{message}</level>\n"
1478+ else:
1479+ return (
1480+ "{time:HH:mm:ss} | <level>{level: <8}</level> "
1481+ "<level>{message}</level>\n"
1482+ )
1483+
1484+ def _gather_all_machine_spec(self):
1485+ for v in self.scn_variants:
1486+ if v.mode == 'remote':
1487+ remote_config = self.config['remote'].copy()
1488+ service_config = self.config['service'].copy()
1489+ remote_release, service_release = v.releases
1490+ remote_config['alias'] = remote_release
1491+ service_config['alias'] = service_release
1492+ self.combo.add(MachineConfig('remote', remote_config))
1493+ self.combo.add(MachineConfig('service', service_config))
1494+ elif v.mode == 'local':
1495+ local_config = self.config['local'].copy()
1496+ local_config['alias'] = v.releases[0]
1497+ self.combo.add(MachineConfig('local', local_config))
1498+
1499+ def _filter_scn_by_tags(self):
1500+ filtered_suite = []
1501+ for scn in self.scn_variants:
1502+ # Add scenario name,file,dir,mode and releases as implicit tags
1503+ scn_tags = set(scn.name.split('.'))
1504+ scn_tags.add(scn.mode)
1505+ scn_tags.update(scn.releases)
1506+ scn_tags.update(getattr(scn, 'tags', set()))
1507+ matched_tags = scn_tags.intersection(self.tags)
1508+ if (
1509+ (matched_tags or not self.tags) and not
1510+ scn_tags.intersection(self.exclude_tags)
1511+ ):
1512+ filtered_suite.append(scn)
1513+ return filtered_suite
1514+
1515+ def setup(self):
1516+ self.scenarios = aggregator.all_scenarios()
1517+ self.scn_variants = []
1518+ # Generate all scenario variants
1519+ for scenario_cls in self.scenarios:
1520+ for mode in scenario_cls.mode:
1521+ if mode not in self.config:
1522+ logger.debug(
1523+ "Skipping a scenario: [{}] {}",
1524+ mode, scenario_cls.name)
1525+ continue
1526+ scn_config = scenario_cls.config_override
1527+ if mode == 'remote':
1528+ try:
1529+ remote_releases = scn_config['remote']['releases']
1530+ except KeyError:
1531+ remote_releases = self.config['remote']['releases']
1532+ try:
1533+ service_releases = scn_config['service']['releases']
1534+ except KeyError:
1535+ service_releases = self.config['service']['releases']
1536+ for r_alias in self.config['remote']['releases']:
1537+ if r_alias not in remote_releases:
1538+ continue
1539+ for s_alias in self.config['service']['releases']:
1540+ if s_alias not in service_releases:
1541+ continue
1542+ self.scn_variants.append(
1543+ scenario_cls(mode, r_alias, s_alias))
1544+ elif mode == 'local':
1545+ try:
1546+ local_releases = scn_config[mode]['releases']
1547+ except KeyError:
1548+ local_releases = self.config[mode]['releases']
1549+ for alias in self.config[mode]['releases']:
1550+ if alias not in local_releases:
1551+ continue
1552+ self.scn_variants.append(scenario_cls(mode, alias))
1553+ if self.args.tags or self.args.exclude_tags:
1554+ if self.args.tags:
1555+ logger.info('Including scenario tag(s): %s' % ', '.join(
1556+ sorted(self.args.tags)))
1557+ if self.args.exclude_tags:
1558+ logger.info('Excluding scenario tag(s): %s' % ', '.join(
1559+ sorted(self.args.exclude_tags)))
1560+ self.scn_variants = self._filter_scn_by_tags()
1561+ if not self.scn_variants:
1562+ logger.warning('No match found!')
1563+ raise SystemExit(1)
1564+ self._gather_all_machine_spec()
1565+ self.machine_provider = LxdMachineProvider(
1566+ self.config, self.combo,
1567+ self.debug_machine_setup, self.dispose)
1568+ self.machine_provider.setup()
1569+
1570+ def _load(self, mode, release_alias):
1571+ config = self.config[mode].copy()
1572+ config['alias'] = release_alias
1573+ config['role'] = mode
1574+ return self.machine_provider.get_machine_by_config(
1575+ MachineConfig(mode, config))
1576+
1577+ def run(self):
1578+ startTime = time.perf_counter()
1579+ for scn in self.scn_variants:
1580+ if scn.mode == "remote":
1581+ scn.remote_machine = self._load("remote", scn.releases[0])
1582+ scn.service_machine = self._load("service", scn.releases[1])
1583+ scn.remote_machine.rollback_to('provisioned')
1584+ scn.service_machine.rollback_to('provisioned')
1585+ if scn.launcher:
1586+ scn.remote_machine.put(scn.LAUNCHER_PATH, scn.launcher)
1587+ scn.service_machine.start_user_session()
1588+ elif scn.mode == "local":
1589+ scn.local_machine = self._load("local", scn.releases[0])
1590+ scn.local_machine.rollback_to('provisioned')
1591+ if scn.launcher:
1592+ scn.local_machine.put(scn.LAUNCHER_PATH, scn.launcher)
1593+ scn.local_machine.start_user_session()
1594+ logger.info("Starting scenario: {}".format(scn.name))
1595+ scn.run()
1596+ if not scn.has_passed():
1597+ self.failed = True
1598+ logger.error("{} scenario has failed.".format(scn.name))
1599+ if self.hold_on_fail:
1600+ if scn.mode == "remote":
1601+ msg = (
1602+ "You may hop onto the target machines by issuing "
1603+ "the following commands:\n{}\n{}\n"
1604+ "Press enter to continue testing").format(
1605+ scn.remote_machine.get_connecting_cmd(),
1606+ scn.service_machine.get_connecting_cmd())
1607+ elif scn.mode == "local":
1608+ msg = (
1609+ "You may hop onto the target machine by issuing "
1610+ "the following command:\n{}\n"
1611+ "Press enter to continue testing").format(
1612+ scn.local_machine.get_connecting_cmd())
1613+ print(msg)
1614+ input()
1615+ else:
1616+ logger.success("{} scenario has passed.".format(scn.name))
1617+ self.machine_provider.cleanup()
1618+ del self.machine_provider
1619+ stopTime = time.perf_counter()
1620+ timeTaken = stopTime - startTime
1621+ print('-' * 80)
1622+ total = len(self.scn_variants)
1623+ status = "Ran {} scenario{} in {:.3f}s".format(
1624+ total, total != 1 and "s" or "", timeTaken)
1625+ if self.wasSuccessful():
1626+ logger.success(status)
1627+ else:
1628+ logger.error(status)
1629+
1630+ def _run_single_scn(self, scenario_cls, mode, *releases):
1631+ pass
1632+
1633+ def wasSuccessful(self):
1634+ return self.failed is False
1635diff --git a/metabox/core/scenario.py b/metabox/core/scenario.py
1636new file mode 100644
1637index 0000000..bbb7673
1638--- /dev/null
1639+++ b/metabox/core/scenario.py
1640@@ -0,0 +1,244 @@
1641+# This file is part of Checkbox.
1642+#
1643+# Copyright 2021 Canonical Ltd.
1644+# Written by:
1645+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
1646+# Sylvain Pineau <sylvain.pineau@canonical.com>
1647+#
1648+# Checkbox is free software: you can redistribute it and/or modify
1649+# it under the terms of the GNU General Public License version 3,
1650+# as published by the Free Software Foundation.
1651+#
1652+# Checkbox is distributed in the hope that it will be useful,
1653+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1654+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1655+# GNU General Public License for more details.
1656+#
1657+# You should have received a copy of the GNU General Public License
1658+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
1659+"""
1660+This module defines the Scenario class.
1661+
1662+See Scenario class properties and the assert_* functions, as they serve as
1663+the interface to a Scenario.
1664+"""
1665+import re
1666+import time
1667+
1668+from metabox.core.actions import Start, Expect, Send, SelectTestPlan
1669+from metabox.core.aggregator import aggregator
1670+
1671+
1672+class Scenario:
1673+ """Definition of how to run a Checkbox session."""
1674+ config_override = {}
1675+ environment = {}
1676+ launcher = None
1677+ LAUNCHER_PATH = '/home/ubuntu/launcher.checkbox'
1678+
1679+ def __init_subclass__(cls, **kwargs):
1680+ super().__init_subclass__(**kwargs)
1681+ cls.name = '{}.{}'.format(cls.__module__, cls.__name__)
1682+ aggregator.add_scenario(cls)
1683+
1684+ def __init__(self, mode, *releases):
1685+ self.mode = mode
1686+ self.releases = releases
1687+ self._checks = []
1688+ self._ret_code = None
1689+ self._stdout = ''
1690+ self._stderr = ''
1691+ self._pts = None
1692+
1693+ def has_passed(self):
1694+ """Check whether all the assertions passed."""
1695+ return all(self._checks)
1696+
1697+ def run(self):
1698+ # Simple scenarios don't need to specify a START step
1699+ if not any([isinstance(s, Start) for s in self.steps]):
1700+ self.steps.insert(0, Start())
1701+ for i, step in enumerate(self.steps):
1702+ # Check how to start checkbox, interactively or not
1703+ if isinstance(step, Start):
1704+ interactive = False
1705+ # CHECK if any EXPECT/SEND command follows
1706+ # w/o a new call to START before it
1707+ for next_step in self.steps[i + 1:]:
1708+ if isinstance(next_step, Start):
1709+ break
1710+ if isinstance(next_step, (Expect, Send, SelectTestPlan)):
1711+ interactive = True
1712+ break
1713+ step.kwargs['interactive'] = interactive
1714+ try:
1715+ step(self)
1716+ except TimeoutError:
1717+ self._checks.append(False)
1718+ break
1719+ if self._pts:
1720+ self._stdout = self._pts.stdout_data_full
1721+ # Mute the PTS since we're about to stop the machine to avoid
1722+ # getting empty log trace events
1723+ self._pts.verbose = False
1724+
1725+ def _assign_outcome(self, ret_code, stdout, stderr):
1726+ """Store remnants of a machine that run the scenario."""
1727+ self._ret_code = ret_code
1728+ self._stdout = stdout
1729+ self._stderr = stderr
1730+
1731+ # TODO: add storing of what actually failed in the assert methods
1732+ def assert_printed(self, pattern):
1733+ """
1734+ Check if during Checkbox execution a line produced that matches the
1735+ pattern.
1736+ :param patter: regular expresion to check against the lines.
1737+ """
1738+ regex = re.compile(pattern)
1739+ self._checks.append(bool(regex.search(self._stdout)))
1740+
1741+ def assert_not_printed(self, pattern):
1742+ """
1743+ Check if during Checkbox execution a line did not produced that matches
1744+ the pattern.
1745+ :param patter: regular expresion to check against the lines.
1746+ """
1747+ regex = re.compile(pattern)
1748+ if self._pts:
1749+ self._checks.append(
1750+ bool(not regex.search(self._pts.stdout_data_full.decode(
1751+ 'utf-8', errors='ignore'))))
1752+ else:
1753+ self._checks.append(bool(not regex.search(self._stdout)))
1754+
1755+ def assert_ret_code(self, code):
1756+ """Check if Checkbox returned given code."""
1757+ self._checks.append(code == self._ret_code)
1758+
1759+ def assertIn(self, member, container):
1760+ self._checks.append(member in container)
1761+
1762+ def assertEqual(self, first, second):
1763+ self._checks.append(first == second)
1764+
1765+ def assertNotEqual(self, first, second):
1766+ self._checks.append(first != second)
1767+
1768+ def start(self, cmd='', interactive=False, timeout=0):
1769+ if self.mode == 'remote':
1770+ outcome = self.start_all(interactive=interactive, timeout=timeout)
1771+ if interactive:
1772+ self._pts = outcome
1773+ else:
1774+ self._assign_outcome(*outcome)
1775+ else:
1776+ if self.launcher:
1777+ cmd = self.LAUNCHER_PATH
1778+ outcome = self.local_machine.start(
1779+ cmd=cmd, env=self.environment,
1780+ interactive=interactive, timeout=timeout)
1781+ if interactive:
1782+ self._pts = outcome
1783+ else:
1784+ self._assign_outcome(*outcome)
1785+
1786+ def start_all(self, interactive=False, timeout=0):
1787+ self.start_service()
1788+ outcome = self.start_remote(interactive, timeout)
1789+ if interactive:
1790+ self._pts = outcome
1791+ else:
1792+ self._assign_outcome(*outcome)
1793+ return outcome
1794+
1795+ def start_remote(self, interactive=False, timeout=0):
1796+ outcome = self.remote_machine.start_remote(
1797+ self.service_machine.address, self.LAUNCHER_PATH, interactive,
1798+ timeout=timeout)
1799+ if interactive:
1800+ self._pts = outcome
1801+ else:
1802+ self._assign_outcome(*outcome)
1803+ return outcome
1804+
1805+ def start_service(self, force=False):
1806+ return self.service_machine.start_service(force)
1807+
1808+ def expect(self, data, timeout=60):
1809+ assert(self._pts is not None)
1810+ outcome = self._pts.expect(data, timeout)
1811+ self._checks.append(outcome)
1812+
1813+ def send(self, data):
1814+ assert(self._pts is not None)
1815+ self._pts.send(data.encode('utf-8'), binary=True)
1816+
1817+ def sleep(self, secs):
1818+ time.sleep(secs)
1819+
1820+ def signal(self, signal):
1821+ assert(self._pts is not None)
1822+ self._pts.send_signal(signal)
1823+
1824+ def select_test_plan(self, testplan_id, timeout=60):
1825+ assert(self._pts is not None)
1826+ outcome = self._pts.select_test_plan(testplan_id, timeout)
1827+ self._checks.append(outcome)
1828+
1829+ def run_cmd(self, cmd, env={}, interactive=False, timeout=0, target='all'):
1830+ if self.mode == 'remote':
1831+ if target == 'remote':
1832+ self.remote_machine.run_cmd(cmd, env, interactive, timeout)
1833+ elif target == 'service':
1834+ self.service_machine.run_cmd(cmd, env, interactive, timeout)
1835+ else:
1836+ self.remote_machine.run_cmd(cmd, env, interactive, timeout)
1837+ self.service_machine.run_cmd(cmd, env, interactive, timeout)
1838+ else:
1839+ self.local_machine.run_cmd(cmd, env, interactive, timeout)
1840+
1841+ def reboot(self, timeout=0, target='all'):
1842+ if self.mode == 'remote':
1843+ if target == 'remote':
1844+ self.remote_machine.reboot(timeout)
1845+ elif target == 'service':
1846+ self.service_machine.reboot(timeout)
1847+ else:
1848+ self.remote_machine.reboot(timeout)
1849+ self.service_machine.reboot(timeout)
1850+ else:
1851+ self.local_machine.reboot(timeout)
1852+
1853+ def switch_on_networking(self, target='all'):
1854+ if self.mode == 'remote':
1855+ if target == 'remote':
1856+ self.remote_machine.switch_on_networking()
1857+ elif target == 'service':
1858+ self.service_machine.switch_on_networking()
1859+ else:
1860+ self.remote_machine.switch_on_networking()
1861+ self.service_machine.switch_on_networking()
1862+ else:
1863+ self.local_machine.switch_on_networking()
1864+
1865+ def switch_off_networking(self, target='all'):
1866+ if self.mode == 'remote':
1867+ if target == 'remote':
1868+ self.remote_machine.switch_off_networking()
1869+ elif target == 'service':
1870+ self.service_machine.switch_off_networking()
1871+ else:
1872+ self.remote_machine.switch_off_networking()
1873+ self.service_machine.switch_off_networking()
1874+ else:
1875+ self.local_machine.switch_off_networking()
1876+
1877+ def stop_service(self):
1878+ return self.service_machine.stop_service()
1879+
1880+ def reboot_service(self):
1881+ return self.service_machine.reboot_service()
1882+
1883+ def is_service_active(self):
1884+ return self.service_machine.is_service_active()
1885diff --git a/metabox/core/utils.py b/metabox/core/utils.py
1886new file mode 100644
1887index 0000000..ed8c1b0
1888--- /dev/null
1889+++ b/metabox/core/utils.py
1890@@ -0,0 +1,49 @@
1891+# This file is part of Checkbox.
1892+#
1893+# Copyright 2021 Canonical Ltd.
1894+# Written by:
1895+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
1896+# Sylvain Pineau <sylvain.pineau@canonical.com>
1897+#
1898+# Checkbox is free software: you can redistribute it and/or modify
1899+# it under the terms of the GNU General Public License version 3,
1900+# as published by the Free Software Foundation.
1901+#
1902+# Checkbox is distributed in the hope that it will be useful,
1903+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1904+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1905+# GNU General Public License for more details.
1906+#
1907+# You should have received a copy of the GNU General Public License
1908+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
1909+import re
1910+from typing import IO, NamedTuple
1911+
1912+__all__ = ('tag', 'ExecuteResult')
1913+
1914+
1915+def tag(*tags):
1916+ """Decorator to add tags to a scenario class."""
1917+ def decorator(obj):
1918+ setattr(obj, 'tags', set(tags))
1919+ return obj
1920+ return decorator
1921+
1922+
1923+class ExecuteResult(NamedTuple):
1924+
1925+ exit_code: int
1926+ stdout: IO
1927+ stderr: IO
1928+
1929+
1930+class _re:
1931+ def __init__(self, pattern, flags=0):
1932+ self._raw_pattern = pattern
1933+ self._pattern = re.compile(pattern.encode('utf-8'), flags)
1934+
1935+ def __repr__(self):
1936+ return f"Regex {self._raw_pattern}"
1937+
1938+ def search(self, data):
1939+ return bool(self._pattern.search(data))
1940diff --git a/metabox/lxd_profiles/checkbox.profile b/metabox/lxd_profiles/checkbox.profile
1941new file mode 100644
1942index 0000000..673590a
1943--- /dev/null
1944+++ b/metabox/lxd_profiles/checkbox.profile
1945@@ -0,0 +1,35 @@
1946+config:
1947+ raw.idmap: "both 1000 1000"
1948+ environment.DISPLAY: :0
1949+ user.user-data: |
1950+ #cloud-config
1951+ runcmd:
1952+ - 'sed -i "s/; enable-shm = yes/enable-shm = no/g" /etc/pulse/client.conf'
1953+ - "perl -i -p0e 's/(Unit.*?)\n\n/$1\nConditionVirtualization=!container\n\n/s' /lib/systemd/system/systemd-remount-fs.service"
1954+ - 'echo export XAUTHORITY=/run/user/1000/gdm/Xauthority | tee --append /home/ubuntu/.profile'
1955+ apt:
1956+ sources:
1957+ stable_ppa:
1958+ source: "ppa:hardware-certification/public"
1959+ packages:
1960+ - alsa-base
1961+ - gir1.2-cheese-3.0
1962+ - gir1.2-gst-plugins-base-1.0
1963+ - gir1.2-gstreamer-1.0
1964+ - gstreamer1.0-plugins-good
1965+ - gstreamer1.0-pulseaudio
1966+ - libgstreamer1.0-0
1967+ - mesa-utils
1968+ - pulseaudio
1969+ - python3-jinja2
1970+ - python3-markupsafe
1971+ - python3-padme
1972+ - python3-pip
1973+ - python3-psutil
1974+ - python3-requests-oauthlib
1975+ - python3-tqdm
1976+ - python3-urwid
1977+ - python3-xlsxwriter
1978+ - virtualenv
1979+ - x11-apps
1980+ - xvfb
1981diff --git a/metabox/lxd_profiles/lowmem.profile b/metabox/lxd_profiles/lowmem.profile
1982new file mode 100644
1983index 0000000..299f2db
1984--- /dev/null
1985+++ b/metabox/lxd_profiles/lowmem.profile
1986@@ -0,0 +1,3 @@
1987+config:
1988+ limits.cpu: "1"
1989+ limits.memory: 512MB
1990diff --git a/metabox/lxd_profiles/snap.profile b/metabox/lxd_profiles/snap.profile
1991new file mode 100644
1992index 0000000..a26370e
1993--- /dev/null
1994+++ b/metabox/lxd_profiles/snap.profile
1995@@ -0,0 +1,24 @@
1996+config:
1997+ raw.idmap: "both 1000 1000"
1998+ environment.DISPLAY: :0
1999+ user.user-data: |
2000+ #cloud-config
2001+ runcmd:
2002+ - 'sed -i "s/; enable-shm = yes/enable-shm = no/g" /etc/pulse/client.conf'
2003+ - "perl -i -p0e 's/(Unit.*?)\n\n/$1\nConditionVirtualization=!container\n\n/s' /lib/systemd/system/systemd-remount-fs.service"
2004+ - 'echo export XAUTHORITY=/run/user/1000/gdm/Xauthority | tee --append /home/ubuntu/.profile'
2005+ packages:
2006+ - alsa-base
2007+ - gir1.2-cheese-3.0
2008+ - gir1.2-gst-plugins-base-1.0
2009+ - gir1.2-gstreamer-1.0
2010+ - gstreamer1.0-plugins-good
2011+ - gstreamer1.0-pulseaudio
2012+ - libgstreamer1.0-0
2013+ - mesa-utils
2014+ - pulseaudio
2015+ - x11-apps
2016+ - xvfb
2017+ snap:
2018+ commands:
2019+ - systemctl start snapd.service
2020diff --git a/metabox/main.py b/metabox/main.py
2021index 7f6de93..44daf47 100644
2022--- a/metabox/main.py
2023+++ b/metabox/main.py
2024@@ -1,10 +1,74 @@
2025+#!/usr/bin/env python3
2026+# This file is part of Checkbox.
2027+#
2028 # Copyright 2021 Canonical Ltd.
2029 # Written by:
2030 # Maciej Kisielewski <maciej.kisielewski@canonical.com>
2031 # Sylvain Pineau <sylvain.pineau@canonical.com>
2032+#
2033+# Checkbox is free software: you can redistribute it and/or modify
2034+# it under the terms of the GNU General Public License version 3,
2035+# as published by the Free Software Foundation.
2036+#
2037+# Checkbox is distributed in the hope that it will be useful,
2038+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2039+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2040+# GNU General Public License for more details.
2041+#
2042+# You should have received a copy of the GNU General Public License
2043+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2044 """
2045 Entry point to the Metabox program.
2046 """
2047+import argparse
2048+import warnings
2049+from pathlib import Path
2050+
2051+from loguru._logger import Core
2052+from metabox.core.runner import Runner
2053+
2054
2055 def main():
2056- pass
2057+ """Entry point to Metabox."""
2058+
2059+ parser = argparse.ArgumentParser()
2060+ parser.add_argument(
2061+ 'config', metavar='CONFIG', type=Path,
2062+ help='Metabox configuration file'
2063+ )
2064+ parser.add_argument(
2065+ '--tag', action='append', dest='tags',
2066+ help='Run only scenario with the specified tag. '
2067+ 'Can be used multiple times.',
2068+ )
2069+ parser.add_argument(
2070+ '--exclude-tag', action='append', dest='exclude_tags',
2071+ help='Do not run scenario with the specified tag. '
2072+ 'Can be used multiple times.',
2073+ )
2074+ parser.add_argument(
2075+ "--log", dest="log_level", choices=Core().levels.keys(),
2076+ default='SUCCESS',
2077+ help="Set the logging level",
2078+ )
2079+ parser.add_argument(
2080+ '--do-not-dispose', action='store_true',
2081+ help="Do not delete LXD containers after the run")
2082+ parser.add_argument(
2083+ '--hold-on-fail', action='store_true',
2084+ help="Pause testing when a scenario fails")
2085+ parser.add_argument(
2086+ '--debug-machine-setup', action='store_true',
2087+ help="Turn on verbosity during machine setup. "
2088+ "Only works with --log TRACE")
2089+ # Ignore warnings issued by pylxd/models/operation.py
2090+ with warnings.catch_warnings():
2091+ warnings.simplefilter("ignore")
2092+ runner = Runner(parser.parse_args())
2093+ runner.setup()
2094+ runner.run()
2095+ raise SystemExit(not runner.wasSuccessful())
2096+
2097+
2098+if __name__ == '__main__':
2099+ main()
2100diff --git a/metabox/metabox-provider/manage.py b/metabox/metabox-provider/manage.py
2101new file mode 100755
2102index 0000000..7dc5255
2103--- /dev/null
2104+++ b/metabox/metabox-provider/manage.py
2105@@ -0,0 +1,9 @@
2106+#!/usr/bin/env python3
2107+from plainbox.provider_manager import setup, N_
2108+
2109+setup(
2110+ name='2021.com.canonical.certification:metabox',
2111+ version="1.0",
2112+ description=N_("The 2021.com.canonical.certification:metabox provider"),
2113+ gettext_domain="2021_com_canonical_certification_metabox",
2114+)
2115diff --git a/metabox/metabox-provider/units/basic-jobs.pxu b/metabox/metabox-provider/units/basic-jobs.pxu
2116new file mode 100644
2117index 0000000..098288a
2118--- /dev/null
2119+++ b/metabox/metabox-provider/units/basic-jobs.pxu
2120@@ -0,0 +1,224 @@
2121+id: basic-shell-passing
2122+flags: simple
2123+command: true
2124+
2125+id: basic-shell-failing
2126+flags: simple
2127+command: false
2128+
2129+id: basic-environ-printing
2130+flags: simple
2131+command: echo foo: $foo
2132+environ: foo
2133+
2134+id: stub/manual
2135+_summary: A simple manual job
2136+_description:
2137+ PURPOSE:
2138+ This test checks that the manual plugin works fine
2139+ STEPS:
2140+ 1. Add a comment
2141+ 2. Set the result as passed
2142+ VERIFICATION:
2143+ Check that in the report the result is passed and the comment is displayed
2144+plugin: manual
2145+estimated_duration: 30
2146+
2147+id: stub/split-fields/manual
2148+_summary: A simple manual job using finer description fields
2149+_purpose:
2150+ This test checks that the manual plugin works fine
2151+_steps:
2152+ 1. Add a comment
2153+ 2. Set the result as passed
2154+_verification:
2155+ Check that in the report the result is passed and the comment is displayed
2156+plugin: manual
2157+estimated_duration: 30
2158+
2159+id: stub/user-interact
2160+_summary: A simple user interaction job
2161+_description:
2162+ PURPOSE:
2163+ This test checks that the user-interact plugin works fine
2164+ STEPS:
2165+ 1. Read this description
2166+ 2. Press the test button
2167+ VERIFICATION:
2168+ Check that in the report the result is passed
2169+plugin: user-interact
2170+flags: preserve-locale
2171+command: true
2172+estimated_duration: 30
2173+
2174+id: stub/split-fields/user-interact
2175+_summary: User-interact job using finer description fields
2176+_purpose:
2177+ This is a purpose part of test description
2178+_steps:
2179+ 1. First step in the user-iteract job
2180+ 2. Second step in the user-iteract job
2181+_verification:
2182+ Verification part of test description
2183+plugin: user-interact
2184+flags: preserve-locale
2185+command: true
2186+estimated_duration: 30
2187+
2188+id: stub/user-verify
2189+_summary: A simple user verification job
2190+_description:
2191+ PURPOSE:
2192+ This test checks that the user-verify plugin works fine
2193+ STEPS:
2194+ 1. Read this description
2195+ 2. Ensure that the command has been started automatically
2196+ 3. Do not press the test button
2197+ 4. Look at the output and determine the outcome of the test
2198+ VERIFICATION:
2199+ The command should have printed "Please select 'pass'"
2200+plugin: user-verify
2201+flags: preserve-locale
2202+command: echo "Please select 'pass'"
2203+estimated_duration: 30
2204+
2205+id: stub/split-fields/user-verify
2206+_summary: User-verify job using finer description fields
2207+_purpose:
2208+ This test checks that the user-verify plugin works fine and that
2209+ description field is split properly
2210+_steps:
2211+ 1. Read this description
2212+ 2. Ensure that the command has been started automatically
2213+ 3. Do not press the test button
2214+ 4. Look at the output and determine the outcome of the test
2215+_verification:
2216+ The command should have printed "Please select 'pass'"
2217+plugin: user-verify
2218+flags: preserve-locale
2219+command: echo "Please select 'pass'"
2220+estimated_duration: 30
2221+
2222+id: stub/user-interact-verify
2223+_summary: A simple user interaction and verification job
2224+_description:
2225+ PURPOSE:
2226+ This test checks that the user-interact-verify plugin works fine
2227+ STEPS:
2228+ 1. Read this description
2229+ 2. Ensure that the command has not been started yet
2230+ 3. Press the test button
2231+ 4. Look at the output and determine the outcome of the test
2232+ VERIFICATION:
2233+ The command should have printed "Please select 'pass'"
2234+plugin: user-interact-verify
2235+flags: preserve-locale
2236+command: echo "Please select 'pass'"
2237+estimated_duration: 25
2238+
2239+id: stub/split-fields/user-interact-verify
2240+_summary: A simple user interaction and verification job using finer
2241+ description fields
2242+_purpose:
2243+ This test checks that the user-interact-verify plugin works fine
2244+_steps:
2245+ 1. Read this description
2246+ 2. Ensure that the command has not been started yet
2247+ 3. Press the test button
2248+ 4. Look at the output and determine the outcome of the test
2249+_verification:
2250+ The command should have printed "Please select 'pass'"
2251+plugin: user-interact-verify
2252+flags: preserve-locale
2253+command: echo "Please select 'pass'"
2254+estimated_duration: 25
2255+
2256+id: stub/user-interact-verify-passing
2257+_summary: A suggested-passing user-verification-interaction job
2258+_description:
2259+ PURPOSE:
2260+ This test checks that the application user interface auto-suggests 'pass'
2261+ as the outcome of a test for user-interact-verify jobs that have a command
2262+ which completes successfully.
2263+ STEPS:
2264+ 1. Read this description
2265+ 2. Ensure that the command has not been started yet
2266+ 3. Press the test button
2267+ 4. Confirm the auto-suggested value
2268+ VERIFICATION:
2269+ The auto suggested value should have been 'pass'
2270+plugin: user-interact-verify
2271+flags: preserve-locale
2272+command: true
2273+estimated_duration: 25
2274+
2275+id: stub/split-fields/user-interact-verify-passing
2276+_summary: A suggested-passing user-verification-interaction job using finer
2277+ description fields
2278+_purpose:
2279+ This test checks that the application user interface auto-suggests 'pass'
2280+ as the outcome of a test for user-interact-verify jobs that have a command
2281+ which completes successfully.
2282+_steps:
2283+ 1. Read this description
2284+ 2. Ensure that the command has not been started yet
2285+ 3. Press the test button
2286+ 4. Confirm the auto-suggested value
2287+_verification:
2288+ The auto suggested value should have been 'pass'
2289+plugin: user-interact-verify
2290+flags: preserve-locale
2291+command: true
2292+estimated_duration: 25
2293+
2294+id: stub/user-interact-verify-failing
2295+_summary: A suggested-failing user-verification-interaction job
2296+_description:
2297+ PURPOSE:
2298+ This test checks that the application user interface auto-suggests 'fail'
2299+ as the outcome of a test for user-interact-verify jobs that have a command
2300+ which completes unsuccessfully.
2301+ STEPS:
2302+ 1. Read this description
2303+ 2. Ensure that the command has not been started yet
2304+ 3. Press the test button
2305+ 4. Confirm the auto-suggested value
2306+ VERIFICATION:
2307+ The auto suggested value should have been 'fail'
2308+plugin: user-interact-verify
2309+flags: preserve-locale
2310+command: false
2311+estimated_duration: 25
2312+
2313+id: stub/split-fields/user-interact-verify-failing
2314+_summary: A suggested-failing user-verification-interaction job using finer
2315+ description fields
2316+_purpose:
2317+ This test checks that the application user interface auto-suggests 'fail'
2318+ as the outcome of a test for user-interact-verify jobs that have a command
2319+ which completes unsuccessfully.
2320+_steps:
2321+ 1. Read this description
2322+ 2. Ensure that the command has not been started yet
2323+ 3. Press the test button
2324+ 4. Confirm the auto-suggested value
2325+_verification:
2326+ The auto suggested value should have been 'fail'
2327+plugin: user-interact-verify
2328+flags: preserve-locale
2329+command: false
2330+estimated_duration: 25
2331+
2332+plugin: user-interact-verify
2333+id: graphics/glxgears
2334+command: glxgears; true
2335+_summary: Test that glxgears works
2336+_description:
2337+ PURPOSE:
2338+ This test tests the basic 3D capabilities of your video card
2339+ STEPS:
2340+ 1. Click "Test" to execute an OpenGL demo. Press ESC at any time to close.
2341+ 2. Verify that the animation is not jerky or slow.
2342+ VERIFICATION:
2343+ 1. Did the 3d animation appear?
2344+ 2. Was the animation free from slowness/jerkiness?
2345diff --git a/metabox/metabox-provider/units/basic-tps.pxu b/metabox/metabox-provider/units/basic-tps.pxu
2346new file mode 100644
2347index 0000000..6e2ef73
2348--- /dev/null
2349+++ b/metabox/metabox-provider/units/basic-tps.pxu
2350@@ -0,0 +1,39 @@
2351+unit: test plan
2352+id: basic-automated
2353+_name: Basic Automated
2354+include:
2355+ basic-shell-passing
2356+ basic-shell-failing
2357+ basic-environ-printing
2358+
2359+unit: test plan
2360+id: basic-automated-passing
2361+_name: Basic Automated Passing
2362+include:
2363+ basic-shell-passing
2364+
2365+unit: test plan
2366+id: basic-automated-failing
2367+_name: Basic Automated Failing
2368+include:
2369+ basic-shell-failing
2370+
2371+unit: test plan
2372+id: basic-manual
2373+_name: Basic Manual
2374+include:
2375+ stub/user-interact
2376+ stub/user-verify
2377+ stub/user-interact-verify
2378+
2379+unit: test plan
2380+id: display-manual
2381+_name: Display Manual
2382+include:
2383+ graphics/glxgears
2384+
2385+unit: test plan
2386+id: audio-manual
2387+_name: Audio Manual
2388+include:
2389+ com.canonical.certification::audio/playback_auto
2390diff --git a/metabox/scenarios/__init__.py b/metabox/scenarios/__init__.py
2391new file mode 100644
2392index 0000000..e69de29
2393--- /dev/null
2394+++ b/metabox/scenarios/__init__.py
2395diff --git a/metabox/scenarios/basic/__init__.py b/metabox/scenarios/basic/__init__.py
2396new file mode 100644
2397index 0000000..e69de29
2398--- /dev/null
2399+++ b/metabox/scenarios/basic/__init__.py
2400diff --git a/metabox/scenarios/basic/launcher.py b/metabox/scenarios/basic/launcher.py
2401new file mode 100644
2402index 0000000..b48c51a
2403--- /dev/null
2404+++ b/metabox/scenarios/basic/launcher.py
2405@@ -0,0 +1,100 @@
2406+# This file is part of Checkbox.
2407+#
2408+# Copyright 2021 Canonical Ltd.
2409+# Written by:
2410+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
2411+# Sylvain Pineau <sylvain.pineau@canonical.com>
2412+#
2413+# Checkbox is free software: you can redistribute it and/or modify
2414+# it under the terms of the GNU General Public License version 3,
2415+# as published by the Free Software Foundation.
2416+
2417+#
2418+# Checkbox is distributed in the hope that it will be useful,
2419+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2420+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2421+# GNU General Public License for more details.
2422+#
2423+# You should have received a copy of the GNU General Public License
2424+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2425+import textwrap
2426+
2427+import metabox.core.keys as keys
2428+from metabox.core.actions import AssertPrinted
2429+from metabox.core.actions import AssertNotPrinted
2430+from metabox.core.actions import Expect
2431+from metabox.core.actions import RunCmd
2432+from metabox.core.actions import Send
2433+from metabox.core.scenario import Scenario
2434+from metabox.core.utils import _re
2435+
2436+
2437+class Reboot(Scenario):
2438+
2439+ mode = ['remote']
2440+ launcher = textwrap.dedent("""
2441+ [launcher]
2442+ launcher_version = 1
2443+ stock_reports = text
2444+ [test plan]
2445+ unit = com.canonical.certification::power-automated
2446+ forced = yes
2447+ [test selection]
2448+ forced = yes
2449+ exclude = .*cold.*
2450+ [ui]
2451+ type = silent
2452+ """)
2453+ steps = [
2454+ AssertPrinted('Connection lost!'),
2455+ AssertPrinted('Reconnecting...'),
2456+ AssertPrinted('job passed : Warm reboot'),
2457+ ]
2458+
2459+
2460+class GlxGears(Scenario):
2461+
2462+ mode = ['local', 'remote']
2463+ launcher = textwrap.dedent("""
2464+ [launcher]
2465+ launcher_version = 1
2466+ stock_reports = text
2467+ [test plan]
2468+ unit = 2021.com.canonical.certification::display-manual
2469+ forced = yes
2470+ [test selection]
2471+ forced = yes
2472+ """)
2473+ steps = [
2474+ Expect('Pick an action'),
2475+ Send(keys.KEY_ENTER),
2476+ Expect('FPS'),
2477+ RunCmd('pkill -2 glxgears'),
2478+ Expect('Pick an action'),
2479+ Send('p' + keys.KEY_ENTER),
2480+ Expect(_re('(☑|job passed).*Test that glxgears works')),
2481+ ]
2482+
2483+
2484+class AudioPlayback(Scenario):
2485+
2486+ mode = ['local', 'remote']
2487+ launcher = textwrap.dedent("""
2488+ [launcher]
2489+ launcher_version = 1
2490+ stock_reports = text
2491+ [test plan]
2492+ unit = 2021.com.canonical.certification::audio-manual
2493+ forced = yes
2494+ [test selection]
2495+ forced = yes
2496+ """)
2497+ steps = [
2498+ Expect('Pick an action'),
2499+ Send(keys.KEY_ENTER),
2500+ Expect('Pipeline initialized, now starting playback.'),
2501+ Expect('Pick an action'),
2502+ Send('p' + keys.KEY_ENTER),
2503+ AssertNotPrinted('Connection failure: Connection refused'),
2504+ Expect(_re('(☑|job passed).*audio/playback_auto'), timeout=10),
2505+ ]
2506diff --git a/metabox/scenarios/basic/run-invocation.py b/metabox/scenarios/basic/run-invocation.py
2507new file mode 100644
2508index 0000000..e8af2ce
2509--- /dev/null
2510+++ b/metabox/scenarios/basic/run-invocation.py
2511@@ -0,0 +1,111 @@
2512+# This file is part of Checkbox.
2513+#
2514+# Copyright 2021 Canonical Ltd.
2515+# Written by:
2516+# Maciej Kisielewski <maciej.kisielewski@canonical.com>
2517+# Sylvain Pineau <sylvain.pineau@canonical.com>
2518+#
2519+# Checkbox is free software: you can redistribute it and/or modify
2520+# it under the terms of the GNU General Public License version 3,
2521+# as published by the Free Software Foundation.
2522+
2523+#
2524+# Checkbox is distributed in the hope that it will be useful,
2525+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2526+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2527+# GNU General Public License for more details.
2528+#
2529+# You should have received a copy of the GNU General Public License
2530+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2531+import metabox.core.keys as keys
2532+from metabox.core.actions import AssertPrinted
2533+from metabox.core.actions import AssertRetCode
2534+from metabox.core.actions import Expect
2535+from metabox.core.actions import Send
2536+from metabox.core.actions import SelectTestPlan
2537+from metabox.core.actions import Start
2538+from metabox.core.scenario import Scenario
2539+
2540+
2541+class RunTestplan(Scenario):
2542+
2543+ mode = ['local']
2544+ config_override = {'local': {'releases': ['bionic']}}
2545+ steps = [
2546+ Start('run 2021.com.canonical.certification::'
2547+ 'basic-automated-passing', timeout=30),
2548+ AssertRetCode(0)
2549+ ]
2550+
2551+
2552+class RunFailingTestplan(Scenario):
2553+
2554+ mode = ['local']
2555+ steps = [
2556+ Start('run 2021.com.canonical.certification::'
2557+ 'basic-automated-failing', timeout=30),
2558+ AssertRetCode(0)
2559+ ]
2560+
2561+
2562+class RunTestplanWithEnvvar(Scenario):
2563+
2564+ mode = ['local']
2565+ environment = {'foo': 42}
2566+ steps = [
2567+ Start('run 2021.com.canonical.certification::basic-automated',
2568+ timeout=30),
2569+ AssertPrinted("foo: 42"),
2570+ ]
2571+
2572+
2573+class RunTestplanWithTimeout(Scenario):
2574+
2575+ mode = ['local']
2576+ steps = [
2577+ Start('run 2021.com.canonical.certification::'
2578+ 'basic-automated-passing', timeout='0.1s'),
2579+ AssertRetCode(137)
2580+ ]
2581+
2582+
2583+class RunManualplan(Scenario):
2584+
2585+ mode = ['local']
2586+ steps = [
2587+ Start('run 2021.com.canonical.certification::basic-manual'),
2588+ Expect('Pick an action'),
2589+ Send(keys.KEY_ENTER),
2590+ Expect('Pick an action', timeout=30),
2591+ Send('p' + keys.KEY_ENTER),
2592+ Expect('Pick an action'),
2593+ Send(keys.KEY_ENTER),
2594+ Expect('Pick an action'),
2595+ Send('p' + keys.KEY_ENTER),
2596+ Expect(' ☑ : '
2597+ 'A simple user interaction and verification job'),
2598+ ]
2599+
2600+
2601+class UrwidTestPlanSelection(Scenario):
2602+
2603+ mode = ['local']
2604+ steps = [
2605+ Expect('Select test plan'),
2606+ SelectTestPlan('com.canonical.certification::stress-pm-graph'),
2607+ SelectTestPlan(
2608+ 'com.canonical.certification::'
2609+ 'after-suspend-graphics-discrete-gpu-cert-automated'),
2610+ SelectTestPlan('com.canonical.certification::client-cert-18-04'),
2611+ Send(keys.KEY_ENTER),
2612+ Expect('Choose tests to run on your system:'),
2613+ Send('d' + keys.KEY_ENTER),
2614+ Expect('Choose tests to run on your system:'),
2615+ Send(keys.KEY_DOWN * 18 + keys.KEY_SPACE + 't'),
2616+ Expect('System Manifest:'),
2617+ Send('y' * 9 + 't'),
2618+ Expect('Pick an action'),
2619+ Send('s' + keys.KEY_ENTER),
2620+ Expect('Finish'),
2621+ Send('f' + keys.KEY_ENTER),
2622+ ]
2623diff --git a/setup.py b/setup.py
2624index 95250eb..db6be37 100755
2625--- a/setup.py
2626+++ b/setup.py
2627@@ -1,22 +1,37 @@
2628 #!/usr/bin/env python3
2629+# This file is part of Checkbox.
2630+#
2631 # Copyright 2021 Canonical Ltd.
2632 # Written by:
2633 # Maciej Kisielewski <maciej.kisielewski@canonical.com>
2634+# Sylvain Pineau <sylvain.pineau@canonical.com>
2635+#
2636+# Checkbox is free software: you can redistribute it and/or modify
2637+# it under the terms of the GNU General Public License version 3,
2638+# as published by the Free Software Foundation.
2639+#
2640+# Checkbox is distributed in the hope that it will be useful,
2641+# but WITHOUT ANY WARRANTY; without even the implied warranty of
2642+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2643+# GNU General Public License for more details.
2644+#
2645+# You should have received a copy of the GNU General Public License
2646+# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
2647
2648 from setuptools import find_packages, setup
2649
2650 setup(
2651 name='metabox',
2652- version='0.1',
2653+ version='0.3',
2654 packages=find_packages(),
2655- install_requires=['pylxd'],
2656+ install_requires=['loguru', 'pylxd'],
2657 entry_points={
2658 'console_scripts': [
2659 'metabox = metabox.main:main',
2660 ]
2661 },
2662- include_package_data = True,
2663- package_data = {
2664+ include_package_data=True,
2665+ package_data={
2666 '': ['metabox/metabox-provider/*'],
2667 }
2668 )

Subscribers

People subscribed via source and target branches