diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 07a1ca231..9a43c0346 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -15,7 +15,7 @@ jobs: pre-run-script: scripts/pre-integration-test.sh provider: lxd test-tox-env: integration-juju2.9 - modules: '["test_charm_fork_repo", "test_charm_no_runner", "test_charm_scheduled_events", "test_charm_one_runner", "test_charm_metrics_success", "test_charm_metrics_failure", "test_self_hosted_runner", "test_charm_with_proxy", "test_charm_with_juju_storage", "test_debug_ssh"]' + modules: '["test_charm_base_image", "test_charm_fork_repo", "test_charm_no_runner", "test_charm_scheduled_events", "test_charm_one_runner", "test_charm_metrics_success", "test_charm_metrics_failure", "test_self_hosted_runner", "test_charm_with_proxy", "test_charm_with_juju_storage", "test_debug_ssh"]' integration-tests: name: Integration test with juju 3.1 uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main @@ -25,7 +25,7 @@ jobs: pre-run-script: scripts/pre-integration-test.sh provider: lxd test-tox-env: integration-juju3.1 - modules: '["test_charm_fork_repo", "test_charm_no_runner", "test_charm_scheduled_events", "test_charm_one_runner", "test_charm_metrics_success", "test_charm_metrics_failure", "test_self_hosted_runner", "test_charm_with_proxy", "test_charm_with_juju_storage", "test_debug_ssh"]' + modules: '["test_charm_base_image", "test_charm_fork_repo", "test_charm_no_runner", "test_charm_scheduled_events", "test_charm_one_runner", "test_charm_metrics_success", "test_charm_metrics_failure", "test_self_hosted_runner", "test_charm_with_proxy", "test_charm_with_juju_storage", "test_debug_ssh"]' # openstack tests use microstack, whose setup is kind of special # - due to the huge resource requirements, we use self-hosted runners for these tests # - microstack requires juju 3.2 and microk8s 1.26 @@ -42,6 +42,7 @@ jobs: channel: 1.26-strict/stable microk8s-addons: "dns ingress hostpath-storage" test-tox-env: integration-juju3.2 - modules: '["test_openstack"]' + modules: '["test_openstack_base_image", "test_openstack_one_runner"]' self-hosted-runner: true self-hosted-runner-label: two-xlarge + tmate-debug: true diff --git a/config.yaml b/config.yaml index 670315ca8..9628df004 100644 --- a/config.yaml +++ b/config.yaml @@ -2,6 +2,13 @@ # See LICENSE file for licensing details. options: + base-image: + type: string + default: "jammy" + description: >- + The base ubuntu OS image to use for the runners. Codename (e.g. "jammy") or version tag + (e.g. 22.04) is supported as input. Currently only supports LTS versions of jammy and higher, + i.e. jammy, noble. denylist: type: string default: "" diff --git a/docs/how-to/set-base-image.md b/docs/how-to/set-base-image.md new file mode 100644 index 000000000..56e9347e1 --- /dev/null +++ b/docs/how-to/set-base-image.md @@ -0,0 +1,14 @@ +# How to set base image + +This charm supports deploying the runners on different base images. + +By using [`juju config`](https://juju.is/docs/juju/juju-config) to change the +[charm configuration labels](https://charmhub.io/github-runner/configure#base-image), the runner +can be deployed with a different Ubuntu base image. Latest two LTS images "jammy" and "noble" are +supported. The default base image is "jammy". + +```shell +juju config base-image= +``` + +An example of a BASE_IMAGE_TAG_OR_NAME value would be "jammy", "22.04", "noble", "24.04". diff --git a/scripts/build-lxd-image.sh b/scripts/build-lxd-image.sh index 96caed895..4feabc0a6 100755 --- a/scripts/build-lxd-image.sh +++ b/scripts/build-lxd-image.sh @@ -51,7 +51,8 @@ cleanup() { HTTP_PROXY="$1" HTTPS_PROXY="$2" NO_PROXY="$3" -MODE="$4" +BASE_IMAGE="$4" +MODE="$5" if [[ -n "$HTTP_PROXY" ]]; then /snap/bin/lxc config set core.proxy_http "$HTTP_PROXY" @@ -63,9 +64,9 @@ fi cleanup '/snap/bin/lxc info builder &> /dev/null' '/snap/bin/lxc delete builder --force' 'Cleanup LXD VM of previous run' 10 if [[ "$MODE" == "test" ]]; then - retry '/snap/bin/lxc launch ubuntu-daily:jammy builder --device root,size=5GiB' 'Starting LXD container' + retry "/snap/bin/lxc launch ubuntu-daily:$BASE_IMAGE builder --device root,size=5GiB" 'Starting LXD container' else - retry '/snap/bin/lxc launch ubuntu-daily:jammy builder --vm --device root,size=8GiB' 'Starting LXD VM' + retry "/snap/bin/lxc launch ubuntu-daily:$BASE_IMAGE builder --vm --device root,size=8GiB" 'Starting LXD VM' fi retry '/snap/bin/lxc exec builder -- /usr/bin/who' 'Wait for lxd agent to be ready' 30 if [[ -n "$HTTP_PROXY" ]]; then @@ -86,7 +87,6 @@ retry '/snap/bin/lxc exec builder -- /usr/bin/nslookup github.com' 'Wait for net /snap/bin/lxc exec builder -- /usr/bin/apt-get update /snap/bin/lxc exec builder --env DEBIAN_FRONTEND=noninteractive -- /usr/bin/apt-get upgrade -yq -/snap/bin/lxc exec builder --env DEBIAN_FRONTEND=noninteractive -- /usr/bin/apt-get install linux-generic-hwe-22.04 -yq # This will remove older version of kernel as HWE is installed now. /snap/bin/lxc exec builder -- /usr/bin/apt-get autoremove --purge @@ -146,9 +146,9 @@ fi /snap/bin/lxc publish builder --alias builder --reuse -f # Swap in the built image -/snap/bin/lxc image alias rename jammy old-jammy || true -/snap/bin/lxc image alias rename builder jammy -/snap/bin/lxc image delete old-jammy || true +/snap/bin/lxc image alias rename "$BASE_IMAGE" "old-$BASE_IMAGE" || true +/snap/bin/lxc image alias rename builder "$BASE_IMAGE" +/snap/bin/lxc image delete "old-$BASE_IMAGE" || true # Clean up LXD instance cleanup '/snap/bin/lxc info builder &> /dev/null' '/snap/bin/lxc delete builder --force' 'Cleanup LXD instance' 10 diff --git a/scripts/build-openstack-image.sh b/scripts/build-openstack-image.sh index d3e41b615..dcd244590 100755 --- a/scripts/build-openstack-image.sh +++ b/scripts/build-openstack-image.sh @@ -14,6 +14,7 @@ HTTPS_PROXY="$3" NO_PROXY="$4" DOCKER_PROXY_SERVICE_CONF="$5" DOCKER_PROXY_CONF="$6" +BASE_IMAGE="$7" # retry function retry() { @@ -108,15 +109,15 @@ sudo modprobe nbd # cleanup any existing mounts cleanup -retry "sudo wget https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-$BIN_ARCH.img \ - -O jammy-server-cloudimg-$BIN_ARCH.img" "Downloading cloud image" 3 +retry "sudo wget https://cloud-images.ubuntu.com/$BASE_IMAGE/current/$BASE_IMAGE-server-cloudimg-$BIN_ARCH.img \ + -O $BASE_IMAGE-server-cloudimg-$BIN_ARCH.img" "Downloading cloud image" 3 # resize image - installing dependencies requires more disk space -sudo qemu-img resize jammy-server-cloudimg-$BIN_ARCH.img +1.5G +sudo qemu-img resize "$BASE_IMAGE-server-cloudimg-$BIN_ARCH.img" +1.5G # mount nbd echo "Connecting network block device to image" -sudo qemu-nbd --connect=/dev/nbd0 jammy-server-cloudimg-$BIN_ARCH.img +sudo qemu-nbd --connect=/dev/nbd0 "$BASE_IMAGE-server-cloudimg-$BIN_ARCH.img" sudo mkdir -p /mnt/ubuntu-image retry "sudo mount -o rw /dev/nbd0p1 /mnt/ubuntu-image" "Mounting nbd0p1 device" 3 @@ -208,4 +209,4 @@ sudo sync cleanup # Reduce image size by removing sparse space & compressing -sudo virt-sparsify --compress jammy-server-cloudimg-$BIN_ARCH.img jammy-server-cloudimg-$BIN_ARCH-compressed.img +sudo virt-sparsify --compress "$BASE_IMAGE-server-cloudimg-$BIN_ARCH.img" "$BASE_IMAGE-server-cloudimg-$BIN_ARCH-compressed.img" diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index 42a6ef056..1aa256c29 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -9,6 +9,7 @@ State of the Charm. --------------- - **ARCHITECTURES_ARM64** - **ARCHITECTURES_X86** +- **BASE_IMAGE_CONFIG_NAME** - **DENYLIST_CONFIG_NAME** - **DOCKERHUB_MIRROR_CONFIG_NAME** - **GROUP_CONFIG_NAME** @@ -26,10 +27,11 @@ State of the Charm. - **LABELS_CONFIG_NAME** - **COS_AGENT_INTEGRATION_NAME** - **DEBUG_SSH_INTEGRATION_NAME** +- **LTS_IMAGE_VERSION_TAG_MAP** --- - + ## function `parse_github_path` @@ -74,6 +76,22 @@ Supported system architectures. +--- + +## class `BaseImage` +The ubuntu OS base image to build and deploy runners on. + + + +**Attributes:** + + - `JAMMY`: The jammy ubuntu LTS image. + - `NOBLE`: The noble ubuntu LTS image. + + + + + --- ## class `CharmConfig` @@ -98,7 +116,7 @@ Some charm configurations are grouped into other configuration models. --- - + ### classmethod `check_reconcile_interval` @@ -127,7 +145,7 @@ Validate the general charm configuration. --- - + ### classmethod `from_charm` @@ -166,7 +184,7 @@ Raised when charm config is invalid. - `msg`: Explanation of the error. - + ### function `__init__` @@ -207,7 +225,7 @@ The charm state. --- - + ### classmethod `from_charm` @@ -252,7 +270,7 @@ Charm configuration related to GitHub. --- - + ### classmethod `from_charm` @@ -297,7 +315,7 @@ Represent GitHub organization. --- - + ### function `path` @@ -330,7 +348,7 @@ Represent GitHub repository. --- - + ### function `path` @@ -346,6 +364,31 @@ Return a string representing the path. Path to the GitHub entity. +--- + +## class `ImmutableConfigChangedError` +Represents an error when changing immutable charm state. + + + +### function `__init__` + +```python +__init__(msg: str) +``` + +Initialize a new instance of the ImmutableConfigChangedError exception. + + + +**Args:** + + - `msg`: Explanation of the error. + + + + + --- ## class `ProxyConfig` @@ -372,7 +415,7 @@ Return the aproxy address. --- - + ### classmethod `check_use_aproxy` @@ -402,7 +445,7 @@ Validate the proxy configuration. --- - + ### classmethod `from_charm` @@ -433,6 +476,7 @@ Runner configurations for the charm. **Attributes:** + - `base_image`: The ubuntu base image to run the runner virtual machines on. - `virtual_machines`: Number of virtual machine-based runner to spawn. - `virtual_machine_resources`: Hardware resource used by one virtual machine for a runner. - `runner_storage`: Storage to be used as disk for the runner. @@ -442,7 +486,7 @@ Runner configurations for the charm. --- - + ### classmethod `check_virtual_machine_resources` @@ -473,7 +517,7 @@ Validate the virtual_machine_resources field values. --- - + ### classmethod `check_virtual_machines` @@ -502,7 +546,7 @@ Validate the virtual machines configuration value. --- - + ### classmethod `from_charm` @@ -565,7 +609,7 @@ SSH connection information for debug workflow. --- - + ### classmethod `from_charm` @@ -598,7 +642,7 @@ Raised when given machine charm architecture is unsupported. - `arch`: The current machine architecture. - + ### function `__init__` diff --git a/src-docs/openstack_manager.md b/src-docs/openstack_manager.md index 2e36391c0..535c76518 100644 --- a/src-docs/openstack_manager.md +++ b/src-docs/openstack_manager.md @@ -8,22 +8,21 @@ Module for handling interactions with OpenStack. **Global Variables** --------------- - **IMAGE_PATH_TMPL** -- **IMAGE_NAME** +- **IMAGE_NAME_TMPL** - **BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME** --- - + ## function `build_image` ```python build_image( - arch: Arch, cloud_config: dict[str, dict], github_client: GithubClient, path: GithubOrg | GithubRepo, - proxies: Optional[ProxyConfig] = None + config: BuildImageConfig ) → str ``` @@ -33,11 +32,10 @@ Build and upload an image to OpenStack. **Args:** - - `arch`: The system architecture to build the image for. - `cloud_config`: The cloud configuration to connect OpenStack with. - `github_client`: The Github client to interact with Github API. - `path`: Github organisation or repository path. - - `proxies`: HTTP proxy settings. + - `config`: The image build configuration values. @@ -53,16 +51,17 @@ Build and upload an image to OpenStack. --- - + ## function `create_instance_config` ```python create_instance_config( unit_name: str, - openstack_image: Image, + openstack_image_id: str, path: GithubOrg | GithubRepo, - github_client: GithubClient + github_client: GithubClient, + base_image: BaseImage ) → InstanceConfig ``` @@ -73,9 +72,10 @@ Create an instance config from charm data. **Args:** - `unit_name`: The charm unit name. - - `openstack_image`: The openstack image object to create the instance with. + - `openstack_image_id`: The openstack image id to create the instance with. - `path`: Github organisation or repository path. - `github_client`: The Github client to interact with Github API. + - `base_image`: The ubuntu base image to use. @@ -85,7 +85,7 @@ Create an instance config from charm data. --- - + ## function `create_instance` @@ -118,7 +118,7 @@ Create an OpenStack instance. --- - + ## class `ProxyStringValues` Wrapper class to proxy values to string. @@ -137,7 +137,7 @@ Wrapper class to proxy values to string. --- - + ## class `InstanceConfig` The configuration values for creating a single runner instance. @@ -150,7 +150,8 @@ The configuration values for creating a single runner instance. - `labels`: The runner instance labels. - `registration_token`: Token for registering the runner on GitHub. - `github_path`: The GitHub repo/org path - - `openstack_image`: The Openstack image to use to boot the instance with. + - `openstack_image_id`: The Openstack image id to use to boot the instance with. + - `base_image`: The ubuntu image to use as image build base. @@ -162,7 +163,43 @@ __init__( labels: Iterable[str], registration_token: str, github_path: GithubOrg | GithubRepo, - openstack_image: Image + openstack_image_id: str, + base_image: BaseImage +) → None +``` + + + + + + + + + +--- + + + +## class `BuildImageConfig` +The configuration values for building openstack image. + + + +**Attributes:** + + - `arch`: The image architecture to build for. + - `base_image`: The ubuntu image to use as image build base. + - `proxies`: HTTP proxy settings. + + + +### method `__init__` + +```python +__init__( + arch: Arch, + base_image: BaseImage, + proxies: Optional[ProxyConfig] = None ) → None ``` @@ -176,7 +213,7 @@ __init__( --- - + ## class `ImageDeleteError` Represents an error while deleting existing openstack image. diff --git a/src-docs/runner_manager.py.md b/src-docs/runner_manager.py.md index d797d3ea4..cda8c26ba 100644 --- a/src-docs/runner_manager.py.md +++ b/src-docs/runner_manager.py.md @@ -50,7 +50,7 @@ Construct RunnerManager object for creating and managing runners. --- - + ### function `build_runner_image` @@ -164,7 +164,7 @@ The runner binary URL changes when a new version is available. --- - + ### function `has_runner_image` @@ -205,7 +205,7 @@ Bring runners in line with target. --- - + ### function `schedule_build_runner_image` diff --git a/src/charm.py b/src/charm.py index ee636b3b1..7ea5ab2bb 100755 --- a/src/charm.py +++ b/src/charm.py @@ -378,7 +378,7 @@ def _get_runner_manager( RunnerManagerConfig( charm_state=state, dockerhub_mirror=state.charm_config.dockerhub_mirror, - image="jammy", + image=state.runner_config.base_image.value, lxd_storage_path=lxd_storage_path, path=path, service_token=self.service_token, @@ -422,18 +422,22 @@ def _common_install_code(self, state: CharmState) -> bool: if self.config.get(TEST_MODE_CONFIG_NAME) == "insecure": self.unit.status = MaintenanceStatus("Building Openstack image") github = GithubClient(token=state.charm_config.token) - image = openstack_manager.build_image( - arch=state.arch, + image_id = openstack_manager.build_image( cloud_config=state.charm_config.openstack_clouds_yaml, github_client=github, path=state.charm_config.path, - proxies=state.proxy_config, + config=openstack_manager.BuildImageConfig( + arch=state.arch, + base_image=state.runner_config.base_image, + proxies=state.proxy_config, + ), ) instance_config = openstack_manager.create_instance_config( unit_name=self.unit.name, - openstack_image=image, + openstack_image_id=image_id, path=state.charm_config.path, github_client=github, + base_image=state.runner_config.base_image, ) self.unit.status = MaintenanceStatus("Creating Openstack test instance") instance = openstack_manager.create_instance( diff --git a/src/charm_state.py b/src/charm_state.py index f4091cccf..83f349bf8 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -30,6 +30,7 @@ CHARM_STATE_PATH = Path("charm_state.json") +BASE_IMAGE_CONFIG_NAME = "base-image" DENYLIST_CONFIG_NAME = "denylist" DOCKERHUB_MIRROR_CONFIG_NAME = "dockerhub-mirror" GROUP_CONFIG_NAME = "group" @@ -113,7 +114,7 @@ def parse_github_path(path_str: str, runner_group: str) -> GithubPath: organization with runner group information. """ if "/" in path_str: - paths = path_str.split("/") + paths = tuple(segment for segment in path_str.split("/") if segment) if len(paths) != 2: raise CharmConfigInvalidError(f"Invalid path configuration {path_str}") owner, repo = paths @@ -443,49 +444,59 @@ def check_reconcile_interval(cls, reconcile_interval: int) -> int: return reconcile_interval +LTS_IMAGE_VERSION_TAG_MAP = {"22.04": "jammy", "24.04": "noble"} + + +class BaseImage(str, Enum): + """The ubuntu OS base image to build and deploy runners on. + + Attributes: + JAMMY: The jammy ubuntu LTS image. + NOBLE: The noble ubuntu LTS image. + """ + + JAMMY = "jammy" + NOBLE = "noble" + + def __str__(self) -> str: + """Interpolate to string value. + + Returns: + The enum string value. + """ + return self.value + + @classmethod + def from_charm(cls, charm: CharmBase) -> "BaseImage": + """Retrieve the base image tag from charm. + + Args: + charm: The charm instance. + + Returns: + The base image configuration of the charm. + """ + image_name = charm.config.get(BASE_IMAGE_CONFIG_NAME, "jammy").lower().strip() + if image_name in LTS_IMAGE_VERSION_TAG_MAP: + return cls(LTS_IMAGE_VERSION_TAG_MAP[image_name]) + return cls(image_name) + + class RunnerCharmConfig(BaseModel): """Runner configurations for the charm. Attributes: + base_image: The ubuntu base image to run the runner virtual machines on. virtual_machines: Number of virtual machine-based runner to spawn. virtual_machine_resources: Hardware resource used by one virtual machine for a runner. runner_storage: Storage to be used as disk for the runner. """ + base_image: BaseImage virtual_machines: int virtual_machine_resources: VirtualMachineResources runner_storage: RunnerStorage - @classmethod - def _check_storage_change(cls, runner_storage: str) -> None: - """Check whether the storage configuration has changed. - - Args: - runner_storage: The current runner_storage config value. - - Raises: - CharmConfigInvalidError: If the runner-storage config value has changed after initial - deployment. - """ - prev_state = None - if CHARM_STATE_PATH.exists(): - json_data = CHARM_STATE_PATH.read_text(encoding="utf-8") - prev_state = json.loads(json_data) - logger.info("Previous charm state: %s", prev_state) - - if ( - prev_state is not None - and prev_state["runner_config"]["runner_storage"] != runner_storage - ): - logger.warning( - "Storage option changed from %s to %s, blocking the charm", - prev_state["runner_config"]["runner_storage"], - runner_storage, - ) - raise CharmConfigInvalidError( - "runner-storage config cannot be changed after deployment, redeploy if needed" - ) - @classmethod def from_charm(cls, charm: CharmBase) -> "RunnerCharmConfig": """Initialize the config from charm. @@ -499,9 +510,13 @@ def from_charm(cls, charm: CharmBase) -> "RunnerCharmConfig": Returns: Current config of the charm. """ + try: + base_image = BaseImage.from_charm(charm) + except ValueError as err: + raise CharmConfigInvalidError("Invalid base image") from err + try: runner_storage = RunnerStorage(charm.config[RUNNER_STORAGE_CONFIG_NAME]) - cls._check_storage_change(runner_storage=runner_storage) except ValueError as err: raise CharmConfigInvalidError( f"Invalid {RUNNER_STORAGE_CONFIG_NAME} configuration" @@ -526,6 +541,7 @@ def from_charm(cls, charm: CharmBase) -> "RunnerCharmConfig": ) return cls( + base_image=base_image, virtual_machines=virtual_machines, virtual_machine_resources=virtual_machine_resources, runner_storage=runner_storage, @@ -765,6 +781,18 @@ def from_charm(cls, charm: CharmBase) -> list["SSHDebugConnection"]: return ssh_debug_connections +class ImmutableConfigChangedError(Exception): + """Represents an error when changing immutable charm state.""" + + def __init__(self, msg: str): + """Initialize a new instance of the ImmutableConfigChangedError exception. + + Args: + msg: Explanation of the error. + """ + self.msg = msg + + @dataclasses.dataclass(frozen=True) class CharmState: """The charm state. @@ -785,6 +813,45 @@ class CharmState: runner_config: RunnerCharmConfig ssh_debug_connections: list[SSHDebugConnection] + @classmethod + def _check_immutable_config_change( + cls, runner_storage: RunnerStorage, base_image: BaseImage + ) -> None: + """Ensure immutable config has not changed. + + Args: + runner_storage: The current runner_storage configuration. + base_image: The current base_image configuration. + + Raises: + ImmutableConfigChangedError: If an immutable configuration has changed. + """ + if not CHARM_STATE_PATH.exists(): + return + + json_data = CHARM_STATE_PATH.read_text(encoding="utf-8") + prev_state = json.loads(json_data) + logger.info("Previous charm state: %s", prev_state) + + if prev_state["runner_config"]["runner_storage"] != runner_storage: + logger.error( + "Storage option changed from %s to %s, blocking the charm", + prev_state["runner_config"]["runner_storage"], + runner_storage, + ) + raise ImmutableConfigChangedError( + msg="runner-storage config cannot be changed after deployment, redeploy if needed" + ) + if prev_state["runner_config"]["base_image"] != base_image.value: + logger.error( + "Base image option changed from %s to %s, blocking the charm", + prev_state["runner_config"]["base_image"], + runner_storage, + ) + raise ImmutableConfigChangedError( + msg="base-image config cannot be changed after deployment, redeploy if needed" + ) + @classmethod def from_charm(cls, charm: CharmBase) -> "CharmState": """Initialize the state from charm. @@ -806,8 +873,14 @@ def from_charm(cls, charm: CharmBase) -> "CharmState": try: charm_config = CharmConfig.from_charm(charm) runner_config = RunnerCharmConfig.from_charm(charm) + cls._check_immutable_config_change( + runner_storage=runner_config.runner_storage, + base_image=runner_config.base_image, + ) except (ValidationError, ValueError) as exc: raise CharmConfigInvalidError(f"Invalid configuration: {str(exc)}") from exc + except ImmutableConfigChangedError as exc: + raise CharmConfigInvalidError(exc.msg) from exc try: arch = _get_supported_arch() diff --git a/src/openstack_cloud/openstack_manager.py b/src/openstack_cloud/openstack_manager.py index ff8b1cf38..a9288369d 100644 --- a/src/openstack_cloud/openstack_manager.py +++ b/src/openstack_cloud/openstack_manager.py @@ -17,7 +17,13 @@ import openstack.image.v2.image from openstack.exceptions import OpenStackCloudException -from charm_state import Arch, ProxyConfig, SSHDebugConnection, UnsupportedArchitectureError +from charm_state import ( + Arch, + BaseImage, + ProxyConfig, + SSHDebugConnection, + UnsupportedArchitectureError, +) from errors import ( OpenstackImageBuildError, OpenstackInstanceLaunchError, @@ -32,8 +38,9 @@ logger = logging.getLogger(__name__) -IMAGE_PATH_TMPL = "jammy-server-cloudimg-{architecture}-compressed.img" -IMAGE_NAME = "jammy" +IMAGE_PATH_TMPL = "{base_image}-server-cloudimg-{architecture}-compressed.img" +# Update the version when the image are modified. +IMAGE_NAME_TMPL = "github-runner-{base_image}-v1" BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME = "scripts/build-openstack-image.sh" @@ -145,17 +152,21 @@ def _generate_docker_client_proxy_config_json( if value } } - } + }, + indent=4, ) def _build_image_command( - runner_info: RunnerApplication, proxies: Optional[ProxyConfig] = None + runner_info: RunnerApplication, + base_image: BaseImage, + proxies: Optional[ProxyConfig] = None, ) -> list[str]: """Get command for building runner image. Args: runner_info: The runner application to fetch runner tar download url. + base_image: The ubuntu base image to use. proxies: HTTP proxy settings. Returns: @@ -180,6 +191,7 @@ def _build_image_command( proxy_values.no_proxy, docker_proxy_service_conf_content, docker_client_proxy_content, + str(base_image), ] return cmd @@ -194,14 +206,16 @@ class InstanceConfig: labels: The runner instance labels. registration_token: Token for registering the runner on GitHub. github_path: The GitHub repo/org path - openstack_image: The Openstack image to use to boot the instance with. + openstack_image_id: The Openstack image id to use to boot the instance with. + base_image: The ubuntu image to use as image build base. """ name: str labels: Iterable[str] registration_token: str github_path: GithubPath - openstack_image: openstack.image.v2.image.Image + openstack_image_id: str + base_image: BaseImage SupportedCloudImageArch = Literal["amd64", "arm64"] @@ -217,7 +231,7 @@ def _get_supported_runner_arch(arch: str) -> SupportedCloudImageArch: and https://cloud-images.ubuntu.com/jammy/current/ Args: - arch: str + arch: The compute architecture to check support for. Raises: UnsupportedArchitectureError: If an unsupported architecture was passed. @@ -226,24 +240,42 @@ def _get_supported_runner_arch(arch: str) -> SupportedCloudImageArch: The supported architecture. """ match arch: - case "x64": + case Arch.X64: return "amd64" - case "arm64": + case Arch.ARM64: return "arm64" case _: raise UnsupportedArchitectureError(arch) +@dataclass +class BuildImageConfig: + """The configuration values for building openstack image. + + Attributes: + arch: The image architecture to build for. + base_image: The ubuntu image to use as image build base. + proxies: HTTP proxy settings. + """ + + arch: Arch + base_image: BaseImage + proxies: Optional[ProxyConfig] = None + + class ImageDeleteError(Exception): """Represents an error while deleting existing openstack image.""" -def _put_image(cloud_config: dict[str, dict], image_arch: SupportedCloudImageArch) -> str: +def _put_image( + cloud_config: dict[str, dict], image_arch: SupportedCloudImageArch, base_image: BaseImage +) -> str: """Create or replace the image with existing name. Args: cloud_config: The cloud configuration to connect OpenStack with. image_arch: Ubuntu cloud image architecture. + base_image: The ubuntu base image to use. Raises: ImageDeleteError: If there was an error deleting the image. @@ -255,14 +287,18 @@ def _put_image(cloud_config: dict[str, dict], image_arch: SupportedCloudImageArc try: with _create_connection(cloud_config) as conn: existing_image: openstack.image.v2.image.Image - for existing_image in conn.search_images(name_or_id=IMAGE_NAME): + for existing_image in conn.search_images( + name_or_id=IMAGE_NAME_TMPL.format(base_image=base_image) + ): # images with same name (different ID) can be created and will error during server # instantiation. if not conn.delete_image(name_or_id=existing_image.id, wait=True): raise ImageDeleteError("Failed to delete duplicate image on Openstack.") image: openstack.image.v2.image.Image = conn.create_image( - name=IMAGE_NAME, - filename=IMAGE_PATH_TMPL.format(architecture=image_arch), + name=IMAGE_NAME_TMPL.format(base_image=base_image.value), + filename=IMAGE_PATH_TMPL.format( + architecture=image_arch, base_image=base_image.value + ), wait=True, ) return image.id @@ -272,20 +308,18 @@ def _put_image(cloud_config: dict[str, dict], image_arch: SupportedCloudImageArc def build_image( - arch: Arch, cloud_config: dict[str, dict], github_client: GithubClient, path: GithubPath, - proxies: Optional[ProxyConfig] = None, + config: BuildImageConfig, ) -> str: """Build and upload an image to OpenStack. Args: - arch: The system architecture to build the image for. cloud_config: The cloud configuration to connect OpenStack with. github_client: The Github client to interact with Github API. path: Github organisation or repository path. - proxies: HTTP proxy settings. + config: The image build configuration values. Raises: OpenstackImageBuildError: If there were errors building/creating the image. @@ -294,40 +328,47 @@ def build_image( The created OpenStack image id. """ try: - runner_application = github_client.get_runner_application(path=path, arch=arch) + runner_application = github_client.get_runner_application(path=path, arch=config.arch) except RunnerBinaryError as exc: raise OpenstackImageBuildError("Failed to fetch runner application.") from exc try: - execute_command(_build_image_command(runner_application, proxies), check_exit=True) + execute_command( + _build_image_command(runner_application, config.base_image, config.proxies), + check_exit=True, + ) except SubprocessError as exc: raise OpenstackImageBuildError("Failed to build image.") from exc + runner_arch = runner_application["architecture"] try: - runner_arch = runner_application["architecture"] - image_arch = _get_supported_runner_arch(arch=runner_arch) + image_arch = _get_supported_runner_arch(arch=config.arch) except UnsupportedArchitectureError as exc: raise OpenstackImageBuildError(f"Unsupported architecture {runner_arch}") from exc try: - return _put_image(cloud_config=cloud_config, image_arch=image_arch) + return _put_image( + cloud_config=cloud_config, image_arch=image_arch, base_image=config.base_image + ) except (ImageDeleteError, OpenStackCloudException) as exc: raise OpenstackImageBuildError(f"Failed to upload image: {str(exc)}") from exc def create_instance_config( unit_name: str, - openstack_image: openstack.image.v2.image.Image, + openstack_image_id: str, path: GithubPath, github_client: GithubClient, + base_image: BaseImage, ) -> InstanceConfig: """Create an instance config from charm data. Args: unit_name: The charm unit name. - openstack_image: The openstack image object to create the instance with. + openstack_image_id: The openstack image id to create the instance with. path: Github organisation or repository path. github_client: The Github client to interact with Github API. + base_image: The ubuntu base image to use. Returns: Instance configuration created. @@ -337,10 +378,11 @@ def create_instance_config( registration_token = github_client.get_runner_registration_token(path=path) return InstanceConfig( name=f"{app_name}-{unit_num}-{suffix}", - labels=(app_name, "jammy"), + labels=(app_name, base_image.value), registration_token=registration_token, github_path=path, - openstack_image=openstack_image, + openstack_image_id=openstack_image_id, + base_image=base_image, ) @@ -423,14 +465,18 @@ def create_instance( templates_env=environment, instance_config=instance_config, runner_env=env_contents ) - try: - with _create_connection(cloud_config) as conn: + with _create_connection(cloud_config) as conn: + try: conn.create_server( name=instance_config.name, - image=instance_config.openstack_image, + image=instance_config.openstack_image_id, flavor="m1.small", + network="demo-network", userdata=cloud_userdata, wait=True, + timeout=1200, ) - except OpenStackCloudException as exc: - raise OpenstackInstanceLaunchError("Failed to launch instance.") from exc + except OpenStackCloudException as exc: + if not conn.delete_server(instance_config.name): + logger.error("Failed to delete server %s", instance_config.name) + raise OpenstackInstanceLaunchError("Failed to launch instance.") from exc diff --git a/src/runner_manager.py b/src/runner_manager.py index cce2b1354..6bbb4f846 100644 --- a/src/runner_manager.py +++ b/src/runner_manager.py @@ -780,6 +780,7 @@ def _build_image_command(self) -> list[str]: http_proxy, https_proxy, no_proxy, + self.config.image, ] if LXD_PROFILE_YAML.exists(): cmd += ["test"] @@ -793,6 +794,7 @@ def has_runner_image(self) -> bool: """ return self._clients.lxd.images.exists(self.config.image) + @retry(tries=3, delay=30, local_logger=logger) def build_runner_image(self) -> None: """Build the LXD image for hosting runner. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 18a990fa7..1685323e9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -217,8 +217,8 @@ async def app_no_runner( return application -@pytest_asyncio.fixture(scope="module") -async def app_openstack_runner( +@pytest_asyncio.fixture(scope="module", name="app_openstack_runner") +async def app_openstack_runner_fixture( model: Model, charm_file: str, app_name: str, diff --git a/tests/integration/test_charm_base_image.py b/tests/integration/test_charm_base_image.py new file mode 100644 index 000000000..b64796bc6 --- /dev/null +++ b/tests/integration/test_charm_base_image.py @@ -0,0 +1,57 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for github-runner charm containing one runner.""" + +from github.Branch import Branch +from github.Repository import Repository +from juju.application import Application +from juju.model import Model + +from charm_state import BASE_IMAGE_CONFIG_NAME +from tests.integration.helpers import ( + DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, + dispatch_workflow, + ensure_charm_has_runner, + get_runner_name, + run_in_lxd_instance, + wait_for, +) + + +async def test_runner_base_image( + model: Model, + app_no_wait: Application, + github_repository: Repository, + test_github_branch: Branch, +) -> None: + """ + arrange: A runner with noble as base image. + act: Dispatch a workflow. + assert: A runner is created with noble OS base and the workflow job is successfully run. + """ + await app_no_wait.set_config( + { + BASE_IMAGE_CONFIG_NAME: "noble", + } + ) + await model.wait_for_idle(apps=[app_no_wait.name], timeout=35 * 60) + await ensure_charm_has_runner(app_no_wait, model) + + # Runner with noble base image is created + unit = app_no_wait.units[0] + runner_name = await get_runner_name(unit) + code, stdout, stderr = await run_in_lxd_instance(unit, runner_name, "lsb_release -a") + assert code == 0, f"Unable to get release name, {stdout} {stderr}" + assert "noble" in str(stdout) + + # Workflow completes successfully + workflow = await dispatch_workflow( + app=app_no_wait, + branch=test_github_branch, + github_repository=github_repository, + conclusion="success", + workflow_id_or_name=DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, + dispatch_input={"runner-tag": app_no_wait.name}, + ) + await wait_for(lambda: workflow.get_runs()[0].status == "completed") diff --git a/tests/integration/test_openstack_base_image.py b/tests/integration/test_openstack_base_image.py new file mode 100644 index 000000000..1958ab704 --- /dev/null +++ b/tests/integration/test_openstack_base_image.py @@ -0,0 +1,55 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for OpenStack integration.""" + +import openstack.connection +from github.Branch import Branch +from github.Repository import Repository +from juju.application import Application +from juju.model import Model +from openstack.compute.v2.server import Server + +from charm_state import BASE_IMAGE_CONFIG_NAME +from tests.integration.helpers import ( + DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, + dispatch_workflow, + wait_for, +) + + +async def test_noble_base_image( + model: Model, + app_openstack_runner: Application, + openstack_connection: openstack.connection.Connection, + github_repository: Repository, + test_github_branch: Branch, +) -> None: + """ + arrange: A runner with noble as base image. + act: Dispatch a workflow. + assert: A server with noble image base is created and the workflow runs successfully. + """ + await app_openstack_runner.set_config( + { + BASE_IMAGE_CONFIG_NAME: "noble", + } + ) + await model.wait_for_idle(apps=[app_openstack_runner.name], status="blocked", timeout=70 * 60) + + # Server with noble base image is created + servers = openstack_connection.list_servers(detailed=True) + assert len(servers) == 1, f"Unexpected number of servers: {len(servers)}" + server: Server = servers[0] + assert "noble" in openstack_connection.get_image(server.image["id"]).name + + # Workflow completes successfully + workflow = await dispatch_workflow( + app=app_openstack_runner, + branch=test_github_branch, + github_repository=github_repository, + conclusion="success", + workflow_id_or_name=DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, + dispatch_input={"runner-tag": app_openstack_runner.name}, + ) + await wait_for(lambda: workflow.get_runs()[0].status == "completed") diff --git a/tests/integration/test_openstack.py b/tests/integration/test_openstack_one_runner.py similarity index 100% rename from tests/integration/test_openstack.py rename to tests/integration/test_openstack_one_runner.py diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index c94280202..d3d9e6d04 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -12,8 +12,11 @@ import charm_state from charm_state import ( + BASE_IMAGE_CONFIG_NAME, COS_AGENT_INTEGRATION_NAME, DEBUG_SSH_INTEGRATION_NAME, + PATH_CONFIG_NAME, + TOKEN_CONFIG_NAME, USE_APROXY_CONFIG_NAME, Arch, CharmConfigInvalidError, @@ -47,6 +50,47 @@ def clouds_yaml() -> dict: } +@pytest.mark.parametrize( + "invalid_path", + [ + pytest.param("canonical/", id="org only"), + pytest.param("/github-runner-operator", id="repository only"), + ], +) +def test_parse_github_path_invalid_path(invalid_path: str): + """ + arrange: Given an invalid Github path. + act: when parse_github_path is called. + assert: CharmConfigInvalidError is raised. + """ + with pytest.raises(CharmConfigInvalidError) as exc: + charm_state.parse_github_path(invalid_path, MagicMock()) + + assert "Invalid path configuration" in str(exc) + + +@pytest.mark.parametrize( + "missing_config", + [ + pytest.param(PATH_CONFIG_NAME, id="missing path"), + pytest.param(TOKEN_CONFIG_NAME, id="missing token"), + ], +) +def test_github_config_from_charm_missing_token(missing_config: str): + """ + arrange: Given charm with missing token config. + act: when GithubConfig.from_charm is called. + assert: CharmConfigInvalidError is raised. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[missing_config] = "" + + with pytest.raises(CharmConfigInvalidError) as exc: + charm_state.GithubConfig.from_charm(mock_charm) + + assert f"Missing {missing_config} configuration" in str(exc) + + def test_metrics_logging_available_true(): """ arrange: Setup mocked charm to return an integration. @@ -91,6 +135,48 @@ def test_aproxy_proxy_missing(): assert "Invalid proxy configuration" in str(exc.value) +@pytest.mark.parametrize( + "image", + [ + pytest.param("eagle", id="non existent image"), + pytest.param("bionic", id="unsupported image"), + ], +) +def test_invalid_base_image(image: str): + """ + arrange: Given an invalid base configuration. + act: Retrieve state from charm. + assert: CharmConfigInvalidError is raised. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[BASE_IMAGE_CONFIG_NAME] = image + + with pytest.raises(CharmConfigInvalidError) as exc: + charm_state.RunnerCharmConfig.from_charm(mock_charm) + assert "Invalid base image" in str(exc.value) + + +@pytest.mark.parametrize( + "image, expected_base_image", + [ + pytest.param("jammy", charm_state.BaseImage.JAMMY, id="jammy"), + pytest.param("22.04", charm_state.BaseImage.JAMMY, id="jammy tag"), + pytest.param("noble", charm_state.BaseImage.NOBLE, id="noble"), + pytest.param("24.04", charm_state.BaseImage.NOBLE, id="noble tag"), + ], +) +def test_base_image(image: str, expected_base_image: charm_state.BaseImage): + """ + arrange: Given supported base image configuration. + act: Retrieve state from charm. + assert: CharmConfigInvalidError is raised. + """ + mock_charm = MockGithubRunnerCharmFactory() + mock_charm.config[BASE_IMAGE_CONFIG_NAME] = image + + assert charm_state.BaseImage.from_charm(mock_charm) == expected_base_image + + def test_proxy_invalid_format(): """ arrange: Setup mocked charm and invalid juju proxy settings. diff --git a/tests/unit/test_openstack_manager.py b/tests/unit/test_openstack_manager.py index 2a36e1f3d..2c27d69a3 100644 --- a/tests/unit/test_openstack_manager.py +++ b/tests/unit/test_openstack_manager.py @@ -1,6 +1,7 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. import secrets +import textwrap from typing import Optional from unittest.mock import MagicMock @@ -8,6 +9,7 @@ import openstack.exceptions import pytest +from charm_state import Arch, BaseImage from errors import OpenStackUnauthorizedError from openstack_cloud import openstack_manager @@ -59,6 +61,20 @@ def patched_create_connection_context_fixture(monkeypatch: pytest.MonkeyPatch): return mock_connection.__enter__() +@pytest.fixture(name="build_image_config") +def build_image_config_fixture(): + """Return a test build image config.""" + return openstack_manager.BuildImageConfig( + arch=Arch.X64, + base_image=BaseImage.NOBLE, + proxies=openstack_manager.ProxyConfig( + http="http://test.internal", + https="https://test.internal", + no_proxy="http://no_proxy.internal", + ), + ) + + def test__create_connection_error(clouds_yaml: dict, openstack_connect_mock: MagicMock): """ arrange: given a monkeypatched connection.authorize() function that raises an error. @@ -311,9 +327,12 @@ def test__build_image_command(): no_proxy=(test_no_proxy := "http://no.proxy"), use_aproxy=False, ) + test_base_image = BaseImage.NOBLE command = openstack_manager._build_image_command( - runner_info=test_runner_info, proxies=test_proxy_config + runner_info=test_runner_info, + proxies=test_proxy_config, + base_image=test_base_image, ) assert command == [ "/usr/bin/bash", @@ -332,12 +351,24 @@ def test__build_image_command(): Environment="NO_PROXY={test_no_proxy}" """, - f"""{{"proxies": {{"default": {{"httpProxy": "{test_http_proxy}", \ -"httpsProxy": "{test_https_proxy}", "noProxy": "{test_no_proxy}"}}}}}}""", + textwrap.dedent( + f""" + {{ + "proxies": {{ + "default": {{ + "httpProxy": "{test_http_proxy}", + "httpsProxy": "{test_https_proxy}", + "noProxy": "{test_no_proxy}" + }} + }} + }} + """ + ).strip(), + test_base_image.value, ], "Unexpected build image command." -def test_build_image_runner_binary_error(): +def test_build_image_runner_binary_error(build_image_config: openstack_manager.BuildImageConfig): """ arrange: given a mocked github client get_runner_application function that raises an error. act: when build_image is called. @@ -348,16 +379,18 @@ def test_build_image_runner_binary_error(): with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=mock_github_client, path=MagicMock(), + config=build_image_config, ) assert "Failed to fetch runner application." in str(exc) -def test_build_image_script_error(monkeypatch: pytest.MonkeyPatch): +def test_build_image_script_error( + monkeypatch: pytest.MonkeyPatch, build_image_config: openstack_manager.BuildImageConfig +): """ arrange: given a monkeypatched execute_command function that raises an error. act: when build_image is called. @@ -375,10 +408,10 @@ def test_build_image_script_error(monkeypatch: pytest.MonkeyPatch): with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=MagicMock(), path=MagicMock(), + config=build_image_config, ) assert "Failed to build image." in str(exc) @@ -386,7 +419,9 @@ def test_build_image_script_error(monkeypatch: pytest.MonkeyPatch): @pytest.mark.usefixtures("patch_execute_command") def test_build_image_runner_arch_error( - monkeypatch: pytest.MonkeyPatch, mock_github_client: MagicMock + monkeypatch: pytest.MonkeyPatch, + mock_github_client: MagicMock, + build_image_config: openstack_manager.BuildImageConfig, ): """ arrange: given _get_supported_runner_arch that raises unsupported architecture error. @@ -403,10 +438,10 @@ def test_build_image_runner_arch_error( with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=mock_github_client, path=MagicMock(), + config=build_image_config, ) assert "Unsupported architecture" in str(exc) @@ -414,7 +449,9 @@ def test_build_image_runner_arch_error( @pytest.mark.usefixtures("patch_execute_command") def test_build_image_delete_image_error( - mock_github_client: MagicMock, patched_create_connection_context: MagicMock + mock_github_client: MagicMock, + patched_create_connection_context: MagicMock, + build_image_config: openstack_manager.BuildImageConfig, ): """ arrange: given a mocked openstack connection that returns existing images and delete_image \ @@ -429,10 +466,10 @@ def test_build_image_delete_image_error( with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=mock_github_client, path=MagicMock(), + config=build_image_config, ) assert "Failed to delete duplicate image on Openstack." in str(exc) @@ -440,7 +477,9 @@ def test_build_image_delete_image_error( @pytest.mark.usefixtures("patch_execute_command") def test_build_image_create_image_error( - patched_create_connection_context: MagicMock, mock_github_client: MagicMock + patched_create_connection_context: MagicMock, + mock_github_client: MagicMock, + build_image_config: openstack_manager.BuildImageConfig, ): """ arrange: given a mocked connection that raises OpenStackCloudException on create_image. @@ -453,18 +492,21 @@ def test_build_image_create_image_error( with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=mock_github_client, path=MagicMock(), - proxies=None, + config=build_image_config, ) assert "Failed to upload image" in str(exc) @pytest.mark.usefixtures("patch_execute_command") -def test_build_image(patched_create_connection_context: MagicMock, mock_github_client: MagicMock): +def test_build_image( + patched_create_connection_context: MagicMock, + mock_github_client: MagicMock, + build_image_config: openstack_manager.BuildImageConfig, +): """ arrange: given monkeypatched execute_command and mocked openstack connection. act: when build_image is called. @@ -476,8 +518,8 @@ def test_build_image(patched_create_connection_context: MagicMock, mock_github_c ) openstack_manager.build_image( - arch=openstack_manager.Arch.X64, cloud_config=MagicMock(), github_client=mock_github_client, path=MagicMock(), + config=build_image_config, ) diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 89a12370b..4201305d9 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -533,7 +533,7 @@ def test_schedule_build_runner_image( cmd = f"/usr/bin/bash {BUILD_IMAGE_SCRIPT_FILENAME.absolute()} {http} {https} {no_proxy}" assert cronfile.exists() - assert cronfile.read_text() == f"4 4,10,16,22 * * * ubuntu {cmd}\n" + assert cronfile.read_text() == f"4 4,10,16,22 * * * ubuntu {cmd} jammy\n" def test_has_runner_image(runner_manager: RunnerManager):