diff --git a/.github/workflows/integration_test_app.yaml b/.github/workflows/integration_test_app.yaml index 4182b5a9..5e2866ac 100644 --- a/.github/workflows/integration_test_app.yaml +++ b/.github/workflows/integration_test_app.yaml @@ -15,18 +15,22 @@ jobs: fail-fast: false matrix: image: [focal, jammy, noble, resolute] - arch: [amd64, arm64, s390x, ppc64le] + arch: [amd64, arm64, armhf, s390x, ppc64le] exclude: - image: focal arch: ppc64le - image: focal arch: s390x + - image: focal + arch: armhf - image: jammy arch: arm64 - image: jammy arch: ppc64le - image: jammy arch: s390x + - image: jammy + arch: armhf - image: resolute arch: arm64 - image: resolute @@ -59,6 +63,13 @@ jobs: run: | tox -e integration -- --arch arm64 --image=${{ matrix.image }} ${{ secrets.INTEGRATION_TEST_ARGS_APP_ARM64 }} working-directory: app + - name: Run integration tests (armhf) + if: matrix.arch == 'armhf' + env: + OPENSTACK_PASSWORD: ${{ secrets.OPENSTACK_PASSWORD_ARM64 }} + run: | + tox -e integration -- --arch armhf --image=${{ matrix.image }} ${{ secrets.INTEGRATION_TEST_ARGS_APP_ARM64 }} + working-directory: app - name: Run integration tests (s390x) if: matrix.arch == 's390x' env: diff --git a/.gitignore b/.gitignore index 2f13cc2a..251faf0f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ testing_key.pem .vale/styles/config/vocabularies/* !.vale/styles/config/vocabularies/local # END VALE WORKFLOW IGNORE + +# Superpowers internal planning artifacts (not product docs) +docs/superpowers/ diff --git a/app/conftest.py b/app/conftest.py index 35cb4663..5268b9f4 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -41,7 +41,7 @@ def pytest_addoption(parser: Parser): "--arch", action="store", help="The architecture to build for.", - choices=["amd64", "arm64", "s390x", "ppc64le"], + choices=["amd64", "arm64", "armhf", "s390x", "ppc64le"], ) parser.addoption( "--openstack-network-name", diff --git a/app/src/github_runner_image_builder/cloud_image.py b/app/src/github_runner_image_builder/cloud_image.py index b16725c5..5ef4af03 100644 --- a/app/src/github_runner_image_builder/cloud_image.py +++ b/app/src/github_runner_image_builder/cloud_image.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) -SupportedBaseImageArch = typing.Literal["amd64", "arm64", "s390x", "ppc64el"] +SupportedBaseImageArch = typing.Literal["amd64", "arm64", "s390x", "ppc64el", "armhf"] CHECKSUM_BUF_SIZE = 65536 # 65kb @@ -95,6 +95,11 @@ def _get_supported_runner_arch(arch: Arch) -> SupportedBaseImageArch: return "s390x" case Arch.PPC64LE: return "ppc64el" # cloud-images.ubuntu.com uses ppc64el instead of ppc64le + case Arch.ARM: + # Download the arm64 (64-bit) base cloud image, not armhf. The armhf runner image is + # an arm64 boot image (64-bit kernel) that runs the 32-bit linux-arm runner via + # multiarch; a native armhf (32-bit kernel) image does not boot on the aarch64 hosts. + return "arm64" case _: raise UnsupportedArchitectureError(f"Detected system arch: {arch} is unsupported.") diff --git a/app/src/github_runner_image_builder/config.py b/app/src/github_runner_image_builder/config.py index 579fe295..1261fc7c 100644 --- a/app/src/github_runner_image_builder/config.py +++ b/app/src/github_runner_image_builder/config.py @@ -20,12 +20,14 @@ class Arch(str, Enum): X64: Represents an X64/AMD64 system architecture. S390X: Represents an S390X system architecture. PPC64LE: Represents a PPC64LE system architecture. + ARM: Represents an ARM (32-bit armhf) system architecture. """ ARM64 = "arm64" X64 = "x64" S390X = "s390x" PPC64LE = "ppc64le" + ARM = "arm" def to_openstack(self) -> str: """Convert the architecture to OpenStack compatible arch string. @@ -42,6 +44,13 @@ def to_openstack(self) -> str: return "s390x" case Arch.PPC64LE: return "ppc64le" + case Arch.ARM: + # The armhf runner image is an arm64 boot image (64-bit kernel) that carries a + # 32-bit linux-arm runner payload. Native armhf images (32-bit kernel) do not boot + # on the aarch64 "virt" machine, so the glance architecture is aarch64 so the base + # image and snapshot schedule and boot on arm64 hosts. The 32-bit runner is + # installed via multiarch (see cloud-init.sh.j2 and ARM_ADDITIONAL_APT_PACKAGES). + return "aarch64" raise ValueError # pragma: nocover @@ -127,6 +136,26 @@ def from_str(cls, tag_or_name: str) -> "BaseImage": "wget", ] S390X_PPC64LE_ADDITIONAL_APT_PACKAGES = ["dotnet-runtime-8.0"] +# The 32-bit linux-arm runner agent runs via the host's native AArch32 support on the arm64 +# image. It needs the armhf loader (ld-linux-armhf.so.3 from libc6:armhf) and the armhf build of +# libatomic (a .NET runtime dependency). rustup provides the armhf/armv7 Rust toolchain and +# docker-buildx enables arm32 container builds. libicu (the other .NET runtime dependency) is +# release-specific and handled by ARM_LIBICU_APT_PACKAGE_BY_BASE below. +ARM_ADDITIONAL_APT_PACKAGES = [ + "libc6:armhf", + "libatomic1:armhf", + "rustup", + "docker-buildx", +] +# rustup (installed on armhf images) conflicts with the distro cargo and rustc packages, so these +# are dropped from the default apt package set on armhf; rustup provides cargo and rustc instead. +ARM_EXCLUDED_DEFAULT_APT_PACKAGES = ("cargo", "rustc") +# The linux-arm runner's bundled .NET runtime dlopens libicu, whose soname is release-specific +# (noble ships libicu74, resolute ships libicu78). Install the armhf build matching the base image. +ARM_LIBICU_APT_PACKAGE_BY_BASE = { + BaseImage.NOBLE: "libicu74:armhf", + BaseImage.RESOLUTE: "libicu78:armhf", +} _LOG_LEVELS = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR) LOG_LEVELS = tuple( diff --git a/app/src/github_runner_image_builder/openstack_builder.py b/app/src/github_runner_image_builder/openstack_builder.py index 093abe32..a1fd96b1 100644 --- a/app/src/github_runner_image_builder/openstack_builder.py +++ b/app/src/github_runner_image_builder/openstack_builder.py @@ -40,6 +40,9 @@ import github_runner_image_builder.errors from github_runner_image_builder import cloud_image, config, store from github_runner_image_builder.config import ( + ARM_ADDITIONAL_APT_PACKAGES, + ARM_EXCLUDED_DEFAULT_APT_PACKAGES, + ARM_LIBICU_APT_PACKAGE_BY_BASE, FORK_RUNNER_BINARY_REPO, IMAGE_DEFAULT_APT_PACKAGES, S390X_PPC64LE_ADDITIONAL_APT_PACKAGES, @@ -60,6 +63,10 @@ SHARED_SECURITY_GROUP_NAME = "github-runner-image-builder-v1" EXTERNAL_SCRIPT_PATH = pathlib.Path("/root/external.sh") +# armhf additional apt packages (libicu74, rustup, docker-buildx) are only available from the +# noble (24.04) archive onwards, so armhf images can only be built on these base images. +ARM_SUPPORTED_BASE_IMAGES = (BaseImage.NOBLE, BaseImage.RESOLUTE) + # Server operation timeout constants (in seconds) CREATE_SERVER_TIMEOUT = 20 * 60 # 20 minutes DELETE_SERVER_TIMEOUT = 20 * 60 # 20 minutes @@ -540,6 +547,10 @@ def _generate_cloud_init_script( image_config: The target image configuration values. proxy: The proxy to enable while setting up the VM. + Raises: + UnsupportedArchitectureError: If an armhf image is requested on a base image older than + the supported ARM base images (noble and newer). + Returns: The cloud-init script to create snapshot image. """ @@ -552,6 +563,29 @@ def _generate_cloud_init_script( apt_packages = IMAGE_DEFAULT_APT_PACKAGES if image_config.arch in (Arch.S390X, Arch.PPC64LE): apt_packages = IMAGE_DEFAULT_APT_PACKAGES + S390X_PPC64LE_ADDITIONAL_APT_PACKAGES + elif image_config.arch == Arch.ARM: + # The armhf additional apt packages (rustup, docker-buildx, the armhf multiarch libs) and + # the release-specific libicu are only available from the noble (24.04) archive onwards, + # so fail fast on older bases rather than letting apt-get fail midway through the build. + if image_config.base not in ARM_SUPPORTED_BASE_IMAGES: + raise github_runner_image_builder.errors.UnsupportedArchitectureError( + f"armhf images require a base image of " + f"{[base.value for base in ARM_SUPPORTED_BASE_IMAGES]} or newer, " + f"got: {image_config.base.value}." + ) + # rustup provides the armhf/armv7 Rust toolchain and conflicts with the distro cargo/rustc + # packages, so drop those from the default set. libicu's soname is release-specific, so + # add the armhf build matching the base image. + default_packages = [ + package + for package in IMAGE_DEFAULT_APT_PACKAGES + if package not in ARM_EXCLUDED_DEFAULT_APT_PACKAGES + ] + apt_packages = ( + default_packages + + ARM_ADDITIONAL_APT_PACKAGES + + [ARM_LIBICU_APT_PACKAGE_BY_BASE[image_config.base]] + ) return template.render( PROXY=proxy, APT_PACKAGES=" ".join(apt_packages), diff --git a/app/src/github_runner_image_builder/store.py b/app/src/github_runner_image_builder/store.py index 648d6099..2674b54b 100644 --- a/app/src/github_runner_image_builder/store.py +++ b/app/src/github_runner_image_builder/store.py @@ -77,12 +77,13 @@ def upload_image( with openstack.connect(cloud=cloud_name) as connection: try: logger.info("Uploading image %s.", image_name) + image_properties = {"architecture": arch.to_openstack()} # ignore type since the library does not provide correct type hinting but the docstring # does define the return type. image: Image = connection.create_image( name=image_name, filename=str(image_path), - properties={"architecture": arch.to_openstack()}, + properties=image_properties, allow_duplicates=True, wait=True, ) # type: ignore diff --git a/app/src/github_runner_image_builder/templates/cloud-init.sh.j2 b/app/src/github_runner_image_builder/templates/cloud-init.sh.j2 index e5991eb0..5deb2a28 100644 --- a/app/src/github_runner_image_builder/templates/cloud-init.sh.j2 +++ b/app/src/github_runner_image_builder/templates/cloud-init.sh.j2 @@ -53,6 +53,34 @@ EOF sleep 5 } +function enable_armhf_multiarch() { + echo "Enabling armhf multiarch" + native_arch=$(/usr/bin/dpkg --print-architecture) + codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + /usr/bin/dpkg --add-architecture armhf + # The arm64 cloud image's apt mirrors only carry the native architecture; armhf is a "ports" + # architecture served from ports.ubuntu.com. Pin the stock sources to the native architecture + # and add a ports.ubuntu.com source for armhf so the 32-bit runner's dependencies resolve. + /usr/bin/sed -i "/^Types: deb/a Architectures: $native_arch" /etc/apt/sources.list.d/ubuntu.sources + /usr/bin/tee /etc/apt/sources.list.d/armhf-ports.sources >/dev/null </dev/null < tuple[Commands, ...]: + """Return the test commands to run for the given architecture. + + Args: + arch: The architecture under test. + + Returns: + The base test commands, plus armhf-specific commands when arch is ARM. + """ + if arch == Arch.ARM: + return TEST_RUNNER_COMMANDS + ARM_RUNNER_COMMANDS + return TEST_RUNNER_COMMANDS diff --git a/app/tests/integration/conftest.py b/app/tests/integration/conftest.py index 36ffebcc..d21075b3 100644 --- a/app/tests/integration/conftest.py +++ b/app/tests/integration/conftest.py @@ -33,6 +33,8 @@ def arch_fixture(pytestconfig: pytest.Config): match arch: case "arm64": return config.Arch.ARM64 + case "armhf": + return config.Arch.ARM case "amd64": return config.Arch.X64 case "s390x": diff --git a/app/tests/integration/helpers.py b/app/tests/integration/helpers.py index 4438fadc..4755c94f 100644 --- a/app/tests/integration/helpers.py +++ b/app/tests/integration/helpers.py @@ -32,17 +32,12 @@ from pylxd.models.instance import Instance, InstanceState from requests_toolbelt import MultipartEncoder +from github_runner_image_builder.config import Arch from tests.integration import commands, types logger = logging.getLogger(__name__) -TESTDATA_TEST_SCRIPT_URL = ( - "https://raw.githubusercontent.com/canonical/github-runner-image-builder-operator/" - "be135aa505b37aae29aec0ab13805909c46b7903/app/tests/integration/testdata/test_script.sh" -) - - P = ParamSpec("P") R = TypeVar("R") S = Callable[P, R] | Callable[P, Awaitable[R]] @@ -410,13 +405,14 @@ def setup_aproxy(ssh_connection: SSHConnection, proxy: str) -> None: assert False, "Aproxy did not start up correctly." -def run_openstack_tests(ssh_connection: SSHConnection): +def run_openstack_tests(ssh_connection: SSHConnection, arch: Arch): """Run test commands on the openstack instance via ssh. Args: ssh_connection: The SSH connection instance to OpenStack test server. + arch: The architecture under test, selecting arch-specific commands. """ - for testcmd in commands.TEST_RUNNER_COMMANDS: + for testcmd in commands.commands_for_arch(arch): logger.info("Running command: %s", testcmd.command) result: Result = ssh_connection.run(testcmd.command, env=testcmd.env) logger.info("Command output: %s %s %s", result.return_code, result.stdout, result.stderr) diff --git a/app/tests/integration/test_openstack_builder.py b/app/tests/integration/test_openstack_builder.py index 29f2bc40..84c93a81 100644 --- a/app/tests/integration/test_openstack_builder.py +++ b/app/tests/integration/test_openstack_builder.py @@ -16,7 +16,7 @@ import pytest import pytest_asyncio from fabric.connection import Connection as SSHConnection -from integration.helpers import TESTDATA_TEST_SCRIPT_URL +from integration.commands import TESTDATA_TEST_SCRIPT_URL from openstack.compute.v2.image import Image from openstack.compute.v2.server import Server from openstack.connection import Connection @@ -219,6 +219,7 @@ async def ssh_connection_fixture( def test_run( ssh_connection: SSHConnection, proxy: types.ProxyConfig, + arch: config.Arch, ): """ arrange: given openstack cloud instance. @@ -227,7 +228,7 @@ def test_run( """ if proxy.http is not None: helpers.setup_aproxy(ssh_connection, proxy.http) - helpers.run_openstack_tests(ssh_connection=ssh_connection) + helpers.run_openstack_tests(ssh_connection=ssh_connection, arch=arch) def test_openstack_state( diff --git a/app/tests/unit/test_cloud_image.py b/app/tests/unit/test_cloud_image.py index 794d546e..1c498089 100644 --- a/app/tests/unit/test_cloud_image.py +++ b/app/tests/unit/test_cloud_image.py @@ -179,6 +179,7 @@ def test__get_supported_runner_arch_unsupported_error(): pytest.param(Arch.X64, "amd64", id="AMD64"), pytest.param(Arch.S390X, "s390x", id="S390X"), pytest.param(Arch.PPC64LE, "ppc64el", id="PPC64LE"), + pytest.param(Arch.ARM, "arm64", id="ARM"), ], ) def test__get_supported_runner_arch(arch: Arch, expected: SupportedBaseImageArch): diff --git a/app/tests/unit/test_config.py b/app/tests/unit/test_config.py index dac4243d..916746e0 100644 --- a/app/tests/unit/test_config.py +++ b/app/tests/unit/test_config.py @@ -22,6 +22,7 @@ pytest.param(Arch.X64, "x86_64", id="amd64"), pytest.param(Arch.S390X, "s390x", id="s390x"), pytest.param(Arch.PPC64LE, "ppc64le", id="ppc64le"), + pytest.param(Arch.ARM, "aarch64", id="arm"), ], ) def test_arch_openstack_conversion(arch: Arch, expected: str): diff --git a/app/tests/unit/test_integration_commands.py b/app/tests/unit/test_integration_commands.py new file mode 100644 index 00000000..32394868 --- /dev/null +++ b/app/tests/unit/test_integration_commands.py @@ -0,0 +1,34 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for arch-conditional integration test command selection.""" + +from github_runner_image_builder.config import Arch +from tests.integration import commands + + +def test_commands_for_arch_includes_arm_commands_for_arm(): + """ + arrange: given the ARM architecture. + act: when commands_for_arch is called. + assert: the returned commands include every ARM_RUNNER_COMMANDS entry and all base commands. + """ + result = commands.commands_for_arch(Arch.ARM) + + for arm_command in commands.ARM_RUNNER_COMMANDS: + assert arm_command in result + for base_command in commands.TEST_RUNNER_COMMANDS: + assert base_command in result + + +def test_commands_for_arch_excludes_arm_commands_for_non_arm(): + """ + arrange: given a non-ARM architecture. + act: when commands_for_arch is called. + assert: the returned commands are exactly the base commands (no ARM_RUNNER_COMMANDS). + """ + result = commands.commands_for_arch(Arch.X64) + + assert tuple(result) == tuple(commands.TEST_RUNNER_COMMANDS) + for arm_command in commands.ARM_RUNNER_COMMANDS: + assert arm_command not in result diff --git a/app/tests/unit/test_openstack_builder.py b/app/tests/unit/test_openstack_builder.py index 90beda21..b7c77163 100644 --- a/app/tests/unit/test_openstack_builder.py +++ b/app/tests/unit/test_openstack_builder.py @@ -661,37 +661,57 @@ def test__determine_network(network_name: str | None): ) +_DEFAULT_APT_PACKAGES = ( + "build-essential cargo docker.io gh jq npm pkg-config python-is-python3 python3-dev " + "python3-pip rustc shellcheck socat tar time unzip wget" +) +# armhf drops the distro cargo/rustc (replaced by rustup) and adds the armhf multiarch runtime +# libs, rustup, docker-buildx and the release-specific libicu (libicu74 on noble). +_ARM_APT_PACKAGES = ( + "build-essential docker.io gh jq npm pkg-config python-is-python3 python3-dev python3-pip " + "shellcheck socat tar time unzip wget libc6:armhf libatomic1:armhf rustup docker-buildx " + "libicu74:armhf" +) + + @pytest.mark.parametrize( - "arch, additional_apt_packages", + "arch, expected_apt_packages", [ - pytest.param(openstack_builder.Arch.X64, [], id="x64"), - pytest.param(openstack_builder.Arch.ARM64, [], id="arm64"), + pytest.param(openstack_builder.Arch.X64, _DEFAULT_APT_PACKAGES, id="x64"), + pytest.param(openstack_builder.Arch.ARM64, _DEFAULT_APT_PACKAGES, id="arm64"), pytest.param( openstack_builder.Arch.S390X, - ["dotnet-runtime-8.0"], + _DEFAULT_APT_PACKAGES + " dotnet-runtime-8.0", id="s390x", ), pytest.param( openstack_builder.Arch.PPC64LE, - ["dotnet-runtime-8.0"], + _DEFAULT_APT_PACKAGES + " dotnet-runtime-8.0", id="ppc64le", ), + pytest.param(openstack_builder.Arch.ARM, _ARM_APT_PACKAGES, id="arm"), ], ) def test__generate_cloud_init_script( arch: openstack_builder.Arch, - additional_apt_packages: list[str], + expected_apt_packages: str, ): """ arrange: A certain architecture. act: when _generate_cloud_init_script is run. assert: expected cloud init template is generated. """ + base = ( + openstack_builder.BaseImage.NOBLE + if arch == openstack_builder.Arch.ARM + else openstack_builder.BaseImage.JAMMY + ) + expected_hwe = openstack_builder.BaseImage.get_version(base) assert ( openstack_builder._generate_cloud_init_script( image_config=openstack_builder.config.ImageConfig( arch=arch, - base=openstack_builder.BaseImage.JAMMY, + base=base, runner_version="", name="test-image", script_config=openstack_builder.config.ScriptConfig( @@ -761,6 +781,34 @@ def test__generate_cloud_init_script( sleep 5 }} +function enable_armhf_multiarch() {{ + echo "Enabling armhf multiarch" + native_arch=$(/usr/bin/dpkg --print-architecture) + codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + /usr/bin/dpkg --add-architecture armhf + # The arm64 cloud image's apt mirrors only carry the native architecture; armhf is a "ports" + # architecture served from ports.ubuntu.com. Pin the stock sources to the native architecture + # and add a ports.ubuntu.com source for armhf so the 32-bit runner's dependencies resolve. + /usr/bin/sed -i "/^Types: deb/a Architectures: $native_arch" /etc/apt/sources.list.d/ubuntu.sources + /usr/bin/tee /etc/apt/sources.list.d/armhf-ports.sources >/dev/null </dev/null < str: @@ -94,6 +96,7 @@ def __str__(self) -> str: X64 = "x64" S390X = "s390x" PPC64LE = "ppc64le" + ARM = "arm" @classmethod def from_charm(cls, charm: ops.CharmBase) -> "Arch": @@ -114,6 +117,8 @@ def from_charm(cls, charm: ops.CharmBase) -> "Arch": match architecture: case arch if arch in ARCHITECTURES_ARM64: return Arch.ARM64 + case arch if arch in ARCHITECTURES_ARM: + return Arch.ARM case arch if arch in ARCHITECTURES_X86: return Arch.X64 case arch if arch in ARCHITECTURES_S390X: diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 3c635178..2a245da7 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -41,6 +41,9 @@ def patch_juju_version_29_fixture(monkeypatch: pytest.MonkeyPatch): pytest.param("amd64", state.Arch.X64, id="amd64"), pytest.param("s390x", state.Arch.S390X, id="s390x"), pytest.param("ppc64le", state.Arch.PPC64LE, id="ppc64le"), + pytest.param("armhf", state.Arch.ARM, id="armhf"), + pytest.param("arm", state.Arch.ARM, id="arm"), + pytest.param("armv7l", state.Arch.ARM, id="armv7l"), ], ) def test_arch_from_charm(arch: str, expected: state.Arch):