Merge ~maas-committers/maas-ci/+git/system-tests:MAASENG-1717-Automated-Image-Testing-feature-branch into ~maas-committers/maas-ci/+git/system-tests:master

Proposed by Alexsander de Souza
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)
Reviewer Review Type Date Requested Status
MAAS Lander Approve
Jack Lloyd-Walters Approve
Review via email: mp+449015@code.launchpad.net

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>

To post a comment you must log in.
Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) wrote :

+1 on the merge once all branches are in

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b MAASENG-1717-Automated-Image-Testing-feature-branch lp:~maas-committers/maas-ci/+git/system-tests into -b master lp:~maas-committers/maas-ci/+git/system-tests

STATUS: SUCCESS
COMMIT: db3f8c2f2a2aff73a2d7a3e2e8f89d1b8fcf11c6

review: Approve
02b5fbe... by Jack Lloyd-Walters

rebase changes and merge again

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b MAASENG-1717-Automated-Image-Testing-feature-branch lp:~maas-committers/maas-ci/+git/system-tests into -b master lp:~maas-committers/maas-ci/+git/system-tests

STATUS: SUCCESS
COMMIT: 02b5fbe61ed5bfe5bffa33775938b9af25486261

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2index 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/
29diff --git a/image_mapping.yaml.sample b/image_mapping.yaml.sample
30index 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
186diff --git a/setup.py b/setup.py
187index 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
206diff --git a/systemtests/api.py b/systemtests/api.py
207index 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."""
274diff --git a/systemtests/conftest.py b/systemtests/conftest.py
275index 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 )
289diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py
290index 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
312diff --git a/systemtests/git_build.py b/systemtests/git_build.py
313index 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(
351diff --git a/systemtests/image_builder/test_packer.py b/systemtests/image_builder/test_packer.py
352index 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)
383diff --git a/systemtests/image_config.py b/systemtests/image_config.py
384index 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}"
405diff --git a/systemtests/packer.py b/systemtests/packer.py
406index 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:
470diff --git a/systemtests/state.py b/systemtests/state.py
471index 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
486diff --git a/systemtests/tests_per_machine/test_machine.py b/systemtests/tests_per_machine/test_machine.py
487index 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")
568diff --git a/systemtests/utils.py b/systemtests/utils.py
569index 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
638diff --git a/temporal/README.md b/temporal/README.md
639new file mode 100644
640index 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`.
732diff --git a/temporal/build_results.py b/temporal/build_results.py
733new file mode 100644
734index 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)
1133diff --git a/temporal/common_tasks.py b/temporal/common_tasks.py
1134new file mode 100644
1135index 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()
1432diff --git a/temporal/e2e_worker.py b/temporal/e2e_worker.py
1433new file mode 100644
1434index 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+ )
1448diff --git a/temporal/e2e_workflow.py b/temporal/e2e_workflow.py
1449new file mode 100644
1450index 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]
1660diff --git a/temporal/image_building_worker.py b/temporal/image_building_worker.py
1661new file mode 100644
1662index 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+ )
1676diff --git a/temporal/image_building_workflow.py b/temporal/image_building_workflow.py
1677new file mode 100644
1678index 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]
1847diff --git a/temporal/image_reporting_worker.py b/temporal/image_reporting_worker.py
1848new file mode 100644
1849index 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+ )
1863diff --git a/temporal/image_reporting_workflow.py b/temporal/image_reporting_workflow.py
1864new file mode 100644
1865index 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]
2319diff --git a/temporal/image_testing_worker.py b/temporal/image_testing_worker.py
2320new file mode 100644
2321index 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+ )
2335diff --git a/temporal/image_testing_workflow.py b/temporal/image_testing_workflow.py
2336new file mode 100644
2337index 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]
2441diff --git a/tox.ini b/tox.ini
2442index 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
2478diff --git a/utils/gen_config.py b/utils/gen_config.py
2479index 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()

Subscribers

People subscribed via source and target branches

to all changes: