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