diff --git a/app/src/github_runner_image_builder/cloud_image.py b/app/src/github_runner_image_builder/cloud_image.py index b16725c5..7b0e73a5 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", "riscv64"] CHECKSUM_BUF_SIZE = 65536 # 65kb @@ -95,6 +95,8 @@ 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.RISCV64: + return "riscv64" 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..1bf9fe82 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. + RISCV64: Represents a RISCV64 system architecture. """ ARM64 = "arm64" X64 = "x64" S390X = "s390x" PPC64LE = "ppc64le" + RISCV64 = "riscv64" def to_openstack(self) -> str: """Convert the architecture to OpenStack compatible arch string. @@ -42,6 +44,8 @@ def to_openstack(self) -> str: return "s390x" case Arch.PPC64LE: return "ppc64le" + case Arch.RISCV64: + return "riscv64" raise ValueError # pragma: nocover @@ -140,6 +144,14 @@ def from_str(cls, tag_or_name: str) -> "BaseImage": FORK_RUNNER_BINARY_REPO = "canonical/github-actions-runner" +# Per-architecture overrides for the GitHub repository that publishes the runner +# release tarballs. The default (canonical/github-actions-runner) is used for any +# architecture not listed here. riscv64 is published on a fork until the tarball +# is folded into the canonical fork's release pipeline. +RUNNER_BINARY_REPO_OVERRIDES: dict[Arch, str] = { + Arch.RISCV64: "vhaudiquet/actions-runner-riscv", +} + @dataclasses.dataclass class ScriptConfig: diff --git a/app/src/github_runner_image_builder/openstack_builder.py b/app/src/github_runner_image_builder/openstack_builder.py index 093abe32..2f1e08b2 100644 --- a/app/src/github_runner_image_builder/openstack_builder.py +++ b/app/src/github_runner_image_builder/openstack_builder.py @@ -42,6 +42,7 @@ from github_runner_image_builder.config import ( FORK_RUNNER_BINARY_REPO, IMAGE_DEFAULT_APT_PACKAGES, + RUNNER_BINARY_REPO_OVERRIDES, S390X_PPC64LE_ADDITIONAL_APT_PACKAGES, Arch, BaseImage, @@ -78,6 +79,10 @@ MIN_RAM = 1024 # M MIN_DISK = 20 # G +# Focal does not publish riscv64 cloud images on cloud-images.ubuntu.com, so the +# focal base cannot be seeded for riscv64. +RISCV64_UNSUPPORTED_BASES = {BaseImage.FOCAL} + # We saw an issue with arm noble images with the latest release date, so we are using a fixed date. NOBLE_ARM64_RELEASE_DATE = date(2025, 11, 13) @@ -128,59 +133,30 @@ def initialize(arch: Arch, cloud_name: str, prefix: str) -> None: prefix: The prefix to use for OpenStack resource names. """ logger.info("Initializing external builder.") - logger.info("Downloading Focal image.") - focal_image_path = cloud_image.download_and_validate_image( - arch=arch, base_image=BaseImage.FOCAL - ) - logger.info("Downloading Jammy image.") - jammy_image_path = cloud_image.download_and_validate_image( - arch=arch, base_image=BaseImage.JAMMY - ) - logger.info("Downloading Noble image.") - # 2025/11/18 We've seen issues with the latest noble arm64 image, the decision is to keep the - # option to pin a specific release date if needed in the future. The comment code snippet below - # is kept for reference. If we want to pin a specific date again, we can expose it as a config - # option on the charm side to enable it on the operations side. - noble_release_date = None # NOBLE_ARM64_RELEASE_DATE if arch == Arch.ARM64 else None - noble_image_path = cloud_image.download_and_validate_image( - arch=arch, base_image=BaseImage.NOBLE, release_date=noble_release_date - ) - logger.info("Downloading Resolute image.") - resolute_image_path = cloud_image.download_and_validate_image( - arch=arch, base_image=BaseImage.RESOLUTE - ) - logger.info("Uploading Focal image.") - store.upload_image( - arch=arch, - cloud_name=cloud_name, - image_name=_get_base_image_name(arch=arch, base=BaseImage.FOCAL, prefix=prefix), - image_path=focal_image_path, - keep_revisions=1, - ) - logger.info("Uploading Jammy image.") - store.upload_image( - arch=arch, - cloud_name=cloud_name, - image_name=_get_base_image_name(arch=arch, base=BaseImage.JAMMY, prefix=prefix), - image_path=jammy_image_path, - keep_revisions=1, - ) - logger.info("Uploading Noble image.") - store.upload_image( - arch=arch, - cloud_name=cloud_name, - image_name=_get_base_image_name(arch=arch, base=BaseImage.NOBLE, prefix=prefix), - image_path=noble_image_path, - keep_revisions=1, - ) - logger.info("Uploading Resolute image.") - store.upload_image( - arch=arch, - cloud_name=cloud_name, - image_name=_get_base_image_name(arch=arch, base=BaseImage.RESOLUTE, prefix=prefix), - image_path=resolute_image_path, - keep_revisions=1, + unsupported_bases = ( + RISCV64_UNSUPPORTED_BASES if arch == Arch.RISCV64 else set() ) + bases: list[BaseImage] = [ + base for base in BaseImage if base not in unsupported_bases + ] + for base in bases: + logger.info("Downloading %s image.", base.value.capitalize()) + # 2025/11/18 We've seen issues with the latest noble arm64 image, the decision is to keep the + # option to pin a specific release date if needed in the future. The comment code snippet below + # is kept for reference. If we want to pin a specific date again, we can expose it as a config + # option on the charm side to enable it on the operations side. + release_date = None # NOBLE_ARM64_RELEASE_DATE if arch == Arch.ARM64 else None + image_path = cloud_image.download_and_validate_image( + arch=arch, base_image=base, release_date=release_date + ) + logger.info("Uploading %s image.", base.value.capitalize()) + store.upload_image( + arch=arch, + cloud_name=cloud_name, + image_name=_get_base_image_name(arch=arch, base=base, prefix=prefix), + image_path=image_path, + keep_revisions=1, + ) with openstack.connect(cloud=cloud_name) as conn: _create_keypair(conn=conn, prefix=prefix) logger.info("Creating security group %s.", SHARED_SECURITY_GROUP_NAME) @@ -558,7 +534,9 @@ def _generate_cloud_init_script( HWE_VERSION=BaseImage.get_version(image_config.base), RUNNER_VERSION=image_config.runner_version, RUNNER_ARCH=image_config.arch.value, - RUNNER_BINARY_REPO=FORK_RUNNER_BINARY_REPO, + RUNNER_BINARY_REPO=RUNNER_BINARY_REPO_OVERRIDES.get( + image_config.arch, FORK_RUNNER_BINARY_REPO + ), ) diff --git a/app/tests/unit/test_cli.py b/app/tests/unit/test_cli.py index 620ead80..f0f0e79d 100644 --- a/app/tests/unit/test_cli.py +++ b/app/tests/unit/test_cli.py @@ -95,6 +95,7 @@ def test_main(cli_runner: CliRunner, action: str): pytest.param("x64", config.Arch.X64, id="amd64"), pytest.param("s390x", config.Arch.S390X, id="s390x"), pytest.param("ppc64le", config.Arch.PPC64LE, id="ppc64le"), + pytest.param("riscv64", config.Arch.RISCV64, id="riscv64"), ], ) def test_initialize( diff --git a/app/tests/unit/test_cloud_image.py b/app/tests/unit/test_cloud_image.py index 794d546e..a881dbc5 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.RISCV64, "riscv64", id="RISCV64"), ], ) 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..3f0d6a34 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.RISCV64, "riscv64", id="riscv64"), ], ) def test_arch_openstack_conversion(arch: Arch, expected: str): diff --git a/app/tests/unit/test_openstack_builder.py b/app/tests/unit/test_openstack_builder.py index 90beda21..308445cb 100644 --- a/app/tests/unit/test_openstack_builder.py +++ b/app/tests/unit/test_openstack_builder.py @@ -20,7 +20,11 @@ import yaml from github_runner_image_builder import cloud_image, errors, openstack_builder, store -from github_runner_image_builder.config import Arch +from github_runner_image_builder.config import ( + FORK_RUNNER_BINARY_REPO, + RUNNER_BINARY_REPO_OVERRIDES, + Arch, +) from github_runner_image_builder.errors import ExternalScriptError from github_runner_image_builder.openstack_builder import EXTERNAL_SCRIPT_PATH @@ -149,6 +153,30 @@ def test_initialize_release_date(monkeypatch: pytest.MonkeyPatch, arch: Arch): assert arch in archs_from_calls +def test_initialize_riscv64_skips_focal(monkeypatch: pytest.MonkeyPatch): + """ + arrange: given monkeypatched cloud_image, store and openstack module functions. + act: when initialize is called for riscv64. + assert: focal base image is not downloaded/uploaded (no riscv64 focal cloud image exists), + while jammy/noble/resolute are. + """ + monkeypatch.setattr(cloud_image, "download_and_validate_image", (download_mock := MagicMock())) + monkeypatch.setattr(store, "upload_image", MagicMock()) + monkeypatch.setattr(openstack_builder.openstack, "connect", MagicMock()) + monkeypatch.setattr(openstack_builder, "_create_keypair", MagicMock()) + monkeypatch.setattr(openstack_builder, "_create_security_group", MagicMock()) + + prefix = secrets.token_hex(8) + cloud_name = "test-cloud" + openstack_builder.initialize(arch=Arch.RISCV64, cloud_name=cloud_name, prefix=prefix) + + base_images_from_calls = {call[1]["base_image"] for call in download_mock.call_args_list} + assert openstack_builder.BaseImage.FOCAL not in base_images_from_calls + assert openstack_builder.BaseImage.JAMMY in base_images_from_calls + assert openstack_builder.BaseImage.NOBLE in base_images_from_calls + assert openstack_builder.BaseImage.RESOLUTE in base_images_from_calls + + def test__create_keypair_already_exists(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path): """ arrange: given monkeypatched openstack connection with keys and mocked key path that exists. @@ -676,6 +704,7 @@ def test__determine_network(network_name: str | None): ["dotnet-runtime-8.0"], id="ppc64le", ), + pytest.param(openstack_builder.Arch.RISCV64, [], id="riscv64"), ], ) def test__generate_cloud_init_script( @@ -914,7 +943,7 @@ def test__generate_cloud_init_script( hwe_version="22.04" github_runner_version="" github_runner_arch="{arch.value}" -runner_binary_repo="canonical/github-actions-runner" +runner_binary_repo="{RUNNER_BINARY_REPO_OVERRIDES.get(arch, FORK_RUNNER_BINARY_REPO)}" configure_proxy "$proxy" install_apt_packages "$apt_packages" "$hwe_version" diff --git a/charmcraft.yaml b/charmcraft.yaml index 3b14d47e..03a39333 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -73,8 +73,8 @@ config: type: string description: | The image architecture to build for using external builder. Can be one of "amd64", "arm64", "s390x", - "ppc64le" (alternatively "ppc64el"). - Support for "s390x" and "ppc64el" is considered experimental. + "ppc64le" (alternatively "ppc64el"), or "riscv64". + Support for "s390x", "ppc64el" and "riscv64" is considered experimental. base-image: type: string default: noble diff --git a/src/state.py b/src/state.py index 3de0a606..432d638e 100644 --- a/src/state.py +++ b/src/state.py @@ -20,6 +20,7 @@ ARCHITECTURES_S390X = {"s390x"} ARCHITECTURES_PPC64LE = {"ppc64le", "ppc64el"} ARCHITECTURES_X86 = {"x86_64", "amd64", "x64"} +ARCHITECTURES_RISCV64 = {"riscv64"} CLOUD_NAME = "builder" LTS_IMAGE_VERSION_TAG_MAP = { "20.04": "focal", @@ -80,6 +81,7 @@ class Arch(str, Enum): X64: Represents an X64/AMD64 system architecture. S390X: Represents an S390X system architecture. PPC64LE: Represents an PPC64LE system architecture. + RISCV64: Represents an RISCV64 system architecture. """ def __str__(self) -> str: @@ -94,6 +96,7 @@ def __str__(self) -> str: X64 = "x64" S390X = "s390x" PPC64LE = "ppc64le" + RISCV64 = "riscv64" @classmethod def from_charm(cls, charm: ops.CharmBase) -> "Arch": @@ -120,6 +123,8 @@ def from_charm(cls, charm: ops.CharmBase) -> "Arch": return Arch.S390X case arch if arch in ARCHITECTURES_PPC64LE: return Arch.PPC64LE + case arch if arch in ARCHITECTURES_RISCV64: + return Arch.RISCV64 case _: raise UnsupportedArchitectureError(msg=f"Unsupported {arch=}") diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 3c635178..362db243 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -41,6 +41,7 @@ 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("riscv64", state.Arch.RISCV64, id="riscv64"), ], ) def test_arch_from_charm(arch: str, expected: state.Arch): @@ -77,6 +78,7 @@ def test_arch_from_charm_unsupported(): pytest.param(state.Arch.X64, state.Arch.X64.value), pytest.param(state.Arch.S390X, state.Arch.S390X.value), pytest.param(state.Arch.PPC64LE, state.Arch.PPC64LE.value), + pytest.param(state.Arch.RISCV64, state.Arch.RISCV64.value), ], ) def test_arch_str(arch: state.Arch, expected_str: str):