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 (community) 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
diff --git a/.gitignore b/.gitignore
0new file mode 1006440new file mode 100644
index 0000000..8d4988b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
1__pycache__/
2metabox.egg-info/
3venv
4build
5dist
6.tox
diff --git a/MANIFEST.in b/MANIFEST.in
0new file mode 1006447new file mode 100644
index 0000000..4511d8e
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
1recursive-include metabox/metabox-provider/ *
2recursive-include metabox/lxd_profiles/ *
diff --git a/configs/checkbox-core-snap-classic-beta-config.py b/configs/checkbox-core-snap-classic-beta-config.py
0new file mode 1006443new file mode 100644
index 0000000..5ffb5da
--- /dev/null
+++ b/configs/checkbox-core-snap-classic-beta-config.py
@@ -0,0 +1,20 @@
1configuration = {
2 'local': {
3 'origin': 'classic_snap',
4 'checkbox_core_snap': {'risk': 'beta'},
5 'checkbox_snap': {'risk': 'stable'},
6 'releases': ['bionic', 'focal'],
7 },
8 'remote': {
9 'origin': 'classic_snap',
10 'checkbox_core_snap': {'risk': 'beta'},
11 'checkbox_snap': {'risk': 'stable'},
12 'releases': ['focal'],
13 },
14 'service': {
15 'origin': 'classic_snap',
16 'checkbox_core_snap': {'risk': 'beta'},
17 'checkbox_snap': {'risk': 'stable'},
18 'releases': ['bionic', 'focal'],
19 },
20}
diff --git a/configs/dev-ppa-config.py b/configs/dev-ppa-config.py
0new file mode 10064421new file mode 100644
index 0000000..a49d96a
--- /dev/null
+++ b/configs/dev-ppa-config.py
@@ -0,0 +1,17 @@
1configuration = {
2 'local': {
3 'origin': 'ppa',
4 'uri': 'ppa:checkbox-dev/ppa',
5 'releases': ['bionic', 'focal'],
6 },
7 'remote': {
8 'origin': 'ppa',
9 'uri': 'ppa:checkbox-dev/ppa',
10 'releases': ['focal'],
11 },
12 'service': {
13 'origin': 'ppa',
14 'uri': 'ppa:checkbox-dev/ppa',
15 'releases': ['bionic', 'focal'],
16 },
17}
diff --git a/configs/testing-ppa-config.py b/configs/testing-ppa-config.py
0new file mode 10064418new file mode 100644
index 0000000..2550937
--- /dev/null
+++ b/configs/testing-ppa-config.py
@@ -0,0 +1,17 @@
1configuration = {
2 'local': {
3 'origin': 'ppa',
4 'uri': 'ppa:checkbox-dev/testing',
5 'releases': ['bionic', 'focal'],
6 },
7 'remote': {
8 'origin': 'ppa',
9 'uri': 'ppa:checkbox-dev/testing',
10 'releases': ['focal'],
11 },
12 'service': {
13 'origin': 'ppa',
14 'uri': 'ppa:checkbox-dev/testing',
15 'releases': ['bionic', 'focal'],
16 },
17}
diff --git a/metabox/__init__.py b/metabox/__init__.py
0new file mode 10064418new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/metabox/__init__.py
diff --git a/metabox/core/__init__.py b/metabox/core/__init__.py
1new file mode 10064419new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/metabox/core/__init__.py
diff --git a/metabox/core/actions.py b/metabox/core/actions.py
2new file mode 10064420new file mode 100644
index 0000000..c4f57b1
--- /dev/null
+++ b/metabox/core/actions.py
@@ -0,0 +1,97 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19"""
20This module defines the Actions classes.
21
22"""
23
24__all__ = [
25 "Start", "Expect", "Send", "SelectTestPlan",
26 "AssertPrinted", "AssertNotPrinted", "AssertRetCode",
27 "AssertServiceActive", "Sleep", "RunCmd", "Signal", "Reboot",
28 "NetUp", "NetDown",
29]
30
31
32class ActionBase:
33 handler = None
34
35 def __init__(self, *args, **kwargs):
36 self.args = args
37 self.kwargs = kwargs
38
39 def __call__(self, scn):
40 assert(self.handler is not None)
41 getattr(scn, self.handler)(*self.args, **self.kwargs)
42
43
44class Start(ActionBase):
45 handler = 'start'
46
47
48class Expect(ActionBase):
49 handler = 'expect'
50
51
52class Send(ActionBase):
53 handler = 'send'
54
55
56class SelectTestPlan(ActionBase):
57 handler = 'select_test_plan'
58
59
60class AssertPrinted(ActionBase):
61 handler = 'assert_printed'
62
63
64class AssertNotPrinted(ActionBase):
65 handler = 'assert_not_printed'
66
67
68class AssertRetCode(ActionBase):
69 handler = 'assert_ret_code'
70
71
72class AssertServiceActive(ActionBase):
73 handler = 'is_service_active'
74
75
76class Sleep(ActionBase):
77 handler = 'sleep'
78
79
80class RunCmd(ActionBase):
81 handler = 'run_cmd'
82
83
84class Signal(ActionBase):
85 handler = 'signal'
86
87
88class Reboot(ActionBase):
89 handler = 'reboot'
90
91
92class NetUp(ActionBase):
93 handler = 'switch_on_networking'
94
95
96class NetDown(ActionBase):
97 handler = 'switch_off_networking'
diff --git a/metabox/core/aggregator.py b/metabox/core/aggregator.py
0new file mode 10064498new file mode 100644
index 0000000..c8886d1
--- /dev/null
+++ b/metabox/core/aggregator.py
@@ -0,0 +1,50 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19"""
20This module implements a mechanism to auto-register metabox scenarios.
21"""
22
23import pkgutil
24
25import metabox.scenarios
26from loguru import logger
27
28
29class _ScenarioAggregator:
30 def __init__(self):
31 self._scenarios = []
32
33 def add_scenario(self, scenario_cls):
34 """Add a scenario to the collection."""
35 logger.debug("Registering a scenario: {}", scenario_cls.name)
36 self._scenarios.append(scenario_cls)
37
38 @staticmethod
39 def load_all():
40 """Import all modules so the scenarios can be auto-loaded."""
41 path = metabox.scenarios.__path__
42 for loader, name, _ in pkgutil.walk_packages(path):
43 loader.find_module(name).load_module(name)
44
45 def all_scenarios(self):
46 """Return all available scenarios."""
47 return self._scenarios
48
49
50aggregator = _ScenarioAggregator()
diff --git a/metabox/core/configuration.py b/metabox/core/configuration.py
0new file mode 10064451new file mode 100644
index 0000000..1ba8a55
--- /dev/null
+++ b/metabox/core/configuration.py
@@ -0,0 +1,144 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19"""
20This module implements functions necessary to load metabox configs.
21"""
22import importlib.util
23import subprocess
24from pathlib import Path
25
26from loguru import logger
27
28
29def read_config(filename):
30 """
31 Parse a config file from the path `filename` and yield
32 a configuration object or raise SystemExit on problems.
33 """
34
35 try:
36 mod_spec = importlib.util.spec_from_file_location(
37 'config_file', filename)
38 module = importlib.util.module_from_spec(mod_spec)
39 mod_spec.loader.exec_module(module)
40 config = module.configuration
41 return config
42 except (AttributeError, SyntaxError, FileNotFoundError) as exc:
43 logger.critical(exc)
44 raise SystemExit()
45
46
47def validate_config(config):
48 """
49 Run a sanity check validation of the config.
50 Raises SystemExit when a problem is found.
51 """
52 if not _has_local_or_remote_declaration(config):
53 logger.critical(
54 "Configuration has to define at least one way of running checkbox."
55 "Define 'local' or 'service' and 'remote'.")
56 raise SystemExit()
57 for kind in config:
58 if kind not in ('local', 'service', 'remote'):
59 logger.critical(
60 "Configuration has to define at least one way "
61 "of running checkbox."
62 "Define 'local' or 'service' and 'remote'.")
63 raise SystemExit()
64 for decl in config[kind]:
65 if not _decl_has_a_valid_origin(config[kind]):
66 logger.critical(
67 "Missing or invalid origin for the {} "
68 "declaration in config!", kind)
69 raise SystemExit()
70
71
72def _has_local_or_remote_declaration(config):
73 """
74 >>> config = {'local': 'something'}
75 >>> _has_local_or_remote_declaration(config)
76 True
77 >>> config = {'local': ['something_else']}
78 >>> _has_local_or_remote_declaration(config)
79 True
80 >>> config = {'local': []}
81 >>> _has_local_or_remote_declaration(config)
82 False
83 >>> config = {'service': 'something'}
84 >>> _has_local_or_remote_declaration(config)
85 False
86 >>> config = {'remote': 'something'}
87 >>> _has_local_or_remote_declaration(config)
88 False
89 >>> config = {'remote': 'something', 'service': 'somethig_else'}
90 >>> _has_local_or_remote_declaration(config)
91 True
92 """
93
94 return bool(config.get('local') or (
95 config.get('service') and config.get('remote')))
96
97
98def _decl_has_a_valid_origin(decl):
99 """
100 >>> decl = {'origin': 'ppa'}
101 >>> _decl_has_a_valid_origin(decl)
102 True
103 >>> decl = {'origin': 'source'}
104 >>> _decl_has_a_valid_origin(decl)
105 True
106 >>> decl = {'origin': 'snap'}
107 >>> _decl_has_a_valid_origin(decl)
108 True
109 >>> decl = {'origin': 'flatpak'}
110 >>> _decl_has_a_valid_origin(decl)
111 False
112 >>> decl = {}
113 >>> _decl_has_a_valid_origin(decl)
114 False
115 """
116 if 'origin' not in decl:
117 return False
118 if decl['origin'] == 'snap':
119 return True
120 elif decl['origin'] == 'classic_snap':
121 return True
122 elif decl['origin'] == 'ppa':
123 return True
124 elif decl['origin'] == 'source':
125 source = Path(decl['uri']).expanduser()
126 if not source.is_dir():
127 logger.error("{} doesn't look like a directory", source)
128 return False
129 setup_file = source / 'setup.py'
130 if not setup_file.exists():
131 logger.error("{} not found", setup_file)
132 return False
133 try:
134 package_name = subprocess.check_output(
135 [setup_file, '--name'],
136 stderr=subprocess.DEVNULL).decode('utf-8').rstrip()
137 except subprocess.CalledProcessError:
138 logger.error("{} --name failed", setup_file)
139 return False
140 if not package_name == 'checkbox-ng':
141 logger.error("{} must be a lp:checkbox-ng fork", source)
142 return False
143 return True
144 return False
diff --git a/metabox/core/keys.py b/metabox/core/keys.py
0new file mode 100644145new file mode 100644
index 0000000..e2cbc6f
--- /dev/null
+++ b/metabox/core/keys.py
@@ -0,0 +1,36 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19"""Key constants and utilities for Scenarios."""
20
21import signal
22
23KEY_ENTER = "\n"
24KEY_UP = "\x1b[A"
25KEY_DOWN = "\x1b[B"
26KEY_RIGHT = "\x1b[C"
27KEY_LEFT = "\x1b[D"
28KEY_PAGEUP = "\x1b[5~"
29KEY_PAGEDOWN = "\x1b[6~"
30KEY_HOME = "\x1b[H"
31KEY_END = "\x1b[F"
32KEY_SPACE = "\x20"
33KEY_ESCAPE = "\x1b"
34
35SIGINT = signal.SIGINT.value
36SIGKILL = signal.SIGKILL.value
diff --git a/metabox/core/lxd_execute.py b/metabox/core/lxd_execute.py
0new file mode 10064437new file mode 100644
index 0000000..1dbb0d7
--- /dev/null
+++ b/metabox/core/lxd_execute.py
@@ -0,0 +1,236 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19import json
20import re
21import shlex
22import threading
23import time
24
25from loguru import logger
26import metabox.core.keys as keys
27from metabox.core.utils import ExecuteResult
28from ws4py.client.threadedclient import WebSocketClient
29
30
31base_env = {
32 'PYTHONUNBUFFERED': '1',
33 'DISABLE_URWID_ESCAPE_CODES': '1',
34 'XDG_RUNTIME_DIR': '/run/user/1000'
35}
36login_shell = ['sudo', '--user', 'ubuntu', '--login']
37
38
39class InteractiveWebsocket(WebSocketClient):
40
41 # https://stackoverflow.com/a/14693789/1154487
42 ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
43
44 def __init__(self, *args, **kwargs):
45 super().__init__(*args, **kwargs)
46 self.stdout_data = bytearray()
47 self.stdout_data_full = bytearray()
48 self.stdout_lock = threading.Lock()
49 self._new_data = False
50 self._lookup_by_id = False
51
52 def received_message(self, message):
53 if len(message.data) == 0:
54 self.close()
55 if self.verbose:
56 raw_msg = self.ansi_escape.sub(
57 '', message.data.decode('utf-8', errors='ignore'))
58 logger.trace(raw_msg.rstrip())
59 with self.stdout_lock:
60 self.stdout_data += message.data
61 self.stdout_data_full += message.data
62 self._new_data = True
63
64 def expect(self, data, timeout=0):
65 not_found = True
66 start_time = time.time()
67 while not_found:
68 time.sleep(0.1)
69 if type(data) != str:
70 check = data.search(self.stdout_data)
71 else:
72 check = data.encode('utf-8') in self.stdout_data
73 if check:
74 with self.stdout_lock:
75 self.stdout_data = bytearray()
76 not_found = False
77 if timeout and time.time() > start_time + timeout:
78 logger.warning(
79 "{} not found! Timeout is reached (set to {})",
80 data, timeout)
81 raise TimeoutError
82 return not_found is False
83
84 def select_test_plan(self, data, timeout=0):
85 if not self._lookup_by_id:
86 self.send(('i' + keys.KEY_HOME).encode('utf-8'), binary=True)
87 self._lookup_by_id = True
88 else:
89 self.send(keys.KEY_HOME.encode('utf-8'), binary=True)
90 not_found = True
91 max_attemps = 10
92 attempt = 0
93 still_on_first_screen = True
94 old_stdout_data = b''
95 if len(data) > 67:
96 data = data[:67] + ' │\r\n│ ' + data[67:]
97 while attempt < max_attemps:
98 if self._new_data and self.stdout_data:
99 if old_stdout_data == self.stdout_data:
100 break
101 check = data.encode('utf-8') in self.stdout_data
102 if not check:
103 self._new_data = False
104 with self.stdout_lock:
105 old_stdout_data = self.stdout_data
106 self.stdout_data = bytearray()
107 stdin_payload = keys.KEY_PAGEDOWN + keys.KEY_SPACE
108 self.send(stdin_payload.encode('utf-8'), binary=True)
109 still_on_first_screen = False
110 attempt = 0
111 else:
112 not_found = False
113 break
114 else:
115 time.sleep(0.1)
116 attempt += 1
117 if not_found:
118 return False
119 data = '(X) ' + data
120 attempt = 0
121 if still_on_first_screen:
122 self.send(keys.KEY_PAGEDOWN.encode('utf-8'), binary=True)
123 while attempt < max_attemps:
124 if self._new_data and self.stdout_data:
125 check = data.encode('utf-8') in self.stdout_data
126 if not check:
127 self._new_data = False
128 with self.stdout_lock:
129 self.stdout_data = bytearray()
130 stdin_payload = keys.KEY_UP + keys.KEY_SPACE
131 self.send(stdin_payload.encode('utf-8'), binary=True)
132 attempt = 0
133 else:
134 not_found = False
135 with self.stdout_lock:
136 self.stdout_data = bytearray()
137 break
138 else:
139 time.sleep(0.1)
140 attempt += 1
141 return not_found is False
142
143 def send_signal(self, signal):
144 self.ctl.send(json.dumps({'command': 'signal', 'signal': signal}))
145
146 @property
147 def container(self):
148 return self._container
149
150 @container.setter
151 def container(self, container):
152 self._container = container
153
154 @property
155 def ctl(self):
156 return self._ctl
157
158 @ctl.setter
159 def ctl(self, ctl):
160 self._ctl = ctl
161
162 @property
163 def verbose(self):
164 return self._verbose
165
166 @verbose.setter
167 def verbose(self, verbose):
168 self._verbose = verbose
169
170
171def env_wrapper(env):
172 env_cmd = ['env']
173 env.update(base_env)
174 env_cmd += [
175 "{key}={value}".format(key=key, value=value)
176 for key, value in sorted(env.items())]
177 return env_cmd
178
179
180def timeout_wrapper(timeout):
181 if timeout:
182 return ['timeout', '--signal=KILL', str(timeout)]
183 else:
184 return []
185
186
187def interactive_execute(container, cmd, env={}, verbose=False, timeout=0):
188 if verbose:
189 logger.trace(cmd)
190 ws_urls = container.raw_interactive_execute(
191 login_shell + env_wrapper(env) + shlex.split(cmd))
192 base_websocket_url = container.client.websocket_url
193 ctl = WebSocketClient(base_websocket_url)
194 ctl.resource = ws_urls['control']
195 ctl.connect()
196 pts = InteractiveWebsocket(base_websocket_url)
197 pts.resource = ws_urls['ws']
198 pts.verbose = verbose
199 pts.container = container
200 pts.ctl = ctl
201 pts.connect()
202 return pts
203
204
205def run_or_raise(container, cmd, env={}, verbose=False, timeout=0):
206 stdout_data = ''
207 stderr_data = ''
208
209 def on_stdout(msg):
210 nonlocal stdout_data
211 stdout_data += msg
212 logger.trace(msg.rstrip())
213
214 def on_stderr(msg):
215 nonlocal stderr_data
216 stderr_data += msg
217 logger.trace(msg.rstrip())
218
219 if verbose:
220 logger.trace(cmd)
221 res = container.execute(
222 login_shell + env_wrapper(env) + timeout_wrapper(timeout)
223 + shlex.split(cmd), # noqa 503
224 stdout_handler=on_stdout if verbose else None,
225 stderr_handler=on_stderr if verbose else None,
226 stdin_payload=open(__file__))
227 if timeout and res.exit_code == 137:
228 logger.warning("{} Timeout is reached (set to {})", cmd, timeout)
229 raise TimeoutError
230 elif res.exit_code:
231 msg = "Failed to run command in the container! Command: \n"
232 msg += cmd + ' ' + res.stderr
233 # raise SystemExit(msg)
234 if verbose:
235 return (ExecuteResult(res.exit_code, stdout_data, stderr_data))
236 return res
diff --git a/metabox/core/lxd_provider.py b/metabox/core/lxd_provider.py
0new file mode 100644237new file mode 100644
index 0000000..df70b8a
--- /dev/null
+++ b/metabox/core/lxd_provider.py
@@ -0,0 +1,229 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19
20"""
21lxd_provider
22============
23
24This module implements the LXD Machine and LXD Machine Provider.
25LXD machines are containers that can run metabox scenarios in them.
26"""
27import json
28import os
29import time
30import yaml
31from pathlib import Path
32
33import pkg_resources
34import pylxd
35from loguru import logger
36from pylxd.exceptions import ClientConnectionFailed, LXDAPIException
37
38from metabox.core.machine import MachineConfig
39from metabox.core.machine import machine_selector
40from metabox.core.lxd_execute import run_or_raise
41
42
43class LxdMachineProvider():
44 """Machine provider that uses container managed by LXD as targets."""
45
46 LXD_CREATE_TIMEOUT = 120
47 LXD_POLL_INTERVAL = 5
48 LXD_INTERNAL_CONFIG_PATH = '/var/tmp/machine_config.json'
49
50 def __init__(self, session_config, effective_machine_config,
51 debug_machine_setup=False, dispose=False):
52 self._session_config = session_config
53 self._machine_config = effective_machine_config
54 self._debug_machine_setup = debug_machine_setup
55 self._owned_containers = []
56 self._dispose = dispose
57
58 # TODO: maybe add handlers for more complicated client connections
59 # like a remote LXD host and/or authenticated access
60 try:
61 # TODO: Find a suitable timeout value here
62 self.client = pylxd.Client(timeout=None)
63 except ClientConnectionFailed as exc:
64 raise SystemExit from exc
65
66 def setup(self):
67 self._create_profiles()
68 self._get_existing_machines()
69 for config in self._machine_config:
70 if config in [oc.config for oc in self._owned_containers]:
71 continue
72 self._create_machine(config)
73
74 def _get_existing_machines(self):
75 for container in self.client.containers.all():
76 try:
77 content = container.files.get(self.LXD_INTERNAL_CONFIG_PATH)
78 config_dict = json.loads(content)
79 config = MachineConfig(config_dict['role'], config_dict)
80 except (KeyError, LXDAPIException):
81 continue
82 if config in self._machine_config:
83 self._owned_containers.append(
84 machine_selector(config, container))
85
86 def _create_profiles(self):
87 profiles_path = pkg_resources.resource_filename(
88 'metabox', 'lxd_profiles')
89 for profile_file in os.listdir(profiles_path):
90 profile_name = Path(profile_file).stem
91 with open(os.path.join(profiles_path, profile_file)) as f:
92 profile_dict = yaml.load(f, Loader=yaml.FullLoader)
93 if self.client.profiles.exists(profile_name):
94 profile = self.client.profiles.get(profile_name)
95 if 'config' in profile_dict:
96 profile.config = profile_dict['config']
97 if 'devices' in profile_dict:
98 profile.devices = profile_dict['devices']
99 profile.save()
100 logger.debug(
101 '{} LXD profile updated successfully', profile_name)
102 else:
103 profile = self.client.profiles.create(
104 profile_name,
105 config=profile_dict.get('config', None),
106 devices=profile_dict.get('devices', None))
107 logger.debug(
108 '{} LXD profile created successfully', profile_name)
109
110 def _create_machine(self, config):
111 name = 'metabox-{}'.format(config)
112 base_profiles = ["default", "checkbox"]
113 if config.origin == 'snap':
114 base_profiles.append('snap')
115 lxd_config = {
116 "name": name,
117 "profiles": base_profiles + config.profiles,
118 "source": {
119 "type": "image",
120 "alias": config.alias,
121 'protocol': 'simplestreams',
122 'server': 'https://cloud-images.ubuntu.com/releases'
123 }
124 }
125 try:
126 logger.opt(colors=True).debug("[<y>creating</y> ] {}", name)
127 container = self.client.containers.create(lxd_config, wait=True)
128 machine = machine_selector(config, container)
129 container.start(wait=True)
130 attempt = 0
131 max_attempt = self.LXD_CREATE_TIMEOUT / self.LXD_POLL_INTERVAL
132 while attempt < max_attempt:
133 time.sleep(self.LXD_POLL_INTERVAL)
134 (ret, out, err) = container.execute(
135 ['cloud-init', 'status', '--long'])
136 if 'status: done' in out:
137 break
138 elif ret != 0:
139 logger.error(out)
140 raise SystemExit(out)
141 attempt += 1
142 else:
143 raise SystemExit("Timeout reached (still running cloud-init)")
144 container.stop(wait=True)
145 container.snapshots.create('base', stateful=False, wait=True)
146 container.start(wait=True)
147 logger.opt(colors=True).debug(
148 "[<y>created</y> ] {}", container.name)
149 logger.opt(colors=True).debug(
150 "[<y>provisioning</y>] {}", container.name)
151 self._run_transfer_commands(machine)
152 time.sleep(5) # FIXME: is it still needed?
153 self._run_setup_commands(machine)
154 self._store_config(machine)
155 container.stop(wait=True)
156 container.snapshots.create(
157 'provisioned', stateful=False, wait=True)
158 container.start(wait=True)
159 self._owned_containers.append(machine)
160 logger.opt(colors=True).debug(
161 "[<y>provisioned</y> ] {}", container.name)
162
163 except LXDAPIException as exc:
164 error = self._api_exc_to_human(exc)
165 raise SystemExit(error) from exc
166
167 def _run_transfer_commands(self, machine):
168 provider_path = pkg_resources.resource_filename(
169 'metabox', 'metabox-provider')
170 metabox_dir_transfers = machine.get_early_dir_transfer() + [
171 (provider_path, '/var/tmp/checkbox-providers/metabox-provider')]
172 for src, dest in metabox_dir_transfers + machine.config.transfer:
173 run_or_raise(
174 machine._container, 'mkdir -p {}'.format(dest),
175 verbose=self._debug_machine_setup)
176 machine._container.files.recursive_put(
177 os.path.expanduser(src), dest)
178 run_or_raise(
179 machine._container,
180 'sudo chown -R ubuntu:ubuntu {}'.format(dest),
181 verbose=self._debug_machine_setup)
182 # FIXME: preserve the +x bit
183 for src, dest in machine.get_file_transfer():
184 with open(src, "rb") as f:
185 machine._container.files.put(dest, f.read())
186 # FIXME: preserve the +x bit
187
188 def _run_setup_commands(self, machine):
189 pre_cmds = machine.get_early_setup()
190 post_cmds = machine.get_late_setup()
191 for cmd in pre_cmds + machine.config.setup + post_cmds:
192 res = run_or_raise(
193 machine._container, cmd,
194 verbose=self._debug_machine_setup)
195 if res.exit_code:
196 msg = "Failed to run command in the container! Command: \n"
197 msg += cmd + '\n' + res.stderr
198 logger.critical(msg)
199 raise SystemExit()
200
201 def _store_config(self, machine):
202 machine._container.files.put(
203 self.LXD_INTERNAL_CONFIG_PATH, json.dumps(machine.config.__dict__))
204
205 def get_machine_by_config(self, config):
206 for machine in self._owned_containers:
207 if config == machine.config:
208 return machine
209
210 def cleanup(self, dispose=False):
211 """Stop and delete (on request) all the containers."""
212 for machine in self._owned_containers:
213 container = machine._container
214 if container.status == "Running":
215 container.stop(wait=True)
216 logger.opt(colors=True).debug(
217 "[<y>stopped</y> ] {}", container.name)
218 if dispose:
219 container.delete(wait=True)
220 logger.opt(colors=True).debug(
221 "[<y>deleted</y> ] {}", container.name)
222
223 def _api_exc_to_human(self, exc):
224 response = json.loads(exc.response.text)
225 # TODO: wrap in try/except and on wrong fields dump all info we have
226 return response.get('error') or response['metadata']['err']
227
228 def __del__(self):
229 self.cleanup(self._dispose)
diff --git a/metabox/core/machine.py b/metabox/core/machine.py
0new file mode 100644230new file mode 100644
index 0000000..2004097
--- /dev/null
+++ b/metabox/core/machine.py
@@ -0,0 +1,433 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19import itertools
20import signal
21import time
22from pathlib import Path
23
24from loguru import logger
25from metabox.core.lxd_execute import interactive_execute
26from metabox.core.lxd_execute import run_or_raise
27
28
29class MachineConfig:
30
31 def __init__(self, role, config):
32 self.role = role
33 self.alias = config['alias']
34 self.origin = config['origin']
35 self.uri = config.get("uri", "")
36 self.profiles = config.get("profiles", [])
37 self.transfer = config.get("transfer", [])
38 self.setup = config.get("setup", [])
39 self.checkbox_core_snap = config.get("checkbox_core_snap", {})
40 self.checkbox_snap = config.get("checkbox_snap", {})
41 self.snap_name = config.get("name", "")
42 if not self.snap_name:
43 if self.origin == 'snap':
44 self.snap_name = 'checkbox-snappy'
45 elif self.origin == 'classic_snap':
46 self.snap_name = 'checkbox-snappy-classic'
47
48 def __members(self):
49 return (self.role, self.alias, self.origin, self.snap_name, self.uri,
50 ' '.join(self.profiles),
51 ' '.join(itertools.chain(*self.transfer)),
52 ' '.join(self.setup))
53
54 def __repr__(self):
55 return "<{} alias:{!r} origin:{!r}>".format(
56 self.role, self.alias, self.origin)
57
58 def __str__(self):
59 return "{}-{}".format(self.role, self.alias)
60
61 def __hash__(self):
62 return hash(self.__members())
63
64 def __eq__(self, other):
65 if type(other) is type(self):
66 return self.__members() == other.__members()
67 else:
68 return False
69
70
71class ContainerBaseMachine():
72 """Base implementation of Machine using LXD container as the backend."""
73
74 CHECKBOX = 'checkbox-cli ' # mind the trailing space !
75
76 def __init__(self, config, container):
77 self.config = config
78 self._container = container
79 self._checkbox_wrapper = self.CHECKBOX
80
81 def execute(self, cmd, env={}, verbose=False, timeout=0):
82 return run_or_raise(
83 self._container, self._checkbox_wrapper + cmd, env, verbose,
84 timeout)
85
86 def interactive_execute(self, cmd, env={}, verbose=False, timeout=0):
87 return interactive_execute(
88 self._container, self._checkbox_wrapper + cmd, env, verbose,
89 timeout)
90
91 def rollback_to(self, savepoint):
92 if self._container.status != 'Stopped':
93 self._container.stop(wait=True)
94 self._container.restore_snapshot(savepoint, wait=True)
95 self._container.start(wait=True)
96 logger.opt(colors=True).debug(
97 "[<y>restored</y> ] {}", self._container.name)
98 attempt = 0
99 # FIXME: in case containers are restarted after reboot, sometime we hit
100 # the degraded state.
101 # So be sure to wait long enough to claim it started.
102 # https://discuss.linuxcontainers.org/t/snap-lxd-activate-service-hanging-after-system-reboot/8374/16
103 max_attempt = 60
104 old_out = ''
105 while attempt < max_attempt:
106 time.sleep(1)
107 (ret, out, err) = self._container.execute(
108 ['systemctl', 'is-system-running'])
109 if out != old_out:
110 logger.opt(colors=True).debug(
111 "[<y>{: <12}</y>] {}", out.rstrip(), self._container.name)
112 old_out = out
113 if 'running' in out:
114 break
115 elif 'degraded' in out:
116 break
117 attempt += 1
118 else:
119 raise SystemExit(
120 "Rollback to {} failed (systemd not in running state)".format(
121 savepoint))
122
123 def put(self, filepath, data, mode=None, uid=1000, gid=1000):
124 self._container.files.put(filepath, data, mode, uid, gid)
125
126 def get_connecting_cmd(self):
127 return "lxc exec {} -- sudo --user ubuntu --login".format(
128 self._container.name)
129
130 @property
131 def address(self):
132 addresses = self._container.state().network['eth0']['addresses']
133 return addresses[0]['address']
134
135 def get_early_dir_transfer(self):
136 """
137 .. note:: You should override this method in your subclass.
138 """
139 return []
140
141 def get_file_transfer(self):
142 """
143 .. note:: You should override this method in your subclass.
144 """
145 return []
146
147 def get_early_setup(self):
148 """
149 .. note:: You should override this method in your subclass.
150 """
151 return []
152
153 def get_late_setup(self):
154 """
155 .. note:: You should override this method in your subclass.
156 """
157 return []
158
159 def start_remote(self, host, launcher, interactive=False, timeout=0):
160 assert(self.config.role == 'remote')
161
162 if interactive:
163 # Return a PTS object to interact with
164 return self.interactive_execute(
165 'remote {} {}'.format(host, launcher), verbose=True,
166 timeout=timeout)
167 else:
168 # Return an ExecuteResult named tuple
169 return self.execute(
170 'remote {} {}'.format(host, launcher), verbose=True,
171 timeout=timeout)
172
173 def start(self, cmd=None, env={}, interactive=False, timeout=0):
174 assert(self.config.role == 'local')
175 if interactive:
176 # Return a PTS object to interact with
177 return self.interactive_execute(
178 cmd, env=env, verbose=True, timeout=timeout)
179 else:
180 # Return an ExecuteResult named tuple
181 return self.execute(
182 cmd, env=env, verbose=True, timeout=timeout)
183
184 def run_cmd(self, cmd, env={}, interactive=False, timeout=0):
185 verbose = True
186 if interactive:
187 # Return a PTS object to interact with
188 return interactive_execute(
189 self._container, cmd, env, verbose, timeout)
190 else:
191 # Return an ExecuteResult named tuple
192 return run_or_raise(
193 self._container, cmd, env, verbose, timeout)
194
195 def start_service(self, force=False):
196 """
197 .. note:: You should override this method in your subclass.
198 """
199 pass
200
201 def stop_service(self):
202 """
203 .. note:: You should override this method in your subclass.
204 """
205 pass
206
207 def reboot(self, timeout=0):
208 verbose = True
209 return run_or_raise(
210 self._container, "sudo reboot", verbose, timeout)
211
212 def is_service_active(self):
213 """
214 .. note:: You should override this method in your subclass.
215 """
216 pass
217
218 def start_user_session(self):
219 assert(self.config.role in ('service', 'local'))
220 # Start a set of ubuntu-user-owned processes to fake an active GDM user
221 # session (A virtual framebuffer and a pulseaudio server with a dummy
222 # output)
223 interactive_execute(
224 self._container, "/usr/bin/Xvfb -screen 0 1280x1024x24")
225 # Note: running the following commands as part of standard setup does
226 # not make them persistent as after restoring snapshots user/1000
227 # is gone from /run
228 pulseaudio_setup_cmds = [
229 'sudo mkdir -v -p /run/user/1000/pulse',
230 'sudo chown -R ubuntu:ubuntu /run/user/1000/',
231 "pulseaudio --start --exit-idle-time=-1 --disallow-module-loading",
232 ]
233 env = {'XDG_RUNTIME_DIR': '/run/user/1000'}
234 for cmd in pulseaudio_setup_cmds:
235 run_or_raise(self._container, cmd, env)
236
237 def switch_off_networking(self):
238 return run_or_raise(self._container, "sudo ip link set eth0 down")
239
240 def switch_on_networking(self):
241 return run_or_raise(self._container, "sudo ip link set eth0 up")
242
243
244class ContainerVenvMachine(ContainerBaseMachine):
245 """
246 Machine using LXD container as the backend and running checkbox
247 in a virtualenv.
248 """
249
250 def __init__(self, config, container):
251 super().__init__(config, container)
252 if self.config.role == 'service':
253 self._checkbox_wrapper = 'sudo /home/ubuntu/run.sh {}'.format(
254 self.CHECKBOX)
255 else:
256 self._checkbox_wrapper = '/home/ubuntu/run.sh {}'.format(
257 self.CHECKBOX)
258 self._pts = None # Keep a pointer to started pts for easy kill
259
260 def get_early_dir_transfer(self):
261 return [(self.config.uri, '/home/ubuntu/checkbox-ng')]
262
263 def get_early_setup(self):
264 """Virtualenv creation."""
265 return [
266 '''bash -c "printf '#!/bin/bash\\n. '''
267 '''/home/ubuntu/checkbox-ng/venv/bin/activate\\n$@' > '''
268 '''/home/ubuntu/run.sh"''',
269 'chmod +x /home/ubuntu/run.sh',
270 'chmod +x /home/ubuntu/checkbox-ng/setup.py',
271 'chmod +x /home/ubuntu/checkbox-ng/mk-venv',
272 "bash -c 'pushd /home/ubuntu/checkbox-ng ; ./mk-venv venv'",
273 ]
274
275 def start_service(self, force=False):
276 assert(self.config.role == 'service')
277 self._pts = self.interactive_execute('service', verbose=True)
278 return self._pts
279
280 def stop_service(self):
281 assert(self.config.role == 'service')
282 return self._pts.send_signal(signal.SIGINT.value)
283
284 def reboot_service(self):
285 """
286 Venv Service is not a systemd service.
287 It won't show up after a reboot.
288 """
289 raise RuntimeError
290
291 def is_service_active(self):
292 assert(self.config.role == 'service')
293 return run_or_raise(
294 self._container, 'pgrep -f "python3.*checkbox-cli service"')
295
296
297class ContainerPPAMachine(ContainerBaseMachine):
298 """
299 Machine using LXD container as the backend and running checkbox
300 from PPA.
301 """
302
303 def __init__(self, config, container):
304 super().__init__(config, container)
305
306 def get_early_setup(self):
307 if self.config.setup:
308 return []
309 if self.config.role == 'remote':
310 deb = 'checkbox-ng'
311 else:
312 deb = 'canonical-certification-client'
313 return [
314 'sudo add-apt-repository {}'.format(self.config.uri),
315 'sudo apt-get update',
316 'sudo apt-get install -y --no-install-recommends {}'.format(deb),
317 ]
318
319 def start_service(self, force=False):
320 assert(self.config.role == 'service')
321 if force:
322 return run_or_raise(
323 self._container, "sudo systemctl start checkbox-ng.service")
324
325 def stop_service(self):
326 assert(self.config.role == 'service')
327 return run_or_raise(
328 self._container, "sudo systemctl stop checkbox-ng.service")
329
330 def is_service_active(self):
331 assert(self.config.role == 'service')
332 return run_or_raise(
333 self._container,
334 "systemctl is-active checkbox-ng.service").stdout == 'active'
335
336
337class ContainerSnapMachine(ContainerBaseMachine):
338 """
339 Machine using LXD container as the backend and running checkbox
340 from a snap.
341 """
342
343 CHECKBOX_CORE_SNAP_MAP = {
344 'xenial': 'checkbox',
345 'bionic': 'checkbox18',
346 'focal': 'checkbox20',
347 }
348 CHECKBOX_SNAP_TRACK_MAP = {
349 'xenial': '16',
350 'bionic': '18',
351 'focal': '20',
352 }
353
354 def __init__(self, config, container):
355 super().__init__(config, container)
356 self._snap_name = self.config.snap_name
357 self._checkbox_wrapper = '{}.{}'.format(self._snap_name, self.CHECKBOX)
358
359 def get_file_transfer(self):
360 file_tranfer_list = []
361 if self.config.checkbox_core_snap.get('uri'):
362 core_filename = Path(
363 self.config.checkbox_core_snap.get('uri')).expanduser()
364 self.core_dest = Path('/home', 'ubuntu', core_filename.name)
365 file_tranfer_list.append((core_filename, self.core_dest))
366 if self.config.checkbox_snap.get('uri'):
367 filename = Path(
368 self.config.checkbox_snap.get('uri')).expanduser()
369 self.dest = Path('/home', 'ubuntu', filename.name)
370 file_tranfer_list.append((filename, self.dest))
371 return file_tranfer_list
372
373 def get_early_setup(self):
374 cmds = []
375 # First install the checkbox core snap if the related section exists
376 if self.config.checkbox_core_snap:
377 if self.config.checkbox_core_snap.get('uri'):
378 cmds.append(f'sudo snap install {self.core_dest} --dangerous')
379 else:
380 core_snap = self.CHECKBOX_CORE_SNAP_MAP[self.config.alias]
381 channel = f"latest/{self.config.checkbox_core_snap['risk']}"
382 cmds.append(
383 f'sudo snap install {core_snap} --channel={channel}')
384 # Then install the checkbox snap
385 confinement = 'devmode'
386 if self.config.origin == 'classic_snap':
387 confinement = 'classic'
388 if self.config.checkbox_snap.get('uri'):
389 cmds.append(
390 f'sudo snap install {self.dest} --{confinement} --dangerous')
391 else:
392 try:
393 track_map = self.config.checkbox_snap['track_map']
394 except KeyError:
395 track_map = self.CHECKBOX_SNAP_TRACK_MAP
396 channel = "{}/{}".format(
397 track_map[self.config.alias],
398 self.config.checkbox_snap['risk'])
399 cmds.append('sudo snap install {} --channel={} --{}'.format(
400 self._snap_name, channel, confinement))
401 return cmds
402
403 def start_service(self, force=False):
404 assert(self.config.role == 'service')
405 if force:
406 return run_or_raise(
407 self._container,
408 "sudo systemctl start snap.{}.service.service".format(
409 self._snap_name))
410
411 def stop_service(self):
412 assert(self.config.role == 'service')
413 return run_or_raise(
414 self._container,
415 "sudo systemctl stop snap.{}.service.service".format(
416 self._snap_name))
417
418 def is_service_active(self):
419 assert(self.config.role == 'service')
420 return run_or_raise(
421 self._container,
422 "systemctl is-active snap.{}.service.service".format(
423 self._snap_name)
424 ).stdout == 'active'
425
426
427def machine_selector(config, container):
428 if config.origin in ('snap', 'classic_snap'):
429 return(ContainerSnapMachine(config, container))
430 elif config.origin == 'ppa':
431 return(ContainerPPAMachine(config, container))
432 elif config.origin == 'source':
433 return(ContainerVenvMachine(config, container))
diff --git a/metabox/core/runner.py b/metabox/core/runner.py
0new file mode 100644434new file mode 100644
index 0000000..57c8530
--- /dev/null
+++ b/metabox/core/runner.py
@@ -0,0 +1,221 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19import sys
20import time
21
22from loguru import logger
23from metabox.core.aggregator import aggregator
24from metabox.core.configuration import read_config
25from metabox.core.configuration import validate_config
26from metabox.core.lxd_provider import LxdMachineProvider
27from metabox.core.machine import MachineConfig
28
29logger = logger.opt(colors=True)
30
31
32class Runner:
33 """Metabox scenario discovery and runner."""
34
35 def __init__(self, args):
36 self.args = args
37 # logging
38 logger.remove()
39 logger.add(
40 sys.stdout,
41 format=self._formatter,
42 level=args.log_level)
43 logger.level("TRACE", color="<w><dim>")
44 logger.level("DEBUG", color="<w><dim>")
45 # session config
46 if not args.config.exists():
47 raise SystemExit('Config file not found!')
48 else:
49 self.config = read_config(args.config)
50 validate_config(self.config)
51 # effective set of machine configs required by scenarios
52 self.combo = set()
53 self.machine_provider = None
54 self.failed = False
55 self.tags = set(self.args.tags or [])
56 self.exclude_tags = set(self.args.exclude_tags or [])
57 self.hold_on_fail = self.args.hold_on_fail
58 self.debug_machine_setup = self.args.debug_machine_setup
59 self.dispose = not self.args.do_not_dispose
60 aggregator.load_all()
61
62 def _formatter(self, record):
63 if record["level"].no < 10:
64 return "<level>{message}</level>\n"
65 else:
66 return (
67 "{time:HH:mm:ss} | <level>{level: <8}</level> "
68 "<level>{message}</level>\n"
69 )
70
71 def _gather_all_machine_spec(self):
72 for v in self.scn_variants:
73 if v.mode == 'remote':
74 remote_config = self.config['remote'].copy()
75 service_config = self.config['service'].copy()
76 remote_release, service_release = v.releases
77 remote_config['alias'] = remote_release
78 service_config['alias'] = service_release
79 self.combo.add(MachineConfig('remote', remote_config))
80 self.combo.add(MachineConfig('service', service_config))
81 elif v.mode == 'local':
82 local_config = self.config['local'].copy()
83 local_config['alias'] = v.releases[0]
84 self.combo.add(MachineConfig('local', local_config))
85
86 def _filter_scn_by_tags(self):
87 filtered_suite = []
88 for scn in self.scn_variants:
89 # Add scenario name,file,dir,mode and releases as implicit tags
90 scn_tags = set(scn.name.split('.'))
91 scn_tags.add(scn.mode)
92 scn_tags.update(scn.releases)
93 scn_tags.update(getattr(scn, 'tags', set()))
94 matched_tags = scn_tags.intersection(self.tags)
95 if (
96 (matched_tags or not self.tags) and not
97 scn_tags.intersection(self.exclude_tags)
98 ):
99 filtered_suite.append(scn)
100 return filtered_suite
101
102 def setup(self):
103 self.scenarios = aggregator.all_scenarios()
104 self.scn_variants = []
105 # Generate all scenario variants
106 for scenario_cls in self.scenarios:
107 for mode in scenario_cls.mode:
108 if mode not in self.config:
109 logger.debug(
110 "Skipping a scenario: [{}] {}",
111 mode, scenario_cls.name)
112 continue
113 scn_config = scenario_cls.config_override
114 if mode == 'remote':
115 try:
116 remote_releases = scn_config['remote']['releases']
117 except KeyError:
118 remote_releases = self.config['remote']['releases']
119 try:
120 service_releases = scn_config['service']['releases']
121 except KeyError:
122 service_releases = self.config['service']['releases']
123 for r_alias in self.config['remote']['releases']:
124 if r_alias not in remote_releases:
125 continue
126 for s_alias in self.config['service']['releases']:
127 if s_alias not in service_releases:
128 continue
129 self.scn_variants.append(
130 scenario_cls(mode, r_alias, s_alias))
131 elif mode == 'local':
132 try:
133 local_releases = scn_config[mode]['releases']
134 except KeyError:
135 local_releases = self.config[mode]['releases']
136 for alias in self.config[mode]['releases']:
137 if alias not in local_releases:
138 continue
139 self.scn_variants.append(scenario_cls(mode, alias))
140 if self.args.tags or self.args.exclude_tags:
141 if self.args.tags:
142 logger.info('Including scenario tag(s): %s' % ', '.join(
143 sorted(self.args.tags)))
144 if self.args.exclude_tags:
145 logger.info('Excluding scenario tag(s): %s' % ', '.join(
146 sorted(self.args.exclude_tags)))
147 self.scn_variants = self._filter_scn_by_tags()
148 if not self.scn_variants:
149 logger.warning('No match found!')
150 raise SystemExit(1)
151 self._gather_all_machine_spec()
152 self.machine_provider = LxdMachineProvider(
153 self.config, self.combo,
154 self.debug_machine_setup, self.dispose)
155 self.machine_provider.setup()
156
157 def _load(self, mode, release_alias):
158 config = self.config[mode].copy()
159 config['alias'] = release_alias
160 config['role'] = mode
161 return self.machine_provider.get_machine_by_config(
162 MachineConfig(mode, config))
163
164 def run(self):
165 startTime = time.perf_counter()
166 for scn in self.scn_variants:
167 if scn.mode == "remote":
168 scn.remote_machine = self._load("remote", scn.releases[0])
169 scn.service_machine = self._load("service", scn.releases[1])
170 scn.remote_machine.rollback_to('provisioned')
171 scn.service_machine.rollback_to('provisioned')
172 if scn.launcher:
173 scn.remote_machine.put(scn.LAUNCHER_PATH, scn.launcher)
174 scn.service_machine.start_user_session()
175 elif scn.mode == "local":
176 scn.local_machine = self._load("local", scn.releases[0])
177 scn.local_machine.rollback_to('provisioned')
178 if scn.launcher:
179 scn.local_machine.put(scn.LAUNCHER_PATH, scn.launcher)
180 scn.local_machine.start_user_session()
181 logger.info("Starting scenario: {}".format(scn.name))
182 scn.run()
183 if not scn.has_passed():
184 self.failed = True
185 logger.error("{} scenario has failed.".format(scn.name))
186 if self.hold_on_fail:
187 if scn.mode == "remote":
188 msg = (
189 "You may hop onto the target machines by issuing "
190 "the following commands:\n{}\n{}\n"
191 "Press enter to continue testing").format(
192 scn.remote_machine.get_connecting_cmd(),
193 scn.service_machine.get_connecting_cmd())
194 elif scn.mode == "local":
195 msg = (
196 "You may hop onto the target machine by issuing "
197 "the following command:\n{}\n"
198 "Press enter to continue testing").format(
199 scn.local_machine.get_connecting_cmd())
200 print(msg)
201 input()
202 else:
203 logger.success("{} scenario has passed.".format(scn.name))
204 self.machine_provider.cleanup()
205 del self.machine_provider
206 stopTime = time.perf_counter()
207 timeTaken = stopTime - startTime
208 print('-' * 80)
209 total = len(self.scn_variants)
210 status = "Ran {} scenario{} in {:.3f}s".format(
211 total, total != 1 and "s" or "", timeTaken)
212 if self.wasSuccessful():
213 logger.success(status)
214 else:
215 logger.error(status)
216
217 def _run_single_scn(self, scenario_cls, mode, *releases):
218 pass
219
220 def wasSuccessful(self):
221 return self.failed is False
diff --git a/metabox/core/scenario.py b/metabox/core/scenario.py
0new file mode 100644222new file mode 100644
index 0000000..bbb7673
--- /dev/null
+++ b/metabox/core/scenario.py
@@ -0,0 +1,244 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19"""
20This module defines the Scenario class.
21
22See Scenario class properties and the assert_* functions, as they serve as
23the interface to a Scenario.
24"""
25import re
26import time
27
28from metabox.core.actions import Start, Expect, Send, SelectTestPlan
29from metabox.core.aggregator import aggregator
30
31
32class Scenario:
33 """Definition of how to run a Checkbox session."""
34 config_override = {}
35 environment = {}
36 launcher = None
37 LAUNCHER_PATH = '/home/ubuntu/launcher.checkbox'
38
39 def __init_subclass__(cls, **kwargs):
40 super().__init_subclass__(**kwargs)
41 cls.name = '{}.{}'.format(cls.__module__, cls.__name__)
42 aggregator.add_scenario(cls)
43
44 def __init__(self, mode, *releases):
45 self.mode = mode
46 self.releases = releases
47 self._checks = []
48 self._ret_code = None
49 self._stdout = ''
50 self._stderr = ''
51 self._pts = None
52
53 def has_passed(self):
54 """Check whether all the assertions passed."""
55 return all(self._checks)
56
57 def run(self):
58 # Simple scenarios don't need to specify a START step
59 if not any([isinstance(s, Start) for s in self.steps]):
60 self.steps.insert(0, Start())
61 for i, step in enumerate(self.steps):
62 # Check how to start checkbox, interactively or not
63 if isinstance(step, Start):
64 interactive = False
65 # CHECK if any EXPECT/SEND command follows
66 # w/o a new call to START before it
67 for next_step in self.steps[i + 1:]:
68 if isinstance(next_step, Start):
69 break
70 if isinstance(next_step, (Expect, Send, SelectTestPlan)):
71 interactive = True
72 break
73 step.kwargs['interactive'] = interactive
74 try:
75 step(self)
76 except TimeoutError:
77 self._checks.append(False)
78 break
79 if self._pts:
80 self._stdout = self._pts.stdout_data_full
81 # Mute the PTS since we're about to stop the machine to avoid
82 # getting empty log trace events
83 self._pts.verbose = False
84
85 def _assign_outcome(self, ret_code, stdout, stderr):
86 """Store remnants of a machine that run the scenario."""
87 self._ret_code = ret_code
88 self._stdout = stdout
89 self._stderr = stderr
90
91 # TODO: add storing of what actually failed in the assert methods
92 def assert_printed(self, pattern):
93 """
94 Check if during Checkbox execution a line produced that matches the
95 pattern.
96 :param patter: regular expresion to check against the lines.
97 """
98 regex = re.compile(pattern)
99 self._checks.append(bool(regex.search(self._stdout)))
100
101 def assert_not_printed(self, pattern):
102 """
103 Check if during Checkbox execution a line did not produced that matches
104 the pattern.
105 :param patter: regular expresion to check against the lines.
106 """
107 regex = re.compile(pattern)
108 if self._pts:
109 self._checks.append(
110 bool(not regex.search(self._pts.stdout_data_full.decode(
111 'utf-8', errors='ignore'))))
112 else:
113 self._checks.append(bool(not regex.search(self._stdout)))
114
115 def assert_ret_code(self, code):
116 """Check if Checkbox returned given code."""
117 self._checks.append(code == self._ret_code)
118
119 def assertIn(self, member, container):
120 self._checks.append(member in container)
121
122 def assertEqual(self, first, second):
123 self._checks.append(first == second)
124
125 def assertNotEqual(self, first, second):
126 self._checks.append(first != second)
127
128 def start(self, cmd='', interactive=False, timeout=0):
129 if self.mode == 'remote':
130 outcome = self.start_all(interactive=interactive, timeout=timeout)
131 if interactive:
132 self._pts = outcome
133 else:
134 self._assign_outcome(*outcome)
135 else:
136 if self.launcher:
137 cmd = self.LAUNCHER_PATH
138 outcome = self.local_machine.start(
139 cmd=cmd, env=self.environment,
140 interactive=interactive, timeout=timeout)
141 if interactive:
142 self._pts = outcome
143 else:
144 self._assign_outcome(*outcome)
145
146 def start_all(self, interactive=False, timeout=0):
147 self.start_service()
148 outcome = self.start_remote(interactive, timeout)
149 if interactive:
150 self._pts = outcome
151 else:
152 self._assign_outcome(*outcome)
153 return outcome
154
155 def start_remote(self, interactive=False, timeout=0):
156 outcome = self.remote_machine.start_remote(
157 self.service_machine.address, self.LAUNCHER_PATH, interactive,
158 timeout=timeout)
159 if interactive:
160 self._pts = outcome
161 else:
162 self._assign_outcome(*outcome)
163 return outcome
164
165 def start_service(self, force=False):
166 return self.service_machine.start_service(force)
167
168 def expect(self, data, timeout=60):
169 assert(self._pts is not None)
170 outcome = self._pts.expect(data, timeout)
171 self._checks.append(outcome)
172
173 def send(self, data):
174 assert(self._pts is not None)
175 self._pts.send(data.encode('utf-8'), binary=True)
176
177 def sleep(self, secs):
178 time.sleep(secs)
179
180 def signal(self, signal):
181 assert(self._pts is not None)
182 self._pts.send_signal(signal)
183
184 def select_test_plan(self, testplan_id, timeout=60):
185 assert(self._pts is not None)
186 outcome = self._pts.select_test_plan(testplan_id, timeout)
187 self._checks.append(outcome)
188
189 def run_cmd(self, cmd, env={}, interactive=False, timeout=0, target='all'):
190 if self.mode == 'remote':
191 if target == 'remote':
192 self.remote_machine.run_cmd(cmd, env, interactive, timeout)
193 elif target == 'service':
194 self.service_machine.run_cmd(cmd, env, interactive, timeout)
195 else:
196 self.remote_machine.run_cmd(cmd, env, interactive, timeout)
197 self.service_machine.run_cmd(cmd, env, interactive, timeout)
198 else:
199 self.local_machine.run_cmd(cmd, env, interactive, timeout)
200
201 def reboot(self, timeout=0, target='all'):
202 if self.mode == 'remote':
203 if target == 'remote':
204 self.remote_machine.reboot(timeout)
205 elif target == 'service':
206 self.service_machine.reboot(timeout)
207 else:
208 self.remote_machine.reboot(timeout)
209 self.service_machine.reboot(timeout)
210 else:
211 self.local_machine.reboot(timeout)
212
213 def switch_on_networking(self, target='all'):
214 if self.mode == 'remote':
215 if target == 'remote':
216 self.remote_machine.switch_on_networking()
217 elif target == 'service':
218 self.service_machine.switch_on_networking()
219 else:
220 self.remote_machine.switch_on_networking()
221 self.service_machine.switch_on_networking()
222 else:
223 self.local_machine.switch_on_networking()
224
225 def switch_off_networking(self, target='all'):
226 if self.mode == 'remote':
227 if target == 'remote':
228 self.remote_machine.switch_off_networking()
229 elif target == 'service':
230 self.service_machine.switch_off_networking()
231 else:
232 self.remote_machine.switch_off_networking()
233 self.service_machine.switch_off_networking()
234 else:
235 self.local_machine.switch_off_networking()
236
237 def stop_service(self):
238 return self.service_machine.stop_service()
239
240 def reboot_service(self):
241 return self.service_machine.reboot_service()
242
243 def is_service_active(self):
244 return self.service_machine.is_service_active()
diff --git a/metabox/core/utils.py b/metabox/core/utils.py
0new file mode 100644245new file mode 100644
index 0000000..ed8c1b0
--- /dev/null
+++ b/metabox/core/utils.py
@@ -0,0 +1,49 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11#
12# Checkbox is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
19import re
20from typing import IO, NamedTuple
21
22__all__ = ('tag', 'ExecuteResult')
23
24
25def tag(*tags):
26 """Decorator to add tags to a scenario class."""
27 def decorator(obj):
28 setattr(obj, 'tags', set(tags))
29 return obj
30 return decorator
31
32
33class ExecuteResult(NamedTuple):
34
35 exit_code: int
36 stdout: IO
37 stderr: IO
38
39
40class _re:
41 def __init__(self, pattern, flags=0):
42 self._raw_pattern = pattern
43 self._pattern = re.compile(pattern.encode('utf-8'), flags)
44
45 def __repr__(self):
46 return f"Regex {self._raw_pattern}"
47
48 def search(self, data):
49 return bool(self._pattern.search(data))
diff --git a/metabox/lxd_profiles/checkbox.profile b/metabox/lxd_profiles/checkbox.profile
0new file mode 10064450new file mode 100644
index 0000000..673590a
--- /dev/null
+++ b/metabox/lxd_profiles/checkbox.profile
@@ -0,0 +1,35 @@
1config:
2 raw.idmap: "both 1000 1000"
3 environment.DISPLAY: :0
4 user.user-data: |
5 #cloud-config
6 runcmd:
7 - 'sed -i "s/; enable-shm = yes/enable-shm = no/g" /etc/pulse/client.conf'
8 - "perl -i -p0e 's/(Unit.*?)\n\n/$1\nConditionVirtualization=!container\n\n/s' /lib/systemd/system/systemd-remount-fs.service"
9 - 'echo export XAUTHORITY=/run/user/1000/gdm/Xauthority | tee --append /home/ubuntu/.profile'
10 apt:
11 sources:
12 stable_ppa:
13 source: "ppa:hardware-certification/public"
14 packages:
15 - alsa-base
16 - gir1.2-cheese-3.0
17 - gir1.2-gst-plugins-base-1.0
18 - gir1.2-gstreamer-1.0
19 - gstreamer1.0-plugins-good
20 - gstreamer1.0-pulseaudio
21 - libgstreamer1.0-0
22 - mesa-utils
23 - pulseaudio
24 - python3-jinja2
25 - python3-markupsafe
26 - python3-padme
27 - python3-pip
28 - python3-psutil
29 - python3-requests-oauthlib
30 - python3-tqdm
31 - python3-urwid
32 - python3-xlsxwriter
33 - virtualenv
34 - x11-apps
35 - xvfb
diff --git a/metabox/lxd_profiles/lowmem.profile b/metabox/lxd_profiles/lowmem.profile
0new file mode 10064436new file mode 100644
index 0000000..299f2db
--- /dev/null
+++ b/metabox/lxd_profiles/lowmem.profile
@@ -0,0 +1,3 @@
1config:
2 limits.cpu: "1"
3 limits.memory: 512MB
diff --git a/metabox/lxd_profiles/snap.profile b/metabox/lxd_profiles/snap.profile
0new file mode 1006444new file mode 100644
index 0000000..a26370e
--- /dev/null
+++ b/metabox/lxd_profiles/snap.profile
@@ -0,0 +1,24 @@
1config:
2 raw.idmap: "both 1000 1000"
3 environment.DISPLAY: :0
4 user.user-data: |
5 #cloud-config
6 runcmd:
7 - 'sed -i "s/; enable-shm = yes/enable-shm = no/g" /etc/pulse/client.conf'
8 - "perl -i -p0e 's/(Unit.*?)\n\n/$1\nConditionVirtualization=!container\n\n/s' /lib/systemd/system/systemd-remount-fs.service"
9 - 'echo export XAUTHORITY=/run/user/1000/gdm/Xauthority | tee --append /home/ubuntu/.profile'
10 packages:
11 - alsa-base
12 - gir1.2-cheese-3.0
13 - gir1.2-gst-plugins-base-1.0
14 - gir1.2-gstreamer-1.0
15 - gstreamer1.0-plugins-good
16 - gstreamer1.0-pulseaudio
17 - libgstreamer1.0-0
18 - mesa-utils
19 - pulseaudio
20 - x11-apps
21 - xvfb
22 snap:
23 commands:
24 - systemctl start snapd.service
diff --git a/metabox/main.py b/metabox/main.py
index 7f6de93..44daf47 100644
--- a/metabox/main.py
+++ b/metabox/main.py
@@ -1,10 +1,74 @@
1#!/usr/bin/env python3
2# This file is part of Checkbox.
3#
1# Copyright 2021 Canonical Ltd.4# Copyright 2021 Canonical Ltd.
2# Written by:5# Written by:
3# Maciej Kisielewski <maciej.kisielewski@canonical.com>6# Maciej Kisielewski <maciej.kisielewski@canonical.com>
4# Sylvain Pineau <sylvain.pineau@canonical.com>7# Sylvain Pineau <sylvain.pineau@canonical.com>
8#
9# Checkbox is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 3,
11# as published by the Free Software Foundation.
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
5"""20"""
6Entry point to the Metabox program.21Entry point to the Metabox program.
7"""22"""
23import argparse
24import warnings
25from pathlib import Path
26
27from loguru._logger import Core
28from metabox.core.runner import Runner
29
830
9def main():31def main():
10 pass32 """Entry point to Metabox."""
33
34 parser = argparse.ArgumentParser()
35 parser.add_argument(
36 'config', metavar='CONFIG', type=Path,
37 help='Metabox configuration file'
38 )
39 parser.add_argument(
40 '--tag', action='append', dest='tags',
41 help='Run only scenario with the specified tag. '
42 'Can be used multiple times.',
43 )
44 parser.add_argument(
45 '--exclude-tag', action='append', dest='exclude_tags',
46 help='Do not run scenario with the specified tag. '
47 'Can be used multiple times.',
48 )
49 parser.add_argument(
50 "--log", dest="log_level", choices=Core().levels.keys(),
51 default='SUCCESS',
52 help="Set the logging level",
53 )
54 parser.add_argument(
55 '--do-not-dispose', action='store_true',
56 help="Do not delete LXD containers after the run")
57 parser.add_argument(
58 '--hold-on-fail', action='store_true',
59 help="Pause testing when a scenario fails")
60 parser.add_argument(
61 '--debug-machine-setup', action='store_true',
62 help="Turn on verbosity during machine setup. "
63 "Only works with --log TRACE")
64 # Ignore warnings issued by pylxd/models/operation.py
65 with warnings.catch_warnings():
66 warnings.simplefilter("ignore")
67 runner = Runner(parser.parse_args())
68 runner.setup()
69 runner.run()
70 raise SystemExit(not runner.wasSuccessful())
71
72
73if __name__ == '__main__':
74 main()
diff --git a/metabox/metabox-provider/manage.py b/metabox/metabox-provider/manage.py
11new file mode 10075575new file mode 100755
index 0000000..7dc5255
--- /dev/null
+++ b/metabox/metabox-provider/manage.py
@@ -0,0 +1,9 @@
1#!/usr/bin/env python3
2from plainbox.provider_manager import setup, N_
3
4setup(
5 name='2021.com.canonical.certification:metabox',
6 version="1.0",
7 description=N_("The 2021.com.canonical.certification:metabox provider"),
8 gettext_domain="2021_com_canonical_certification_metabox",
9)
diff --git a/metabox/metabox-provider/units/basic-jobs.pxu b/metabox/metabox-provider/units/basic-jobs.pxu
0new file mode 10064410new file mode 100644
index 0000000..098288a
--- /dev/null
+++ b/metabox/metabox-provider/units/basic-jobs.pxu
@@ -0,0 +1,224 @@
1id: basic-shell-passing
2flags: simple
3command: true
4
5id: basic-shell-failing
6flags: simple
7command: false
8
9id: basic-environ-printing
10flags: simple
11command: echo foo: $foo
12environ: foo
13
14id: stub/manual
15_summary: A simple manual job
16_description:
17 PURPOSE:
18 This test checks that the manual plugin works fine
19 STEPS:
20 1. Add a comment
21 2. Set the result as passed
22 VERIFICATION:
23 Check that in the report the result is passed and the comment is displayed
24plugin: manual
25estimated_duration: 30
26
27id: stub/split-fields/manual
28_summary: A simple manual job using finer description fields
29_purpose:
30 This test checks that the manual plugin works fine
31_steps:
32 1. Add a comment
33 2. Set the result as passed
34_verification:
35 Check that in the report the result is passed and the comment is displayed
36plugin: manual
37estimated_duration: 30
38
39id: stub/user-interact
40_summary: A simple user interaction job
41_description:
42 PURPOSE:
43 This test checks that the user-interact plugin works fine
44 STEPS:
45 1. Read this description
46 2. Press the test button
47 VERIFICATION:
48 Check that in the report the result is passed
49plugin: user-interact
50flags: preserve-locale
51command: true
52estimated_duration: 30
53
54id: stub/split-fields/user-interact
55_summary: User-interact job using finer description fields
56_purpose:
57 This is a purpose part of test description
58_steps:
59 1. First step in the user-iteract job
60 2. Second step in the user-iteract job
61_verification:
62 Verification part of test description
63plugin: user-interact
64flags: preserve-locale
65command: true
66estimated_duration: 30
67
68id: stub/user-verify
69_summary: A simple user verification job
70_description:
71 PURPOSE:
72 This test checks that the user-verify plugin works fine
73 STEPS:
74 1. Read this description
75 2. Ensure that the command has been started automatically
76 3. Do not press the test button
77 4. Look at the output and determine the outcome of the test
78 VERIFICATION:
79 The command should have printed "Please select 'pass'"
80plugin: user-verify
81flags: preserve-locale
82command: echo "Please select 'pass'"
83estimated_duration: 30
84
85id: stub/split-fields/user-verify
86_summary: User-verify job using finer description fields
87_purpose:
88 This test checks that the user-verify plugin works fine and that
89 description field is split properly
90_steps:
91 1. Read this description
92 2. Ensure that the command has been started automatically
93 3. Do not press the test button
94 4. Look at the output and determine the outcome of the test
95_verification:
96 The command should have printed "Please select 'pass'"
97plugin: user-verify
98flags: preserve-locale
99command: echo "Please select 'pass'"
100estimated_duration: 30
101
102id: stub/user-interact-verify
103_summary: A simple user interaction and verification job
104_description:
105 PURPOSE:
106 This test checks that the user-interact-verify plugin works fine
107 STEPS:
108 1. Read this description
109 2. Ensure that the command has not been started yet
110 3. Press the test button
111 4. Look at the output and determine the outcome of the test
112 VERIFICATION:
113 The command should have printed "Please select 'pass'"
114plugin: user-interact-verify
115flags: preserve-locale
116command: echo "Please select 'pass'"
117estimated_duration: 25
118
119id: stub/split-fields/user-interact-verify
120_summary: A simple user interaction and verification job using finer
121 description fields
122_purpose:
123 This test checks that the user-interact-verify plugin works fine
124_steps:
125 1. Read this description
126 2. Ensure that the command has not been started yet
127 3. Press the test button
128 4. Look at the output and determine the outcome of the test
129_verification:
130 The command should have printed "Please select 'pass'"
131plugin: user-interact-verify
132flags: preserve-locale
133command: echo "Please select 'pass'"
134estimated_duration: 25
135
136id: stub/user-interact-verify-passing
137_summary: A suggested-passing user-verification-interaction job
138_description:
139 PURPOSE:
140 This test checks that the application user interface auto-suggests 'pass'
141 as the outcome of a test for user-interact-verify jobs that have a command
142 which completes successfully.
143 STEPS:
144 1. Read this description
145 2. Ensure that the command has not been started yet
146 3. Press the test button
147 4. Confirm the auto-suggested value
148 VERIFICATION:
149 The auto suggested value should have been 'pass'
150plugin: user-interact-verify
151flags: preserve-locale
152command: true
153estimated_duration: 25
154
155id: stub/split-fields/user-interact-verify-passing
156_summary: A suggested-passing user-verification-interaction job using finer
157 description fields
158_purpose:
159 This test checks that the application user interface auto-suggests 'pass'
160 as the outcome of a test for user-interact-verify jobs that have a command
161 which completes successfully.
162_steps:
163 1. Read this description
164 2. Ensure that the command has not been started yet
165 3. Press the test button
166 4. Confirm the auto-suggested value
167_verification:
168 The auto suggested value should have been 'pass'
169plugin: user-interact-verify
170flags: preserve-locale
171command: true
172estimated_duration: 25
173
174id: stub/user-interact-verify-failing
175_summary: A suggested-failing user-verification-interaction job
176_description:
177 PURPOSE:
178 This test checks that the application user interface auto-suggests 'fail'
179 as the outcome of a test for user-interact-verify jobs that have a command
180 which completes unsuccessfully.
181 STEPS:
182 1. Read this description
183 2. Ensure that the command has not been started yet
184 3. Press the test button
185 4. Confirm the auto-suggested value
186 VERIFICATION:
187 The auto suggested value should have been 'fail'
188plugin: user-interact-verify
189flags: preserve-locale
190command: false
191estimated_duration: 25
192
193id: stub/split-fields/user-interact-verify-failing
194_summary: A suggested-failing user-verification-interaction job using finer
195 description fields
196_purpose:
197 This test checks that the application user interface auto-suggests 'fail'
198 as the outcome of a test for user-interact-verify jobs that have a command
199 which completes unsuccessfully.
200_steps:
201 1. Read this description
202 2. Ensure that the command has not been started yet
203 3. Press the test button
204 4. Confirm the auto-suggested value
205_verification:
206 The auto suggested value should have been 'fail'
207plugin: user-interact-verify
208flags: preserve-locale
209command: false
210estimated_duration: 25
211
212plugin: user-interact-verify
213id: graphics/glxgears
214command: glxgears; true
215_summary: Test that glxgears works
216_description:
217 PURPOSE:
218 This test tests the basic 3D capabilities of your video card
219 STEPS:
220 1. Click "Test" to execute an OpenGL demo. Press ESC at any time to close.
221 2. Verify that the animation is not jerky or slow.
222 VERIFICATION:
223 1. Did the 3d animation appear?
224 2. Was the animation free from slowness/jerkiness?
diff --git a/metabox/metabox-provider/units/basic-tps.pxu b/metabox/metabox-provider/units/basic-tps.pxu
0new file mode 100644225new file mode 100644
index 0000000..6e2ef73
--- /dev/null
+++ b/metabox/metabox-provider/units/basic-tps.pxu
@@ -0,0 +1,39 @@
1unit: test plan
2id: basic-automated
3_name: Basic Automated
4include:
5 basic-shell-passing
6 basic-shell-failing
7 basic-environ-printing
8
9unit: test plan
10id: basic-automated-passing
11_name: Basic Automated Passing
12include:
13 basic-shell-passing
14
15unit: test plan
16id: basic-automated-failing
17_name: Basic Automated Failing
18include:
19 basic-shell-failing
20
21unit: test plan
22id: basic-manual
23_name: Basic Manual
24include:
25 stub/user-interact
26 stub/user-verify
27 stub/user-interact-verify
28
29unit: test plan
30id: display-manual
31_name: Display Manual
32include:
33 graphics/glxgears
34
35unit: test plan
36id: audio-manual
37_name: Audio Manual
38include:
39 com.canonical.certification::audio/playback_auto
diff --git a/metabox/scenarios/__init__.py b/metabox/scenarios/__init__.py
0new file mode 10064440new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/metabox/scenarios/__init__.py
diff --git a/metabox/scenarios/basic/__init__.py b/metabox/scenarios/basic/__init__.py
1new file mode 10064441new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/metabox/scenarios/basic/__init__.py
diff --git a/metabox/scenarios/basic/run-invocation.py b/metabox/scenarios/basic/run-invocation.py
2new file mode 10064442new file mode 100644
index 0000000..fc2d198
--- /dev/null
+++ b/metabox/scenarios/basic/run-invocation.py
@@ -0,0 +1,86 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
20import metabox.core.keys as keys
21from metabox.core.actions import AssertPrinted
22from metabox.core.actions import AssertRetCode
23from metabox.core.actions import Expect
24from metabox.core.actions import Send
25from metabox.core.actions import Start
26from metabox.core.scenario import Scenario
27
28
29class RunTestplan(Scenario):
30
31 mode = ['local']
32 config_override = {'local': {'releases': ['bionic']}}
33 steps = [
34 Start('run 2021.com.canonical.certification::'
35 'basic-automated-passing', timeout=30),
36 AssertRetCode(0)
37 ]
38
39
40class RunFailingTestplan(Scenario):
41
42 mode = ['local']
43 steps = [
44 Start('run 2021.com.canonical.certification::'
45 'basic-automated-failing', timeout=30),
46 AssertRetCode(0)
47 ]
48
49
50class RunTestplanWithEnvvar(Scenario):
51
52 mode = ['local']
53 environment = {'foo': 42}
54 steps = [
55 Start('run 2021.com.canonical.certification::basic-automated',
56 timeout=30),
57 AssertPrinted("foo: 42"),
58 ]
59
60
61class RunTestplanWithTimeout(Scenario):
62
63 mode = ['local']
64 steps = [
65 Start('run 2021.com.canonical.certification::'
66 'basic-automated-passing', timeout='0.1s'),
67 AssertRetCode(137)
68 ]
69
70
71class RunManualplan(Scenario):
72
73 mode = ['local']
74 steps = [
75 Start('run 2021.com.canonical.certification::basic-manual'),
76 Expect('Pick an action'),
77 Send(keys.KEY_ENTER),
78 Expect('Pick an action', timeout=30),
79 Send('p' + keys.KEY_ENTER),
80 Expect('Pick an action'),
81 Send(keys.KEY_ENTER),
82 Expect('Pick an action'),
83 Send('p' + keys.KEY_ENTER),
84 Expect(' ☑ : '
85 'A simple user interaction and verification job'),
86 ]
diff --git a/metabox/scenarios/desktop_env/launcher.py b/metabox/scenarios/desktop_env/launcher.py
0new file mode 10064487new file mode 100644
index 0000000..8711e74
--- /dev/null
+++ b/metabox/scenarios/desktop_env/launcher.py
@@ -0,0 +1,76 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
20import textwrap
21
22import metabox.core.keys as keys
23from metabox.core.actions import AssertNotPrinted
24from metabox.core.actions import Expect
25from metabox.core.actions import RunCmd
26from metabox.core.actions import Send
27from metabox.core.scenario import Scenario
28from metabox.core.utils import _re
29
30
31class GlxGears(Scenario):
32
33 mode = ['local', 'remote']
34 launcher = textwrap.dedent("""
35 [launcher]
36 launcher_version = 1
37 stock_reports = text
38 [test plan]
39 unit = 2021.com.canonical.certification::display-manual
40 forced = yes
41 [test selection]
42 forced = yes
43 """)
44 steps = [
45 Expect('Pick an action'),
46 Send(keys.KEY_ENTER),
47 Expect('FPS'),
48 RunCmd('pkill -2 glxgears'),
49 Expect('Pick an action'),
50 Send('p' + keys.KEY_ENTER),
51 Expect(_re('(☑|job passed).*Test that glxgears works')),
52 ]
53
54
55class AudioPlayback(Scenario):
56
57 mode = ['local', 'remote']
58 launcher = textwrap.dedent("""
59 [launcher]
60 launcher_version = 1
61 stock_reports = text
62 [test plan]
63 unit = 2021.com.canonical.certification::audio-manual
64 forced = yes
65 [test selection]
66 forced = yes
67 """)
68 steps = [
69 Expect('Pick an action'),
70 Send(keys.KEY_ENTER),
71 Expect('Pipeline initialized, now starting playback.'),
72 Expect('Pick an action'),
73 Send('p' + keys.KEY_ENTER),
74 AssertNotPrinted('Connection failure: Connection refused'),
75 Expect(_re('(☑|job passed).*audio/playback_auto'), timeout=10),
76 ]
diff --git a/metabox/scenarios/restart/launcher.py b/metabox/scenarios/restart/launcher.py
0new file mode 10064477new file mode 100644
index 0000000..57d13d1
--- /dev/null
+++ b/metabox/scenarios/restart/launcher.py
@@ -0,0 +1,46 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
20import textwrap
21
22from metabox.core.actions import AssertPrinted
23from metabox.core.scenario import Scenario
24
25
26class Reboot(Scenario):
27
28 mode = ['remote']
29 launcher = textwrap.dedent("""
30 [launcher]
31 launcher_version = 1
32 stock_reports = text
33 [test plan]
34 unit = com.canonical.certification::power-automated
35 forced = yes
36 [test selection]
37 forced = yes
38 exclude = .*cold.*
39 [ui]
40 type = silent
41 """)
42 steps = [
43 AssertPrinted('Connection lost!'),
44 AssertPrinted('Reconnecting...'),
45 AssertPrinted('job passed : Warm reboot'),
46 ]
diff --git a/metabox/scenarios/urwid/run-invocation.py b/metabox/scenarios/urwid/run-invocation.py
0new file mode 10064447new file mode 100644
index 0000000..e8af2ce
--- /dev/null
+++ b/metabox/scenarios/urwid/run-invocation.py
@@ -0,0 +1,111 @@
1# This file is part of Checkbox.
2#
3# Copyright 2021 Canonical Ltd.
4# Written by:
5# Maciej Kisielewski <maciej.kisielewski@canonical.com>
6# Sylvain Pineau <sylvain.pineau@canonical.com>
7#
8# Checkbox is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License version 3,
10# as published by the Free Software Foundation.
11
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
20import metabox.core.keys as keys
21from metabox.core.actions import AssertPrinted
22from metabox.core.actions import AssertRetCode
23from metabox.core.actions import Expect
24from metabox.core.actions import Send
25from metabox.core.actions import SelectTestPlan
26from metabox.core.actions import Start
27from metabox.core.scenario import Scenario
28
29
30class RunTestplan(Scenario):
31
32 mode = ['local']
33 config_override = {'local': {'releases': ['bionic']}}
34 steps = [
35 Start('run 2021.com.canonical.certification::'
36 'basic-automated-passing', timeout=30),
37 AssertRetCode(0)
38 ]
39
40
41class RunFailingTestplan(Scenario):
42
43 mode = ['local']
44 steps = [
45 Start('run 2021.com.canonical.certification::'
46 'basic-automated-failing', timeout=30),
47 AssertRetCode(0)
48 ]
49
50
51class RunTestplanWithEnvvar(Scenario):
52
53 mode = ['local']
54 environment = {'foo': 42}
55 steps = [
56 Start('run 2021.com.canonical.certification::basic-automated',
57 timeout=30),
58 AssertPrinted("foo: 42"),
59 ]
60
61
62class RunTestplanWithTimeout(Scenario):
63
64 mode = ['local']
65 steps = [
66 Start('run 2021.com.canonical.certification::'
67 'basic-automated-passing', timeout='0.1s'),
68 AssertRetCode(137)
69 ]
70
71
72class RunManualplan(Scenario):
73
74 mode = ['local']
75 steps = [
76 Start('run 2021.com.canonical.certification::basic-manual'),
77 Expect('Pick an action'),
78 Send(keys.KEY_ENTER),
79 Expect('Pick an action', timeout=30),
80 Send('p' + keys.KEY_ENTER),
81 Expect('Pick an action'),
82 Send(keys.KEY_ENTER),
83 Expect('Pick an action'),
84 Send('p' + keys.KEY_ENTER),
85 Expect(' ☑ : '
86 'A simple user interaction and verification job'),
87 ]
88
89
90class UrwidTestPlanSelection(Scenario):
91
92 mode = ['local']
93 steps = [
94 Expect('Select test plan'),
95 SelectTestPlan('com.canonical.certification::stress-pm-graph'),
96 SelectTestPlan(
97 'com.canonical.certification::'
98 'after-suspend-graphics-discrete-gpu-cert-automated'),
99 SelectTestPlan('com.canonical.certification::client-cert-18-04'),
100 Send(keys.KEY_ENTER),
101 Expect('Choose tests to run on your system:'),
102 Send('d' + keys.KEY_ENTER),
103 Expect('Choose tests to run on your system:'),
104 Send(keys.KEY_DOWN * 18 + keys.KEY_SPACE + 't'),
105 Expect('System Manifest:'),
106 Send('y' * 9 + 't'),
107 Expect('Pick an action'),
108 Send('s' + keys.KEY_ENTER),
109 Expect('Finish'),
110 Send('f' + keys.KEY_ENTER),
111 ]
diff --git a/setup.py b/setup.py
index 95250eb..bac43ad 100755
--- a/setup.py
+++ b/setup.py
@@ -1,22 +1,37 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2# This file is part of Checkbox.
3#
2# Copyright 2021 Canonical Ltd.4# Copyright 2021 Canonical Ltd.
3# Written by:5# Written by:
4# Maciej Kisielewski <maciej.kisielewski@canonical.com>6# Maciej Kisielewski <maciej.kisielewski@canonical.com>
7# Sylvain Pineau <sylvain.pineau@canonical.com>
8#
9# Checkbox is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 3,
11# as published by the Free Software Foundation.
12#
13# Checkbox is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with Checkbox. If not, see <http://www.gnu.org/licenses/>.
520
6from setuptools import find_packages, setup21from setuptools import find_packages, setup
722
8setup(23setup(
9 name='metabox',24 name='metabox',
10 version='0.1',25 version='0.3',
11 packages=find_packages(),26 packages=find_packages(),
12 install_requires=['pylxd'],27 install_requires=['loguru', 'pylxd', 'pyyaml'],
13 entry_points={28 entry_points={
14 'console_scripts': [29 'console_scripts': [
15 'metabox = metabox.main:main',30 'metabox = metabox.main:main',
16 ]31 ]
17 },32 },
18 include_package_data = True,33 include_package_data=True,
19 package_data = {34 package_data={
20 '': ['metabox/metabox-provider/*'],35 '': ['metabox/metabox-provider/*'],
21 }36 }
22)37)

Subscribers

People subscribed via source and target branches