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