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
diff --git a/.gitignore b/.gitignore
index e71b819..3ed1283 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,16 +1,16 @@
1*.egg-info1__pycache__
2.vscode
3.idea2.idea
4.tox
5.mypy_cache3.mypy_cache
4.tox
5.vscode
6*.egg-info
7base_config.yaml
8build-*.log
6build/9build/
7__pycache__
8config.yaml10config.yaml
9credentials.yaml11credentials.yaml
10base_config.yaml
11image_mapping.yaml12image_mapping.yaml
13images/
12junit*.xml14junit*.xml
13sosreport15sosreport
14systemtests*.log16systemtests*.log
15images/
16build/
diff --git a/image_mapping.yaml.sample b/image_mapping.yaml.sample
index d23a1fc..72b2c35 100644
--- a/image_mapping.yaml.sample
+++ b/image_mapping.yaml.sample
@@ -5,7 +5,7 @@
5# An example of a mapping is:5# An example of a mapping is:
6# images:6# images:
7# $IMAGE_NAME:7# $IMAGE_NAME:
8# url: $IMAGE_URL8# filename: $IMAGE_FILENAME
9# filetype: $IMAGE_FILETYPE9# filetype: $IMAGE_FILETYPE
10# architecture: $IMAGE_ARCH10# architecture: $IMAGE_ARCH
11# osystem: $IMAGE_OSYSTEM11# osystem: $IMAGE_OSYSTEM
@@ -17,7 +17,7 @@
1717
18images:18images:
19 centos7:19 centos7:
20 url: centos7.tar.gz20 filename: centos7.tar.gz
21 filetype: tgz21 filetype: tgz
22 architecture: amd64/generic22 architecture: amd64/generic
23 osystem: centos23 osystem: centos
@@ -25,7 +25,7 @@ images:
25 packer_template: centos725 packer_template: centos7
26 ssh_username: centos26 ssh_username: centos
27 centos8:27 centos8:
28 url: centos8.tar.gz28 filename: centos8.tar.gz
29 filetype: tgz29 filetype: tgz
30 architecture: amd64/generic30 architecture: amd64/generic
31 osystem: centos31 osystem: centos
@@ -33,7 +33,7 @@ images:
33 packer_template: centos833 packer_template: centos8
34 ssh_username: centos34 ssh_username: centos
35 centos8-stream:35 centos8-stream:
36 url: centos8-stream.tar.gz36 filename: centos8-stream.tar.gz
37 filetype: tgz37 filetype: tgz
38 architecture: amd64/generic38 architecture: amd64/generic
39 osystem: centos39 osystem: centos
@@ -41,7 +41,7 @@ images:
41 packer_template: centos8-stream41 packer_template: centos8-stream
42 ssh_username: centos42 ssh_username: centos
43 rhel7:43 rhel7:
44 url: rhel7.tar.gz44 filename: rhel7.tar.gz
45 filetype: tgz45 filetype: tgz
46 architecture: amd64/generic46 architecture: amd64/generic
47 osystem: rhel47 osystem: rhel
@@ -50,7 +50,7 @@ images:
50 source_iso: rhel-server-7.9-x86_64-dvd.iso50 source_iso: rhel-server-7.9-x86_64-dvd.iso
51 ssh_username: cloud-user51 ssh_username: cloud-user
52 rhel8:52 rhel8:
53 url: rhel8.tar.gz53 filename: rhel8.tar.gz
54 filetype: tgz54 filetype: tgz
55 architecture: amd64/generic55 architecture: amd64/generic
56 osystem: rhel56 osystem: rhel
@@ -59,7 +59,7 @@ images:
59 source_iso: rhel-8.6-x86_64-dvd.iso59 source_iso: rhel-8.6-x86_64-dvd.iso
60 ssh_username: cloud-user60 ssh_username: cloud-user
61 rhel9:61 rhel9:
62 url: rhel9.tar.gz62 filename: rhel9.tar.gz
63 filetype: tgz63 filetype: tgz
64 architecture: amd64/generic64 architecture: amd64/generic
65 osystem: rhel65 osystem: rhel
@@ -68,7 +68,7 @@ images:
68 source_iso: rhel-baseos-9.1-x86_64-dvd.iso68 source_iso: rhel-baseos-9.1-x86_64-dvd.iso
69 ssh_username: cloud-user69 ssh_username: cloud-user
70 rocky8:70 rocky8:
71 url: rocky8.tar.gz71 filename: rocky8.tar.gz
72 filetype: tgz72 filetype: tgz
73 architecture: amd64/generic73 architecture: amd64/generic
74 osystem: custom74 osystem: custom
@@ -77,7 +77,7 @@ images:
77 base_image: "rhel/8"77 base_image: "rhel/8"
78 ssh_username: cloud-user78 ssh_username: cloud-user
79 rocky9:79 rocky9:
80 url: rocky9.tar.gz80 filename: rocky9.tar.gz
81 filetype: tgz81 filetype: tgz
82 architecture: amd64/generic82 architecture: amd64/generic
83 osystem: custom83 osystem: custom
@@ -86,7 +86,7 @@ images:
86 base_image: "rhel/9"86 base_image: "rhel/9"
87 ssh_username: cloud-user87 ssh_username: cloud-user
88 sles12:88 sles12:
89 url: sles12.tar.gz89 filename: sles12.tar.gz
90 filetype: tgz90 filetype: tgz
91 architecture: amd64/generic91 architecture: amd64/generic
92 osystem: suse92 osystem: suse
@@ -95,7 +95,7 @@ images:
95 source_iso: SLES12-SP5-JeOS.x86_64-12.5-OpenStack-Cloud-GM.qcow295 source_iso: SLES12-SP5-JeOS.x86_64-12.5-OpenStack-Cloud-GM.qcow2
96 ssh_username: sles96 ssh_username: sles
97 sles15:97 sles15:
98 url: sles15.tar.gz98 filename: sles15.tar.gz
99 filetype: tgz99 filetype: tgz
100 architecture: amd64/generic100 architecture: amd64/generic
101 osystem: suse101 osystem: suse
@@ -104,7 +104,7 @@ images:
104 source_iso: SLE-15-SP4-Full-x86_64-GM-Media1.iso104 source_iso: SLE-15-SP4-Full-x86_64-GM-Media1.iso
105 ssh_username: sles105 ssh_username: sles
106 esxi6:106 esxi6:
107 url: vmware-esxi-6.dd.gz107 filename: vmware-esxi-6.dd.gz
108 filetype: ddgz108 filetype: ddgz
109 architecture: amd64/generic109 architecture: amd64/generic
110 osystem: esxi110 osystem: esxi
@@ -113,7 +113,7 @@ images:
113 source_iso: VMware-VMvisor-Installer-6.7.0.update03-14320388.x86_64.iso113 source_iso: VMware-VMvisor-Installer-6.7.0.update03-14320388.x86_64.iso
114 ssh_username: root114 ssh_username: root
115 esxi7:115 esxi7:
116 url: vmware-esxi-7.dd.gz116 filename: vmware-esxi-7.dd.gz
117 filetype: ddgz117 filetype: ddgz
118 architecture: amd64/generic118 architecture: amd64/generic
119 osystem: esxi119 osystem: esxi
@@ -122,7 +122,7 @@ images:
122 source_iso: VMware-VMvisor-Installer-7.0U3g-20328353.x86_64.iso122 source_iso: VMware-VMvisor-Installer-7.0U3g-20328353.x86_64.iso
123 ssh_username: root123 ssh_username: root
124 esxi8:124 esxi8:
125 url: vmware-esxi-8.dd.gz125 filename: vmware-esxi-8.dd.gz
126 filetype: ddgz126 filetype: ddgz
127 architecture: amd64/generic127 architecture: amd64/generic
128 osystem: esxi128 osystem: esxi
@@ -131,7 +131,7 @@ images:
131 source_iso: VMware-VMvisor-Installer-8.0b-21203435.x86_64.iso131 source_iso: VMware-VMvisor-Installer-8.0b-21203435.x86_64.iso
132 ssh_username: root132 ssh_username: root
133 ubuntu:133 ubuntu:
134 url: ubuntu-cloudimg.tar.gz134 filename: ubuntu-cloudimg.tar.gz
135 filetype: tgz135 filetype: tgz
136 architecture: amd64/generic136 architecture: amd64/generic
137 osystem: custom137 osystem: custom
@@ -139,7 +139,7 @@ images:
139 packer_template: ubuntu139 packer_template: ubuntu
140 packer_target: custom-cloudimg.tar.gz140 packer_target: custom-cloudimg.tar.gz
141 ubuntu-flat:141 ubuntu-flat:
142 url: ubuntu-flat.tar.gz142 filename: ubuntu-flat.tar.gz
143 filetype: tgz143 filetype: tgz
144 architecture: amd64/generic144 architecture: amd64/generic
145 osystem: custom145 osystem: custom
@@ -147,7 +147,7 @@ images:
147 packer_template: ubuntu147 packer_template: ubuntu
148 packer_target: custom-ubuntu.tar.gz148 packer_target: custom-ubuntu.tar.gz
149 ubuntu-lvm:149 ubuntu-lvm:
150 url: ubuntu-lvm.tar.gz150 filename: ubuntu-lvm.tar.gz
151 filetype: ddgz151 filetype: ddgz
152 architecture: amd64/generic152 architecture: amd64/generic
153 osystem: custom153 osystem: custom
diff --git a/setup.py b/setup.py
index f6d6ae4..b6c9b32 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,7 @@
1from setuptools import find_packages, setup1from setuptools import find_packages, setup
22
3install_requires = (3install_requires = (
4 'jenkinsapi',
4 'netaddr',5 'netaddr',
5 'paramiko',6 'paramiko',
6 'pytest-dependency',7 'pytest-dependency',
@@ -12,6 +13,7 @@ install_requires = (
12 'requests',13 'requests',
13 'retry',14 'retry',
14 'ruamel.yaml',15 'ruamel.yaml',
16 'temporalio'
15)17)
1618
1719
diff --git a/systemtests/api.py b/systemtests/api.py
index ec76b0e..dde94dd 100644
--- a/systemtests/api.py
+++ b/systemtests/api.py
@@ -78,6 +78,7 @@ class BootSource(TypedDict):
78# TODO: Expand these to TypedDict matching API response structure78# TODO: Expand these to TypedDict matching API response structure
7979
80Subnet = Dict[str, Any]80Subnet = Dict[str, Any]
81Interface = Dict[str, Any]
81RackController = Dict[str, Any]82RackController = Dict[str, Any]
82RegionController = Dict[str, Any]83RegionController = Dict[str, Any]
83IPRange = Dict[str, Any]84IPRange = Dict[str, Any]
@@ -256,6 +257,7 @@ class AuthenticatedAPIClient:
256 architecture: str,257 architecture: str,
257 filetype: str,258 filetype: str,
258 image_file_path: str,259 image_file_path: str,
260 base_image: str | None = None,
259 ) -> None:261 ) -> None:
260 cmd = [262 cmd = [
261 "boot-resources",263 "boot-resources",
@@ -266,6 +268,8 @@ class AuthenticatedAPIClient:
266 f"filetype={filetype}",268 f"filetype={filetype}",
267 f"content@={image_file_path}",269 f"content@={image_file_path}",
268 ]270 ]
271 if base_image:
272 cmd.append(f"base_image={base_image}")
269 self.execute(cmd, json_output=False)273 self.execute(cmd, json_output=False)
270274
271 def import_boot_resources(self) -> str:275 def import_boot_resources(self) -> str:
@@ -716,6 +720,38 @@ class AuthenticatedAPIClient:
716 + [f"{k}={v}" for k, v in options.items()]720 + [f"{k}={v}" for k, v in options.items()]
717 )721 )
718722
723 def create_interface(
724 self, machine: Machine, network_type: str, options: dict[str, str] = {}
725 ) -> Interface:
726 """bond, bridge,"""
727 interface: Interface = self.execute(
728 ["interfaces", f"create-{network_type}", machine["system_id"]]
729 + [f"{k}={v}" for k, v in options.items()]
730 )
731 return interface
732
733 def delete_interface(self, machine: Machine, interface: Interface) -> str:
734 result: str = self.execute(
735 ["interface", "delete", machine["systed_id"], str(interface["id"])],
736 json_output=False,
737 )
738 return result
739
740 def read_interfaces(self, machine: Machine) -> list[Interface]:
741 result: list[Interface] = self.execute(
742 ["interfaces", "read", machine["system_id"]]
743 )
744 return result
745
746 def update_interface(
747 self, machine: Machine, interface: Interface, options: dict[str, str]
748 ) -> Interface:
749 updated_interface: Interface = self.execute(
750 ["interface", "update", machine["system_id"], str(interface["id"])]
751 + [f"{k}={v}" for k, v in options.items()]
752 )
753 return updated_interface
754
719755
720class QuietAuthenticatedAPIClient(AuthenticatedAPIClient):756class QuietAuthenticatedAPIClient(AuthenticatedAPIClient):
721 """An Authenticated API Client that is quiet."""757 """An Authenticated API Client that is quiet."""
diff --git a/systemtests/conftest.py b/systemtests/conftest.py
index a069d84..6acabf7 100644
--- a/systemtests/conftest.py
+++ b/systemtests/conftest.py
@@ -358,7 +358,9 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
358 metafunc.parametrize("instance_config", instance_config, ids=str, indirect=True)358 metafunc.parametrize("instance_config", instance_config, ids=str, indirect=True)
359359
360 if "image_to_test" in metafunc.fixturenames:360 if "image_to_test" in metafunc.fixturenames:
361 if images_to_test := [image for image in generate_images(cfg) if image.url]:361 if images_to_test := [
362 image for image in generate_images(cfg) if image.filename
363 ]:
362 metafunc.parametrize(364 metafunc.parametrize(
363 "image_to_test", images_to_test, ids=str, indirect=True365 "image_to_test", images_to_test, ids=str, indirect=True
364 )366 )
diff --git a/systemtests/fixtures.py b/systemtests/fixtures.py
index 7521c7d..e53ba45 100644
--- a/systemtests/fixtures.py
+++ b/systemtests/fixtures.py
@@ -763,7 +763,9 @@ def dns_tester(
763763
764764
765@pytest.fixture(scope="session")765@pytest.fixture(scope="session")
766def packer_main(config: dict[str, Any]) -> Optional[Iterator[PackerMain]]:766def packer_main(
767 request: pytest.FixtureRequest, config: dict[str, Any]
768) -> Optional[Iterator[PackerMain]]:
767 """Set up a new LXD container with Packer installed."""769 """Set up a new LXD container with Packer installed."""
768 packer_config = config.get("packer-maas", {})770 packer_config = config.get("packer-maas", {})
769 repo = packer_config.get("git-repo")771 repo = packer_config.get("git-repo")
@@ -787,6 +789,7 @@ def packer_main(config: dict[str, Any]) -> Optional[Iterator[PackerMain]]:
787 proxy_env=proxy_env,789 proxy_env=proxy_env,
788 file_store=config.get("file-store", {}),790 file_store=config.get("file-store", {}),
789 debug=packer_config.get("verbosity", ""),791 debug=packer_config.get("verbosity", ""),
792 root_path=request.config.rootpath,
790 )793 )
791 main.setup()794 main.setup()
792 yield main795 yield main
diff --git a/systemtests/git_build.py b/systemtests/git_build.py
index 342fa0c..3803322 100644
--- a/systemtests/git_build.py
+++ b/systemtests/git_build.py
@@ -5,6 +5,7 @@ from contextlib import closing
5from functools import partial5from functools import partial
6from pathlib import Path6from pathlib import Path
7from subprocess import CalledProcessError7from subprocess import CalledProcessError
8from textwrap import dedent
8from timeit import Timer9from timeit import Timer
9from typing import TYPE_CHECKING, Any, Callable10from typing import TYPE_CHECKING, Any, Callable
10from urllib.request import urlopen11from urllib.request import urlopen
@@ -33,6 +34,7 @@ class GitBuild:
33 self._repos = repo34 self._repos = repo
34 self._branch = branch35 self._branch = branch
35 self._clone_path = clone_path36 self._clone_path = clone_path
37 self._set_apt_proxy()
3638
37 @property39 @property
38 def clone_path(self) -> str:40 def clone_path(self) -> str:
@@ -46,6 +48,18 @@ class GitBuild:
46 def logger(self, logger: Logger) -> None:48 def logger(self, logger: Logger) -> None:
47 self._instance.logger = logger49 self._instance.logger = logger
4850
51 def _set_apt_proxy(self) -> None:
52 if proxy := self._env.get("http_proxy"):
53 conf = self._instance.files["/etc/apt/apt.conf.d/99-proxy.conf"]
54 conf.write(
55 dedent(
56 f"""\
57 Acquire::http::Proxy "{proxy}";
58 Acquire::https::Proxy "{proxy}";
59 """
60 )
61 )
62
49 def apt_update(self) -> None:63 def apt_update(self) -> None:
50 """Update APT indices, fix broken dpkg."""64 """Update APT indices, fix broken dpkg."""
51 self._instance.quietly_execute(65 self._instance.quietly_execute(
diff --git a/systemtests/image_builder/test_packer.py b/systemtests/image_builder/test_packer.py
index 3bf5836..3619ff2 100644
--- a/systemtests/image_builder/test_packer.py
+++ b/systemtests/image_builder/test_packer.py
@@ -19,7 +19,10 @@ class TestPackerMAASConfig:
19 assert readme.exists(), f"README.md not found in {packer_main.clone_path}"19 assert readme.exists(), f"README.md not found in {packer_main.clone_path}"
2020
21 def test_build_image(21 def test_build_image(
22 self, testlog: Logger, packer_main: PackerMain, image_to_build: TestableImage22 self,
23 testlog: Logger,
24 packer_main: PackerMain,
25 image_to_build: TestableImage,
23 ) -> None:26 ) -> None:
24 # tell mypy we have this under control27 # tell mypy we have this under control
25 assert image_to_build.packer_template is not None28 assert image_to_build.packer_template is not None
@@ -28,12 +31,12 @@ class TestPackerMAASConfig:
28 image = packer_main.build_image(31 image = packer_main.build_image(
29 image_to_build.packer_template,32 image_to_build.packer_template,
30 image_to_build.packer_target,33 image_to_build.packer_target,
31 image_to_build.filename,34 image_to_build.packer_filename,
32 image_to_build.source_iso,35 image_to_build.source_iso,
33 )36 )
34 assert image is not None37 assert image is not None
35 img_file = packer_main._instance.files[image]38 img_file = packer_main._instance.files[image]
36 assert img_file.exists(), f"failed to produce the expected image ({img_file})"39 assert img_file.exists(), f"failed to produce the expected image ({img_file})"
3740
38 if image_to_build.url is not None:41 if image_to_build.filename:
39 packer_main.upload_image(img_file, image_to_build.url)42 packer_main.upload_image(img_file, image_to_build.filename)
diff --git a/systemtests/image_config.py b/systemtests/image_config.py
index 4f7a0e0..d92bff2 100644
--- a/systemtests/image_config.py
+++ b/systemtests/image_config.py
@@ -21,7 +21,7 @@ EXTENSION_MAP = {
21@dataclass(frozen=True)21@dataclass(frozen=True)
22class TestableImage:22class TestableImage:
23 name: str23 name: str
24 url: str | None24 filename: str
25 filetype: str = "targz"25 filetype: str = "targz"
26 architecture: str = "amd64/generic"26 architecture: str = "amd64/generic"
27 osystem: str = "ubuntu"27 osystem: str = "ubuntu"
@@ -48,7 +48,7 @@ class TestableImage:
48 )48 )
4949
50 @property50 @property
51 def filename(self) -> str:51 def packer_filename(self) -> str:
52 ext = EXTENSION_MAP[self.filetype]52 ext = EXTENSION_MAP[self.filetype]
53 if self.packer_template is None:53 if self.packer_template is None:
54 return f"{self.name}.{ext}"54 return f"{self.name}.{ext}"
diff --git a/systemtests/packer.py b/systemtests/packer.py
index 693d6a4..03beb5e 100644
--- a/systemtests/packer.py
+++ b/systemtests/packer.py
@@ -29,6 +29,7 @@ class PackerMain(GitBuild):
29 file_store: dict[str, Any],29 file_store: dict[str, Any],
30 proxy_env: dict[str, str] | None,30 proxy_env: dict[str, str] | None,
31 debug: str | None,31 debug: str | None,
32 root_path: Path,
32 ) -> None:33 ) -> None:
33 super().__init__(34 super().__init__(
34 packer_repo,35 packer_repo,
@@ -40,8 +41,14 @@ class PackerMain(GitBuild):
40 )41 )
41 self.default_debug = debug or ""42 self.default_debug = debug or ""
42 self.file_store = file_store43 self.file_store = file_store
44 self.root_path = root_path
4345
44 def setup(self) -> None:46 def setup(self) -> None:
47 if "http_proxy" in self._env:
48 sudoers = self._instance.files["/etc/sudoers.d/50-preserve-proxy"]
49 sudoers.write(
50 'Defaults env_keep += "ftp_proxy http_proxy https_proxy no_proxy"'
51 )
45 self.apt_source_add(52 self.apt_source_add(
46 "packer",53 "packer",
47 "https://apt.releases.hashicorp.com",54 "https://apt.releases.hashicorp.com",
@@ -101,8 +108,14 @@ class PackerMain(GitBuild):
101 source_iso: str | None,108 source_iso: str | None,
102 ) -> str | None:109 ) -> str | None:
103 env = self._env.copy()110 env = self._env.copy()
111 env["SUDO"] = "sudo -E"
112 log_file = f"build-{packer_template}-{packer_target or 'all'}.log"
113 env["PACKER_LOG"] = "on"
114 env["PACKER_LOG_PATH"] = f"{self.clone_path}/{log_file}"
104 if source_iso:115 if source_iso:
105 env["ISO"] = self.download_image(source_iso)116 env["ISO"] = self.download_image(source_iso)
117 if proxy := env.get("https_proxy"):
118 env["KS_PROXY"] = f'--proxy="{proxy}"'
106 cmd: list[str] = [119 cmd: list[str] = [
107 "eatmydata",120 "eatmydata",
108 "make",121 "make",
@@ -110,12 +123,16 @@ class PackerMain(GitBuild):
110 f"{self.clone_path}/{packer_template}",123 f"{self.clone_path}/{packer_template}",
111 f"{packer_target or 'all'}",124 f"{packer_target or 'all'}",
112 ]125 ]
113 runtime = self.timed(126 try:
114 self._instance.execute,127 runtime = self.timed(
115 command=cmd,128 self._instance.execute,
116 environment=env,129 command=cmd,
117 )130 environment=env,
118 self.logger.info(f"Image built in {runtime:.2f}s")131 )
132 self.logger.info(f"Image built in {runtime:.2f}s")
133 finally:
134 build_log = self._instance.files[env["PACKER_LOG_PATH"]]
135 build_log.pull(str(self.root_path / log_file))
119 return f"{self.clone_path}/{packer_template}/{img_filename}"136 return f"{self.clone_path}/{packer_template}/{img_filename}"
120137
121 def __repr__(self) -> str:138 def __repr__(self) -> str:
diff --git a/systemtests/state.py b/systemtests/state.py
index 36b89ba..7ca5be8 100644
--- a/systemtests/state.py
+++ b/systemtests/state.py
@@ -10,9 +10,8 @@ from urllib.parse import urljoin, urlparse
10import pytest10import pytest
11from retry import retry11from retry import retry
1212
13from systemtests.image_config import TestableImage13from .image_config import TestableImage
14from systemtests.packer import UnknowStorageBackendError14from .packer import UnknowStorageBackendError
15
16from .region import get_rack_controllers15from .region import get_rack_controllers
17from .utils import waits_for_event_after16from .utils import waits_for_event_after
1817
diff --git a/systemtests/tests_per_machine/test_machine.py b/systemtests/tests_per_machine/test_machine.py
index c4995b3..7c11328 100644
--- a/systemtests/tests_per_machine/test_machine.py
+++ b/systemtests/tests_per_machine/test_machine.py
@@ -11,6 +11,7 @@ from ..utils import (
11 assert_machine_in_machines,11 assert_machine_in_machines,
12 assert_machine_not_in_machines,12 assert_machine_not_in_machines,
13 release_and_redeploy_machine,13 release_and_redeploy_machine,
14 report_feature_tests,
14 ssh_execute_command,15 ssh_execute_command,
15 wait_for_machine,16 wait_for_machine,
16 wait_for_machine_to_power_off,17 wait_for_machine_to_power_off,
@@ -27,7 +28,7 @@ if TYPE_CHECKING:
27 from ..machine_config import MachineConfig28 from ..machine_config import MachineConfig
2829
2930
30@test_steps("enlist", "metadata", "commission", "deploy", "rescue")31@test_steps("enlist", "metadata", "commission", "deploy", "test_image", "rescue")
31def test_full_circle(32def test_full_circle(
32 maas_api_client: AuthenticatedAPIClient,33 maas_api_client: AuthenticatedAPIClient,
33 machine_config: MachineConfig,34 machine_config: MachineConfig,
@@ -147,21 +148,47 @@ def test_full_circle(
147 yield148 yield
148149
149 if image_to_test:150 if image_to_test:
150 testable_layouts = ["flat", "lvm", "bcache"]151 testable_configs: dict[str, dict[str, str]] = {
151 for storage_layout in testable_layouts:152 "bond": {"parents": "1"},
152 testlog.info(f"Testing storage layout: {storage_layout}")153 "bridge": {},
153 passed = False154 }
154 try:155 for network_config, network_options in testable_configs.items():
156 with report_feature_tests(testlog, f"network layout {network_config}"):
155 with release_and_redeploy_machine(157 with release_and_redeploy_machine(
156 maas_api_client, machine, timeout=TIMEOUT158 maas_api_client,
157 ) as redeployed:159 machine,
158 maas_api_client.create_storage_layout(160 osystem=deploy_osystem,
159 redeployed, storage_layout, {}161 oseries=deploy_oseries,
162 timeout=TIMEOUT,
163 ):
164 interface = maas_api_client.create_interface(
165 machine, network_config, network_options
160 )166 )
161 passed = True167 assert interface in maas_api_client.read_interfaces(machine)
162 finally:168 with release_and_redeploy_machine(
163 status = "PASSED" if passed else "FAILED"169 maas_api_client,
164 testlog.info(f"Storage layout: {storage_layout} {status}")170 machine,
171 osystem=deploy_osystem,
172 oseries=deploy_oseries,
173 timeout=TIMEOUT,
174 ):
175 maas_api_client.delete_interface(machine, interface)
176 assert interface not in maas_api_client.read_interfaces(machine)
177 testable_layouts = ["flat", "lvm", "bcache"]
178 for storage_layout in testable_layouts:
179 with report_feature_tests(
180 testlog, f"storage layout {storage_layout}"
181 ), release_and_redeploy_machine(
182 maas_api_client,
183 machine,
184 osystem=deploy_osystem,
185 oseries=deploy_oseries,
186 timeout=TIMEOUT,
187 ):
188 # release the machine, add a new storage layout,
189 # assert the machine can redeploy
190 maas_api_client.create_storage_layout(machine, storage_layout, {})
191 yield
165192
166 if deploy_osystem == "windows" or (193 if deploy_osystem == "windows" or (
167 deploy_osystem == "custom" and deploy_oseries.startswith("esxi")194 deploy_osystem == "custom" and deploy_oseries.startswith("esxi")
diff --git a/systemtests/utils.py b/systemtests/utils.py
index 66ebc8b..b412813 100644
--- a/systemtests/utils.py
+++ b/systemtests/utils.py
@@ -9,6 +9,7 @@ import time
9from contextlib import contextmanager9from contextlib import contextmanager
10from dataclasses import dataclass10from dataclasses import dataclass
11from logging import Logger11from logging import Logger
12from subprocess import CalledProcessError
12from typing import Iterator, Optional, TypedDict, Union13from typing import Iterator, Optional, TypedDict, Union
1314
14import paramiko15import paramiko
@@ -300,32 +301,51 @@ def assert_machine_not_in_machines(
300def release_and_redeploy_machine(301def release_and_redeploy_machine(
301 maas_api_client: api.AuthenticatedAPIClient,302 maas_api_client: api.AuthenticatedAPIClient,
302 machine: api.Machine,303 machine: api.Machine,
304 osystem: str,
305 oseries: str | None = None,
303 timeout: int = 60 * 40,306 timeout: int = 60 * 40,
304) -> Iterator[api.Machine]:307) -> Iterator[api.Machine]:
305 name, osystem = machine["name"], machine["osystem"]
306 try:308 try:
307 maas_api_client.release_machine(machine)309 maas_api_client.release_machine(machine)
308 wait_for_machine(310 yield wait_for_machine(
309 maas_api_client,311 maas_api_client,
310 machine,312 machine,
311 status="Ready",313 status="Ready",
312 abort_status="Releasing failed",314 abort_status="Releasing failed",
313 machine_id=name,
314 timeout=timeout,315 timeout=timeout,
315 )316 )
316 yield machine
317 finally:317 finally:
318 maas_api_client.deploy_machine(machine, osystem=osystem)318 maas_api_client.deploy_machine(
319 machine, osystem=osystem, distro_series=oseries or osystem
320 )
319 wait_for_machine(321 wait_for_machine(
320 maas_api_client,322 maas_api_client,
321 machine,323 machine,
322 status="Deployed",324 status="Deployed",
323 abort_status="Failed deployment",325 abort_status="Failed deployment",
324 machine_id=name,
325 timeout=timeout,326 timeout=timeout,
326 )327 )
327328
328329
330@contextmanager
331def report_feature_tests(testlog: Logger, feature_name: str) -> Iterator[Logger]:
332 """Return a context manager for reporting on a feature.
333 Ensures we always report a paas/fail state, irrespective of errors.
334 """
335 feature_status = False
336 feature_logger = testlog.getChild(feature_name)
337 feature_logger.info("Starting test")
338 try:
339 yield feature_logger
340 feature_status = True
341 except CalledProcessError as exc:
342 feature_logger.exception(exc.stderr)
343 except Exception as e:
344 feature_logger.exception(e)
345 finally:
346 feature_logger.info("PASSED" if feature_status else "FAILED")
347
348
329@dataclass349@dataclass
330class IPRange:350class IPRange:
331 start: ipaddress.IPv4Address351 start: ipaddress.IPv4Address
diff --git a/temporal/README.md b/temporal/README.md
332new file mode 100644352new file mode 100644
index 0000000..3817166
--- /dev/null
+++ b/temporal/README.md
@@ -0,0 +1,88 @@
1# Temporal workflows for OS Image Testing
2
3Here be dragons.
4(Well, maybe not quite)
5
6Contained 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.
7
8## Workflows
9
10We distribute four workflows, each with a correspondingly named worker that should be ran to execute that workflow.
11
12- `image_building_workflow` - Builds an image according to the makefile listed in PackerMAAS.
13- `image_testing_workflow` - Tests an image against `tests_per_mahcine` in this repo,
14- `image_reporting_workflow` - Compiles the results of the two above workflows into YAML, exporting it to the remote store.
15- `e2e_workflow` - Orchestrates the above as child workflows. Additionally performs some some mild pre-processing for the `image_reporting` workflow.
16
17## Execution
18
19Connect 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:
20```bash
21temporal 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}'
22```
23
24The `e2e_workflow` will then call it's children workflows as required to test the requested images.
25
26### Parameters
27
28#### Required
29
30- `image_name` - The name, or list of names, of images to test.
31
32- Jenkins details
33
34 - `jenkins_url` - The url of the Jenkins server where image tests are located.
35
36 - `jenkins_user` - The username to use to login to the Jenkins server.
37
38 - `jenkins_pass` - The password to use to login to the Jenkins server.
39
40#### Optional
41
42- Filepaths
43
44 - `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.
45
46 - `repo_location` - The filepath of the location where the image results repo is to be cloned.
47
48- Test instances
49
50 - `maas_snap_channel` - The snap channel to use when installing MAAS in image tests, defaults as `latest/edge`.
51
52 - `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`.
53
54 - `system_test_branch` - The branch in the system-test repo to use for building and tetsing images, defaults as `master`.
55
56 - `packer_maas_repo` - The url of the PackerMAAS repo to use for building images, defaults as `https://github.com/canonical/packer-maas.git`.
57
58 - `packer_maas_branch` - The branch in the PackerMAas repo to use for building images, defaults as `main`.
59
60 - `parallel_tests` - A flag to request a single image test build for all images, rather than a test build per image, defaults as `False`.
61
62 - `overwite_results` - A flag to request new results overwrite old results rather than combining with them, defaults as `False`.
63
64- Retries
65
66 - `max_retry_attempts` - How many times workflow activities should retry before throwing an exception, defaults as `10`
67
68 - `heartbeat_delay` - How many seconds between heartbeats for long running workflow activities, defaults as `15`
69
70- Timeouts
71
72 - 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.
73
74 - `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.
75
76 - `jenkins_login_timeout` - How long we wait to log into the Jenkins server.
77
78 - `return_status_timeout` - How long we wait for an activity to fetch the status of a Jenkins build.
79
80 - `get_results_timeout` - How long we wait for the results of a Jenkins build to be available.
81
82 - `fetch_results_timeout` - How long we wait for an activity to fetch the results of a Jenkins build, and perform some operation on them.
83
84 - `log_details_timeout` - How long we wait for an activity to fetch logs from a Jenkins build, and perform some operation on them.
85
86 - `request_build_timeout` - How long we wait for an activity to request a Jenkins build.
87
88 - `build_complete_timeout` - How long we wait for a Jenkins build to complete, defaults as `7200`.
diff --git a/temporal/build_results.py b/temporal/build_results.py
0new file mode 10064489new file mode 100644
index 0000000..f98eed8
--- /dev/null
+++ b/temporal/build_results.py
@@ -0,0 +1,395 @@
1from __future__ import annotations
2
3import re
4import subprocess
5from collections import defaultdict
6from contextlib import contextmanager
7from dataclasses import dataclass
8from functools import cached_property
9from typing import Any, Iterator
10
11from common_tasks import cleanup_files
12
13
14class TestStatus:
15 # failure
16 FAILED = 0
17 REGRESSION = 1
18 # successes
19 PASSED = 10
20 FIXED = 11
21 # no known state
22 UNKNOWN = 100
23
24 def __init__(self, state: str | None = None, code: int | None = None) -> None:
25 if state is None and code is None:
26 s, c = "UNKNOWN", self.UNKNOWN
27 elif state is None and code is not None:
28 s, c = self._code_to_state_(code), code
29 elif state is not None and code is None:
30 s, c = state, self._state_to_code_(state)
31 elif state is not None and code is not None:
32 s, c = state, code
33 self._state_, self._code_ = s, c
34
35 def __str__(self) -> str:
36 return f"{self._state_} {self._code_}"
37
38 def __repr__(self) -> str:
39 return str(self)
40
41 @cached_property
42 def _code_state_map_(self) -> dict[int, str]:
43 return {
44 getattr(self, attr): attr for attr in dir(self) if not attr.startswith("_")
45 }
46
47 @cached_property
48 def _state_code_map_(self) -> dict[str, int]:
49 return {v: k for k, v in self._code_state_map_.items()}
50
51 def _code_to_state_(self, code: int) -> str:
52 return self._code_state_map_.get(code, "UNKNOWN")
53
54 def _state_to_code_(self, state: str) -> int:
55 return self._state_code_map_.get(state.upper(), self.UNKNOWN)
56
57 def _is_positive_state_(self, state: str) -> bool:
58 return self._is_positive_code_(self._state_to_code_(state))
59
60 def _is_positive_code_(self, code: int) -> bool:
61 return False if code == self.UNKNOWN else code >= self.PASSED
62
63 @property
64 def _is_positive_(self) -> bool:
65 return self._is_positive_code_(self._code_)
66
67 @property
68 def _has_custom_state_(self) -> bool:
69 return (self._state_to_code_(self._state_) == self.UNKNOWN) and (
70 self._state_ != "UNKNOWN"
71 )
72
73 def to_dict(self) -> dict[str, str | int]:
74 return {"state": self._state_, "code": self._code_}
75
76 def __add__(self, other: Any) -> TestStatus:
77 if not isinstance(other, TestStatus):
78 return self
79 newcode = min(self._code_, other._code_)
80 custom_states = [self._has_custom_state_, other._has_custom_state_]
81 if all(custom_states):
82 newstate = self._state_ + "; " + other._state_
83 elif any(custom_states):
84 newstate = self._state_ if self._has_custom_state_ else other._state_
85 else:
86 newstate = self._code_to_state_(newcode)
87 return TestStatus(newstate, newcode)
88
89 def __radd__(self, other: Any) -> TestStatus:
90 if isinstance(other, TestStatus):
91 return self + other
92 return self
93
94 def __iadd__(self, other: Any) -> TestStatus:
95 if isinstance(other, TestStatus):
96 return self + other
97 return self
98
99
100@dataclass
101class FeatureStatus:
102 name: str = ""
103 state: bool = False
104 readable_state: str | dict[str, Any] = "Failed"
105 info: str = "Could not complete test"
106
107 def __str__(self) -> str:
108 return "\n - ".join([f"{self.name}: {self.readable_state}", self.info])
109
110 def to_dict(self) -> dict[str, Any]:
111 return {
112 self.name: {
113 "state": "passed" if self.state else "failed",
114 "summary": self.readable_state,
115 "info": self.info,
116 }
117 }
118
119 def __add__(self, other: FeatureStatus) -> FeatureStatus:
120 if not other.state:
121 return self
122 elif not self.state:
123 return other
124 if self.name != other.name:
125 raise Exception(f"{other} does not correspond to the same feature!")
126 return FeatureStatus(
127 name=self.name,
128 state=self.state or other.state,
129 readable_state=self.readable_state,
130 info=self.info,
131 )
132
133
134class ImageTestResults:
135 def __init__(
136 self,
137 image: str = "",
138 maas_version: list[str] = [],
139 packer_version: list[str] = [],
140 readable_state: str = "",
141 tested_arches: list[str] = [],
142 prerequisites: list[str] = [],
143 ) -> None:
144 self.image = image
145 self.maas_version = maas_version
146 self.readable_state = readable_state
147 self.tested_arches = tested_arches
148 self.packer_version = packer_version
149 self.prerequisites = prerequisites
150
151 @property
152 def _feature_dicts_(self) -> dict[str, Any]:
153 out: dict[str, Any] = {}
154 for feature in self._results_:
155 out |= getattr(self, feature).to_dict()
156 return out
157
158 @property
159 def _features_(self) -> list[str]:
160 """Return a short summary of all test results of all features
161 for MAAS Image tests."""
162 return [getattr(self, feature) for feature in self._results_]
163
164 @property
165 def _results_(self) -> list[str]:
166 """Return a list of all features whose results have been collected"""
167 return list(set(self.__dict__) - set(ImageTestResults().__dict__))
168
169 def __str__(self) -> str:
170 return "\n".join(
171 [f"{self.image}: {self.readable_state}"]
172 + [str(feature) for feature in self._features_]
173 )
174
175 @property
176 def state(self) -> str:
177 """Image test state, short pass/fail result as a single bianry string.
178 results formatted as:
179 0b00000{storage}{network}{deploy}"""
180 byte = sum(
181 2**i * getattr(result, "state", 0)
182 for i, result in enumerate(self._results_)
183 )
184 return f"{byte:08b}"
185
186 def to_dict(self) -> dict[str, Any]:
187 return {
188 self.image: {
189 "summary": self.readable_state,
190 "maas_version": self.maas_version,
191 "architectures": list(self.tested_arches),
192 "packer_versions": self.packer_version,
193 "prerequisites": list(self.prerequisites),
194 }
195 | self._feature_dicts_
196 }
197
198 def from_dict(self, fromdict: dict[str, Any]) -> ImageTestResults:
199 image, details = tuple(fromdict.items())[0]
200 results = ImageTestResults(
201 image=image,
202 maas_version=details.get("maas_version", []),
203 packer_version=details.get("packer_versions", []),
204 readable_state=details.get("summary", ""),
205 tested_arches=details.get("architectures", []),
206 prerequisites=details.get("prerequisites", []),
207 )
208 for key in list(results.to_dict().values())[0].keys():
209 details.pop(key)
210 for feature, feature_dict in details.items():
211 setattr(
212 results,
213 feature,
214 FeatureStatus(
215 name=feature,
216 state=feature_dict["state"] == "passed",
217 readable_state=feature_dict["summary"],
218 info=feature_dict["info"],
219 ),
220 )
221 return results
222
223 def __add__(self, other: ImageTestResults) -> ImageTestResults:
224 if self.image != other.image:
225 raise Exception(f"{other} does not correspond to the same image!")
226 # return itself if the other failed
227 if not int(other.state, 2) & 1:
228 return self
229 elif not int(self.state, 2) & 1:
230 return other
231
232 def force_set(var: str | list[Any] | set[Any]) -> set[Any]:
233 return set([var]) if isinstance(var, str) else set(var)
234
235 def combine_sets(
236 var: str | list[Any] | set[Any], var2: str | list[Any] | set[Any]
237 ) -> list[Any]:
238 return list(force_set(var).union(force_set(var2)))
239
240 combined_state = TestStatus(state=self.readable_state) + TestStatus(
241 state=other.readable_state
242 )
243 results = ImageTestResults(
244 image=self.image,
245 maas_version=combine_sets(self.maas_version, other.maas_version),
246 packer_version=combine_sets(self.packer_version, other.packer_version),
247 readable_state=combined_state._state_,
248 tested_arches=combine_sets(self.tested_arches, other.tested_arches),
249 prerequisites=combine_sets(self.prerequisites, other.prerequisites),
250 )
251 for feature in set(self._results_).union(set(other._results_)):
252 setattr(
253 results,
254 feature,
255 getattr(self, feature, FeatureStatus())
256 + getattr(self, feature, FeatureStatus()),
257 )
258 return results
259
260
261def todict(nested: defaultdict[str, Any] | dict[str, Any]) -> dict[str, Any]:
262 for k, v in nested.items():
263 if isinstance(v, dict):
264 nested[k] = todict(v)
265 return dict(nested)
266
267
268def nested_dict() -> defaultdict[str, Any]:
269 return defaultdict(nested_dict)
270
271
272def feature_dict_summary(
273 feature_dict: dict[str, dict[str, list[str]]]
274) -> tuple[bool, dict[str, list[str]], str]:
275 # /artificial data for testing
276 states = set(feature_dict.keys())
277 failed = set(feature_dict["FAILED"].keys())
278 passed = set(feature_dict["PASSED"].keys())
279 unknown: set[str] = set()
280 for unknown_states in states - {"PASSED", "FAILED"}:
281 unknown |= set(feature_dict[unknown_states].keys())
282
283 # overall pass fail for the entire feature
284 state = not (len(failed) or len(unknown))
285 # overall pass fail for each value of the feature
286 summary: dict[str, list[str]] = {}
287 if full_pass := passed - (failed | unknown):
288 summary["PASS"] = list(full_pass)
289 if full_fail := failed - (passed | unknown):
290 summary["FAIL"] = list(full_fail)
291 if partial_fail := (passed & failed) | unknown:
292 summary["PARTIAL"] = list(partial_fail)
293 # specific pass fail for each value of the feature
294 info = []
295 for fstate, fvalue in feature_dict.items():
296 info.extend(
297 [fstate.lower()]
298 + [f" - {layout}: {', '.join(arch)}" for layout, arch in fvalue.items()]
299 )
300 return state, summary, "\n".join(info)
301
302
303def scan_log_for_feature(
304 feature_name: str, arches: dict[str, Any]
305) -> dict[str, dict[str, list[str]]]:
306 tested = nested_dict()
307 """ Matches the two ways we can show test results:
308 'storage layout flat: PASSED'
309 'Storage layout: bcache - FAILED'
310 returns the feature (flat, bcache) and result (PASSED, FAILED)
311 """
312 versioning_match = r":?\s(\w+):?\s(?:\-\s)?([A-Z]{4,})"
313 feature_match = re.compile(f"{feature_name}{versioning_match}", flags=re.IGNORECASE)
314 for arch_name, arch in arches.items():
315 arch_log = "\n".join(arch["log"])
316 for feature, state in feature_match.findall(arch_log):
317 if feature not in tested[state]:
318 tested[state][feature] = []
319 tested[state][feature].append(arch_name)
320 return todict(tested)
321
322
323def determine_feature_state(
324 feature_name: str, arches: dict[str, Any]
325) -> tuple[bool, dict[str, list[str]], str] | None:
326 if feature_tested := scan_log_for_feature(feature_name, arches):
327 return feature_dict_summary(feature_tested)
328 return None
329
330
331def execute(
332 command: list[str], cwd: str | None = None
333) -> subprocess.CompletedProcess[str]:
334 """Execute a command"""
335 __tracebackhide__ = True
336 return subprocess.run(
337 command,
338 capture_output=True,
339 check=True,
340 encoding="utf-8",
341 errors="backslashreplace",
342 cwd=cwd,
343 )
344
345
346@contextmanager
347def checkout_and_commit(
348 branch: str,
349 commit_message: str,
350 base_branch: str | None = None,
351 add_file: str | list[str] | None = None,
352 cwd: str | None = None,
353) -> Iterator[None]:
354 branches = execute(["git", "branch", "-a"], cwd=cwd).stdout
355 branch_base = base_branch or ("main" if "main" in branches else "master")
356 current_branch = execute(["git", "rev-parse", "--abbrev-ref HEAD"], cwd=cwd).stdout
357
358 # ensure we're up to date with the base branch first
359 if current_branch != branch_base:
360 execute(["git", "checkout", branch_base], cwd=cwd)
361 execute(["git", "pull"], cwd=cwd)
362 current_branch = branch_base
363
364 # navigate to the correct branch
365 if current_branch != branch:
366 if branch in branches:
367 execute(["git", "checkout", branch], cwd=cwd)
368 try:
369 execute(["git", "pull"], cwd=cwd)
370 except Exception as e:
371 print(e)
372 else:
373 execute(["git", "checkout", "-b", branch], cwd=cwd)
374
375 yield
376
377 if cwd and add_file:
378 cleanup_files(cwd, preserve=add_file)
379
380 # if the previous commit matches the one we want to make, combine them
381 reset = False
382 while (
383 execute(["git", "show-branch", "--no-name", "HEAD~1"], cwd=cwd).stdout
384 == f"{commit_message}"
385 ):
386 execute(["git", "reset", "--hard", "HEAD~1"], cwd=cwd)
387 reset = True
388
389 # add files and commit
390 execute(["git", "add", "."], cwd=cwd)
391 execute(["git", "commit", "-m", f'"{commit_message}"'], cwd=cwd)
392 if reset:
393 execute(["git", "push", "-f"], cwd=cwd)
394 else:
395 execute(["git", "push"], cwd=cwd)
diff --git a/temporal/common_tasks.py b/temporal/common_tasks.py
0new file mode 100644396new file mode 100644
index 0000000..8fc6011
--- /dev/null
+++ b/temporal/common_tasks.py
@@ -0,0 +1,293 @@
1import argparse
2import asyncio
3import os
4import sys
5from dataclasses import dataclass
6from datetime import timedelta
7from time import sleep
8from typing import Any
9
10import yaml
11from temporalio import activity, workflow
12from temporalio.client import Client
13from temporalio.worker import Worker
14
15with workflow.unsafe.imports_passed_through():
16 from jenkinsapi.build import Artifact, Build # type:ignore[import]
17 from jenkinsapi.jenkins import Jenkins # type:ignore[import]
18 from jenkinsapi.job import Job # type:ignore[import]
19
20
21# Workflow parameter class
22@dataclass
23class workflow_parameters:
24 jenkins_url: str
25 jenkins_user: str
26 jenkins_pass: str
27 job_name: str = ""
28 build_num: int = -1
29
30 # retry stuff
31 max_retry_attempts: int = 10
32 heartbeat_delay: int = 15
33
34 # default timeout to be used if none available
35 default_timeout: int = 300
36 # how long should we wait to login
37 jenkins_login_timeout: int = -1
38 # how long should we wait for the build to complete
39 return_status_timeout: int = -1
40 # how long should we wait to get build results?
41 fetch_results_timeout: int = -1
42 # how long should we wait for log scanning to occur?
43 log_details_timeout: int = -1
44 # how long should we wait for this build to be requested
45 request_build_timeout: int = -1
46 # how long should we wait for the build to complete
47 build_complete_timeout: int = 7200
48 # how long should we wait for the results to be available
49 get_results_timeout: int = -1
50
51 # return the default timeout if the set timeout is not applicable
52 def gettimeout(self, timeout_name: str = "") -> timedelta:
53 if (timeout := self.__dict__.get(timeout_name, 0)) > 0:
54 return timedelta(seconds=timeout)
55 return timedelta(seconds=self.default_timeout)
56
57
58# common functions
59
60
61def cleanup_files(file_path: str, preserve: str | list[str] | None = None) -> None:
62 if os.path.exists(file_path):
63 files = os.listdir(file_path)
64 files.remove(".git")
65 if preserve:
66 for preserved_file in aslist(preserve):
67 this_file = os.path.basename(preserved_file)
68 if this_file in files:
69 files.remove(this_file)
70 if files:
71 print(f"Removing: {files}")
72 for cleanup in files:
73 os.remove(f"{file_path}/{cleanup}")
74
75
76def aslist(to_list: str | list[Any]) -> list[Any]:
77 if isinstance(to_list, list):
78 return to_list
79 return [to_list] if to_list else []
80
81
82def get_server(params: workflow_parameters) -> Jenkins:
83 return Jenkins(
84 params.jenkins_url,
85 username=params.jenkins_user,
86 password=params.jenkins_pass,
87 timeout=params.gettimeout("jenkins_login_timeout").seconds,
88 max_retries=params.max_retry_attempts,
89 )
90
91
92def get_job(
93 params: workflow_parameters,
94 job_name: str | None = None,
95) -> Job:
96 return get_server(params).get_job(job_name or params.job_name)
97
98
99def get_build(
100 params: workflow_parameters,
101 job_name: str | None = None,
102 build_num: int | None = None,
103) -> Build:
104 job = get_job(params, job_name=job_name)
105 if (num := build_num or params.build_num) >= 0:
106 return job.get_build(num)
107 return job.get_last_build()
108
109
110def get_params(
111 params: workflow_parameters,
112 job_name: str | None = None,
113 build_num: int | None = None,
114) -> dict[str, Any]:
115 build = get_build(params, job_name=job_name, build_num=build_num)
116 return build.get_params() # type: ignore
117
118
119def get_logs(
120 params: workflow_parameters,
121 job_name: str | None = None,
122 build_num: int | None = None,
123) -> dict[str, str]:
124 # attempt utf-8. If that doesn't work, try utf-16
125 def decode_artifact_data(artifact: Artifact) -> str:
126 data = artifact.get_data()
127 try:
128 return str(data, encoding="utf-8")
129 except Exception as e:
130 print(e)
131 return str(data, encoding="utf-16")
132
133 build = get_build(params, job_name=job_name, build_num=build_num)
134 logs = {
135 name.split(".")[-2]: decode_artifact_data(artifact)
136 for name, artifact in build.get_artifact_dict().items()
137 if ".log" in name
138 }
139 return logs
140
141
142def get_config(
143 params: workflow_parameters,
144 job_name: str | None = None,
145 build_num: int | None = None,
146) -> dict[str, Any]:
147 build = get_build(params, job_name=job_name, build_num=build_num)
148 return yaml.safe_load( # type: ignore
149 [
150 artifact.get_data()
151 for name, artifact in build.get_artifact_dict().items()
152 if "config.yaml" in name
153 ][0]
154 )
155
156
157def get_results(
158 params: workflow_parameters,
159 job_name: str | None = None,
160 build_num: int | None = None,
161) -> dict[str, Any]:
162 build = get_build(params, job_name=job_name, build_num=build_num)
163 results = build.get_resultset()
164 return {k: v.__dict__ for k, v in results.items()}
165
166
167def request_build(
168 params: workflow_parameters, job_params: dict[str, Any], job_name: str | None = None
169) -> int:
170 server = get_server(params)
171 last_build = int(server.get_job(params.job_name or job_name).get_last_buildnumber())
172 server.build_job(params.job_name, job_params)
173 return last_build + 1
174
175
176# common activities
177
178
179@activity.defn
180async def check_jenkins_reachable(params: workflow_parameters) -> bool:
181 server = get_server(params)
182 return bool(server and (server.version != "0.0"))
183
184
185@activity.defn
186async def check_build_has_results(params: workflow_parameters) -> bool:
187 build = get_build(params)
188 return bool(build.has_resultset())
189
190
191@activity.defn
192async def fetch_build_status(params: workflow_parameters) -> str:
193 build = get_build(params)
194 while build.is_running():
195 sleep(params.heartbeat_delay)
196 activity.heartbeat("Awaiting build finish")
197 return str(build.get_status())
198
199
200@activity.defn
201async def fetch_build_and_result(
202 params: workflow_parameters,
203) -> dict[str, dict[str, str]]:
204 build = get_build(params)
205 while not build.has_resultset():
206 sleep(params.heartbeat_delay)
207 activity.heartbeat("Awaiting build results")
208 return {k: {"status": v.status} for k, v in build.get_resultset().items()}
209
210
211@activity.defn
212async def await_build_exists(params: workflow_parameters) -> None:
213 job = get_job(params)
214 while not job.is_queued_or_running():
215 sleep(params.heartbeat_delay)
216 activity.heartbeat("Awaiting job start")
217 build = None
218 while True:
219 try:
220 if build is None:
221 build = get_build(params)
222 if build.is_running():
223 break
224 except Exception as e:
225 activity.heartbeat(f"Could not fetch build: {e}")
226 sleep(params.heartbeat_delay)
227 activity.heartbeat("Awaiting build running")
228
229
230@activity.defn
231async def await_build_complete(params: workflow_parameters) -> None:
232 build = get_build(params)
233 while build.is_running():
234 sleep(params.heartbeat_delay)
235 activity.heartbeat("Awaiting job completion")
236
237
238# workers
239
240
241def worker_url(argv: list[str]) -> str:
242 parser = argparse.ArgumentParser()
243 parser.add_argument(
244 "temporal_url",
245 type=str,
246 default="localhost:7233",
247 help="url of the temporal server",
248 )
249 args = parser.parse_args(argv)
250 return str(args.temporal_url)
251
252
253async def worker_main(
254 interrupt_event: asyncio.Event,
255 temporal_url: str,
256 task_queue: str,
257 workflows: list[Any],
258 activities: list[Any],
259) -> None:
260 client = await Client.connect(temporal_url)
261 async with Worker(
262 client,
263 task_queue=task_queue.lower().replace(" ", "_"),
264 workflows=workflows,
265 activities=activities,
266 ):
267 print(
268 f"{task_queue} worker started, ctrl+c to exit".capitalize().replace(
269 "_", " "
270 )
271 )
272 await interrupt_event.wait()
273
274
275def start_worker(task_queue: str, workflows: list[Any], activities: list[Any]) -> None:
276 temporal_url = worker_url(sys.argv[1:])
277 interrupt_event = asyncio.Event()
278
279 loop = asyncio.new_event_loop()
280 asyncio.set_event_loop(loop)
281 try:
282 loop.run_until_complete(
283 worker_main(
284 interrupt_event, temporal_url, task_queue, workflows, activities
285 )
286 )
287 except KeyboardInterrupt:
288 interrupt_event.set()
289 interrupt_event.clear()
290 loop.run_until_complete(interrupt_event.wait())
291 finally:
292 loop.run_until_complete(loop.shutdown_asyncgens())
293 loop.close()
diff --git a/temporal/e2e_worker.py b/temporal/e2e_worker.py
0new file mode 100644294new file mode 100644
index 0000000..97c5260
--- /dev/null
+++ b/temporal/e2e_worker.py
@@ -0,0 +1,10 @@
1from common_tasks import start_worker
2from e2e_workflow import activities as e2e_activities
3from e2e_workflow import workflows as e2e_workflows
4
5if __name__ == "__main__":
6 start_worker(
7 task_queue="e2e_tests",
8 workflows=e2e_workflows,
9 activities=e2e_activities,
10 )
diff --git a/temporal/e2e_workflow.py b/temporal/e2e_workflow.py
0new file mode 10064411new file mode 100644
index 0000000..82f2ba7
--- /dev/null
+++ b/temporal/e2e_workflow.py
@@ -0,0 +1,206 @@
1import re
2from dataclasses import dataclass
3from typing import Any
4
5from build_results import nested_dict, todict
6from common_tasks import aslist, get_logs, workflow_parameters
7from image_building_workflow import image_building_param, image_building_workflow
8from image_reporting_workflow import image_reporting_param, image_reporting_workflow
9from image_testing_workflow import image_testing_param, image_testing_workflow
10from temporalio import activity, workflow
11from temporalio.common import RetryPolicy
12
13
14@dataclass
15class e2e_workflow_params(workflow_parameters):
16 image_name: str | list[str] = ""
17 image_mapping: str = (
18 "image_mapping.yaml" # this needs to be accessible to the worker
19 )
20
21 system_test_repo: str = (
22 "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests"
23 )
24 system_test_branch: str = "master"
25 packer_naas_repo: str = "https://github.com/canonical/packer-maas.git"
26 packer_maas_branch: str = "main"
27
28 maas_snap_channel: str = "latest/edge"
29
30 repo_location: str = "image_results_repo"
31
32 overwrite_results: bool = False
33 # reccommended to leave this false until the rescue issue at CI is fixed
34 parallel_tests: bool = False
35
36
37@activity.defn
38async def fetch_packer_version_from_logs(
39 params: e2e_workflow_params,
40) -> dict[str, Any]:
41 logs = get_logs(params, job_name="maas-automated-image-builder")
42 packer_details = nested_dict()
43 for image in aslist(params.image_name):
44 packer_details[image]["packer_version"] = ""
45 packer_details[image]["prerequisites"] = []
46 # fetch the build log for this image
47 if log := [v for k, v in logs.items() if image in k]:
48 # fetch the packer version
49 if search := re.search(r"Packer version\: ((\d+\.\d+)\.\d+)", log[0]):
50 long_version, _ = search.groups()
51 packer_details[image]["packer_version"] = long_version
52 else:
53 packer_details[image]["packer_version"] = ""
54 # search for prerequisites
55 return todict(packer_details)
56
57
58@activity.defn
59async def fetch_image_details(params: dict[str, Any]) -> dict[str, Any]:
60 details: dict[str, Any] = {}
61 for image in aslist(params["images"]):
62 image_packer_details = params.get("packer_details", {}).get(image, {})
63 image_test_details = params.get("image_results", {}).get(image, {})
64 details[image] = {
65 "built": image not in params.get("failed_images", []),
66 "tested": bool(image_test_details),
67 "build_num": params.get("build_num", -1),
68 "test_num": image_test_details.get("build_num"),
69 "packer_version": image_packer_details.get("packer_version", "0.0"),
70 "prerequisites": image_packer_details.get("prerequisites", []),
71 }
72 return details
73
74
75@workflow.defn
76class e2e_workflow:
77 @workflow.run
78 async def run(self, params: e2e_workflow_params) -> None:
79 # build images
80 image_building_results: dict[str, Any] = await workflow.execute_child_workflow(
81 image_building_workflow,
82 image_building_param(
83 # building parameters
84 image_name=params.image_name,
85 image_mapping=params.image_mapping,
86 system_test_repo=params.system_test_repo,
87 system_test_branch=params.system_test_branch,
88 packer_naas_repo=params.packer_naas_repo,
89 packer_maas_branch=params.packer_maas_branch,
90 # jenkins stuff
91 jenkins_url=params.jenkins_url,
92 jenkins_user=params.jenkins_user,
93 jenkins_pass=params.jenkins_pass,
94 # timeouts and retry
95 max_retry_attempts=params.max_retry_attempts,
96 heartbeat_delay=params.heartbeat_delay,
97 default_timeout=params.default_timeout,
98 jenkins_login_timeout=params.jenkins_login_timeout,
99 return_status_timeout=params.return_status_timeout,
100 fetch_results_timeout=params.fetch_results_timeout,
101 log_details_timeout=params.log_details_timeout,
102 request_build_timeout=params.request_build_timeout,
103 build_complete_timeout=params.build_complete_timeout,
104 get_results_timeout=params.get_results_timeout,
105 ),
106 task_queue="image_building",
107 id=f"Building: {','.join(params.image_name)}",
108 )
109 # images that failed or succeeded to be built
110 params.build_num = image_building_results.get("build_num", -1)
111 images_built = image_building_results["image_results"]
112 failed_images = [image for image, built in images_built.items() if not built]
113 passed_images = [image for image, built in images_built.items() if built]
114 # get the packer version and prerequisites
115 packer_details = await workflow.execute_activity(
116 fetch_packer_version_from_logs,
117 params,
118 start_to_close_timeout=params.gettimeout("log_details_timeout"),
119 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
120 )
121 # get all of the images that were built
122 image_testing_results: dict[str, Any] = {}
123 if images_to_test := passed_images:
124 # test images
125 # if we are testing images in parallel, this list will have one entry.
126 for image_test_group in (
127 [images_to_test] if params.parallel_tests else images_to_test
128 ):
129 try:
130 image_testing_results |= await workflow.execute_child_workflow(
131 image_testing_workflow,
132 image_testing_param(
133 # testing parameters
134 image_name=image_test_group,
135 system_test_repo=params.system_test_repo,
136 system_test_branch=params.system_test_branch,
137 maas_snap_channel=params.maas_snap_channel,
138 parallel_tests=params.parallel_tests,
139 # jenkins stuff
140 jenkins_url=params.jenkins_url,
141 jenkins_user=params.jenkins_user,
142 jenkins_pass=params.jenkins_pass,
143 # timeouts and retry
144 max_retry_attempts=params.max_retry_attempts,
145 heartbeat_delay=params.heartbeat_delay,
146 default_timeout=params.default_timeout,
147 jenkins_login_timeout=params.jenkins_login_timeout,
148 return_status_timeout=params.return_status_timeout,
149 fetch_results_timeout=params.fetch_results_timeout,
150 log_details_timeout=params.log_details_timeout,
151 request_build_timeout=params.request_build_timeout,
152 build_complete_timeout=params.build_complete_timeout,
153 get_results_timeout=params.get_results_timeout,
154 ),
155 task_queue="image_testing",
156 id=f"Testing: {','.join(aslist(image_test_group))}",
157 )
158 except Exception as e:
159 workflow.logger.exception(f"Could not test {image_test_group}: {e}")
160
161 # populate image details from test results
162 image_details = await workflow.execute_activity(
163 fetch_image_details,
164 {
165 "images": params.image_name,
166 "packer_details": packer_details,
167 "failed_images": failed_images,
168 "build_num": params.build_num,
169 "image_results": image_testing_results,
170 },
171 start_to_close_timeout=params.gettimeout("log_details_timeout"),
172 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
173 )
174
175 # report image results
176 await workflow.execute_child_workflow(
177 image_reporting_workflow,
178 image_reporting_param(
179 # reporting parameters
180 image_details=image_details,
181 repo_location=params.repo_location,
182 overwrite_results=params.overwrite_results,
183 maas_snap_channel=params.maas_snap_channel,
184 # jenkins stuff
185 jenkins_url=params.jenkins_url,
186 jenkins_user=params.jenkins_user,
187 jenkins_pass=params.jenkins_pass,
188 # timeouts and retry
189 max_retry_attempts=params.max_retry_attempts,
190 heartbeat_delay=params.heartbeat_delay,
191 default_timeout=params.default_timeout,
192 jenkins_login_timeout=params.jenkins_login_timeout,
193 return_status_timeout=params.return_status_timeout,
194 fetch_results_timeout=params.fetch_results_timeout,
195 log_details_timeout=params.log_details_timeout,
196 request_build_timeout=params.request_build_timeout,
197 build_complete_timeout=params.build_complete_timeout,
198 get_results_timeout=params.get_results_timeout,
199 ),
200 task_queue="image_reporting",
201 id=f"Reporting: {','.join(params.image_name)}",
202 )
203
204
205activities = [fetch_packer_version_from_logs, fetch_image_details]
206workflows = [e2e_workflow]
diff --git a/temporal/image_building_worker.py b/temporal/image_building_worker.py
0new file mode 100644207new file mode 100644
index 0000000..885f578
--- /dev/null
+++ b/temporal/image_building_worker.py
@@ -0,0 +1,10 @@
1from common_tasks import start_worker
2from image_building_workflow import activities as image_build_activities
3from image_building_workflow import workflows as image_build_workflows
4
5if __name__ == "__main__":
6 start_worker(
7 task_queue="image_building",
8 workflows=image_build_workflows,
9 activities=image_build_activities,
10 )
diff --git a/temporal/image_building_workflow.py b/temporal/image_building_workflow.py
0new file mode 10064411new file mode 100644
index 0000000..586f1f4
--- /dev/null
+++ b/temporal/image_building_workflow.py
@@ -0,0 +1,165 @@
1import re
2from dataclasses import dataclass
3from typing import Any
4
5import yaml
6from common_tasks import (
7 aslist,
8 await_build_complete,
9 await_build_exists,
10 check_jenkins_reachable,
11 fetch_build_and_result,
12 fetch_build_status,
13 request_build,
14 workflow_parameters,
15)
16from temporalio import activity, workflow
17from temporalio.common import RetryPolicy
18
19
20@dataclass
21class image_building_param(workflow_parameters):
22 image_name: str | list[str] = "" # allow builk image building if desired
23 image_mapping: str = (
24 "image_mapping.yaml" # this needs to be accessible to the worker
25 )
26
27 job_name: str = "maas-automated-image-builder"
28 build_num: int = -1
29
30 # job details with default values we may want to change
31 system_test_repo: str = (
32 "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests"
33 )
34 system_test_branch: str = "master"
35 packer_naas_repo: str = "https://github.com/canonical/packer-maas.git"
36 packer_maas_branch: str = "main"
37
38
39@activity.defn
40async def request_images_built(params: image_building_param) -> int:
41 """Start an image testing job, returning the job number."""
42 job_params: dict[str, Any] = {
43 "IMAGE_NAMES": ",".join(image for image in aslist(params.image_name)),
44 "SYSTEMTESTS_GIT_REPO": params.system_test_repo,
45 "SYSTEMTESTS_GIT_BRANCH": params.system_test_branch,
46 "PACKER_MAAS_GIT_REPO": params.packer_naas_repo,
47 "PACKER_MAAS_GIT_BRANCH": params.packer_maas_branch,
48 }
49 return request_build(params, job_params)
50
51
52@activity.defn
53async def fetch_image_mapping(
54 params: image_building_param,
55) -> dict[str, dict[str, Any]]:
56 with open(params.image_mapping, "r") as fh:
57 image_cfg: dict[str, Any] = yaml.safe_load(fh)
58 return image_cfg
59
60
61@activity.defn
62async def fetch_image_built_status(params: dict[str, Any]) -> dict[str, bool]:
63 results: dict[str, dict[str, str]] = params["results"]
64 mapping: dict[str, dict[str, Any]] = params["mapping"]
65 image_built_results: dict[str, bool] = {}
66
67 for image in params["image"]:
68 this_image = mapping["images"].get(image, {})
69 oseries = this_image.get("oseries")
70 osystem = mapping["images"].get(image, {}).get("osystem")
71 image_name = f"{osystem}/{oseries}"
72 status = False
73 for test_name, test_result in results.items():
74 if re.search(rf"test_build_image.*{image_name}", test_name):
75 if test_result["status"] in ["FIXED", "PASSED"]:
76 status = True
77 break
78 image_built_results[image] = status
79 return image_built_results
80
81
82@workflow.defn
83class image_building_workflow:
84 @workflow.run
85 async def run(
86 self, params: image_building_param
87 ) -> dict[str, int | dict[str, bool]]:
88 # await an open connection to the server
89 await workflow.execute_activity(
90 check_jenkins_reachable,
91 params,
92 start_to_close_timeout=params.gettimeout("jenkins_login_timeout"),
93 )
94 # only attempt to build the image once
95 params.build_num = await workflow.execute_activity(
96 request_images_built,
97 params,
98 start_to_close_timeout=params.gettimeout("request_build_timeout"),
99 )
100 # try multiple times to get the results or status
101 await workflow.execute_activity(
102 await_build_exists,
103 params,
104 start_to_close_timeout=params.gettimeout("request_build_timeout"),
105 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
106 )
107 await workflow.execute_activity(
108 await_build_complete,
109 params,
110 start_to_close_timeout=params.gettimeout("build_complete_timeout"),
111 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
112 )
113 # return a default failure state if the build was aborted
114 build_status = await workflow.execute_activity(
115 fetch_build_status,
116 params,
117 start_to_close_timeout=params.gettimeout("build_complete_timeout"),
118 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
119 )
120 image_results: dict[str, bool] = {k: False for k in aslist(params.image_name)}
121 if build_status.lower() != "aborted":
122 try:
123 # return pass/fail status for image/images being built
124 results = await workflow.execute_activity(
125 fetch_build_and_result,
126 params,
127 start_to_close_timeout=params.gettimeout("get_results_timeout"),
128 retry_policy=RetryPolicy(
129 maximum_attempts=params.max_retry_attempts
130 ),
131 )
132 # these should never require a retry
133 mapping = await workflow.execute_activity(
134 fetch_image_mapping,
135 params,
136 start_to_close_timeout=params.gettimeout(),
137 )
138 image_results = await workflow.execute_activity(
139 fetch_image_built_status,
140 {
141 "results": results,
142 "image": aslist(params.image_name),
143 "mapping": mapping,
144 },
145 start_to_close_timeout=params.gettimeout("return_status_timeout"),
146 )
147 except Exception as e:
148 workflow.logger.exception(e)
149 return {
150 "build_num": params.build_num,
151 "image_results": image_results,
152 }
153
154
155activities = [
156 check_jenkins_reachable,
157 await_build_exists,
158 await_build_complete,
159 request_images_built,
160 fetch_build_status,
161 fetch_build_and_result,
162 fetch_image_mapping,
163 fetch_image_built_status,
164]
165workflows = [image_building_workflow]
diff --git a/temporal/image_reporting_worker.py b/temporal/image_reporting_worker.py
0new file mode 100644166new file mode 100644
index 0000000..bd1f08b
--- /dev/null
+++ b/temporal/image_reporting_worker.py
@@ -0,0 +1,10 @@
1from common_tasks import start_worker
2from image_reporting_workflow import activities as image_reporting_activities
3from image_reporting_workflow import workflows as image_reporting_workflows
4
5if __name__ == "__main__":
6 start_worker(
7 task_queue="image_reporting",
8 workflows=image_reporting_workflows,
9 activities=image_reporting_activities,
10 )
diff --git a/temporal/image_reporting_workflow.py b/temporal/image_reporting_workflow.py
0new file mode 10064411new file mode 100644
index 0000000..05d5b63
--- /dev/null
+++ b/temporal/image_reporting_workflow.py
@@ -0,0 +1,450 @@
1import copy
2import os
3import re
4from dataclasses import dataclass
5from typing import Any
6
7import yaml
8from build_results import (
9 FeatureStatus,
10 ImageTestResults,
11 TestStatus,
12 checkout_and_commit,
13 determine_feature_state,
14 execute,
15)
16from common_tasks import (
17 check_jenkins_reachable,
18 get_build,
19 get_config,
20 get_logs,
21 get_results,
22 workflow_parameters,
23)
24from temporalio import activity, workflow
25from temporalio.common import RetryPolicy
26
27STEPS_TO_PARSE = ["deploy", "test_image"]
28
29
30@dataclass
31class image_reporting_param(workflow_parameters):
32 image_details: None | dict[str, Any] = None
33
34 job_name: str = "maas-automated-image-tester"
35
36 repo_location: str = "image_results_repo"
37
38 maas_snap_channel: str = "latest/edge"
39
40 overwrite_results: bool = False
41
42
43@dataclass
44class Filtered_Results:
45 # image: arch: step: data
46 data: dict[str, Any] = {}
47
48 def _add_image_(self, image: str) -> None:
49 if image not in self.data:
50 self.data[image] = {}
51 if "state" not in self.data[image]:
52 self.data[image]["state"] = TestStatus()
53
54 def add_result(
55 self, image: str, arch: str, step: str, data: dict[str, Any], status: TestStatus
56 ) -> None:
57 self._add_image_(image)
58 if arch not in self.data[image]:
59 self.data[image][arch] = {}
60 self.data[image][arch][step] = data
61 self.data[image]["state"] += status
62
63 def to_dict(self) -> dict[str, Any]:
64 data = copy.deepcopy(self.data)
65 # convert statuses to dicts
66 for image, image_data in data.items():
67 status: TestStatus = image_data["state"]
68 data[image]["state"] = status.to_dict()
69 # return
70 return data
71
72
73def image_from_osytem_oseries(
74 params: image_reporting_param,
75 osystem: str,
76 oseries: str,
77 job_name: str | None = None,
78 build_num: str | int | None = None,
79) -> str:
80 cfg = get_config(
81 params, job_name=job_name, build_num=int(build_num) if build_num else None
82 )
83 images = cfg.get("image-tests", {})
84 return [
85 str(k)
86 for k, v in images.items()
87 if v["osystem"] == osystem and v["oseries"] == oseries
88 ][0]
89
90
91@activity.defn
92async def get_test_numbers(params: dict[str, Any]) -> dict[str, dict[str, Any]]:
93 parameters = image_reporting_param(**params["params"])
94 image_details: dict[str, Any] = params["image_details"]
95
96 test_details: dict[str, dict[str, str | bool]] = {}
97 test_numbers = list(set(details["test_num"] for details in image_details.values()))
98 for test_num in test_numbers:
99 if test_num:
100 this_test = get_build(parameters, build_num=int(test_num))
101 test_details[str(test_num)] = {
102 "status": str(this_test.get_status()),
103 "has_results": bool(this_test.has_resultset()),
104 }
105 return test_details
106
107
108@activity.defn
109async def fetch_maas_version_from_logs(
110 params: dict[str, Any],
111) -> dict[str, dict[str, str]]:
112 """MAAS version from a test log: ie: ["3.5","3.5.0~alpha1-14542-g.6d2c926d8"]"""
113 parameters = image_reporting_param(**params["params"])
114 tests: list[str] = params["tests"]
115
116 maas_snap_info = str(execute(["snap", "info", "maas"]).stdout)
117 long_version, short_version = ("", "")
118 if search := re.search(
119 rf"{parameters.maas_snap_channel}\:\s+((\d+\.\d+)\.\d+[^\s]+)", maas_snap_info
120 ):
121 long_version, short_version = search.groups()
122
123 versions: dict[str, dict[str, str]] = {
124 "None": {"short": short_version, "long": long_version},
125 }
126 for test in tests:
127 test_logs = get_logs(parameters, build_num=int(test))
128 log = [v for k, v in test_logs.items() if k == "env_builder"][0]
129 if search := re.search(
130 r"maas\-client\: \|maas\s+((\d+\.\d+)\.\d+[^\s]+).*canonical\*", log
131 ):
132 long_version, short_version = search.groups()
133 versions[test] = {"short": short_version, "long": long_version}
134 continue
135 raise Exception("Cannot determine MAAS version.")
136 return versions
137
138
139@activity.defn
140async def filter_test_results(params: dict[str, Any]) -> dict[str, Any]:
141 parameters = image_reporting_param(**params["params"])
142 test_num: str = params["test_num"]
143 filtered_result = Filtered_Results()
144 log = (
145 get_logs(parameters, build_num=int(test_num))
146 .get("tests_per_machine", "")
147 .split("\n")
148 )
149 results = get_results(parameters, build_num=int(test_num))
150 for test_name, test_result in results.items():
151 if "test_full_circle" not in test_name:
152 continue
153 if search := re.search(r"\[(.*)\.(.*)\-(.*)\/(.*)\-(.*)\]", test_name):
154 machine, arch, osystem, oseries, step = search.groups()
155 if step.lower() not in STEPS_TO_PARSE:
156 continue
157
158 image = image_from_osytem_oseries(
159 parameters, osystem, oseries, build_num=int(test_num)
160 )
161
162 this_status = TestStatus(test_result["status"])
163 this_result = {
164 "result": test_result,
165 "state": this_status.to_dict(),
166 "error": test_result["errorDetails"],
167 "error_trace": test_result["errorStackTrace"],
168 "log": [line for line in log if test_result["name"] in line],
169 }
170 filtered_result.add_result(image, arch, step, this_result, this_status)
171 # pack the results status so it is serialisable
172 return filtered_result.to_dict()
173
174
175@activity.defn
176async def parse_test_results(params: dict[str, Any]) -> dict[str, Any]:
177 maas_version: str = params["maas_version"]
178 image_details: dict[str, Any] = params["image_details"]
179 filtered_results: dict[str, Any] = params["results"]
180 results: dict[str, Any] = {}
181
182 def get_step_from_results(
183 image_results: dict[str, Any], step: str
184 ) -> dict[str, Any]:
185 arches = set(image_results.keys()) - {"state"}
186 return {
187 arch: image_results[arch].get(step)
188 for arch in arches
189 if step in image_results[arch]
190 }
191
192 for image, this_image_result in filtered_results.items():
193 this_image_details: dict[str, Any] = image_details[image]
194 packer_version: str = this_image_details["packer_version"]
195 prereq: list[str] = this_image_details["prerequisites"]
196 arches = set(this_image_result.keys()) - {"state"}
197 image_results = ImageTestResults(
198 image=image,
199 maas_version=[maas_version],
200 readable_state=this_image_result["state"]["state"],
201 tested_arches=list(arches),
202 packer_version=[packer_version],
203 prerequisites=prereq,
204 )
205
206 # check for the deployment state
207 if deployed := get_step_from_results(this_image_result, "deploy"):
208 # Image deployment
209 if deploy_state := sum(
210 TestStatus(**arch["state"]) for arch in deployed.values()
211 ):
212 deployable = FeatureStatus(
213 name="Deployable",
214 state=deploy_state._is_positive_,
215 readable_state=deploy_state._state_,
216 info="All machines deployed"
217 if deploy_state._is_positive_
218 else "; ".join(
219 f"{name}:{arch['error']}"
220 for name, arch in deployed.items()
221 if arch["error"]
222 ),
223 )
224 image_results.deployable = deployable # type: ignore[attr-defined]
225 # check to see if we did any tests of the image after it deployed
226 if image_tests := get_step_from_results(this_image_result, "test_image"):
227 # storage configuration
228 if storage_state := determine_feature_state("storage layout", image_tests):
229 state, readable, info = storage_state
230 storage_conf = FeatureStatus(
231 "Storage Configuration",
232 state=state,
233 readable_state=readable,
234 info=info,
235 )
236 image_results.storage_conf = storage_conf # type:ignore[attr-defined]
237 # network configuration
238 if network_state := determine_feature_state("network layout", image_tests):
239 state, readable, info = network_state
240 net_conf = FeatureStatus(
241 "Network Configuration",
242 state=state,
243 readable_state=readable,
244 info=info,
245 )
246 image_results.net_conf = net_conf # type:ignore[attr-defined]
247 # add to image results list
248 results |= image_results.to_dict()
249 return results
250
251
252@activity.defn
253async def parse_failed_images(params: dict[str, Any]) -> dict[str, Any]:
254 maas_version: dict[str, dict[str, str]] = params["maas_version"]
255 image_details: dict[str, Any] = params["image_details"]
256 passed_images: list[str] = params["passed_images"]
257 results: dict[str, Any] = {}
258
259 default_maas_version = maas_version["None"]
260
261 # report on images that failed one of the steps
262 for image, details in image_details.items():
263 # don't report on images we've already recovered test statuses for
264 if image in passed_images:
265 continue
266
267 test_num = str(details["test_num"])
268
269 readable_state = "Unkown Error"
270 if not details["built"]:
271 readable_state = "Could not build image"
272 elif not details["tested"]:
273 readable_state = "Could not test image"
274 results |= ImageTestResults(
275 image=image,
276 maas_version=[maas_version.get(test_num, default_maas_version)["short"]],
277 readable_state=readable_state,
278 packer_version=[details["packer_version"]],
279 prerequisites=details["prerequisites"],
280 ).to_dict()
281 return results
282
283
284@activity.defn
285async def post_test_results(params: dict[str, Any]) -> None:
286 image_results: dict[str, Any] = params["image_results"]
287 maas_version: dict[str, dict[str, str]] = params["maas_version"]
288 repo_location: str = params["repo_location"]
289 image_details: dict[str, Any] = params["image_details"]
290 overwrite_results: bool = params["overwrite_results"]
291 # clone the results repo
292 if not os.path.exists(repo_location):
293 execute(
294 [
295 "git",
296 "clone",
297 "https://github.com/maas/MAAS-Image-Results",
298 repo_location,
299 ]
300 )
301
302 # read the combined results
303 combined_results: dict[str, dict[str, Any]] = {"images": {}}
304 combined_results_path = f"{repo_location}/image_results.yaml"
305 with open(combined_results_path, "r") as result_file:
306 if old_results := yaml.safe_load(result_file):
307 combined_results = old_results
308
309 test_nums = set()
310 # write the results for each image
311 for image, image_results in params["image_results"].items():
312 this_result_path = f"{repo_location}/{image}.yaml"
313 results: ImageTestResults = ImageTestResults().from_dict({image: image_results})
314 details: dict[str, Any] = image_details[image]
315 this_test_num: str = str(details["test_num"])
316 default_maas_version = maas_version["None"]
317 this_maas_version: str = maas_version.get(this_test_num, default_maas_version)[
318 "long"
319 ]
320 test_nums.add(int(this_test_num))
321
322 with checkout_and_commit(
323 branch=image,
324 commit_message=f"{image} results: {this_maas_version} - {this_test_num}",
325 add_file=this_result_path,
326 cwd=repo_location,
327 ):
328 if os.path.exists(this_result_path) and not overwrite_results:
329 with open(this_result_path, "r") as result_file:
330 if old_results := yaml.safe_load(result_file):
331 results += ImageTestResults().from_dict(old_results)
332
333 if combined_results["images"]:
334 combined_results["images"] |= results.to_dict()
335 else:
336 combined_results["images"] = results.to_dict()
337
338 with open(this_result_path, "w") as result_file:
339 yaml.safe_dump(results.to_dict(), result_file)
340
341 tested_builds = (
342 f"{min(test_nums)} - {max(test_nums)}" if len(test_nums) > 1 else f"{test_nums}"
343 )
344
345 # write the combined results to main
346 with checkout_and_commit(
347 branch="main",
348 commit_message=f"Combined results: {tested_builds}",
349 add_file=combined_results_path,
350 cwd=repo_location,
351 ), open(combined_results_path, "w") as result_file:
352 yaml.safe_dump(combined_results, result_file)
353
354
355@workflow.defn
356class image_reporting_workflow:
357 @workflow.run
358 async def run(self, params: image_reporting_param) -> None:
359 if not params.image_details:
360 raise Exception("No Image details provided")
361 # await an open connection to the server
362 await workflow.execute_activity(
363 check_jenkins_reachable,
364 params,
365 start_to_close_timeout=params.gettimeout("jenkins_login_timeout"),
366 )
367 test_numbers = await workflow.execute_activity(
368 get_test_numbers,
369 {
370 "image_details": params.image_details,
371 "params": params,
372 },
373 start_to_close_timeout=params.gettimeout("log_details_timeout"),
374 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
375 )
376 maas_versions = await workflow.execute_activity(
377 fetch_maas_version_from_logs,
378 {
379 "params": params,
380 "tests": list(test_numbers.keys()),
381 },
382 start_to_close_timeout=params.gettimeout("log_details_timeout"),
383 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
384 )
385 results_to_report: dict[str, Any] = {}
386 for test_num, test_details in test_numbers.items():
387 # if the tests completed and results are available.
388 if (
389 test_details["status"].lower() != "aborted"
390 and test_details["has_results"]
391 ):
392 results = await workflow.execute_activity(
393 filter_test_results,
394 {"params": params, "test_num": test_num},
395 start_to_close_timeout=params.gettimeout("fetch_results_timeout"),
396 retry_policy=RetryPolicy(
397 maximum_attempts=params.max_retry_attempts
398 ),
399 )
400 default_maas_version = maas_versions["None"]
401 results_to_report |= await workflow.execute_activity(
402 parse_test_results,
403 {
404 "maas_version": maas_versions.get(
405 test_num, default_maas_version
406 )["short"],
407 "image_details": params.image_details,
408 "results": results,
409 },
410 start_to_close_timeout=params.gettimeout("fetch_results_timeout"),
411 retry_policy=RetryPolicy(
412 maximum_attempts=params.max_retry_attempts
413 ),
414 )
415 # add any images that didn't test
416 results_to_report |= await workflow.execute_activity(
417 parse_failed_images,
418 {
419 "image_details": params.image_details,
420 "maas_version": maas_versions,
421 "passed_images": list(results_to_report.keys()),
422 },
423 start_to_close_timeout=params.gettimeout("fetch_results_timeout"),
424 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
425 )
426 # only try to upload once.
427 await workflow.execute_activity(
428 post_test_results,
429 {
430 "image_results": results_to_report,
431 "maas_version": maas_versions,
432 "repo_location": params.repo_location,
433 "image_details": params.image_details,
434 "overwrite_results": params.overwrite_results,
435 },
436 start_to_close_timeout=params.gettimeout("fetch_results_timeout"),
437 retry_policy=RetryPolicy(maximum_attempts=1),
438 )
439
440
441activities = [
442 check_jenkins_reachable,
443 get_test_numbers,
444 fetch_maas_version_from_logs,
445 filter_test_results,
446 parse_test_results,
447 parse_failed_images,
448 post_test_results,
449]
450workflows = [image_reporting_workflow]
diff --git a/temporal/image_testing_worker.py b/temporal/image_testing_worker.py
0new file mode 100644451new file mode 100644
index 0000000..f28bb23
--- /dev/null
+++ b/temporal/image_testing_worker.py
@@ -0,0 +1,10 @@
1from common_tasks import start_worker
2from image_testing_workflow import activities as image_test_activities
3from image_testing_workflow import workflows as image_test_workflows
4
5if __name__ == "__main__":
6 start_worker(
7 task_queue="image_testing",
8 workflows=image_test_workflows,
9 activities=image_test_activities,
10 )
diff --git a/temporal/image_testing_workflow.py b/temporal/image_testing_workflow.py
0new file mode 10064411new file mode 100644
index 0000000..a587b4b
--- /dev/null
+++ b/temporal/image_testing_workflow.py
@@ -0,0 +1,100 @@
1from dataclasses import dataclass
2from typing import Any
3
4from common_tasks import (
5 aslist,
6 await_build_complete,
7 await_build_exists,
8 check_jenkins_reachable,
9 fetch_build_status,
10 request_build,
11 workflow_parameters,
12)
13from temporalio import activity, workflow
14from temporalio.common import RetryPolicy
15
16
17@dataclass
18class image_testing_param(workflow_parameters):
19 image_name: str | list[str] = "" # allow builk image building if desired
20
21 job_name: str = (
22 "maas-automated-image-tester" # Need to check which job actually does this
23 )
24 build_num: int = -1
25
26 # job details with default values we may want to change
27 system_test_repo: str = (
28 "https://git.launchpad.net/~maas-committers/maas-ci/+git/system-tests"
29 )
30 system_test_branch: str = "master"
31
32 maas_snap_channel: str = "latest/edge"
33
34 parallel_tests: bool = False
35
36
37@activity.defn
38async def request_images_test(params: image_testing_param) -> int:
39 """Start an image testing job, returning the job number."""
40 job_params: dict[str, Any] = {
41 "IMAGE_NAMES": ",".join(image for image in aslist(params.image_name)),
42 "SYSTEMTESTS_GIT_REPO": params.system_test_repo,
43 "SYSTEMTESTS_GIT_BRANCH": params.system_test_branch,
44 "MAAS_SNAP_CHANNEL": params.maas_snap_channel,
45 }
46 return request_build(params, job_params)
47
48
49@workflow.defn
50class image_testing_workflow:
51 @workflow.run
52 async def run(self, params: image_testing_param) -> dict[str, Any]:
53 # await an open connection to the server
54 await workflow.execute_activity(
55 check_jenkins_reachable,
56 params,
57 start_to_close_timeout=params.gettimeout("jenkins_login_timeout"),
58 )
59 # test the image, only trigger once
60 params.build_num = await workflow.execute_activity(
61 request_images_test,
62 params,
63 start_to_close_timeout=params.gettimeout("request_build_timeout"),
64 )
65 # try multiple times to get the results or status
66 await workflow.execute_activity(
67 await_build_exists,
68 params,
69 start_to_close_timeout=params.gettimeout("request_build_timeout"),
70 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
71 )
72 await workflow.execute_activity(
73 await_build_complete,
74 params,
75 start_to_close_timeout=params.gettimeout("build_complete_timeout"),
76 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
77 )
78 # return a default failure state if the build was aborted
79 build_status = await workflow.execute_activity(
80 fetch_build_status,
81 params,
82 start_to_close_timeout=params.gettimeout("build_complete_timeout"),
83 retry_policy=RetryPolicy(maximum_attempts=params.max_retry_attempts),
84 )
85
86 # return the image details in the correct format
87 return {
88 image: {"build_num": params.build_num, "build_status": build_status}
89 for image in aslist(params.image_name)
90 }
91
92
93activities = [
94 check_jenkins_reachable,
95 request_images_test,
96 await_build_exists,
97 await_build_complete,
98 fetch_build_status,
99]
100workflows = [image_testing_workflow]
diff --git a/tox.ini b/tox.ini
index e4efc18..f347a7b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -66,8 +66,8 @@ description=Reformat Python code and README.md
66deps= -rrequirements.txt66deps= -rrequirements.txt
67skip_install = true67skip_install = true
68commands=68commands=
69 isort --profile black systemtests utils69 isort --profile black systemtests utils temporal
70 black systemtests utils70 black systemtests utils temporal
71 cog -r README.md71 cog -r README.md
7272
73[testenv:lint]73[testenv:lint]
@@ -76,10 +76,10 @@ deps= -rrequirements.txt
76allowlist_externals=sh76allowlist_externals=sh
77skip_install = true77skip_install = true
78commands=78commands=
79 isort --profile black --check-only systemtests utils79 isort --profile black --check-only systemtests utils temporal
80 black --check systemtests utils80 black --check systemtests utils temporal
81 cog --verbosity=0 --check README.md81 cog --verbosity=0 --check README.md
82 flake8 systemtests utils82 flake8 systemtests utils temporal
83 sh -c 'git ls-files \*.yaml\* | xargs -r yamllint'83 sh -c 'git ls-files \*.yaml\* | xargs -r yamllint'
8484
85[testenv:mypy]85[testenv:mypy]
@@ -95,6 +95,7 @@ deps=
95 types-netaddr95 types-netaddr
96commands=96commands=
97 mypy -p systemtests -p utils --install-types97 mypy -p systemtests -p utils --install-types
98 mypy temporal
9899
99[testenv:generate_config]100[testenv:generate_config]
100description=Generate config.yaml101description=Generate config.yaml
diff --git a/utils/gen_config.py b/utils/gen_config.py
index 3a1a4cd..4ea3e5e 100755
--- a/utils/gen_config.py
+++ b/utils/gen_config.py
@@ -144,10 +144,14 @@ def main(argv: list[str]) -> int:
144 packer_group.add_argument(144 packer_group.add_argument(
145 "--packer-repo",145 "--packer-repo",
146 type=str,146 type=str,
147 metavar="REPOS",
147 help="Which git repository to use to get Packer from",148 help="Which git repository to use to get Packer from",
148 )149 )
149 packer_group.add_argument(150 packer_group.add_argument(
150 "--packer-branch", type=str, help="Which git branch use to get Packer"151 "--packer-branch",
152 type=str,
153 metavar="BRANCH",
154 help="Which git branch use to get Packer",
151 )155 )
152 packer_group.add_argument(156 packer_group.add_argument(
153 "--packer-container-image",157 "--packer-container-image",
@@ -318,7 +322,7 @@ def main(argv: list[str]) -> int:
318 # if running custom image tests, only use compatible machines322 # if running custom image tests, only use compatible machines
319 target_arches = (323 target_arches = (
320 args.architecture324 args.architecture
321 if not args.image_tests325 if "image-tests" not in config
322 else [image["architecture"] for image in config["image-tests"].values()]326 else [image["architecture"] for image in config["image-tests"].values()]
323 )327 )
324 # Filter out machines with architectures not matching specified ones.328 # Filter out machines with architectures not matching specified ones.
@@ -333,12 +337,12 @@ def main(argv: list[str]) -> int:
333 machines["hardware"] = {337 machines["hardware"] = {
334 name: details338 name: details
335 for name, details in hardware.items()339 for name, details in hardware.items()
336 if name not in args.machine340 if name in args.machine
337 }341 }
338342
339 if args.vm_machine:343 if vms:
340 # Filter out VMs with name not listed in specified vm_machines344 # Filter out VMs with name not listed in specified vm_machines
341 if vms:345 if args.vm_machine:
342 vms["instances"] = {346 vms["instances"] = {
343 vm_name: vm_config347 vm_name: vm_config
344 for vm_name, vm_config in vms["instances"].items()348 for vm_name, vm_config in vms["instances"].items()

Subscribers

People subscribed via source and target branches

to all changes: