Merge ~maas-committers/maas-ci/+git/system-tests:MAASENG-1717-Automated-Image-Testing-feature-branch into ~maas-committers/maas-ci/+git/system-tests:master
- Git
- lp:~maas-committers/maas-ci/+git/system-tests
- MAASENG-1717-Automated-Image-Testing-feature-branch
- Merge into master
Status: | Merged |
---|---|
Approved by: | Alexsander de Souza |
Approved revision: | 02b5fbe61ed5bfe5bffa33775938b9af25486261 |
Merge reported by: | MAAS Lander |
Merged at revision: | not available |
Proposed branch: | ~maas-committers/maas-ci/+git/system-tests:MAASENG-1717-Automated-Image-Testing-feature-branch |
Merge into: | ~maas-committers/maas-ci/+git/system-tests:master |
Diff against target: |
2522 lines (+1936/-71) 26 files modified
.gitignore (+7/-7) image_mapping.yaml.sample (+17/-17) setup.py (+2/-0) systemtests/api.py (+36/-0) systemtests/conftest.py (+3/-1) systemtests/fixtures.py (+4/-1) systemtests/git_build.py (+14/-0) systemtests/image_builder/test_packer.py (+7/-4) systemtests/image_config.py (+2/-2) systemtests/packer.py (+23/-6) systemtests/state.py (+2/-3) systemtests/tests_per_machine/test_machine.py (+41/-14) systemtests/utils.py (+26/-6) temporal/README.md (+88/-0) temporal/build_results.py (+395/-0) temporal/common_tasks.py (+293/-0) temporal/e2e_worker.py (+10/-0) temporal/e2e_workflow.py (+206/-0) temporal/image_building_worker.py (+10/-0) temporal/image_building_workflow.py (+165/-0) temporal/image_reporting_worker.py (+10/-0) temporal/image_reporting_workflow.py (+450/-0) temporal/image_testing_worker.py (+10/-0) temporal/image_testing_workflow.py (+100/-0) tox.ini (+6/-5) utils/gen_config.py (+9/-5) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MAAS Lander | Approve | ||
Jack Lloyd-Walters | Approve | ||
Review via email:
|
Commit message
automated image testing
adds the capability of:
- building custom images using packer-maas
- testing the deployment of custom images
includes Temporal workflows to build, test and report the results
Co-authored-by: Jack Lloyd-Walters <email address hidden>
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b MAASENG-
STATUS: SUCCESS
COMMIT: db3f8c2f2a2aff7
- 02b5fbe... by Jack Lloyd-Walters
-
rebase changes and merge again
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b MAASENG-
STATUS: SUCCESS
COMMIT: 02b5fbe61ed5bfe
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | index e71b819..3ed1283 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -1,16 +1,16 @@ |
6 | -*.egg-info |
7 | -.vscode |
8 | +__pycache__ |
9 | .idea |
10 | -.tox |
11 | .mypy_cache |
12 | +.tox |
13 | +.vscode |
14 | +*.egg-info |
15 | +base_config.yaml |
16 | +build-*.log |
17 | build/ |
18 | -__pycache__ |
19 | config.yaml |
20 | credentials.yaml |
21 | -base_config.yaml |
22 | image_mapping.yaml |
23 | +images/ |
24 | junit*.xml |
25 | sosreport |
26 | systemtests*.log |
27 | -images/ |
28 | -build/ |
29 | diff --git a/image_mapping.yaml.sample b/image_mapping.yaml.sample |
30 | index d23a1fc..72b2c35 100644 |
31 | --- a/image_mapping.yaml.sample |
32 | +++ b/image_mapping.yaml.sample |
33 | @@ -5,7 +5,7 @@ |
34 | # An example of a mapping is: |
35 | # images: |
36 | # $IMAGE_NAME: |
37 | -# url: $IMAGE_URL |
38 | +# filename: $IMAGE_FILENAME |
39 | # filetype: $IMAGE_FILETYPE |
40 | # architecture: $IMAGE_ARCH |
41 | # osystem: $IMAGE_OSYSTEM |
42 | @@ -17,7 +17,7 @@ |
43 | |
44 | images: |
45 | centos7: |
46 | - url: centos7.tar.gz |
47 | + filename: centos7.tar.gz |
48 | filetype: tgz |
49 | architecture: amd64/generic |
50 | osystem: centos |
51 | @@ -25,7 +25,7 @@ images: |
52 | packer_template: centos7 |
53 | ssh_username: centos |
54 | centos8: |
55 | - url: centos8.tar.gz |
56 | + filename: centos8.tar.gz |
57 | filetype: tgz |
58 | architecture: amd64/generic |
59 | osystem: centos |
60 | @@ -33,7 +33,7 @@ images: |
61 | packer_template: centos8 |
62 | ssh_username: centos |
63 | centos8-stream: |
64 | - url: centos8-stream.tar.gz |
65 | + filename: centos8-stream.tar.gz |
66 | filetype: tgz |
67 | architecture: amd64/generic |
68 | osystem: centos |
69 | @@ -41,7 +41,7 @@ images: |
70 | packer_template: centos8-stream |
71 | ssh_username: centos |
72 | rhel7: |
73 | - url: rhel7.tar.gz |
74 | + filename: rhel7.tar.gz |
75 | filetype: tgz |
76 | architecture: amd64/generic |
77 | osystem: rhel |
78 | @@ -50,7 +50,7 @@ images: |
79 | source_iso: rhel-server-7.9-x86_64-dvd.iso |
80 | ssh_username: cloud-user |
81 | rhel8: |
82 | - url: rhel8.tar.gz |
83 | + filename: rhel8.tar.gz |
84 | filetype: tgz |
85 | architecture: amd64/generic |
86 | osystem: rhel |
87 | @@ -59,7 +59,7 @@ images: |
88 | source_iso: rhel-8.6-x86_64-dvd.iso |
89 | ssh_username: cloud-user |
90 | rhel9: |
91 | - url: rhel9.tar.gz |
92 | + filename: rhel9.tar.gz |
93 | filetype: tgz |
94 | architecture: amd64/generic |
95 | osystem: rhel |
96 | @@ -68,7 +68,7 @@ images: |
97 | source_iso: rhel-baseos-9.1-x86_64-dvd.iso |
98 | ssh_username: cloud-user |
99 | rocky8: |
100 | - url: rocky8.tar.gz |
101 | + filename: rocky8.tar.gz |
102 | filetype: tgz |
103 | architecture: amd64/generic |
104 | osystem: custom |
105 | @@ -77,7 +77,7 @@ images: |
106 | base_image: "rhel/8" |
107 | ssh_username: cloud-user |
108 | rocky9: |
109 | - url: rocky9.tar.gz |
110 | + filename: rocky9.tar.gz |
111 | filetype: tgz |
112 | architecture: amd64/generic |
113 | osystem: custom |
114 | @@ -86,7 +86,7 @@ images: |
115 | base_image: "rhel/9" |
116 | ssh_username: cloud-user |
117 | sles12: |
118 | - url: sles12.tar.gz |
119 | + filename: sles12.tar.gz |
120 | filetype: tgz |
121 | architecture: amd64/generic |
122 | osystem: suse |
123 | @@ -95,7 +95,7 @@ images: |
124 | source_iso: SLES12-SP5-JeOS.x86_64-12.5-OpenStack-Cloud-GM.qcow2 |
125 | ssh_username: sles |
126 | sles15: |
127 | - url: sles15.tar.gz |
128 | + filename: sles15.tar.gz |
129 | filetype: tgz |
130 | architecture: amd64/generic |
131 | osystem: suse |
132 | @@ -104,7 +104,7 @@ images: |
133 | source_iso: SLE-15-SP4-Full-x86_64-GM-Media1.iso |
134 | ssh_username: sles |
135 | esxi6: |
136 | - url: vmware-esxi-6.dd.gz |
137 | + filename: vmware-esxi-6.dd.gz |
138 | filetype: ddgz |
139 | architecture: amd64/generic |
140 | osystem: esxi |
141 | @@ -113,7 +113,7 @@ images: |
142 | source_iso: VMware-VMvisor-Installer-6.7.0.update03-14320388.x86_64.iso |
143 | ssh_username: root |
144 | esxi7: |
145 | - url: vmware-esxi-7.dd.gz |
146 | + filename: vmware-esxi-7.dd.gz |
147 | filetype: ddgz |
148 | architecture: amd64/generic |
149 | osystem: esxi |
150 | @@ -122,7 +122,7 @@ images: |
151 | source_iso: VMware-VMvisor-Installer-7.0U3g-20328353.x86_64.iso |
152 | ssh_username: root |
153 | esxi8: |
154 | - url: vmware-esxi-8.dd.gz |
155 | + filename: vmware-esxi-8.dd.gz |
156 | filetype: ddgz |
157 | architecture: amd64/generic |
158 | osystem: esxi |
159 | @@ -131,7 +131,7 @@ images: |
160 | source_iso: VMware-VMvisor-Installer-8.0b-21203435.x86_64.iso |
161 | ssh_username: root |
162 | ubuntu: |
163 | - url: ubuntu-cloudimg.tar.gz |
164 | + filename: ubuntu-cloudimg.tar.gz |
165 | filetype: tgz |
166 | architecture: amd64/generic |
167 | osystem: custom |
168 | @@ -139,7 +139,7 @@ images: |
169 | packer_template: ubuntu |
170 | packer_target: custom-cloudimg.tar.gz |
171 | ubuntu-flat: |
172 | - url: ubuntu-flat.tar.gz |
173 | + filename: ubuntu-flat.tar.gz |
174 | filetype: tgz |
175 | architecture: amd64/generic |
176 | osystem: custom |
177 | @@ -147,7 +147,7 @@ images: |
178 | packer_template: ubuntu |
179 | packer_target: custom-ubuntu.tar.gz |
180 | ubuntu-lvm: |
181 | - url: ubuntu-lvm.tar.gz |
182 | + filename: ubuntu-lvm.tar.gz |
183 | filetype: ddgz |
184 | architecture: amd64/generic |
185 | osystem: custom |
186 | diff --git a/setup.py b/setup.py |
187 | index f6d6ae4..b6c9b32 100644 |
188 | --- a/setup.py |
189 | +++ b/setup.py |
190 | @@ -1,6 +1,7 @@ |
191 | from setuptools import find_packages, setup |
192 | |
193 | install_requires = ( |
194 | + 'jenkinsapi', |
195 | 'netaddr', |
196 | 'paramiko', |
197 | 'pytest-dependency', |
198 | @@ -12,6 +13,7 @@ install_requires = ( |
199 | 'requests', |
200 | 'retry', |
201 | 'ruamel.yaml', |
202 | + 'temporalio' |
203 | ) |
204 | |
205 | |
206 | diff --git a/systemtests/api.py b/systemtests/api.py |
207 | index ec76b0e..dde94dd 100644 |
208 | --- a/systemtests/api.py |
209 | +++ b/systemtests/api.py |
210 | @@ -78,6 +78,7 @@ class BootSource(TypedDict): |
211 | # TODO: Expand these to TypedDict matching API response structure |
212 | |
213 | Subnet = Dict[str, Any] |
214 | +Interface = Dict[str, Any] |
215 | RackController = Dict[str, Any] |
216 | RegionController = Dict[str, Any] |
217 | IPRange = Dict[str, Any] |
218 | @@ -256,6 +257,7 @@ class AuthenticatedAPIClient: |
219 | architecture: str, |
220 | filetype: str, |
221 | image_file_path: str, |
222 | + base_image: str | None = None, |
223 | ) -> None: |
224 | cmd = [ |
225 | "boot-resources", |
226 | @@ -266,6 +268,8 @@ class AuthenticatedAPIClient: |
227 | f"filetype={filetype}", |
228 | f"content@={image_file_path}", |
229 | ] |
230 | + if base_image: |
231 | + cmd.append(f"base_image={base_image}") |
232 | self.execute(cmd, json_output=False) |
233 | |
234 | def import_boot_resources(self) -> str: |
235 | @@ -716,6 +720,38 @@ class AuthenticatedAPIClient: |
236 | + [f"{k}={v}" for k, v in options.items()] |
237 | ) |
238 | |
239 | + def create_interface( |
240 | + self, machine: Machine, network_type: str, options: dict[str, str] = {} |
241 | + ) -> Interface: |
242 | + """bond, bridge,""" |
243 | + interface: Interface = self.execute( |
244 | + ["interfaces", f"create-{network_type}", machine["system_id"]] |
245 | + + [f"{k}={v}" for k, v in options.items()] |
246 | + ) |
247 | + return interface |
248 | + |
249 | + def delete_interface(self, machine: Machine, interface: Interface) -> str: |
250 | + result: str = self.execute( |
251 | + ["interface", "delete", machine["systed_id"], str(interface["id"])], |
252 | + json_output=False, |
253 | + ) |
254 | + return result |
255 | + |
256 | + def read_interfaces(self, machine: Machine) -> list[Interface]: |
257 | + result: list[Interface] = self.execute( |
258 | + ["interfaces", "read", machine["system_id"]] |
259 | + ) |
260 | + return result |
261 | + |
262 | + def update_interface( |
263 | + self, machine: Machine, interface: Interface, options: dict[str, str] |
264 | + ) -> Interface: |
265 | + updated_interface: Interface = self.execute( |
266 | + ["interface", "update", machine["system_id"], str(interface["id"])] |
267 | + + [f"{k}={v}" for k, v in options.items()] |
268 | + ) |
269 | + return updated_interface |
270 | + |
271 | |
272 | class QuietAuthenticatedAPIClient(AuthenticatedAPIClient): |
273 | """An Authenticated API Client that is quiet.""" |
274 | diff --git a/systemtests/conftest.py b/systemtests/conftest.py |
275 | index a069d84..6acabf7 100644 |
276 | --- a/systemtests/conftest.py |
277 | +++ b/systemtests/conftest.py |
278 | @@ -358,7 +358,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None: |
279 | metafunc.parametrize("instance_config", instance_config, ids=str, indirect=True) |
280 | |
281 | if "image_to_test" in metafunc.fixturenames: |
282 | - if images_to_test := [image for image in generate_images(cfg) if image.url]: |
283 | + if images_to_test := [ |
284 | + image for image in generate_images(cfg) if image.filename |
285 | + ]: |
286 | metafunc.parametrize( |
287 | "image_to_test", images_to_test, ids=str, indirect=True |
288 | ) |
289 | diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py |
290 | index 7521c7d..e53ba45 100644 |
291 | --- a/systemtests/fixtures.py |
292 | +++ b/systemtests/fixtures.py |
293 | @@ -763,7 +763,9 @@ def dns_tester( |
294 | |
295 | |
296 | @pytest.fixture(scope="session") |
297 | -def packer_main(config: dict[str, Any]) -> Optional[Iterator[PackerMain]]: |
298 | +def packer_main( |
299 | + request: pytest.FixtureRequest, config: dict[str, Any] |
300 | +) -> Optional[Iterator[PackerMain]]: |
301 | """Set up a new LXD container with Packer installed.""" |
302 | packer_config = config.get("packer-maas", {}) |
303 | repo = packer_config.get("git-repo") |
304 | @@ -787,6 +789,7 @@ def packer_main(config: dict[str, Any]) -> Optional[Iterator[PackerMain]]: |
305 | proxy_env=proxy_env, |
306 | file_store=config.get("file-store", {}), |
307 | debug=packer_config.get("verbosity", ""), |
308 | + root_path=request.config.rootpath, |
309 | ) |
310 | main.setup() |
311 | yield main |
312 | diff --git a/systemtests/git_build.py b/systemtests/git_build.py |
313 | index 342fa0c..3803322 100644 |
314 | --- a/systemtests/git_build.py |
315 | +++ b/systemtests/git_build.py |
316 | @@ -5,6 +5,7 @@ from contextlib import closing |
317 | from functools import partial |
318 | from pathlib import Path |
319 | from subprocess import CalledProcessError |
320 | +from textwrap import dedent |
321 | from timeit import Timer |
322 | from typing import TYPE_CHECKING, Any, Callable |
323 | from urllib.request import urlopen |
324 | @@ -33,6 +34,7 @@ class GitBuild: |
325 | self._repos = repo |
326 | self._branch = branch |
327 | self._clone_path = clone_path |
328 | + self._set_apt_proxy() |
329 | |
330 | @property |
331 | def clone_path(self) -> str: |
332 | @@ -46,6 +48,18 @@ class GitBuild: |
333 | def logger(self, logger: Logger) -> None: |
334 | self._instance.logger = logger |
335 | |
336 | + def _set_apt_proxy(self) -> None: |
337 | + if proxy := self._env.get("http_proxy"): |
338 | + conf = self._instance.files["/etc/apt/apt.conf.d/99-proxy.conf"] |
339 | + conf.write( |
340 | + dedent( |
341 | + f"""\ |
342 | + Acquire::http::Proxy "{proxy}"; |
343 | + Acquire::https::Proxy "{proxy}"; |
344 | + """ |
345 | + ) |
346 | + ) |
347 | + |
348 | def apt_update(self) -> None: |
349 | """Update APT indices, fix broken dpkg.""" |
350 | self._instance.quietly_execute( |
351 | diff --git a/systemtests/image_builder/test_packer.py b/systemtests/image_builder/test_packer.py |
352 | index 3bf5836..3619ff2 100644 |
353 | --- a/systemtests/image_builder/test_packer.py |
354 | +++ b/systemtests/image_builder/test_packer.py |
355 | @@ -19,7 +19,10 @@ class TestPackerMAASConfig: |
356 | assert readme.exists(), f"README.md not found in {packer_main.clone_path}" |
357 | |
358 | def test_build_image( |
359 | - self, testlog: Logger, packer_main: PackerMain, image_to_build: TestableImage |
360 | + self, |
361 | + testlog: Logger, |
362 | + packer_main: PackerMain, |
363 | + image_to_build: TestableImage, |
364 | ) -> None: |
365 | # tell mypy we have this under control |
366 | assert image_to_build.packer_template is not None |
367 | @@ -28,12 +31,12 @@ class TestPackerMAASConfig: |
368 | image = packer_main.build_image( |
369 | image_to_build.packer_template, |
370 | image_to_build.packer_target, |
371 | - image_to_build.filename, |
372 | + image_to_build.packer_filename, |
373 | image_to_build.source_iso, |
374 | ) |
375 | assert image is not None |
376 | img_file = packer_main._instance.files[image] |
377 | assert img_file.exists(), f"failed to produce the expected image ({img_file})" |
378 | |
379 | - if image_to_build.url is not None: |
380 | - packer_main.upload_image(img_file, image_to_build.url) |
381 | + if image_to_build.filename: |
382 | + packer_main.upload_image(img_file, image_to_build.filename) |
383 | diff --git a/systemtests/image_config.py b/systemtests/image_config.py |
384 | index 4f7a0e0..d92bff2 100644 |
385 | --- a/systemtests/image_config.py |
386 | +++ b/systemtests/image_config.py |
387 | @@ -21,7 +21,7 @@ EXTENSION_MAP = { |
388 | @dataclass(frozen=True) |
389 | class TestableImage: |
390 | name: str |
391 | - url: str | None |
392 | + filename: str |
393 | filetype: str = "targz" |
394 | architecture: str = "amd64/generic" |
395 | osystem: str = "ubuntu" |
396 | @@ -48,7 +48,7 @@ class TestableImage: |
397 | ) |
398 | |
399 | @property |
400 | - def filename(self) -> str: |
401 | + def packer_filename(self) -> str: |
402 | ext = EXTENSION_MAP[self.filetype] |
403 | if self.packer_template is None: |
404 | return f"{self.name}.{ext}" |
405 | diff --git a/systemtests/packer.py b/systemtests/packer.py |
406 | index 693d6a4..03beb5e 100644 |
407 | --- a/systemtests/packer.py |
408 | +++ b/systemtests/packer.py |
409 | @@ -29,6 +29,7 @@ class PackerMain(GitBuild): |
410 | file_store: dict[str, Any], |
411 | proxy_env: dict[str, str] | None, |
412 | debug: str | None, |
413 | + root_path: Path, |
414 | ) -> None: |
415 | super().__init__( |
416 | packer_repo, |
417 | @@ -40,8 +41,14 @@ class PackerMain(GitBuild): |
418 | ) |
419 | self.default_debug = debug or "" |
420 | self.file_store = file_store |
421 | + self.root_path = root_path |
422 | |
423 | def setup(self) -> None: |
424 | + if "http_proxy" in self._env: |
425 | + sudoers = self._instance.files["/etc/sudoers.d/50-preserve-proxy"] |
426 | + sudoers.write( |
427 | + 'Defaults env_keep += "ftp_proxy http_proxy https_proxy no_proxy"' |
428 | + ) |
429 | self.apt_source_add( |
430 | "packer", |
431 | "https://apt.releases.hashicorp.com", |
432 | @@ -101,8 +108,14 @@ class PackerMain(GitBuild): |
433 | source_iso: str | None, |
434 | ) -> str | None: |
435 | env = self._env.copy() |
436 | + env["SUDO"] = "sudo -E" |
437 | + log_file = f"build-{packer_template}-{packer_target or 'all'}.log" |
438 | + env["PACKER_LOG"] = "on" |
439 | + env["PACKER_LOG_PATH"] = f"{self.clone_path}/{log_file}" |
440 | if source_iso: |
441 | env["ISO"] = self.download_image(source_iso) |
442 | + if proxy := env.get("https_proxy"): |
443 | + env["KS_PROXY"] = f'--proxy="{proxy}"' |
444 | cmd: list[str] = [ |
445 | "eatmydata", |
446 | "make", |
447 | @@ -110,12 +123,16 @@ class PackerMain(GitBuild): |
448 | f"{self.clone_path}/{packer_template}", |
449 | f"{packer_target or 'all'}", |
450 | ] |
451 | - runtime = self.timed( |
452 | - self._instance.execute, |
453 | - command=cmd, |
454 | - environment=env, |
455 | - ) |
456 | - self.logger.info(f"Image built in {runtime:.2f}s") |
457 | + try: |
458 | + runtime = self.timed( |
459 | + self._instance.execute, |
460 | + command=cmd, |
461 | + environment=env, |
462 | + ) |
463 | + self.logger.info(f"Image built in {runtime:.2f}s") |
464 | + finally: |
465 | + build_log = self._instance.files[env["PACKER_LOG_PATH"]] |
466 | + build_log.pull(str(self.root_path / log_file)) |
467 | return f"{self.clone_path}/{packer_template}/{img_filename}" |
468 | |
469 | def __repr__(self) -> str: |
470 | diff --git a/systemtests/state.py b/systemtests/state.py |
471 | index 36b89ba..7ca5be8 100644 |
472 | --- a/systemtests/state.py |
473 | +++ b/systemtests/state.py |
474 | @@ -10,9 +10,8 @@ from urllib.parse import urljoin, urlparse |
475 | import pytest |
476 | from retry import retry |
477 | |
478 | -from systemtests.image_config import TestableImage |
479 | -from systemtests.packer import UnknowStorageBackendError |
480 | - |
481 | +from .image_config import TestableImage |
482 | +from .packer import UnknowStorageBackendError |
483 | from .region import get_rack_controllers |
484 | from .utils import waits_for_event_after |
485 | |
486 | diff --git a/systemtests/tests_per_machine/test_machine.py b/systemtests/tests_per_machine/test_machine.py |
487 | index c4995b3..7c11328 100644 |
488 | --- a/systemtests/tests_per_machine/test_machine.py |
489 | +++ b/systemtests/tests_per_machine/test_machine.py |
490 | @@ -11,6 +11,7 @@ from ..utils import ( |
491 | assert_machine_in_machines, |
492 | assert_machine_not_in_machines, |
493 | release_and_redeploy_machine, |
494 | + report_feature_tests, |
495 | ssh_execute_command, |
496 | wait_for_machine, |
497 | wait_for_machine_to_power_off, |
498 | @@ -27,7 +28,7 @@ if TYPE_CHECKING: |
499 | from ..machine_config import MachineConfig |
500 | |
501 | |
502 | -@test_steps("enlist", "metadata", "commission", "deploy", "rescue") |
503 | +@test_steps("enlist", "metadata", "commission", "deploy", "test_image", "rescue") |
504 | def test_full_circle( |
505 | maas_api_client: AuthenticatedAPIClient, |
506 | machine_config: MachineConfig, |
507 | @@ -147,21 +148,47 @@ def test_full_circle( |
508 | yield |
509 | |
510 | if image_to_test: |
511 | - testable_layouts = ["flat", "lvm", "bcache"] |
512 | - for storage_layout in testable_layouts: |
513 | - testlog.info(f"Testing storage layout: {storage_layout}") |
514 | - passed = False |
515 | - try: |
516 | + testable_configs: dict[str, dict[str, str]] = { |
517 | + "bond": {"parents": "1"}, |
518 | + "bridge": {}, |
519 | + } |
520 | + for network_config, network_options in testable_configs.items(): |
521 | + with report_feature_tests(testlog, f"network layout {network_config}"): |
522 | with release_and_redeploy_machine( |
523 | - maas_api_client, machine, timeout=TIMEOUT |
524 | - ) as redeployed: |
525 | - maas_api_client.create_storage_layout( |
526 | - redeployed, storage_layout, {} |
527 | + maas_api_client, |
528 | + machine, |
529 | + osystem=deploy_osystem, |
530 | + oseries=deploy_oseries, |
531 | + timeout=TIMEOUT, |
532 | + ): |
533 | + interface = maas_api_client.create_interface( |
534 | + machine, network_config, network_options |
535 | ) |
536 | - passed = True |
537 | - finally: |
538 | - status = "PASSED" if passed else "FAILED" |
539 | - testlog.info(f"Storage layout: {storage_layout} {status}") |
540 | + assert interface in maas_api_client.read_interfaces(machine) |
541 | + with release_and_redeploy_machine( |
542 | + maas_api_client, |
543 | + machine, |
544 | + osystem=deploy_osystem, |
545 | + oseries=deploy_oseries, |
546 | + timeout=TIMEOUT, |
547 | + ): |
548 | + maas_api_client.delete_interface(machine, interface) |
549 | + assert interface not in maas_api_client.read_interfaces(machine) |
550 | + testable_layouts = ["flat", "lvm", "bcache"] |
551 | + for storage_layout in testable_layouts: |
552 | + with report_feature_tests( |
553 | + testlog, f"storage layout {storage_layout}" |
554 | + ), release_and_redeploy_machine( |
555 | + maas_api_client, |
556 | + machine, |
557 | + osystem=deploy_osystem, |
558 | + oseries=deploy_oseries, |
559 | + timeout=TIMEOUT, |
560 | + ): |
561 | + # release the machine, add a new storage layout, |
562 | + # assert the machine can redeploy |
563 | + maas_api_client.create_storage_layout(machine, storage_layout, {}) |
564 | + yield |
565 | |
566 | if deploy_osystem == "windows" or ( |
567 | deploy_osystem == "custom" and deploy_oseries.startswith("esxi") |
568 | diff --git a/systemtests/utils.py b/systemtests/utils.py |
569 | index 66ebc8b..b412813 100644 |
570 | --- a/systemtests/utils.py |
571 | +++ b/systemtests/utils.py |
572 | @@ -9,6 +9,7 @@ import time |
573 | from contextlib import contextmanager |
574 | from dataclasses import dataclass |
575 | from logging import Logger |
576 | +from subprocess import CalledProcessError |
577 | from typing import Iterator, Optional, TypedDict, Union |
578 | |
579 | import paramiko |
580 | @@ -300,32 +301,51 @@ def assert_machine_not_in_machines( |
581 | def release_and_redeploy_machine( |
582 | maas_api_client: api.AuthenticatedAPIClient, |
583 | machine: api.Machine, |
584 | + osystem: str, |
585 | + oseries: str | None = None, |
586 | timeout: int = 60 * 40, |
587 | ) -> Iterator[api.Machine]: |
588 | - name, osystem = machine["name"], machine["osystem"] |
589 | try: |
590 | maas_api_client.release_machine(machine) |
591 | - wait_for_machine( |
592 | + yield wait_for_machine( |
593 | maas_api_client, |
594 | machine, |
595 | status="Ready", |
596 | abort_status="Releasing failed", |
597 | - machine_id=name, |
598 | timeout=timeout, |
599 | ) |
600 | - yield machine |
601 | finally: |
602 | - maas_api_client.deploy_machine(machine, osystem=osystem) |
603 | + maas_api_client.deploy_machine( |
604 | + machine, osystem=osystem, distro_series=oseries or osystem |
605 | + ) |
606 | wait_for_machine( |
607 | maas_api_client, |
608 | machine, |
609 | status="Deployed", |
610 | abort_status="Failed deployment", |
611 | - machine_id=name, |
612 | timeout=timeout, |
613 | ) |
614 | |
615 | |
616 | +@contextmanager |
617 | +def report_feature_tests(testlog: Logger, feature_name: str) -> Iterator[Logger]: |
618 | + """Return a context manager for reporting on a feature. |
619 | + Ensures we always report a paas/fail state, irrespective of errors. |
620 | + """ |
621 | + feature_status = False |
622 | + feature_logger = testlog.getChild(feature_name) |
623 | + feature_logger.info("Starting test") |
624 | + try: |
625 | + yield feature_logger |
626 | + feature_status = True |
627 | + except CalledProcessError as exc: |
628 | + feature_logger.exception(exc.stderr) |
629 | + except Exception as e: |
630 | + feature_logger.exception(e) |
631 | + finally: |
632 | + feature_logger.info("PASSED" if feature_status else "FAILED") |
633 | + |
634 | + |
635 | @dataclass |
636 | class IPRange: |
637 | start: ipaddress.IPv4Address |
638 | diff --git a/temporal/README.md b/temporal/README.md |
639 | new file mode 100644 |
640 | index 0000000..3817166 |
641 | --- /dev/null |
642 | +++ b/temporal/README.md |
643 | @@ -0,0 +1,88 @@ |
644 | +# Temporal workflows for OS Image Testing |
645 | + |
646 | +Here be dragons. |
647 | +(Well, maybe not quite) |
648 | + |
649 | +Contained are the set of scripts required to take a supported image in the [PackerMAAS](https://github.com/canonical/packer-maas/tree/main) repository, build and test it's capabilities on a set MAAS version, and report the results of those tests to a [results area](https://github.com/maas/MAAS-Image-Results) ready to be consumed by documentation. |
650 | + |
651 | +## Workflows |
652 | + |
653 | +We distribute four workflows, each with a correspondingly named worker that should be ran to execute that workflow. |
654 | + |
655 | +- `image_building_workflow` - Builds an image according to the makefile listed in PackerMAAS. |
656 | +- `image_testing_workflow` - Tests an image against `tests_per_mahcine` in this repo, |
657 | +- `image_reporting_workflow` - Compiles the results of the two above workflows into YAML, exporting it to the remote store. |
658 | +- `e2e_workflow` - Orchestrates the above as child workflows. Additionally performs some some mild pre-processing for the `image_reporting` workflow. |
659 | + |
660 | +## Execution |
661 | + |
662 | +Connect all four workers to a running temporal server instance. An image test can then be requested with a single call to `e2e_workflow`, such as: |
663 | +```bash |
664 | +temporal workflow start -t e2e_tests --type e2e_workflow -w 'centos_tests' -i '{"image_name": ["centos7", "centos8"], "maas_snap_channel": "3.3/stable", "jenkins_url": $jenkins_url, "jenkins_user": $jenkins_user, "jenkins_pass": $jenkins_pass}' |
665 | +``` |
666 | + |
667 | +The `e2e_workflow` will then call it's children workflows as required to test the requested images. |
668 | + |
669 | +### Parameters |
670 | + |
671 | +#### Required |
672 | + |
673 | +- `image_name` - The name, or list of names, of images to test. |
674 | + |
675 | +- Jenkins details |
676 | + |
677 | + - `jenkins_url` - The url of the Jenkins server where image tests are located. |
678 | + |
679 | + - `jenkins_user` - The username to use to login to the Jenkins server. |
680 | + |
681 | + - `jenkins_pass` - The password to use to login to the Jenkins server. |
682 | + |
683 | +#### Optional |
684 | + |
685 | +- Filepaths |
686 | + |
687 | + - `image_mapping` - The filepath of the image mapping YAML distributed as part of MAAS-Integration-CI, defaults as `image_mapping.yaml` in the current working directory. |
688 | + |
689 | + - `repo_location` - The filepath of the location where the image results repo is to be cloned. |
690 | + |
691 | +- Test instances |
692 | + |
693 | + - `maas_snap_channel` - The snap channel to use when installing MAAS in image tests, defaults as `latest/edge`. |
694 | + |
695 | + - `system_test_repo` - The url of the system-tests repo to use for building and testing images, defaults as `https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests`. |
696 | + |
697 | + - `system_test_branch` - The branch in the system-test repo to use for building and tetsing images, defaults as `master`. |
698 | + |
699 | + - `packer_maas_repo` - The url of the PackerMAAS repo to use for building images, defaults as `https://github.com/canonical/packer-maas.git`. |
700 | + |
701 | + - `packer_maas_branch` - The branch in the PackerMAas repo to use for building images, defaults as `main`. |
702 | + |
703 | + - `parallel_tests` - A flag to request a single image test build for all images, rather than a test build per image, defaults as `False`. |
704 | + |
705 | + - `overwite_results` - A flag to request new results overwrite old results rather than combining with them, defaults as `False`. |
706 | + |
707 | +- Retries |
708 | + |
709 | + - `max_retry_attempts` - How many times workflow activities should retry before throwing an exception, defaults as `10` |
710 | + |
711 | + - `heartbeat_delay` - How many seconds between heartbeats for long running workflow activities, defaults as `15` |
712 | + |
713 | +- Timeouts |
714 | + |
715 | + - Timeouts given are in seconds, and are passed to temporal as [`start_to_close`](https://www.temporal.io/blog/activity-timeouts), which defines the maximum execution time of a single invocation. |
716 | + |
717 | + - `default_timeout` - How long a workflow activity can run before being timed out, defaults as `300`. This is used in place of any timeouts below that are not set. |
718 | + |
719 | + - `jenkins_login_timeout` - How long we wait to log into the Jenkins server. |
720 | + |
721 | + - `return_status_timeout` - How long we wait for an activity to fetch the status of a Jenkins build. |
722 | + |
723 | + - `get_results_timeout` - How long we wait for the results of a Jenkins build to be available. |
724 | + |
725 | + - `fetch_results_timeout` - How long we wait for an activity to fetch the results of a Jenkins build, and perform some operation on them. |
726 | + |
727 | + - `log_details_timeout` - How long we wait for an activity to fetch logs from a Jenkins build, and perform some operation on them. |
728 | + |
729 | + - `request_build_timeout` - How long we wait for an activity to request a Jenkins build. |
730 | + |
731 | + - `build_complete_timeout` - How long we wait for a Jenkins build to complete, defaults as `7200`. |
732 | diff --git a/temporal/build_results.py b/temporal/build_results.py |
733 | new file mode 100644 |
734 | index 0000000..f98eed8 |
735 | --- /dev/null |
736 | +++ b/temporal/build_results.py |
737 | @@ -0,0 +1,395 @@ |
738 | +from __future__ import annotations |
739 | + |
740 | +import re |
741 | +import subprocess |
742 | +from collections import defaultdict |
743 | +from contextlib import contextmanager |
744 | +from dataclasses import dataclass |
745 | +from functools import cached_property |
746 | +from typing import Any, Iterator |
747 | + |
748 | +from common_tasks import cleanup_files |
749 | + |
750 | + |
751 | +class TestStatus: |
752 | + # failure |
753 | + FAILED = 0 |
754 | + REGRESSION = 1 |
755 | + # successes |
756 | + PASSED = 10 |
757 | + FIXED = 11 |
758 | + # no known state |
759 | + UNKNOWN = 100 |
760 | + |
761 | + def __init__(self, state: str | None = None, code: int | None = None) -> None: |
762 | + if state is None and code is None: |
763 | + s, c = "UNKNOWN", self.UNKNOWN |
764 | + elif state is None and code is not None: |
765 | + s, c = self._code_to_state_(code), code |
766 | + elif state is not None and code is None: |
767 | + s, c = state, self._state_to_code_(state) |
768 | + elif state is not None and code is not None: |
769 | + s, c = state, code |
770 | + self._state_, self._code_ = s, c |
771 | + |
772 | + def __str__(self) -> str: |
773 | + return f"{self._state_} {self._code_}" |
774 | + |
775 | + def __repr__(self) -> str: |
776 | + return str(self) |
777 | + |
778 | + @cached_property |
779 | + def _code_state_map_(self) -> dict[int, str]: |
780 | + return { |
781 | + getattr(self, attr): attr for attr in dir(self) if not attr.startswith("_") |
782 | + } |
783 | + |
784 | + @cached_property |
785 | + def _state_code_map_(self) -> dict[str, int]: |
786 | + return {v: k for k, v in self._code_state_map_.items()} |
787 | + |
788 | + def _code_to_state_(self, code: int) -> str: |
789 | + return self._code_state_map_.get(code, "UNKNOWN") |
790 | + |
791 | + def _state_to_code_(self, state: str) -> int: |
792 | + return self._state_code_map_.get(state.upper(), self.UNKNOWN) |
793 | + |
794 | + def _is_positive_state_(self, state: str) -> bool: |
795 | + return self._is_positive_code_(self._state_to_code_(state)) |
796 | + |
797 | + def _is_positive_code_(self, code: int) -> bool: |
798 | + return False if code == self.UNKNOWN else code >= self.PASSED |
799 | + |
800 | + @property |
801 | + def _is_positive_(self) -> bool: |
802 | + return self._is_positive_code_(self._code_) |
803 | + |
804 | + @property |
805 | + def _has_custom_state_(self) -> bool: |
806 | + return (self._state_to_code_(self._state_) == self.UNKNOWN) and ( |
807 | + self._state_ != "UNKNOWN" |
808 | + ) |
809 | + |
810 | + def to_dict(self) -> dict[str, str | int]: |
811 | + return {"state": self._state_, "code": self._code_} |
812 | + |
813 | + def __add__(self, other: Any) -> TestStatus: |
814 | + if not isinstance(other, TestStatus): |
815 | + return self |
816 | + newcode = min(self._code_, other._code_) |
817 | + custom_states = [self._has_custom_state_, other._has_custom_state_] |
818 | + if all(custom_states): |
819 | + newstate = self._state_ + "; " + other._state_ |
820 | + elif any(custom_states): |
821 | + newstate = self._state_ if self._has_custom_state_ else other._state_ |
822 | + else: |
823 | + newstate = self._code_to_state_(newcode) |
824 | + return TestStatus(newstate, newcode) |
825 | + |
826 | + def __radd__(self, other: Any) -> TestStatus: |
827 | + if isinstance(other, TestStatus): |
828 | + return self + other |
829 | + return self |
830 | + |
831 | + def __iadd__(self, other: Any) -> TestStatus: |
832 | + if isinstance(other, TestStatus): |
833 | + return self + other |
834 | + return self |
835 | + |
836 | + |
837 | +@dataclass |
838 | +class FeatureStatus: |
839 | + name: str = "" |
840 | + state: bool = False |
841 | + readable_state: str | dict[str, Any] = "Failed" |
842 | + info: str = "Could not complete test" |
843 | + |
844 | + def __str__(self) -> str: |
845 | + return "\n - ".join([f"{self.name}: {self.readable_state}", self.info]) |
846 | + |
847 | + def to_dict(self) -> dict[str, Any]: |
848 | + return { |
849 | + self.name: { |
850 | + "state": "passed" if self.state else "failed", |
851 | + "summary": self.readable_state, |
852 | + "info": self.info, |
853 | + } |
854 | + } |
855 | + |
856 | + def __add__(self, other: FeatureStatus) -> FeatureStatus: |
857 | + if not other.state: |
858 | + return self |
859 | + elif not self.state: |
860 | + return other |
861 | + if self.name != other.name: |
862 | + raise Exception(f"{other} does not correspond to the same feature!") |
863 | + return FeatureStatus( |
864 | + name=self.name, |
865 | + state=self.state or other.state, |
866 | + readable_state=self.readable_state, |
867 | + info=self.info, |
868 | + ) |
869 | + |
870 | + |
871 | +class ImageTestResults: |
872 | + def __init__( |
873 | + self, |
874 | + image: str = "", |
875 | + maas_version: list[str] = [], |
876 | + packer_version: list[str] = [], |
877 | + readable_state: str = "", |
878 | + tested_arches: list[str] = [], |
879 | + prerequisites: list[str] = [], |
880 | + ) -> None: |
881 | + self.image = image |
882 | + self.maas_version = maas_version |
883 | + self.readable_state = readable_state |
884 | + self.tested_arches = tested_arches |
885 | + self.packer_version = packer_version |
886 | + self.prerequisites = prerequisites |
887 | + |
888 | + @property |
889 | + def _feature_dicts_(self) -> dict[str, Any]: |
890 | + out: dict[str, Any] = {} |
891 | + for feature in self._results_: |
892 | + out |= getattr(self, feature).to_dict() |
893 | + return out |
894 | + |
895 | + @property |
896 | + def _features_(self) -> list[str]: |
897 | + """Return a short summary of all test results of all features |
898 | + for MAAS Image tests.""" |
899 | + return [getattr(self, feature) for feature in self._results_] |
900 | + |
901 | + @property |
902 | + def _results_(self) -> list[str]: |
903 | + """Return a list of all features whose results have been collected""" |
904 | + return list(set(self.__dict__) - set(ImageTestResults().__dict__)) |
905 | + |
906 | + def __str__(self) -> str: |
907 | + return "\n".join( |
908 | + [f"{self.image}: {self.readable_state}"] |
909 | + + [str(feature) for feature in self._features_] |
910 | + ) |
911 | + |
912 | + @property |
913 | + def state(self) -> str: |
914 | + """Image test state, short pass/fail result as a single bianry string. |
915 | + results formatted as: |
916 | + 0b00000{storage}{network}{deploy}""" |
917 | + byte = sum( |
918 | + 2**i * getattr(result, "state", 0) |
919 | + for i, result in enumerate(self._results_) |
920 | + ) |
921 | + return f"{byte:08b}" |
922 | + |
923 | + def to_dict(self) -> dict[str, Any]: |
924 | + return { |
925 | + self.image: { |
926 | + "summary": self.readable_state, |
927 | + "maas_version": self.maas_version, |
928 | + "architectures": list(self.tested_arches), |
929 | + "packer_versions": self.packer_version, |
930 | + "prerequisites": list(self.prerequisites), |
931 | + } |
932 | + | self._feature_dicts_ |
933 | + } |
934 | + |
935 | + def from_dict(self, fromdict: dict[str, Any]) -> ImageTestResults: |
936 | + image, details = tuple(fromdict.items())[0] |
937 | + results = ImageTestResults( |
938 | + image=image, |
939 | + maas_version=details.get("maas_version", []), |
940 | + packer_version=details.get("packer_versions", []), |
941 | + readable_state=details.get("summary", ""), |
942 | + tested_arches=details.get("architectures", []), |
943 | + prerequisites=details.get("prerequisites", []), |
944 | + ) |
945 | + for key in list(results.to_dict().values())[0].keys(): |
946 | + details.pop(key) |
947 | + for feature, feature_dict in details.items(): |
948 | + setattr( |
949 | + results, |
950 | + feature, |
951 | + FeatureStatus( |
952 | + name=feature, |
953 | + state=feature_dict["state"] == "passed", |
954 | + readable_state=feature_dict["summary"], |
955 | + info=feature_dict["info"], |
956 | + ), |
957 | + ) |
958 | + return results |
959 | + |
960 | + def __add__(self, other: ImageTestResults) -> ImageTestResults: |
961 | + if self.image != other.image: |
962 | + raise Exception(f"{other} does not correspond to the same image!") |
963 | + # return itself if the other failed |
964 | + if not int(other.state, 2) & 1: |
965 | + return self |
966 | + elif not int(self.state, 2) & 1: |
967 | + return other |
968 | + |
969 | + def force_set(var: str | list[Any] | set[Any]) -> set[Any]: |
970 | + return set([var]) if isinstance(var, str) else set(var) |
971 | + |
972 | + def combine_sets( |
973 | + var: str | list[Any] | set[Any], var2: str | list[Any] | set[Any] |
974 | + ) -> list[Any]: |
975 | + return list(force_set(var).union(force_set(var2))) |
976 | + |
977 | + combined_state = TestStatus(state=self.readable_state) + TestStatus( |
978 | + state=other.readable_state |
979 | + ) |
980 | + results = ImageTestResults( |
981 | + image=self.image, |
982 | + maas_version=combine_sets(self.maas_version, other.maas_version), |
983 | + packer_version=combine_sets(self.packer_version, other.packer_version), |
984 | + readable_state=combined_state._state_, |
985 | + tested_arches=combine_sets(self.tested_arches, other.tested_arches), |
986 | + prerequisites=combine_sets(self.prerequisites, other.prerequisites), |
987 | + ) |
988 | + for feature in set(self._results_).union(set(other._results_)): |
989 | + setattr( |
990 | + results, |
991 | + feature, |
992 | + getattr(self, feature, FeatureStatus()) |
993 | + + getattr(self, feature, FeatureStatus()), |
994 | + ) |
995 | + return results |
996 | + |
997 | + |
998 | +def todict(nested: defaultdict[str, Any] | dict[str, Any]) -> dict[str, Any]: |
999 | + for k, v in nested.items(): |
1000 | + if isinstance(v, dict): |
1001 | + nested[k] = todict(v) |
1002 | + return dict(nested) |
1003 | + |
1004 | + |
1005 | +def nested_dict() -> defaultdict[str, Any]: |
1006 | + return defaultdict(nested_dict) |
1007 | + |
1008 | + |
1009 | +def feature_dict_summary( |
1010 | + feature_dict: dict[str, dict[str, list[str]]] |
1011 | +) -> tuple[bool, dict[str, list[str]], str]: |
1012 | + # /artificial data for testing |
1013 | + states = set(feature_dict.keys()) |
1014 | + failed = set(feature_dict["FAILED"].keys()) |
1015 | + passed = set(feature_dict["PASSED"].keys()) |
1016 | + unknown: set[str] = set() |
1017 | + for unknown_states in states - {"PASSED", "FAILED"}: |
1018 | + unknown |= set(feature_dict[unknown_states].keys()) |
1019 | + |
1020 | + # overall pass fail for the entire feature |
1021 | + state = not (len(failed) or len(unknown)) |
1022 | + # overall pass fail for each value of the feature |
1023 | + summary: dict[str, list[str]] = {} |
1024 | + if full_pass := passed - (failed | unknown): |
1025 | + summary["PASS"] = list(full_pass) |
1026 | + if full_fail := failed - (passed | unknown): |
1027 | + summary["FAIL"] = list(full_fail) |
1028 | + if partial_fail := (passed & failed) | unknown: |
1029 | + summary["PARTIAL"] = list(partial_fail) |
1030 | + # specific pass fail for each value of the feature |
1031 | + info = [] |
1032 | + for fstate, fvalue in feature_dict.items(): |
1033 | + info.extend( |
1034 | + [fstate.lower()] |
1035 | + + [f" - {layout}: {', '.join(arch)}" for layout, arch in fvalue.items()] |
1036 | + ) |
1037 | + return state, summary, "\n".join(info) |
1038 | + |
1039 | + |
1040 | +def scan_log_for_feature( |
1041 | + feature_name: str, arches: dict[str, Any] |
1042 | +) -> dict[str, dict[str, list[str]]]: |
1043 | + tested = nested_dict() |
1044 | + """ Matches the two ways we can show test results: |
1045 | + 'storage layout flat: PASSED' |
1046 | + 'Storage layout: bcache - FAILED' |
1047 | + returns the feature (flat, bcache) and result (PASSED, FAILED) |
1048 | + """ |
1049 | + versioning_match = r":?\s(\w+):?\s(?:\-\s)?([A-Z]{4,})" |
1050 | + feature_match = re.compile(f"{feature_name}{versioning_match}", flags=re.IGNORECASE) |
1051 | + for arch_name, arch in arches.items(): |
1052 | + arch_log = "\n".join(arch["log"]) |
1053 | + for feature, state in feature_match.findall(arch_log): |
1054 | + if feature not in tested[state]: |
1055 | + tested[state][feature] = [] |
1056 | + tested[state][feature].append(arch_name) |
1057 | + return todict(tested) |
1058 | + |
1059 | + |
1060 | +def determine_feature_state( |
1061 | + feature_name: str, arches: dict[str, Any] |
1062 | +) -> tuple[bool, dict[str, list[str]], str] | None: |
1063 | + if feature_tested := scan_log_for_feature(feature_name, arches): |
1064 | + return feature_dict_summary(feature_tested) |
1065 | + return None |
1066 | + |
1067 | + |
1068 | +def execute( |
1069 | + command: list[str], cwd: str | None = None |
1070 | +) -> subprocess.CompletedProcess[str]: |
1071 | + """Execute a command""" |
1072 | + __tracebackhide__ = True |
1073 | + return subprocess.run( |
1074 | + command, |
1075 | + capture_output=True, |
1076 | + check=True, |
1077 | + encoding="utf-8", |
1078 | + errors="backslashreplace", |
1079 | + cwd=cwd, |
1080 | + ) |
1081 | + |
1082 | + |
1083 | +@contextmanager |
1084 | +def checkout_and_commit( |
1085 | + branch: str, |
1086 | + commit_message: str, |
1087 | + base_branch: str | None = None, |
1088 | + add_file: str | list[str] | None = None, |
1089 | + cwd: str | None = None, |
1090 | +) -> Iterator[None]: |
1091 | + branches = execute(["git", "branch", "-a"], cwd=cwd).stdout |
1092 | + branch_base = base_branch or ("main" if "main" in branches else "master") |
1093 | + current_branch = execute(["git", "rev-parse", "--abbrev-ref HEAD"], cwd=cwd).stdout |
1094 | + |
1095 | + # ensure we're up to date with the base branch first |
1096 | + if current_branch != branch_base: |
1097 | + execute(["git", "checkout", branch_base], cwd=cwd) |
1098 | + execute(["git", "pull"], cwd=cwd) |
1099 | + current_branch = branch_base |
1100 | + |
1101 | + # navigate to the correct branch |
1102 | + if current_branch != branch: |
1103 | + if branch in branches: |
1104 | + execute(["git", "checkout", branch], cwd=cwd) |
1105 | + try: |
1106 | + execute(["git", "pull"], cwd=cwd) |
1107 | + except Exception as e: |
1108 | + print(e) |
1109 | + else: |
1110 | + execute(["git", "checkout", "-b", branch], cwd=cwd) |
1111 | + |
1112 | + yield |
1113 | + |
1114 | + if cwd and add_file: |
1115 | + cleanup_files(cwd, preserve=add_file) |
1116 | + |
1117 | + # if the previous commit matches the one we want to make, combine them |
1118 | + reset = False |
1119 | + while ( |
1120 | + execute(["git", "show-branch", "--no-name", "HEAD~1"], cwd=cwd).stdout |
1121 | + == f"{commit_message}" |
1122 | + ): |
1123 | + execute(["git", "reset", "--hard", "HEAD~1"], cwd=cwd) |
1124 | + reset = True |
1125 | + |
1126 | + # add files and commit |
1127 | + execute(["git", "add", "."], cwd=cwd) |
1128 | + execute(["git", "commit", "-m", f'"{commit_message}"'], cwd=cwd) |
1129 | + if reset: |
1130 | + execute(["git", "push", "-f"], cwd=cwd) |
1131 | + else: |
1132 | + execute(["git", "push"], cwd=cwd) |
1133 | diff --git a/temporal/common_tasks.py b/temporal/common_tasks.py |
1134 | new file mode 100644 |
1135 | index 0000000..8fc6011 |
1136 | --- /dev/null |
1137 | +++ b/temporal/common_tasks.py |
1138 | @@ -0,0 +1,293 @@ |
1139 | +import argparse |
1140 | +import asyncio |
1141 | +import os |
1142 | +import sys |
1143 | +from dataclasses import dataclass |
1144 | +from datetime import timedelta |
1145 | +from time import sleep |
1146 | +from typing import Any |
1147 | + |
1148 | +import yaml |
1149 | +from temporalio import activity, workflow |
1150 | +from temporalio.client import Client |
1151 | +from temporalio.worker import Worker |
1152 | + |
1153 | +with workflow.unsafe.imports_passed_through(): |
1154 | + from jenkinsapi.build import Artifact, Build # type:ignore[import] |
1155 | + from jenkinsapi.jenkins import Jenkins # type:ignore[import] |
1156 | + from jenkinsapi.job import Job # type:ignore[import] |
1157 | + |
1158 | + |
1159 | +# Workflow parameter class |
1160 | +@dataclass |
1161 | +class workflow_parameters: |
1162 | + jenkins_url: str |
1163 | + jenkins_user: str |
1164 | + jenkins_pass: str |
1165 | + job_name: str = "" |
1166 | + build_num: int = -1 |
1167 | + |
1168 | + # retry stuff |
1169 | + max_retry_attempts: int = 10 |
1170 | + heartbeat_delay: int = 15 |
1171 | + |
1172 | + # default timeout to be used if none available |
1173 | + default_timeout: int = 300 |
1174 | + # how long should we wait to login |
1175 | + jenkins_login_timeout: int = -1 |
1176 | + # how long should we wait for the build to complete |
1177 | + return_status_timeout: int = -1 |
1178 | + # how long should we wait to get build results? |
1179 | + fetch_results_timeout: int = -1 |
1180 | + # how long should we wait for log scanning to occur? |
1181 | + log_details_timeout: int = -1 |
1182 | + # how long should we wait for this build to be requested |
1183 | + request_build_timeout: int = -1 |
1184 | + # how long should we wait for the build to complete |
1185 | + build_complete_timeout: int = 7200 |
1186 | + # how long should we wait for the results to be available |
1187 | + get_results_timeout: int = -1 |
1188 | + |
1189 | + # return the default timeout if the set timeout is not applicable |
1190 | + def gettimeout(self, timeout_name: str = "") -> timedelta: |
1191 | + if (timeout := self.__dict__.get(timeout_name, 0)) > 0: |
1192 | + return timedelta(seconds=timeout) |
1193 | + return timedelta(seconds=self.default_timeout) |
1194 | + |
1195 | + |
1196 | +# common functions |
1197 | + |
1198 | + |
1199 | +def cleanup_files(file_path: str, preserve: str | list[str] | None = None) -> None: |
1200 | + if os.path.exists(file_path): |
1201 | + files = os.listdir(file_path) |
1202 | + files.remove(".git") |
1203 | + if preserve: |
1204 | + for preserved_file in aslist(preserve): |
1205 | + this_file = os.path.basename(preserved_file) |
1206 | + if this_file in files: |
1207 | + files.remove(this_file) |
1208 | + if files: |
1209 | + print(f"Removing: {files}") |
1210 | + for cleanup in files: |
1211 | + os.remove(f"{file_path}/{cleanup}") |
1212 | + |
1213 | + |
1214 | +def aslist(to_list: str | list[Any]) -> list[Any]: |
1215 | + if isinstance(to_list, list): |
1216 | + return to_list |
1217 | + return [to_list] if to_list else [] |
1218 | + |
1219 | + |
1220 | +def get_server(params: workflow_parameters) -> Jenkins: |
1221 | + return Jenkins( |
1222 | + params.jenkins_url, |
1223 | + username=params.jenkins_user, |
1224 | + password=params.jenkins_pass, |
1225 | + timeout=params.gettimeout("jenkins_login_timeout").seconds, |
1226 | + max_retries=params.max_retry_attempts, |
1227 | + ) |
1228 | + |
1229 | + |
1230 | +def get_job( |
1231 | + params: workflow_parameters, |
1232 | + job_name: str | None = None, |
1233 | +) -> Job: |
1234 | + return get_server(params).get_job(job_name or params.job_name) |
1235 | + |
1236 | + |
1237 | +def get_build( |
1238 | + params: workflow_parameters, |
1239 | + job_name: str | None = None, |
1240 | + build_num: int | None = None, |
1241 | +) -> Build: |
1242 | + job = get_job(params, job_name=job_name) |
1243 | + if (num := build_num or params.build_num) >= 0: |
1244 | + return job.get_build(num) |
1245 | + return job.get_last_build() |
1246 | + |
1247 | + |
1248 | +def get_params( |
1249 | + params: workflow_parameters, |
1250 | + job_name: str | None = None, |
1251 | + build_num: int | None = None, |
1252 | +) -> dict[str, Any]: |
1253 | + build = get_build(params, job_name=job_name, build_num=build_num) |
1254 | + return build.get_params() # type: ignore |
1255 | + |
1256 | + |
1257 | +def get_logs( |
1258 | + params: workflow_parameters, |
1259 | + job_name: str | None = None, |
1260 | + build_num: int | None = None, |
1261 | +) -> dict[str, str]: |
1262 | + # attempt utf-8. If that doesn't work, try utf-16 |
1263 | + def decode_artifact_data(artifact: Artifact) -> str: |
1264 | + data = artifact.get_data() |
1265 | + try: |
1266 | + return str(data, encoding="utf-8") |
1267 | + except Exception as e: |
1268 | + print(e) |
1269 | + return str(data, encoding="utf-16") |
1270 | + |
1271 | + build = get_build(params, job_name=job_name, build_num=build_num) |
1272 | + logs = { |
1273 | + name.split(".")[-2]: decode_artifact_data(artifact) |
1274 | + for name, artifact in build.get_artifact_dict().items() |
1275 | + if ".log" in name |
1276 | + } |
1277 | + return logs |
1278 | + |
1279 | + |
1280 | +def get_config( |
1281 | + params: workflow_parameters, |
1282 | + job_name: str | None = None, |
1283 | + build_num: int | None = None, |
1284 | +) -> dict[str, Any]: |
1285 | + build = get_build(params, job_name=job_name, build_num=build_num) |
1286 | + return yaml.safe_load( # type: ignore |
1287 | + [ |
1288 | + artifact.get_data() |
1289 | + for name, artifact in build.get_artifact_dict().items() |
1290 | + if "config.yaml" in name |
1291 | + ][0] |
1292 | + ) |
1293 | + |
1294 | + |
1295 | +def get_results( |
1296 | + params: workflow_parameters, |
1297 | + job_name: str | None = None, |
1298 | + build_num: int | None = None, |
1299 | +) -> dict[str, Any]: |
1300 | + build = get_build(params, job_name=job_name, build_num=build_num) |
1301 | + results = build.get_resultset() |
1302 | + return {k: v.__dict__ for k, v in results.items()} |
1303 | + |
1304 | + |
1305 | +def request_build( |
1306 | + params: workflow_parameters, job_params: dict[str, Any], job_name: str | None = None |
1307 | +) -> int: |
1308 | + server = get_server(params) |
1309 | + last_build = int(server.get_job(params.job_name or job_name).get_last_buildnumber()) |
1310 | + server.build_job(params.job_name, job_params) |
1311 | + return last_build + 1 |
1312 | + |
1313 | + |
1314 | +# common activities |
1315 | + |
1316 | + |
1317 | +@activity.defn |
1318 | +async def check_jenkins_reachable(params: workflow_parameters) -> bool: |
1319 | + server = get_server(params) |
1320 | + return bool(server and (server.version != "0.0")) |
1321 | + |
1322 | + |
1323 | +@activity.defn |
1324 | +async def check_build_has_results(params: workflow_parameters) -> bool: |
1325 | + build = get_build(params) |
1326 | + return bool(build.has_resultset()) |
1327 | + |
1328 | + |
1329 | +@activity.defn |
1330 | +async def fetch_build_status(params: workflow_parameters) -> str: |
1331 | + build = get_build(params) |
1332 | + while build.is_running(): |
1333 | + sleep(params.heartbeat_delay) |
1334 | + activity.heartbeat("Awaiting build finish") |
1335 | + return str(build.get_status()) |
1336 | + |
1337 | + |
1338 | +@activity.defn |
1339 | +async def fetch_build_and_result( |
1340 | + params: workflow_parameters, |
1341 | +) -> dict[str, dict[str, str]]: |
1342 | + build = get_build(params) |
1343 | + while not build.has_resultset(): |
1344 | + sleep(params.heartbeat_delay) |
1345 | + activity.heartbeat("Awaiting build results") |
1346 | + return {k: {"status": v.status} for k, v in build.get_resultset().items()} |
1347 | + |
1348 | + |
1349 | +@activity.defn |
1350 | +async def await_build_exists(params: workflow_parameters) -> None: |
1351 | + job = get_job(params) |
1352 | + while not job.is_queued_or_running(): |
1353 | + sleep(params.heartbeat_delay) |
1354 | + activity.heartbeat("Awaiting job start") |
1355 | + build = None |
1356 | + while True: |
1357 | + try: |
1358 | + if build is None: |
1359 | + build = get_build(params) |
1360 | + if build.is_running(): |
1361 | + break |
1362 | + except Exception as e: |
1363 | + activity.heartbeat(f"Could not fetch build: {e}") |
1364 | + sleep(params.heartbeat_delay) |
1365 | + activity.heartbeat("Awaiting build running") |
1366 | + |
1367 | + |
1368 | +@activity.defn |
1369 | +async def await_build_complete(params: workflow_parameters) -> None: |
1370 | + build = get_build(params) |
1371 | + while build.is_running(): |
1372 | + sleep(params.heartbeat_delay) |
1373 | + activity.heartbeat("Awaiting job completion") |
1374 | + |
1375 | + |
1376 | +# workers |
1377 | + |
1378 | + |
1379 | +def worker_url(argv: list[str]) -> str: |
1380 | + parser = argparse.ArgumentParser() |
1381 | + parser.add_argument( |
1382 | + "temporal_url", |
1383 | + type=str, |
1384 | + default="localhost:7233", |
1385 | + help="url of the temporal server", |
1386 | + ) |
1387 | + args = parser.parse_args(argv) |
1388 | + return str(args.temporal_url) |
1389 | + |
1390 | + |
1391 | +async def worker_main( |
1392 | + interrupt_event: asyncio.Event, |
1393 | + temporal_url: str, |
1394 | + task_queue: str, |
1395 | + workflows: list[Any], |
1396 | + activities: list[Any], |
1397 | +) -> None: |
1398 | + client = await Client.connect(temporal_url) |
1399 | + async with Worker( |
1400 | + client, |
1401 | + task_queue=task_queue.lower().replace(" ", "_"), |
1402 | + workflows=workflows, |
1403 | + activities=activities, |
1404 | + ): |
1405 | + print( |
1406 | + f"{task_queue} worker started, ctrl+c to exit".capitalize().replace( |
1407 | + "_", " " |
1408 | + ) |
1409 | + ) |
1410 | + await interrupt_event.wait() |
1411 | + |
1412 | + |
1413 | +def start_worker(task_queue: str, workflows: list[Any], activities: list[Any]) -> None: |
1414 | + temporal_url = worker_url(sys.argv[1:]) |
1415 | + interrupt_event = asyncio.Event() |
1416 | + |
1417 | + loop = asyncio.new_event_loop() |
1418 | + asyncio.set_event_loop(loop) |
1419 | + try: |
1420 | + loop.run_until_complete( |
1421 | + worker_main( |
1422 | + interrupt_event, temporal_url, task_queue, workflows, activities |
1423 | + ) |
1424 | + ) |
1425 | + except KeyboardInterrupt: |
1426 | + interrupt_event.set() |
1427 | + interrupt_event.clear() |
1428 | + loop.run_until_complete(interrupt_event.wait()) |
1429 | + finally: |
1430 | + loop.run_until_complete(loop.shutdown_asyncgens()) |
1431 | + loop.close() |
1432 | diff --git a/temporal/e2e_worker.py b/temporal/e2e_worker.py |
1433 | new file mode 100644 |
1434 | index 0000000..97c5260 |
1435 | --- /dev/null |
1436 | +++ b/temporal/e2e_worker.py |
1437 | @@ -0,0 +1,10 @@ |
1438 | +from common_tasks import start_worker |
1439 | +from e2e_workflow import activities as e2e_activities |
1440 | +from e2e_workflow import workflows as e2e_workflows |
1441 | + |
1442 | +if __name__ == "__main__": |
1443 | + start_worker( |
1444 | + task_queue="e2e_tests", |
1445 | + workflows=e2e_workflows, |
1446 | + activities=e2e_activities, |
1447 | + ) |
1448 | diff --git a/temporal/e2e_workflow.py b/temporal/e2e_workflow.py |
1449 | new file mode 100644 |
1450 | index 0000000..82f2ba7 |
1451 | --- /dev/null |
1452 | +++ b/temporal/e2e_workflow.py |
1453 | @@ -0,0 +1,206 @@ |
1454 | +import re |
1455 | +from dataclasses import dataclass |
1456 | +from typing import Any |
1457 | + |
1458 | +from build_results import nested_dict, todict |
1459 | +from common_tasks import aslist, get_logs, workflow_parameters |
1460 | +from image_building_workflow import image_building_param, image_building_workflow |
1461 | +from image_reporting_workflow import image_reporting_param, image_reporting_workflow |
1462 | +from image_testing_workflow import image_testing_param, image_testing_workflow |
1463 | +from temporalio import activity, workflow |
1464 | +from temporalio.common import RetryPolicy |
1465 | + |
1466 | + |
1467 | +@dataclass |
1468 | +class e2e_workflow_params(workflow_parameters): |
1469 | + image_name: str | list[str] = "" |
1470 | + image_mapping: str = ( |
1471 | + "image_mapping.yaml" # this needs to be accessible to the worker |
1472 | + ) |
1473 | + |
1474 | + system_test_repo: str = ( |
1475 | + "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests" |
1476 | + ) |
1477 | + system_test_branch: str = "master" |
1478 | + packer_naas_repo: str = "https://github.com/canonical/packer-maas.git" |
1479 | + packer_maas_branch: str = "main" |
1480 | + |
1481 | + maas_snap_channel: str = "latest/edge" |
1482 | + |
1483 | + repo_location: str = "image_results_repo" |
1484 | + |
1485 | + overwrite_results: bool = False |
1486 | + # reccommended to leave this false until the rescue issue at CI is fixed |
1487 | + parallel_tests: bool = False |
1488 | + |
1489 | + |
1490 | +@activity.defn |
1491 | +async def fetch_packer_version_from_logs( |
1492 | + params: e2e_workflow_params, |
1493 | +) -> dict[str, Any]: |
1494 | + logs = get_logs(params, job_name="maas-automated-image-builder") |
1495 | + packer_details = nested_dict() |
1496 | + for image in aslist(params.image_name): |
1497 | + packer_details[image]["packer_version"] = "" |
1498 | + packer_details[image]["prerequisites"] = [] |
1499 | + # fetch the build log for this image |
1500 | + if log := [v for k, v in logs.items() if image in k]: |
1501 | + # fetch the packer version |
1502 | + if search := re.search(r"Packer version\: ((\d+\.\d+)\.\d+)", log[0]): |
1503 | + long_version, _ = search.groups() |
1504 | + packer_details[image]["packer_version"] = long_version |
1505 | + else: |
1506 | + packer_details[image]["packer_version"] = "" |
1507 | + # search for prerequisites |
1508 | + return todict(packer_details) |
1509 | + |
1510 | + |
1511 | +@activity.defn |
1512 | +async def fetch_image_details(params: dict[str, Any]) -> dict[str, Any]: |
1513 | + details: dict[str, Any] = {} |
1514 | + for image in aslist(params["images"]): |
1515 | + image_packer_details = params.get("packer_details", {}).get(image, {}) |
1516 | + image_test_details = params.get("image_results", {}).get(image, {}) |
1517 | + details[image] = { |
1518 | + "built": image not in params.get("failed_images", []), |
1519 | + "tested": bool(image_test_details), |
1520 | + "build_num": params.get("build_num", -1), |
1521 | + "test_num": image_test_details.get("build_num"), |
1522 | + "packer_version": image_packer_details.get("packer_version", "0.0"), |
1523 | + "prerequisites": image_packer_details.get("prerequisites", []), |
1524 | + } |
1525 | + return details |
1526 | + |
1527 | + |
1528 | +@workflow.defn |
1529 | +class e2e_workflow: |
1530 | + @workflow.run |
1531 | + async def run(self, params: e2e_workflow_params) -> None: |
1532 | + # build images |
1533 | + image_building_results: dict[str, Any] = await workflow.execute_child_workflow( |
1534 | + image_building_workflow, |
1535 | + image_building_param( |
1536 | + # building parameters |
1537 | + image_name=params.image_name, |
1538 | + image_mapping=params.image_mapping, |
1539 | + system_test_repo=params.system_test_repo, |
1540 | + system_test_branch=params.system_test_branch, |
1541 | + packer_naas_repo=params.packer_naas_repo, |
1542 | + packer_maas_branch=params.packer_maas_branch, |
1543 | + # jenkins stuff |
1544 | + jenkins_url=params.jenkins_url, |
1545 | + jenkins_user=params.jenkins_user, |
1546 | + jenkins_pass=params.jenkins_pass, |
1547 | + # timeouts and retry |
1548 | + max_retry_attempts=params.max_retry_attempts, |
1549 | + heartbeat_delay=params.heartbeat_delay, |
1550 | + default_timeout=params.default_timeout, |
1551 | + jenkins_login_timeout=params.jenkins_login_timeout, |
1552 | + return_status_timeout=params.return_status_timeout, |
1553 | + fetch_results_timeout=params.fetch_results_timeout, |
1554 | + log_details_timeout=params.log_details_timeout, |
1555 | + request_build_timeout=params.request_build_timeout, |
1556 | + build_complete_timeout=params.build_complete_timeout, |
1557 | + get_results_timeout=params.get_results_timeout, |
1558 | + ), |
1559 | + task_queue="image_building", |
1560 | + id=f"Building: {','.join(params.image_name)}", |
1561 | + ) |
1562 | + # images that failed or succeeded to be built |
1563 | + params.build_num = image_building_results.get("build_num", -1) |
1564 | + images_built = image_building_results["image_results"] |
1565 | + failed_images = [image for image, built in images_built.items() if not built] |
1566 | + passed_images = [image for image, built in images_built.items() if built] |
1567 | + # get the packer version and prerequisites |
1568 | + packer_details = await workflow.execute_activity( |
1569 | + fetch_packer_version_from_logs, |
1570 | + params, |
1571 | + start_to_close_timeout=params.gettimeout("log_details_timeout"), |
1572 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
1573 | + ) |
1574 | + # get all of the images that were built |
1575 | + image_testing_results: dict[str, Any] = {} |
1576 | + if images_to_test := passed_images: |
1577 | + # test images |
1578 | + # if we are testing images in parallel, this list will have one entry. |
1579 | + for image_test_group in ( |
1580 | + [images_to_test] if params.parallel_tests else images_to_test |
1581 | + ): |
1582 | + try: |
1583 | + image_testing_results |= await workflow.execute_child_workflow( |
1584 | + image_testing_workflow, |
1585 | + image_testing_param( |
1586 | + # testing parameters |
1587 | + image_name=image_test_group, |
1588 | + system_test_repo=params.system_test_repo, |
1589 | + system_test_branch=params.system_test_branch, |
1590 | + maas_snap_channel=params.maas_snap_channel, |
1591 | + parallel_tests=params.parallel_tests, |
1592 | + # jenkins stuff |
1593 | + jenkins_url=params.jenkins_url, |
1594 | + jenkins_user=params.jenkins_user, |
1595 | + jenkins_pass=params.jenkins_pass, |
1596 | + # timeouts and retry |
1597 | + max_retry_attempts=params.max_retry_attempts, |
1598 | + heartbeat_delay=params.heartbeat_delay, |
1599 | + default_timeout=params.default_timeout, |
1600 | + jenkins_login_timeout=params.jenkins_login_timeout, |
1601 | + return_status_timeout=params.return_status_timeout, |
1602 | + fetch_results_timeout=params.fetch_results_timeout, |
1603 | + log_details_timeout=params.log_details_timeout, |
1604 | + request_build_timeout=params.request_build_timeout, |
1605 | + build_complete_timeout=params.build_complete_timeout, |
1606 | + get_results_timeout=params.get_results_timeout, |
1607 | + ), |
1608 | + task_queue="image_testing", |
1609 | + id=f"Testing: {','.join(aslist(image_test_group))}", |
1610 | + ) |
1611 | + except Exception as e: |
1612 | + workflow.logger.exception(f"Could not test {image_test_group}: {e}") |
1613 | + |
1614 | + # populate image details from test results |
1615 | + image_details = await workflow.execute_activity( |
1616 | + fetch_image_details, |
1617 | + { |
1618 | + "images": params.image_name, |
1619 | + "packer_details": packer_details, |
1620 | + "failed_images": failed_images, |
1621 | + "build_num": params.build_num, |
1622 | + "image_results": image_testing_results, |
1623 | + }, |
1624 | + start_to_close_timeout=params.gettimeout("log_details_timeout"), |
1625 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
1626 | + ) |
1627 | + |
1628 | + # report image results |
1629 | + await workflow.execute_child_workflow( |
1630 | + image_reporting_workflow, |
1631 | + image_reporting_param( |
1632 | + # reporting parameters |
1633 | + image_details=image_details, |
1634 | + repo_location=params.repo_location, |
1635 | + overwrite_results=params.overwrite_results, |
1636 | + maas_snap_channel=params.maas_snap_channel, |
1637 | + # jenkins stuff |
1638 | + jenkins_url=params.jenkins_url, |
1639 | + jenkins_user=params.jenkins_user, |
1640 | + jenkins_pass=params.jenkins_pass, |
1641 | + # timeouts and retry |
1642 | + max_retry_attempts=params.max_retry_attempts, |
1643 | + heartbeat_delay=params.heartbeat_delay, |
1644 | + default_timeout=params.default_timeout, |
1645 | + jenkins_login_timeout=params.jenkins_login_timeout, |
1646 | + return_status_timeout=params.return_status_timeout, |
1647 | + fetch_results_timeout=params.fetch_results_timeout, |
1648 | + log_details_timeout=params.log_details_timeout, |
1649 | + request_build_timeout=params.request_build_timeout, |
1650 | + build_complete_timeout=params.build_complete_timeout, |
1651 | + get_results_timeout=params.get_results_timeout, |
1652 | + ), |
1653 | + task_queue="image_reporting", |
1654 | + id=f"Reporting: {','.join(params.image_name)}", |
1655 | + ) |
1656 | + |
1657 | + |
1658 | +activities = [fetch_packer_version_from_logs, fetch_image_details] |
1659 | +workflows = [e2e_workflow] |
1660 | diff --git a/temporal/image_building_worker.py b/temporal/image_building_worker.py |
1661 | new file mode 100644 |
1662 | index 0000000..885f578 |
1663 | --- /dev/null |
1664 | +++ b/temporal/image_building_worker.py |
1665 | @@ -0,0 +1,10 @@ |
1666 | +from common_tasks import start_worker |
1667 | +from image_building_workflow import activities as image_build_activities |
1668 | +from image_building_workflow import workflows as image_build_workflows |
1669 | + |
1670 | +if __name__ == "__main__": |
1671 | + start_worker( |
1672 | + task_queue="image_building", |
1673 | + workflows=image_build_workflows, |
1674 | + activities=image_build_activities, |
1675 | + ) |
1676 | diff --git a/temporal/image_building_workflow.py b/temporal/image_building_workflow.py |
1677 | new file mode 100644 |
1678 | index 0000000..586f1f4 |
1679 | --- /dev/null |
1680 | +++ b/temporal/image_building_workflow.py |
1681 | @@ -0,0 +1,165 @@ |
1682 | +import re |
1683 | +from dataclasses import dataclass |
1684 | +from typing import Any |
1685 | + |
1686 | +import yaml |
1687 | +from common_tasks import ( |
1688 | + aslist, |
1689 | + await_build_complete, |
1690 | + await_build_exists, |
1691 | + check_jenkins_reachable, |
1692 | + fetch_build_and_result, |
1693 | + fetch_build_status, |
1694 | + request_build, |
1695 | + workflow_parameters, |
1696 | +) |
1697 | +from temporalio import activity, workflow |
1698 | +from temporalio.common import RetryPolicy |
1699 | + |
1700 | + |
1701 | +@dataclass |
1702 | +class image_building_param(workflow_parameters): |
1703 | + image_name: str | list[str] = "" # allow builk image building if desired |
1704 | + image_mapping: str = ( |
1705 | + "image_mapping.yaml" # this needs to be accessible to the worker |
1706 | + ) |
1707 | + |
1708 | + job_name: str = "maas-automated-image-builder" |
1709 | + build_num: int = -1 |
1710 | + |
1711 | + # job details with default values we may want to change |
1712 | + system_test_repo: str = ( |
1713 | + "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests" |
1714 | + ) |
1715 | + system_test_branch: str = "master" |
1716 | + packer_naas_repo: str = "https://github.com/canonical/packer-maas.git" |
1717 | + packer_maas_branch: str = "main" |
1718 | + |
1719 | + |
1720 | +@activity.defn |
1721 | +async def request_images_built(params: image_building_param) -> int: |
1722 | + """Start an image testing job, returning the job number.""" |
1723 | + job_params: dict[str, Any] = { |
1724 | + "IMAGE_NAMES": ",".join(image for image in aslist(params.image_name)), |
1725 | + "SYSTEMTESTS_GIT_REPO": params.system_test_repo, |
1726 | + "SYSTEMTESTS_GIT_BRANCH": params.system_test_branch, |
1727 | + "PACKER_MAAS_GIT_REPO": params.packer_naas_repo, |
1728 | + "PACKER_MAAS_GIT_BRANCH": params.packer_maas_branch, |
1729 | + } |
1730 | + return request_build(params, job_params) |
1731 | + |
1732 | + |
1733 | +@activity.defn |
1734 | +async def fetch_image_mapping( |
1735 | + params: image_building_param, |
1736 | +) -> dict[str, dict[str, Any]]: |
1737 | + with open(params.image_mapping, "r") as fh: |
1738 | + image_cfg: dict[str, Any] = yaml.safe_load(fh) |
1739 | + return image_cfg |
1740 | + |
1741 | + |
1742 | +@activity.defn |
1743 | +async def fetch_image_built_status(params: dict[str, Any]) -> dict[str, bool]: |
1744 | + results: dict[str, dict[str, str]] = params["results"] |
1745 | + mapping: dict[str, dict[str, Any]] = params["mapping"] |
1746 | + image_built_results: dict[str, bool] = {} |
1747 | + |
1748 | + for image in params["image"]: |
1749 | + this_image = mapping["images"].get(image, {}) |
1750 | + oseries = this_image.get("oseries") |
1751 | + osystem = mapping["images"].get(image, {}).get("osystem") |
1752 | + image_name = f"{osystem}/{oseries}" |
1753 | + status = False |
1754 | + for test_name, test_result in results.items(): |
1755 | + if re.search(rf"test_build_image.*{image_name}", test_name): |
1756 | + if test_result["status"] in ["FIXED", "PASSED"]: |
1757 | + status = True |
1758 | + break |
1759 | + image_built_results[image] = status |
1760 | + return image_built_results |
1761 | + |
1762 | + |
1763 | +@workflow.defn |
1764 | +class image_building_workflow: |
1765 | + @workflow.run |
1766 | + async def run( |
1767 | + self, params: image_building_param |
1768 | + ) -> dict[str, int | dict[str, bool]]: |
1769 | + # await an open connection to the server |
1770 | + await workflow.execute_activity( |
1771 | + check_jenkins_reachable, |
1772 | + params, |
1773 | + start_to_close_timeout=params.gettimeout("jenkins_login_timeout"), |
1774 | + ) |
1775 | + # only attempt to build the image once |
1776 | + params.build_num = await workflow.execute_activity( |
1777 | + request_images_built, |
1778 | + params, |
1779 | + start_to_close_timeout=params.gettimeout("request_build_timeout"), |
1780 | + ) |
1781 | + # try multiple times to get the results or status |
1782 | + await workflow.execute_activity( |
1783 | + await_build_exists, |
1784 | + params, |
1785 | + start_to_close_timeout=params.gettimeout("request_build_timeout"), |
1786 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
1787 | + ) |
1788 | + await workflow.execute_activity( |
1789 | + await_build_complete, |
1790 | + params, |
1791 | + start_to_close_timeout=params.gettimeout("build_complete_timeout"), |
1792 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
1793 | + ) |
1794 | + # return a default failure state if the build was aborted |
1795 | + build_status = await workflow.execute_activity( |
1796 | + fetch_build_status, |
1797 | + params, |
1798 | + start_to_close_timeout=params.gettimeout("build_complete_timeout"), |
1799 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
1800 | + ) |
1801 | + image_results: dict[str, bool] = {k: False for k in aslist(params.image_name)} |
1802 | + if build_status.lower() != "aborted": |
1803 | + try: |
1804 | + # return pass/fail status for image/images being built |
1805 | + results = await workflow.execute_activity( |
1806 | + fetch_build_and_result, |
1807 | + params, |
1808 | + start_to_close_timeout=params.gettimeout("get_results_timeout"), |
1809 | + retry_policy=RetryPolicy( |
1810 | + maximum_attempts=params.max_retry_attempts |
1811 | + ), |
1812 | + ) |
1813 | + # these should never require a retry |
1814 | + mapping = await workflow.execute_activity( |
1815 | + fetch_image_mapping, |
1816 | + params, |
1817 | + start_to_close_timeout=params.gettimeout(), |
1818 | + ) |
1819 | + image_results = await workflow.execute_activity( |
1820 | + fetch_image_built_status, |
1821 | + { |
1822 | + "results": results, |
1823 | + "image": aslist(params.image_name), |
1824 | + "mapping": mapping, |
1825 | + }, |
1826 | + start_to_close_timeout=params.gettimeout("return_status_timeout"), |
1827 | + ) |
1828 | + except Exception as e: |
1829 | + workflow.logger.exception(e) |
1830 | + return { |
1831 | + "build_num": params.build_num, |
1832 | + "image_results": image_results, |
1833 | + } |
1834 | + |
1835 | + |
1836 | +activities = [ |
1837 | + check_jenkins_reachable, |
1838 | + await_build_exists, |
1839 | + await_build_complete, |
1840 | + request_images_built, |
1841 | + fetch_build_status, |
1842 | + fetch_build_and_result, |
1843 | + fetch_image_mapping, |
1844 | + fetch_image_built_status, |
1845 | +] |
1846 | +workflows = [image_building_workflow] |
1847 | diff --git a/temporal/image_reporting_worker.py b/temporal/image_reporting_worker.py |
1848 | new file mode 100644 |
1849 | index 0000000..bd1f08b |
1850 | --- /dev/null |
1851 | +++ b/temporal/image_reporting_worker.py |
1852 | @@ -0,0 +1,10 @@ |
1853 | +from common_tasks import start_worker |
1854 | +from image_reporting_workflow import activities as image_reporting_activities |
1855 | +from image_reporting_workflow import workflows as image_reporting_workflows |
1856 | + |
1857 | +if __name__ == "__main__": |
1858 | + start_worker( |
1859 | + task_queue="image_reporting", |
1860 | + workflows=image_reporting_workflows, |
1861 | + activities=image_reporting_activities, |
1862 | + ) |
1863 | diff --git a/temporal/image_reporting_workflow.py b/temporal/image_reporting_workflow.py |
1864 | new file mode 100644 |
1865 | index 0000000..05d5b63 |
1866 | --- /dev/null |
1867 | +++ b/temporal/image_reporting_workflow.py |
1868 | @@ -0,0 +1,450 @@ |
1869 | +import copy |
1870 | +import os |
1871 | +import re |
1872 | +from dataclasses import dataclass |
1873 | +from typing import Any |
1874 | + |
1875 | +import yaml |
1876 | +from build_results import ( |
1877 | + FeatureStatus, |
1878 | + ImageTestResults, |
1879 | + TestStatus, |
1880 | + checkout_and_commit, |
1881 | + determine_feature_state, |
1882 | + execute, |
1883 | +) |
1884 | +from common_tasks import ( |
1885 | + check_jenkins_reachable, |
1886 | + get_build, |
1887 | + get_config, |
1888 | + get_logs, |
1889 | + get_results, |
1890 | + workflow_parameters, |
1891 | +) |
1892 | +from temporalio import activity, workflow |
1893 | +from temporalio.common import RetryPolicy |
1894 | + |
1895 | +STEPS_TO_PARSE = ["deploy", "test_image"] |
1896 | + |
1897 | + |
1898 | +@dataclass |
1899 | +class image_reporting_param(workflow_parameters): |
1900 | + image_details: None | dict[str, Any] = None |
1901 | + |
1902 | + job_name: str = "maas-automated-image-tester" |
1903 | + |
1904 | + repo_location: str = "image_results_repo" |
1905 | + |
1906 | + maas_snap_channel: str = "latest/edge" |
1907 | + |
1908 | + overwrite_results: bool = False |
1909 | + |
1910 | + |
1911 | +@dataclass |
1912 | +class Filtered_Results: |
1913 | + # image: arch: step: data |
1914 | + data: dict[str, Any] = {} |
1915 | + |
1916 | + def _add_image_(self, image: str) -> None: |
1917 | + if image not in self.data: |
1918 | + self.data[image] = {} |
1919 | + if "state" not in self.data[image]: |
1920 | + self.data[image]["state"] = TestStatus() |
1921 | + |
1922 | + def add_result( |
1923 | + self, image: str, arch: str, step: str, data: dict[str, Any], status: TestStatus |
1924 | + ) -> None: |
1925 | + self._add_image_(image) |
1926 | + if arch not in self.data[image]: |
1927 | + self.data[image][arch] = {} |
1928 | + self.data[image][arch][step] = data |
1929 | + self.data[image]["state"] += status |
1930 | + |
1931 | + def to_dict(self) -> dict[str, Any]: |
1932 | + data = copy.deepcopy(self.data) |
1933 | + # convert statuses to dicts |
1934 | + for image, image_data in data.items(): |
1935 | + status: TestStatus = image_data["state"] |
1936 | + data[image]["state"] = status.to_dict() |
1937 | + # return |
1938 | + return data |
1939 | + |
1940 | + |
1941 | +def image_from_osytem_oseries( |
1942 | + params: image_reporting_param, |
1943 | + osystem: str, |
1944 | + oseries: str, |
1945 | + job_name: str | None = None, |
1946 | + build_num: str | int | None = None, |
1947 | +) -> str: |
1948 | + cfg = get_config( |
1949 | + params, job_name=job_name, build_num=int(build_num) if build_num else None |
1950 | + ) |
1951 | + images = cfg.get("image-tests", {}) |
1952 | + return [ |
1953 | + str(k) |
1954 | + for k, v in images.items() |
1955 | + if v["osystem"] == osystem and v["oseries"] == oseries |
1956 | + ][0] |
1957 | + |
1958 | + |
1959 | +@activity.defn |
1960 | +async def get_test_numbers(params: dict[str, Any]) -> dict[str, dict[str, Any]]: |
1961 | + parameters = image_reporting_param(**params["params"]) |
1962 | + image_details: dict[str, Any] = params["image_details"] |
1963 | + |
1964 | + test_details: dict[str, dict[str, str | bool]] = {} |
1965 | + test_numbers = list(set(details["test_num"] for details in image_details.values())) |
1966 | + for test_num in test_numbers: |
1967 | + if test_num: |
1968 | + this_test = get_build(parameters, build_num=int(test_num)) |
1969 | + test_details[str(test_num)] = { |
1970 | + "status": str(this_test.get_status()), |
1971 | + "has_results": bool(this_test.has_resultset()), |
1972 | + } |
1973 | + return test_details |
1974 | + |
1975 | + |
1976 | +@activity.defn |
1977 | +async def fetch_maas_version_from_logs( |
1978 | + params: dict[str, Any], |
1979 | +) -> dict[str, dict[str, str]]: |
1980 | + """MAAS version from a test log: ie: ["3.5","3.5.0~alpha1-14542-g.6d2c926d8"]""" |
1981 | + parameters = image_reporting_param(**params["params"]) |
1982 | + tests: list[str] = params["tests"] |
1983 | + |
1984 | + maas_snap_info = str(execute(["snap", "info", "maas"]).stdout) |
1985 | + long_version, short_version = ("", "") |
1986 | + if search := re.search( |
1987 | + rf"{parameters.maas_snap_channel}\:\s+((\d+\.\d+)\.\d+[^\s]+)", maas_snap_info |
1988 | + ): |
1989 | + long_version, short_version = search.groups() |
1990 | + |
1991 | + versions: dict[str, dict[str, str]] = { |
1992 | + "None": {"short": short_version, "long": long_version}, |
1993 | + } |
1994 | + for test in tests: |
1995 | + test_logs = get_logs(parameters, build_num=int(test)) |
1996 | + log = [v for k, v in test_logs.items() if k == "env_builder"][0] |
1997 | + if search := re.search( |
1998 | + r"maas\-client\: \|maas\s+((\d+\.\d+)\.\d+[^\s]+).*canonical\*", log |
1999 | + ): |
2000 | + long_version, short_version = search.groups() |
2001 | + versions[test] = {"short": short_version, "long": long_version} |
2002 | + continue |
2003 | + raise Exception("Cannot determine MAAS version.") |
2004 | + return versions |
2005 | + |
2006 | + |
2007 | +@activity.defn |
2008 | +async def filter_test_results(params: dict[str, Any]) -> dict[str, Any]: |
2009 | + parameters = image_reporting_param(**params["params"]) |
2010 | + test_num: str = params["test_num"] |
2011 | + filtered_result = Filtered_Results() |
2012 | + log = ( |
2013 | + get_logs(parameters, build_num=int(test_num)) |
2014 | + .get("tests_per_machine", "") |
2015 | + .split("\n") |
2016 | + ) |
2017 | + results = get_results(parameters, build_num=int(test_num)) |
2018 | + for test_name, test_result in results.items(): |
2019 | + if "test_full_circle" not in test_name: |
2020 | + continue |
2021 | + if search := re.search(r"\[(.*)\.(.*)\-(.*)\/(.*)\-(.*)\]", test_name): |
2022 | + machine, arch, osystem, oseries, step = search.groups() |
2023 | + if step.lower() not in STEPS_TO_PARSE: |
2024 | + continue |
2025 | + |
2026 | + image = image_from_osytem_oseries( |
2027 | + parameters, osystem, oseries, build_num=int(test_num) |
2028 | + ) |
2029 | + |
2030 | + this_status = TestStatus(test_result["status"]) |
2031 | + this_result = { |
2032 | + "result": test_result, |
2033 | + "state": this_status.to_dict(), |
2034 | + "error": test_result["errorDetails"], |
2035 | + "error_trace": test_result["errorStackTrace"], |
2036 | + "log": [line for line in log if test_result["name"] in line], |
2037 | + } |
2038 | + filtered_result.add_result(image, arch, step, this_result, this_status) |
2039 | + # pack the results status so it is serialisable |
2040 | + return filtered_result.to_dict() |
2041 | + |
2042 | + |
2043 | +@activity.defn |
2044 | +async def parse_test_results(params: dict[str, Any]) -> dict[str, Any]: |
2045 | + maas_version: str = params["maas_version"] |
2046 | + image_details: dict[str, Any] = params["image_details"] |
2047 | + filtered_results: dict[str, Any] = params["results"] |
2048 | + results: dict[str, Any] = {} |
2049 | + |
2050 | + def get_step_from_results( |
2051 | + image_results: dict[str, Any], step: str |
2052 | + ) -> dict[str, Any]: |
2053 | + arches = set(image_results.keys()) - {"state"} |
2054 | + return { |
2055 | + arch: image_results[arch].get(step) |
2056 | + for arch in arches |
2057 | + if step in image_results[arch] |
2058 | + } |
2059 | + |
2060 | + for image, this_image_result in filtered_results.items(): |
2061 | + this_image_details: dict[str, Any] = image_details[image] |
2062 | + packer_version: str = this_image_details["packer_version"] |
2063 | + prereq: list[str] = this_image_details["prerequisites"] |
2064 | + arches = set(this_image_result.keys()) - {"state"} |
2065 | + image_results = ImageTestResults( |
2066 | + image=image, |
2067 | + maas_version=[maas_version], |
2068 | + readable_state=this_image_result["state"]["state"], |
2069 | + tested_arches=list(arches), |
2070 | + packer_version=[packer_version], |
2071 | + prerequisites=prereq, |
2072 | + ) |
2073 | + |
2074 | + # check for the deployment state |
2075 | + if deployed := get_step_from_results(this_image_result, "deploy"): |
2076 | + # Image deployment |
2077 | + if deploy_state := sum( |
2078 | + TestStatus(**arch["state"]) for arch in deployed.values() |
2079 | + ): |
2080 | + deployable = FeatureStatus( |
2081 | + name="Deployable", |
2082 | + state=deploy_state._is_positive_, |
2083 | + readable_state=deploy_state._state_, |
2084 | + info="All machines deployed" |
2085 | + if deploy_state._is_positive_ |
2086 | + else "; ".join( |
2087 | + f"{name}:{arch['error']}" |
2088 | + for name, arch in deployed.items() |
2089 | + if arch["error"] |
2090 | + ), |
2091 | + ) |
2092 | + image_results.deployable = deployable # type: ignore[attr-defined] |
2093 | + # check to see if we did any tests of the image after it deployed |
2094 | + if image_tests := get_step_from_results(this_image_result, "test_image"): |
2095 | + # storage configuration |
2096 | + if storage_state := determine_feature_state("storage layout", image_tests): |
2097 | + state, readable, info = storage_state |
2098 | + storage_conf = FeatureStatus( |
2099 | + "Storage Configuration", |
2100 | + state=state, |
2101 | + readable_state=readable, |
2102 | + info=info, |
2103 | + ) |
2104 | + image_results.storage_conf = storage_conf # type:ignore[attr-defined] |
2105 | + # network configuration |
2106 | + if network_state := determine_feature_state("network layout", image_tests): |
2107 | + state, readable, info = network_state |
2108 | + net_conf = FeatureStatus( |
2109 | + "Network Configuration", |
2110 | + state=state, |
2111 | + readable_state=readable, |
2112 | + info=info, |
2113 | + ) |
2114 | + image_results.net_conf = net_conf # type:ignore[attr-defined] |
2115 | + # add to image results list |
2116 | + results |= image_results.to_dict() |
2117 | + return results |
2118 | + |
2119 | + |
2120 | +@activity.defn |
2121 | +async def parse_failed_images(params: dict[str, Any]) -> dict[str, Any]: |
2122 | + maas_version: dict[str, dict[str, str]] = params["maas_version"] |
2123 | + image_details: dict[str, Any] = params["image_details"] |
2124 | + passed_images: list[str] = params["passed_images"] |
2125 | + results: dict[str, Any] = {} |
2126 | + |
2127 | + default_maas_version = maas_version["None"] |
2128 | + |
2129 | + # report on images that failed one of the steps |
2130 | + for image, details in image_details.items(): |
2131 | + # don't report on images we've already recovered test statuses for |
2132 | + if image in passed_images: |
2133 | + continue |
2134 | + |
2135 | + test_num = str(details["test_num"]) |
2136 | + |
2137 | + readable_state = "Unkown Error" |
2138 | + if not details["built"]: |
2139 | + readable_state = "Could not build image" |
2140 | + elif not details["tested"]: |
2141 | + readable_state = "Could not test image" |
2142 | + results |= ImageTestResults( |
2143 | + image=image, |
2144 | + maas_version=[maas_version.get(test_num, default_maas_version)["short"]], |
2145 | + readable_state=readable_state, |
2146 | + packer_version=[details["packer_version"]], |
2147 | + prerequisites=details["prerequisites"], |
2148 | + ).to_dict() |
2149 | + return results |
2150 | + |
2151 | + |
2152 | +@activity.defn |
2153 | +async def post_test_results(params: dict[str, Any]) -> None: |
2154 | + image_results: dict[str, Any] = params["image_results"] |
2155 | + maas_version: dict[str, dict[str, str]] = params["maas_version"] |
2156 | + repo_location: str = params["repo_location"] |
2157 | + image_details: dict[str, Any] = params["image_details"] |
2158 | + overwrite_results: bool = params["overwrite_results"] |
2159 | + # clone the results repo |
2160 | + if not os.path.exists(repo_location): |
2161 | + execute( |
2162 | + [ |
2163 | + "git", |
2164 | + "clone", |
2165 | + "https://github.com/maas/MAAS-Image-Results", |
2166 | + repo_location, |
2167 | + ] |
2168 | + ) |
2169 | + |
2170 | + # read the combined results |
2171 | + combined_results: dict[str, dict[str, Any]] = {"images": {}} |
2172 | + combined_results_path = f"{repo_location}/image_results.yaml" |
2173 | + with open(combined_results_path, "r") as result_file: |
2174 | + if old_results := yaml.safe_load(result_file): |
2175 | + combined_results = old_results |
2176 | + |
2177 | + test_nums = set() |
2178 | + # write the results for each image |
2179 | + for image, image_results in params["image_results"].items(): |
2180 | + this_result_path = f"{repo_location}/{image}.yaml" |
2181 | + results: ImageTestResults = ImageTestResults().from_dict({image: image_results}) |
2182 | + details: dict[str, Any] = image_details[image] |
2183 | + this_test_num: str = str(details["test_num"]) |
2184 | + default_maas_version = maas_version["None"] |
2185 | + this_maas_version: str = maas_version.get(this_test_num, default_maas_version)[ |
2186 | + "long" |
2187 | + ] |
2188 | + test_nums.add(int(this_test_num)) |
2189 | + |
2190 | + with checkout_and_commit( |
2191 | + branch=image, |
2192 | + commit_message=f"{image} results: {this_maas_version} - {this_test_num}", |
2193 | + add_file=this_result_path, |
2194 | + cwd=repo_location, |
2195 | + ): |
2196 | + if os.path.exists(this_result_path) and not overwrite_results: |
2197 | + with open(this_result_path, "r") as result_file: |
2198 | + if old_results := yaml.safe_load(result_file): |
2199 | + results += ImageTestResults().from_dict(old_results) |
2200 | + |
2201 | + if combined_results["images"]: |
2202 | + combined_results["images"] |= results.to_dict() |
2203 | + else: |
2204 | + combined_results["images"] = results.to_dict() |
2205 | + |
2206 | + with open(this_result_path, "w") as result_file: |
2207 | + yaml.safe_dump(results.to_dict(), result_file) |
2208 | + |
2209 | + tested_builds = ( |
2210 | + f"{min(test_nums)} - {max(test_nums)}" if len(test_nums) > 1 else f"{test_nums}" |
2211 | + ) |
2212 | + |
2213 | + # write the combined results to main |
2214 | + with checkout_and_commit( |
2215 | + branch="main", |
2216 | + commit_message=f"Combined results: {tested_builds}", |
2217 | + add_file=combined_results_path, |
2218 | + cwd=repo_location, |
2219 | + ), open(combined_results_path, "w") as result_file: |
2220 | + yaml.safe_dump(combined_results, result_file) |
2221 | + |
2222 | + |
2223 | +@workflow.defn |
2224 | +class image_reporting_workflow: |
2225 | + @workflow.run |
2226 | + async def run(self, params: image_reporting_param) -> None: |
2227 | + if not params.image_details: |
2228 | + raise Exception("No Image details provided") |
2229 | + # await an open connection to the server |
2230 | + await workflow.execute_activity( |
2231 | + check_jenkins_reachable, |
2232 | + params, |
2233 | + start_to_close_timeout=params.gettimeout("jenkins_login_timeout"), |
2234 | + ) |
2235 | + test_numbers = await workflow.execute_activity( |
2236 | + get_test_numbers, |
2237 | + { |
2238 | + "image_details": params.image_details, |
2239 | + "params": params, |
2240 | + }, |
2241 | + start_to_close_timeout=params.gettimeout("log_details_timeout"), |
2242 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2243 | + ) |
2244 | + maas_versions = await workflow.execute_activity( |
2245 | + fetch_maas_version_from_logs, |
2246 | + { |
2247 | + "params": params, |
2248 | + "tests": list(test_numbers.keys()), |
2249 | + }, |
2250 | + start_to_close_timeout=params.gettimeout("log_details_timeout"), |
2251 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2252 | + ) |
2253 | + results_to_report: dict[str, Any] = {} |
2254 | + for test_num, test_details in test_numbers.items(): |
2255 | + # if the tests completed and results are available. |
2256 | + if ( |
2257 | + test_details["status"].lower() != "aborted" |
2258 | + and test_details["has_results"] |
2259 | + ): |
2260 | + results = await workflow.execute_activity( |
2261 | + filter_test_results, |
2262 | + {"params": params, "test_num": test_num}, |
2263 | + start_to_close_timeout=params.gettimeout("fetch_results_timeout"), |
2264 | + retry_policy=RetryPolicy( |
2265 | + maximum_attempts=params.max_retry_attempts |
2266 | + ), |
2267 | + ) |
2268 | + default_maas_version = maas_versions["None"] |
2269 | + results_to_report |= await workflow.execute_activity( |
2270 | + parse_test_results, |
2271 | + { |
2272 | + "maas_version": maas_versions.get( |
2273 | + test_num, default_maas_version |
2274 | + )["short"], |
2275 | + "image_details": params.image_details, |
2276 | + "results": results, |
2277 | + }, |
2278 | + start_to_close_timeout=params.gettimeout("fetch_results_timeout"), |
2279 | + retry_policy=RetryPolicy( |
2280 | + maximum_attempts=params.max_retry_attempts |
2281 | + ), |
2282 | + ) |
2283 | + # add any images that didn't test |
2284 | + results_to_report |= await workflow.execute_activity( |
2285 | + parse_failed_images, |
2286 | + { |
2287 | + "image_details": params.image_details, |
2288 | + "maas_version": maas_versions, |
2289 | + "passed_images": list(results_to_report.keys()), |
2290 | + }, |
2291 | + start_to_close_timeout=params.gettimeout("fetch_results_timeout"), |
2292 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2293 | + ) |
2294 | + # only try to upload once. |
2295 | + await workflow.execute_activity( |
2296 | + post_test_results, |
2297 | + { |
2298 | + "image_results": results_to_report, |
2299 | + "maas_version": maas_versions, |
2300 | + "repo_location": params.repo_location, |
2301 | + "image_details": params.image_details, |
2302 | + "overwrite_results": params.overwrite_results, |
2303 | + }, |
2304 | + start_to_close_timeout=params.gettimeout("fetch_results_timeout"), |
2305 | + retry_policy=RetryPolicy(maximum_attempts=1), |
2306 | + ) |
2307 | + |
2308 | + |
2309 | +activities = [ |
2310 | + check_jenkins_reachable, |
2311 | + get_test_numbers, |
2312 | + fetch_maas_version_from_logs, |
2313 | + filter_test_results, |
2314 | + parse_test_results, |
2315 | + parse_failed_images, |
2316 | + post_test_results, |
2317 | +] |
2318 | +workflows = [image_reporting_workflow] |
2319 | diff --git a/temporal/image_testing_worker.py b/temporal/image_testing_worker.py |
2320 | new file mode 100644 |
2321 | index 0000000..f28bb23 |
2322 | --- /dev/null |
2323 | +++ b/temporal/image_testing_worker.py |
2324 | @@ -0,0 +1,10 @@ |
2325 | +from common_tasks import start_worker |
2326 | +from image_testing_workflow import activities as image_test_activities |
2327 | +from image_testing_workflow import workflows as image_test_workflows |
2328 | + |
2329 | +if __name__ == "__main__": |
2330 | + start_worker( |
2331 | + task_queue="image_testing", |
2332 | + workflows=image_test_workflows, |
2333 | + activities=image_test_activities, |
2334 | + ) |
2335 | diff --git a/temporal/image_testing_workflow.py b/temporal/image_testing_workflow.py |
2336 | new file mode 100644 |
2337 | index 0000000..a587b4b |
2338 | --- /dev/null |
2339 | +++ b/temporal/image_testing_workflow.py |
2340 | @@ -0,0 +1,100 @@ |
2341 | +from dataclasses import dataclass |
2342 | +from typing import Any |
2343 | + |
2344 | +from common_tasks import ( |
2345 | + aslist, |
2346 | + await_build_complete, |
2347 | + await_build_exists, |
2348 | + check_jenkins_reachable, |
2349 | + fetch_build_status, |
2350 | + request_build, |
2351 | + workflow_parameters, |
2352 | +) |
2353 | +from temporalio import activity, workflow |
2354 | +from temporalio.common import RetryPolicy |
2355 | + |
2356 | + |
2357 | +@dataclass |
2358 | +class image_testing_param(workflow_parameters): |
2359 | + image_name: str | list[str] = "" # allow builk image building if desired |
2360 | + |
2361 | + job_name: str = ( |
2362 | + "maas-automated-image-tester" # Need to check which job actually does this |
2363 | + ) |
2364 | + build_num: int = -1 |
2365 | + |
2366 | + # job details with default values we may want to change |
2367 | + system_test_repo: str = ( |
2368 | + "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests" |
2369 | + ) |
2370 | + system_test_branch: str = "master" |
2371 | + |
2372 | + maas_snap_channel: str = "latest/edge" |
2373 | + |
2374 | + parallel_tests: bool = False |
2375 | + |
2376 | + |
2377 | +@activity.defn |
2378 | +async def request_images_test(params: image_testing_param) -> int: |
2379 | + """Start an image testing job, returning the job number.""" |
2380 | + job_params: dict[str, Any] = { |
2381 | + "IMAGE_NAMES": ",".join(image for image in aslist(params.image_name)), |
2382 | + "SYSTEMTESTS_GIT_REPO": params.system_test_repo, |
2383 | + "SYSTEMTESTS_GIT_BRANCH": params.system_test_branch, |
2384 | + "MAAS_SNAP_CHANNEL": params.maas_snap_channel, |
2385 | + } |
2386 | + return request_build(params, job_params) |
2387 | + |
2388 | + |
2389 | +@workflow.defn |
2390 | +class image_testing_workflow: |
2391 | + @workflow.run |
2392 | + async def run(self, params: image_testing_param) -> dict[str, Any]: |
2393 | + # await an open connection to the server |
2394 | + await workflow.execute_activity( |
2395 | + check_jenkins_reachable, |
2396 | + params, |
2397 | + start_to_close_timeout=params.gettimeout("jenkins_login_timeout"), |
2398 | + ) |
2399 | + # test the image, only trigger once |
2400 | + params.build_num = await workflow.execute_activity( |
2401 | + request_images_test, |
2402 | + params, |
2403 | + start_to_close_timeout=params.gettimeout("request_build_timeout"), |
2404 | + ) |
2405 | + # try multiple times to get the results or status |
2406 | + await workflow.execute_activity( |
2407 | + await_build_exists, |
2408 | + params, |
2409 | + start_to_close_timeout=params.gettimeout("request_build_timeout"), |
2410 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2411 | + ) |
2412 | + await workflow.execute_activity( |
2413 | + await_build_complete, |
2414 | + params, |
2415 | + start_to_close_timeout=params.gettimeout("build_complete_timeout"), |
2416 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2417 | + ) |
2418 | + # return a default failure state if the build was aborted |
2419 | + build_status = await workflow.execute_activity( |
2420 | + fetch_build_status, |
2421 | + params, |
2422 | + start_to_close_timeout=params.gettimeout("build_complete_timeout"), |
2423 | + retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts), |
2424 | + ) |
2425 | + |
2426 | + # return the image details in the correct format |
2427 | + return { |
2428 | + image: {"build_num": params.build_num, "build_status": build_status} |
2429 | + for image in aslist(params.image_name) |
2430 | + } |
2431 | + |
2432 | + |
2433 | +activities = [ |
2434 | + check_jenkins_reachable, |
2435 | + request_images_test, |
2436 | + await_build_exists, |
2437 | + await_build_complete, |
2438 | + fetch_build_status, |
2439 | +] |
2440 | +workflows = [image_testing_workflow] |
2441 | diff --git a/tox.ini b/tox.ini |
2442 | index e4efc18..f347a7b 100644 |
2443 | --- a/tox.ini |
2444 | +++ b/tox.ini |
2445 | @@ -66,8 +66,8 @@ description=Reformat Python code and README.md |
2446 | deps= -rrequirements.txt |
2447 | skip_install = true |
2448 | commands= |
2449 | - isort --profile black systemtests utils |
2450 | - black systemtests utils |
2451 | + isort --profile black systemtests utils temporal |
2452 | + black systemtests utils temporal |
2453 | cog -r README.md |
2454 | |
2455 | [testenv:lint] |
2456 | @@ -76,10 +76,10 @@ deps= -rrequirements.txt |
2457 | allowlist_externals=sh |
2458 | skip_install = true |
2459 | commands= |
2460 | - isort --profile black --check-only systemtests utils |
2461 | - black --check systemtests utils |
2462 | + isort --profile black --check-only systemtests utils temporal |
2463 | + black --check systemtests utils temporal |
2464 | cog --verbosity=0 --check README.md |
2465 | - flake8 systemtests utils |
2466 | + flake8 systemtests utils temporal |
2467 | sh -c 'git ls-files \*.yaml\* | xargs -r yamllint' |
2468 | |
2469 | [testenv:mypy] |
2470 | @@ -95,6 +95,7 @@ deps= |
2471 | types-netaddr |
2472 | commands= |
2473 | mypy -p systemtests -p utils --install-types |
2474 | + mypy temporal |
2475 | |
2476 | [testenv:generate_config] |
2477 | description=Generate config.yaml |
2478 | diff --git a/utils/gen_config.py b/utils/gen_config.py |
2479 | index 3a1a4cd..4ea3e5e 100755 |
2480 | --- a/utils/gen_config.py |
2481 | +++ b/utils/gen_config.py |
2482 | @@ -144,10 +144,14 @@ def main(argv: list[str]) -> int: |
2483 | packer_group.add_argument( |
2484 | "--packer-repo", |
2485 | type=str, |
2486 | + metavar="REPOS", |
2487 | help="Which git repository to use to get Packer from", |
2488 | ) |
2489 | packer_group.add_argument( |
2490 | - "--packer-branch", type=str, help="Which git branch use to get Packer" |
2491 | + "--packer-branch", |
2492 | + type=str, |
2493 | + metavar="BRANCH", |
2494 | + help="Which git branch use to get Packer", |
2495 | ) |
2496 | packer_group.add_argument( |
2497 | "--packer-container-image", |
2498 | @@ -318,7 +322,7 @@ def main(argv: list[str]) -> int: |
2499 | # if running custom image tests, only use compatible machines |
2500 | target_arches = ( |
2501 | args.architecture |
2502 | - if not args.image_tests |
2503 | + if "image-tests" not in config |
2504 | else [image["architecture"] for image in config["image-tests"].values()] |
2505 | ) |
2506 | # Filter out machines with architectures not matching specified ones. |
2507 | @@ -333,12 +337,12 @@ def main(argv: list[str]) -> int: |
2508 | machines["hardware"] = { |
2509 | name: details |
2510 | for name, details in hardware.items() |
2511 | - if name not in args.machine |
2512 | + if name in args.machine |
2513 | } |
2514 | |
2515 | - if args.vm_machine: |
2516 | + if vms: |
2517 | # Filter out VMs with name not listed in specified vm_machines |
2518 | - if vms: |
2519 | + if args.vm_machine: |
2520 | vms["instances"] = { |
2521 | vm_name: vm_config |
2522 | for vm_name, vm_config in vms["instances"].items() |
+1 on the merge once all branches are in