Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/github_runner_image_builder/cloud_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.")

Expand Down
12 changes: 12 additions & 0 deletions app/src/github_runner_image_builder/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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:
Expand Down
84 changes: 31 additions & 53 deletions app/src/github_runner_image_builder/openstack_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
),
)


Expand Down
1 change: 1 addition & 0 deletions app/tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions app/tests/unit/test_cloud_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions app/tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
33 changes: 31 additions & 2 deletions app/tests/unit/test_openstack_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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":
Expand All @@ -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=}")

Expand Down
2 changes: 2 additions & 0 deletions tests/unit/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading