Merge ~sylvain-pineau/checkbox/+git/metabox:configs into ~checkbox-dev/checkbox/+git/metabox:main
- Git
- lp:~sylvain-pineau/checkbox/+git/metabox
- configs
- Merge into main
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) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Maciej Kisielewski | Approve | ||
Sylvain Pineau (community) | Needs Resubmitting | ||
Review via email: mp+400333@code.launchpad.net |
Commit message
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)
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:/
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.
Sylvain Pineau (sylvain-pineau) wrote : | # |
Fixed blocking issues, scn structure is not perfect though.
Maciej Kisielewski (kissiel) wrote : | # |
We'll improve as we go.
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | new file mode 100644 |
3 | index 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 |
13 | diff --git a/MANIFEST.in b/MANIFEST.in |
14 | new file mode 100644 |
15 | index 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/ * |
21 | diff --git a/configs/checkbox-core-snap-classic-beta-config.py b/configs/checkbox-core-snap-classic-beta-config.py |
22 | new file mode 100644 |
23 | index 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 | +} |
47 | diff --git a/configs/dev-ppa-config.py b/configs/dev-ppa-config.py |
48 | new file mode 100644 |
49 | index 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 | +} |
70 | diff --git a/configs/testing-ppa-config.py b/configs/testing-ppa-config.py |
71 | new file mode 100644 |
72 | index 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 | +} |
93 | diff --git a/metabox/__init__.py b/metabox/__init__.py |
94 | new file mode 100644 |
95 | index 0000000..e69de29 |
96 | --- /dev/null |
97 | +++ b/metabox/__init__.py |
98 | diff --git a/metabox/core/__init__.py b/metabox/core/__init__.py |
99 | new file mode 100644 |
100 | index 0000000..e69de29 |
101 | --- /dev/null |
102 | +++ b/metabox/core/__init__.py |
103 | diff --git a/metabox/core/actions.py b/metabox/core/actions.py |
104 | new file mode 100644 |
105 | index 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' |
206 | diff --git a/metabox/core/aggregator.py b/metabox/core/aggregator.py |
207 | new file mode 100644 |
208 | index 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() |
262 | diff --git a/metabox/core/configuration.py b/metabox/core/configuration.py |
263 | new file mode 100644 |
264 | index 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 |
412 | diff --git a/metabox/core/keys.py b/metabox/core/keys.py |
413 | new file mode 100644 |
414 | index 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 |
454 | diff --git a/metabox/core/lxd_execute.py b/metabox/core/lxd_execute.py |
455 | new file mode 100644 |
456 | index 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 |
696 | diff --git a/metabox/core/lxd_provider.py b/metabox/core/lxd_provider.py |
697 | new file mode 100644 |
698 | index 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) |
931 | diff --git a/metabox/core/machine.py b/metabox/core/machine.py |
932 | new file mode 100644 |
933 | index 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)) |
1370 | diff --git a/metabox/core/runner.py b/metabox/core/runner.py |
1371 | new file mode 100644 |
1372 | index 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 |
1597 | diff --git a/metabox/core/scenario.py b/metabox/core/scenario.py |
1598 | new file mode 100644 |
1599 | index 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() |
1847 | diff --git a/metabox/core/utils.py b/metabox/core/utils.py |
1848 | new file mode 100644 |
1849 | index 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)) |
1902 | diff --git a/metabox/lxd_profiles/checkbox.profile b/metabox/lxd_profiles/checkbox.profile |
1903 | new file mode 100644 |
1904 | index 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 |
1943 | diff --git a/metabox/lxd_profiles/lowmem.profile b/metabox/lxd_profiles/lowmem.profile |
1944 | new file mode 100644 |
1945 | index 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 |
1952 | diff --git a/metabox/lxd_profiles/snap.profile b/metabox/lxd_profiles/snap.profile |
1953 | new file mode 100644 |
1954 | index 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 |
1982 | diff --git a/metabox/main.py b/metabox/main.py |
1983 | index 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() |
2062 | diff --git a/metabox/metabox-provider/manage.py b/metabox/metabox-provider/manage.py |
2063 | new file mode 100755 |
2064 | index 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 | +) |
2077 | diff --git a/metabox/metabox-provider/units/basic-jobs.pxu b/metabox/metabox-provider/units/basic-jobs.pxu |
2078 | new file mode 100644 |
2079 | index 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? |
2307 | diff --git a/metabox/metabox-provider/units/basic-tps.pxu b/metabox/metabox-provider/units/basic-tps.pxu |
2308 | new file mode 100644 |
2309 | index 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 |
2352 | diff --git a/metabox/scenarios/__init__.py b/metabox/scenarios/__init__.py |
2353 | new file mode 100644 |
2354 | index 0000000..e69de29 |
2355 | --- /dev/null |
2356 | +++ b/metabox/scenarios/__init__.py |
2357 | diff --git a/metabox/scenarios/basic/__init__.py b/metabox/scenarios/basic/__init__.py |
2358 | new file mode 100644 |
2359 | index 0000000..e69de29 |
2360 | --- /dev/null |
2361 | +++ b/metabox/scenarios/basic/__init__.py |
2362 | diff --git a/metabox/scenarios/basic/run-invocation.py b/metabox/scenarios/basic/run-invocation.py |
2363 | new file mode 100644 |
2364 | index 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(' [32;1m☑ [0m: ' |
2452 | + 'A simple user interaction and verification job'), |
2453 | + ] |
2454 | diff --git a/metabox/scenarios/desktop_env/launcher.py b/metabox/scenarios/desktop_env/launcher.py |
2455 | new file mode 100644 |
2456 | index 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 | + ] |
2536 | diff --git a/metabox/scenarios/restart/launcher.py b/metabox/scenarios/restart/launcher.py |
2537 | new file mode 100644 |
2538 | index 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 | + ] |
2588 | diff --git a/metabox/scenarios/urwid/run-invocation.py b/metabox/scenarios/urwid/run-invocation.py |
2589 | new file mode 100644 |
2590 | index 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(' [32;1m☑ [0m: ' |
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 | + ] |
2705 | diff --git a/setup.py b/setup.py |
2706 | index 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 | ) |
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.