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

Proposed by Sylvain Pineau
Status: Merged
Approved by: Sylvain Pineau
Approved revision: eef9b23a625e4b2028ba23a4e0b7c819d4a25507
Merged at revision: dcca97f373be96584de91512bd64c26453d704f1
Proposed branch: ~sylvain-pineau/checkbox/+git/metabox:configs
Merge into: ~checkbox-dev/checkbox/+git/metabox:main
Diff against target: 2750 lines (+2538/-5)
31 files modified
.gitignore (+6/-0)
MANIFEST.in (+2/-0)
configs/checkbox-core-snap-classic-beta-config.py (+20/-0)
configs/dev-ppa-config.py (+17/-0)
configs/testing-ppa-config.py (+17/-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 (+144/-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/run-invocation.py (+86/-0)
metabox/scenarios/desktop_env/launcher.py (+76/-0)
metabox/scenarios/restart/launcher.py (+46/-0)
metabox/scenarios/urwid/run-invocation.py (+111/-0)
setup.py (+19/-4)
Reviewer Review Type Date Requested Status
Maciej Kisielewski Approve
Sylvain Pineau (community) Needs Resubmitting
Review via email: mp+400333@code.launchpad.net

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.
Revision history for this message
Maciej Kisielewski (kissiel) wrote :

2.5kLOC dump is difficult to review. This is why I started with just configs/cli opts. Do you have a version with a better split? Like a more granular git history or something.

Also one quick comment inline.

review: Needs Information
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

I can drop comments in the config.py indeed or even prepare some production files, one for ppa, one for checkbox-core snaps

Here's the unsquashed version of the history: https://code.launchpad.net/~sylvain-pineau/checkbox/+git/metabox/+merge/400341 (not super clean though)

Revision history for this message
Maciej Kisielewski (kissiel) wrote :

Blocking problems:
I'd like to ask for a cleanup of physical structure of scenarios.
As in reboot is not basic, neither is audio/opengl stuff.
The configs cleanup.

./setup test fails, no dependency on yaml declared.

Non-blocking issues (Don't have to be resolved at this time):

It'd be nicer if yaml files had a '.yaml' suffix.

In the scenarios I'd not match against fancy unicode (checkmark). Does remote even print that glyph?

In the steps:
- Send is a misnomer. Send signal? Send file? It's for typing keys, so TBH, it should just be PushKeys, Type, or soemthing that's close to reality.
For the keys themselves, I think having to use keys.KEY_SPACE for ' ' is bad. The spaces from the string should get auto translated into that. Same goes for enter (\n or \r).

I still think that no audio or rendering should happen. It's just against test design principles. If checkbox is responsible for forwarding some environment info that's needed for audio tests then we should test for those variables, not variables _and_ actual hardware. This frees up dependencies, time, etc.

I still think that assertions are not actions. Assertions should be checked against completed scenario. Like assertion on output or a report. The action is expect, where indeed there is a sequence of things that happen.

The setup commands run in the provider are worse than what we originally had. Less readable, with more escaping and bashisms.

Encoding re patterns to bytes and running searches on bytes is backwards. The output that's being searched should be decoded using stdout's encoding. Instead of hardcoding the pattern encoding.

review: Needs Fixing
Revision history for this message
Sylvain Pineau (sylvain-pineau) wrote :

Fixed blocking issues, scn structure is not perfect though.

review: Needs Resubmitting
Revision history for this message
Maciej Kisielewski (kissiel) wrote :

We'll improve as we go.

review: Approve

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

Subscribers

People subscribed via source and target branches