From 680569764e3fcf309f2f88497e9a037f530c1d0d Mon Sep 17 00:00:00 2001 From: Yanks Yoon <37652070+yanksyoon@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:55:27 +0900 Subject: [PATCH] feat: image builder relation (#312) * pass in image * registration token & runner url during scale * chore: remove runner download url * feat: labels * chore: merge conflict fixes * test: lint fixes * test: fix unit tests * test: unit tests for openstack image state * test: test image relation * chore: rename image to image_id * docs: update openstack runner docs * fix: lint * test: fix openstack config for image builder * test: config * test: config typo fix * test: specify revision for image builfer * test: model integrate with relation name * test: empty commit (trigger) * Update src/charm.py Co-authored-by: Christopher Bartz * Update docs/how-to/openstack-runner.md Co-authored-by: Christopher Bartz * chore: step-down rule * chore: add tiemout to runner download * test: try waiting for model & add debug info logs * test: add concurrency group * docs: update docs * try testing w/ different workflows ref * Revert "try testing w/ different workflows ref" This reverts commit 974bac3057b91ed9489274d49dd4f90638d67e2e. * debug * test: try lxd version pin * chore: remove tmate debug --------- Co-authored-by: Christopher Bartz --- .github/workflows/integration_test.yaml | 8 +- charmcraft.yaml | 1 - docs/how-to/openstack-runner.md | 16 +- metadata.yaml | 2 + requirements.txt | 4 +- src-docs/charm.py.md | 1 + src-docs/charm_state.py.md | 81 ++++-- src-docs/errors.py.md | 18 -- src-docs/openstack_manager.md | 110 ++------ src-docs/runner_manager_type.py.md | 1 + src/charm.py | 75 +++-- src/charm_state.py | 58 +++- src/errors.py | 8 - src/openstack_cloud/openstack_manager.py | 306 +++------------------ src/runner_manager.py | 2 +- src/runner_manager_type.py | 5 +- tests/integration/conftest.py | 71 ++++- tests/integration/helpers/openstack.py | 24 +- tests/integration/test_charm_no_runner.py | 8 + tests/unit/conftest.py | 2 +- tests/unit/factories.py | 2 - tests/unit/test_charm.py | 205 +++++++++++++- tests/unit/test_charm_state.py | 63 +++++ tests/unit/test_openstack_manager.py | 317 +--------------------- 24 files changed, 610 insertions(+), 778 deletions(-) diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 6cd1a7ab3..b83d8da81 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -3,6 +3,10 @@ name: integration-tests on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: # test option values defined at test/conftest.py are passed on via repository secret # INTEGRATION_TEST_ARGS to operator-workflows automatically. @@ -16,6 +20,8 @@ jobs: provider: lxd test-tox-env: integration-juju2.9 modules: '["test_charm_base_image", "test_charm_fork_repo", "test_charm_no_runner", "test_charm_scheduled_events", "test_charm_lxd_runner", "test_charm_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", "test_charm_upgrade"]' + self-hosted-runner: true + self-hosted-runner-label: xlarge,X64 integration-tests: name: Integration test with juju 3.1 uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main @@ -38,4 +44,4 @@ jobs: modules: '["test_charm_metrics_failure", "test_charm_metrics_success", "test_charm_fork_repo", "test_charm_runner", "test_e2e"]' extra-arguments: "-m openstack --openstack-flavor-name=builder-cpu4-ram8-disk50 --http-proxy=http://squid.internal:3128 --https-proxy=http://squid.internal:3128 --no-proxy=keystone.ps6.canonical.com,glance.ps6.canonical.com,nova.ps6.canonical.com,neutron.ps6.canonical.com" self-hosted-runner: true - self-hosted-runner-label: stg-private-endpoint \ No newline at end of file + self-hosted-runner-label: stg-private-endpoint diff --git a/charmcraft.yaml b/charmcraft.yaml index e850c9221..e3b1a27dc 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -14,7 +14,6 @@ parts: - pkg-config # for cryptography prime: - scripts/build-lxd-image.sh - - scripts/build-openstack-image.sh - scripts/repo_policy_compliance_service.py bases: - build-on: diff --git a/docs/how-to/openstack-runner.md b/docs/how-to/openstack-runner.md index 57750f660..6e3f2ba61 100644 --- a/docs/how-to/openstack-runner.md +++ b/docs/how-to/openstack-runner.md @@ -8,6 +8,20 @@ enabled the charm cannot be changed to use other virtualization methods. There are three configuration that the charm needs to be deployed with to enable OpenStack integration: `openstack-clouds-yaml`, `openstack-flavor`, and `openstack-network`. +## Integration + +The image will take about 10-15 minutes to build and be fully integrated. Deploy the +`github-runner-image-builder` charm and wait for the image to be successfully provided via the +relation data. + +```bash +juju deploy github-runner-image-builder +juju integrate github-runner-image-builder github-runner +juju status github-runner +``` + +The image will take about 10-15 minutes to build and be ready via the relation. + ### OpenStack clouds.yaml The `openstack-clouds-yaml` configuration contains the authorization information needed for the charm to log in to the openstack cloud. @@ -38,7 +52,7 @@ The flavors documentation is [here](https://docs.openstack.org/nova/rocky/user/f ### OpenStack Network -The `openstack-network` configuration sets the network used to create the OpenStack virtual machine when spawning new runners. +The `openstack-network` configuration sets the network used to create the OpenStack virtual machine when spawning new runners. Note that the network should be configured to allow traffic from the charm deployment (juju machine) to the OpenStack virtual machine, and traffic from the OpenStack virtual machine to GitHub. diff --git a/metadata.yaml b/metadata.yaml index 0ab93c1d0..c84ea959e 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -28,6 +28,8 @@ provides: requires: debug-ssh: interface: debug-ssh + image: + interface: github_runner_image_v0 storage: runner: diff --git a/requirements.txt b/requirements.txt index 0ebf547b3..f454dee53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ jinja2 fabric >=3,<4 openstacksdk>=3,<4 ops>=2.8 -pylxd @ git+https://github.com/canonical/pylxd +pylxd @ git+https://github.com/canonical/pylxd@46b58e61a465a970937cd97bbbf93622f98caa8c requests typing-extensions cryptography <=42.0.5 @@ -11,4 +11,4 @@ pydantic ==1.10.17 cosl ==0.0.12 # juju 3.1.2.0 depends on pyyaml<=6.0 and >=5.1.2 PyYAML ==6.0.* -pyOpenSSL==24.1.0 \ No newline at end of file +pyOpenSSL==24.1.0 diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index 28269c2d9..61b19f5e7 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -9,6 +9,7 @@ Charm for creating and managing GitHub self-hosted runner instances. --------------- - **DEBUG_SSH_INTEGRATION_NAME** - **GROUP_CONFIG_NAME** +- **IMAGE_INTEGRATION_NAME** - **LABELS_CONFIG_NAME** - **PATH_CONFIG_NAME** - **RECONCILE_INTERVAL_CONFIG_NAME** diff --git a/src-docs/charm_state.py.md b/src-docs/charm_state.py.md index 31615fa2f..f6bb37338 100644 --- a/src-docs/charm_state.py.md +++ b/src-docs/charm_state.py.md @@ -17,7 +17,6 @@ State of the Charm. - **OPENSTACK_CLOUDS_YAML_CONFIG_NAME** - **OPENSTACK_NETWORK_CONFIG_NAME** - **OPENSTACK_FLAVOR_CONFIG_NAME** -- **OPENSTACK_IMAGE_BUILD_UNIT_CONFIG_NAME** - **PATH_CONFIG_NAME** - **RECONCILE_INTERVAL_CONFIG_NAME** - **REPO_POLICY_COMPLIANCE_TOKEN_CONFIG_NAME** @@ -32,11 +31,12 @@ State of the Charm. - **VM_DISK_CONFIG_NAME** - **COS_AGENT_INTEGRATION_NAME** - **DEBUG_SSH_INTEGRATION_NAME** +- **IMAGE_INTEGRATION_NAME** - **LTS_IMAGE_VERSION_TAG_MAP** --- - + ## function `parse_github_path` @@ -137,7 +137,7 @@ Some charm configurations are grouped into other configuration models. --- - + ### classmethod `check_reconcile_interval` @@ -166,7 +166,7 @@ Validate the general charm configuration. --- - + ### classmethod `from_charm` @@ -205,7 +205,7 @@ Raised when charm config is invalid. - `msg`: Explanation of the error. - + ### function `__init__` @@ -247,7 +247,7 @@ The charm state. --- - + ### classmethod `from_charm` @@ -292,7 +292,7 @@ Charm configuration related to GitHub. --- - + ### classmethod `from_charm` @@ -337,7 +337,7 @@ Represent GitHub organization. --- - + ### function `path` @@ -370,7 +370,7 @@ Represent GitHub repository. --- - + ### function `path` @@ -391,7 +391,7 @@ Return a string representing the path. ## class `ImmutableConfigChangedError` Represents an error when changing immutable charm state. - + ### function `__init__` @@ -446,7 +446,7 @@ Runner configurations for local LXD instances. --- - + ### classmethod `check_virtual_machine_resources` @@ -477,7 +477,7 @@ Validate the virtual_machine_resources field values. --- - + ### classmethod `check_virtual_machines` @@ -506,7 +506,7 @@ Validate the virtual machines configuration value. --- - + ### classmethod `from_charm` @@ -534,6 +534,47 @@ Initialize the config from charm. Local LXD runner config of the charm. +--- + +## class `OpenstackImage` +OpenstackImage from image builder relation data. + + + +**Attributes:** + + - `id`: The OpenStack image ID. + - `tags`: Image tags, e.g. jammy + + + + +--- + + + +### classmethod `from_charm` + +```python +from_charm(charm: CharmBase) → OpenstackImage | None +``` + +Initialize the OpenstackImage info from relation data. + +None represents relation not established. None values for id/tags represent image not yet ready but the relation exists. + + + +**Args:** + + - `charm`: The charm instance. + + + +**Returns:** + OpenstackImage metadata from charm relation data. + + --- ## class `OpenstackRunnerConfig` @@ -546,14 +587,14 @@ Runner configuration for OpenStack Instances. - `virtual_machines`: Number of virtual machine-based runner to spawn. - `openstack_flavor`: flavor on openstack to use for virtual machines. - `openstack_network`: Network on openstack to use for virtual machines. - - `build_image`: Whether to build the image on this juju unit. + - `openstack_image`: Openstack image to use for virtual machines. --- - + ### classmethod `from_charm` @@ -607,7 +648,7 @@ Return the aproxy address. --- - + ### classmethod `check_use_aproxy` @@ -637,7 +678,7 @@ Validate the proxy configuration. --- - + ### classmethod `from_charm` @@ -676,7 +717,7 @@ Configuration for the repo policy compliance service. --- - + ### classmethod `from_charm` @@ -739,7 +780,7 @@ SSH connection information for debug workflow. --- - + ### classmethod `from_charm` @@ -772,7 +813,7 @@ Raised when given machine charm architecture is unsupported. - `arch`: The current machine architecture. - + ### function `__init__` diff --git a/src-docs/errors.py.md b/src-docs/errors.py.md index 0a3ef404f..cfc86e655 100644 --- a/src-docs/errors.py.md +++ b/src-docs/errors.py.md @@ -160,24 +160,6 @@ Represents an unauthorized connection to OpenStack. ---- - -## class `OpenstackImageBuildError` -Exception representing an error during image build process. - - - - - ---- - -## class `OpenstackInstanceLaunchError` -Exception representing an error during instance launch process. - - - - - --- ## class `QuarantineMetricsStorageError` diff --git a/src-docs/openstack_manager.md b/src-docs/openstack_manager.md index ac4966a43..3b1be2577 100644 --- a/src-docs/openstack_manager.md +++ b/src-docs/openstack_manager.md @@ -8,8 +8,6 @@ Module for handling interactions with OpenStack. **Global Variables** --------------- - **RUNNER_INSTALLED_TS_FILE_NAME** -- **IMAGE_PATH_TMPL** -- **IMAGE_NAME** - **SECURITY_GROUP_NAME** - **BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME** - **MAX_METRICS_FILE_SIZE** @@ -20,47 +18,7 @@ Module for handling interactions with OpenStack. --- - - -## function `build_image` - -```python -build_image( - arch: Arch, - cloud_config: dict[str, dict], - github_client: GithubClient, - path: GithubOrg | GithubRepo, - proxies: Optional[ProxyConfig] = None -) → str -``` - -Build and upload an image to OpenStack. - - - -**Args:** - - - `arch`: The architecture of the image. - - `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. - - - -**Raises:** - - - `OpenstackImageBuildError`: If there were errors building/creating the image. - - - -**Returns:** - The created OpenStack image id. - - ---- - - + ## function `create_instance_config` @@ -68,10 +26,10 @@ Build and upload an image to OpenStack. create_instance_config( app_name: str, unit_num: int, - openstack_image: str, + image_id: str, path: GithubOrg | GithubRepo, labels: Iterable[str], - github_token: str + registration_token: str ) → InstanceConfig ``` @@ -83,10 +41,10 @@ Create an instance config from charm data. - `app_name`: The juju application name. - `unit_num`: The juju unit number. - - `openstack_image`: The openstack image object to create the instance with. + - `image_id`: The openstack image id to create the instance with. - `path`: Github organisation or repository path. - `labels`: Addition labels for the runner. - - `github_token`: The Github PAT for interaction with Github API. + - `registration_token`: The Github runner registration token. See https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-a-repository @@ -96,7 +54,7 @@ Create an instance config from charm data. --- - + ## class `InstanceConfig` The configuration values for creating a single runner instance. @@ -105,11 +63,11 @@ The configuration values for creating a single runner instance. **Attributes:** - - `name`: Name of the image to launch the GitHub runner instance with. + - `github_path`: The GitHub repo/org path to register the runner. + - `image_id`: The Openstack image id to use to boot the instance with. - `labels`: The runner instance labels. + - `name`: Name of the image to launch the GitHub runner instance with. - `registration_token`: Token for registering the runner on GitHub. - - `github_path`: The GitHub repo/org path to register the runner. - - `openstack_image`: The Openstack image to use to boot the instance with. @@ -117,11 +75,11 @@ The configuration values for creating a single runner instance. ```python __init__( - name: str, - labels: Iterable[str], - registration_token: str, github_path: GithubOrg | GithubRepo, - openstack_image: str + image_id: str, + labels: Iterable[str], + name: str, + registration_token: str ) → None ``` @@ -135,37 +93,7 @@ __init__( --- - - -## class `ProxyStringValues` -Wrapper class to proxy values to string. - - - -**Attributes:** - - - `http`: HTTP proxy address. - - `https`: HTTPS proxy address. - - `no_proxy`: Comma-separated list of hosts that should not be proxied. - - - - - ---- - - - -## class `OpenstackUpdateImageError` -Represents an error while updating image on Openstack. - - - - - ---- - - + ## class `GithubRunnerRemoveError` Represents an error removing registered runner from Github. @@ -176,7 +104,7 @@ Represents an error removing registered runner from Github. --- - + ## class `OpenstackRunnerManager` Runner manager for OpenStack-based instances. @@ -189,7 +117,7 @@ Runner manager for OpenStack-based instances. - `unit_num`: The juju unit number. - `instance_name`: Prefix of the name for the set of runners. - + ### method `__init__` @@ -218,7 +146,7 @@ Construct OpenstackRunnerManager object. --- - + ### method `flush` @@ -235,7 +163,7 @@ Flush Openstack servers. --- - + ### method `get_github_runner_info` @@ -252,7 +180,7 @@ Get information on GitHub for the runners. --- - + ### method `reconcile` diff --git a/src-docs/runner_manager_type.py.md b/src-docs/runner_manager_type.py.md index 624f1b0eb..742ee34a3 100644 --- a/src-docs/runner_manager_type.py.md +++ b/src-docs/runner_manager_type.py.md @@ -40,6 +40,7 @@ Configuration of runner manager. - `labels`: Additional labels for the runners. - `token`: GitHub personal access token to register runner to the repository or organization. - `flavor`: OpenStack flavor for defining the runner resources. + - `image`: Openstack image id to boot the runner with. - `network`: OpenStack network for runner network access. - `dockerhub_mirror`: URL of dockerhub mirror to use. diff --git a/src/charm.py b/src/charm.py index fab15a1c3..17d9f8233 100755 --- a/src/charm.py +++ b/src/charm.py @@ -33,12 +33,13 @@ ) from ops.framework import StoredState from ops.main import main -from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus import metrics.events as metric_events from charm_state import ( DEBUG_SSH_INTEGRATION_NAME, GROUP_CONFIG_NAME, + IMAGE_INTEGRATION_NAME, LABELS_CONFIG_NAME, PATH_CONFIG_NAME, RECONCILE_INTERVAL_CONFIG_NAME, @@ -48,6 +49,7 @@ CharmState, GithubPath, InstanceType, + OpenstackImage, ProxyConfig, RunnerStorage, VirtualMachineResources, @@ -65,9 +67,7 @@ ) from event_timer import EventTimer, TimerStatusError from firewall import Firewall, FirewallEntry -from github_client import GithubClient from github_type import GitHubRunnerStatus -from openstack_cloud import openstack_manager from openstack_cloud.openstack_manager import OpenstackRunnerManager from runner import LXD_PROFILE_YAML from runner_manager import RunnerManager, RunnerManagerConfig @@ -230,6 +230,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.on[DEBUG_SSH_INTEGRATION_NAME].relation_changed, self._on_debug_ssh_relation_changed, ) + self.framework.observe( + self.on[IMAGE_INTEGRATION_NAME].relation_changed, + self._on_image_relation_changed, + ) self.framework.observe(self.on.reconcile_runners, self._on_reconcile_runners) self.framework.observe(self.on.check_runners_action, self._on_check_runners_action) self.framework.observe(self.on.reconcile_runners_action, self._on_reconcile_runners_action) @@ -409,17 +413,6 @@ def _common_install_code(self, state: CharmState) -> bool: # noqa: C901 raise if state.instance_type == InstanceType.OPENSTACK: - if state.runner_config.build_image: - self.unit.status = MaintenanceStatus("Building Openstack image") - github = GithubClient(token=state.charm_config.token) - openstack_manager.build_image( - arch=state.arch, - cloud_config=state.charm_config.openstack_clouds_yaml, - github_client=github, - path=state.charm_config.path, - proxies=state.proxy_config, - ) - self.unit.status = ActiveStatus() return True self.unit.status = MaintenanceStatus("Installing packages") @@ -564,8 +557,9 @@ def _on_upgrade_charm(self, _: UpgradeCharmEvent) -> None: state.runner_config.virtual_machine_resources, ) + # Temporarily ignore too-complex since this is subject to refactor. @catch_charm_errors - def _on_config_changed(self, _: ConfigChangedEvent) -> None: + def _on_config_changed(self, _: ConfigChangedEvent) -> None: # noqa: C901 """Handle the configuration change.""" state = self._setup_state() self._set_reconcile_timer() @@ -597,6 +591,8 @@ def _on_config_changed(self, _: ConfigChangedEvent) -> None: state = self._setup_state() if state.instance_type == InstanceType.OPENSTACK: + if not self._get_set_image_ready_status(): + return if state.charm_config.token != self._stored.token: openstack_runner_manager = self._get_openstack_runner_manager(state) openstack_runner_manager.flush() @@ -683,6 +679,8 @@ def _on_reconcile_runners(self, _: ReconcileRunnersEvent) -> None: state = self._setup_state() if state.instance_type == InstanceType.OPENSTACK: + if not self._get_set_image_ready_status(): + return runner_manager = self._get_openstack_runner_manager(state) runner_manager.reconcile(state.runner_config.virtual_machines) self.unit.status = ActiveStatus() @@ -775,6 +773,9 @@ def _on_reconcile_runners_action(self, event: ActionEvent) -> None: state = self._setup_state() if state.instance_type == InstanceType.OPENSTACK: + if not self._get_set_image_ready_status(): + event.fail("Openstack image not yet provided/ready.") + return runner_manager = self._get_openstack_runner_manager(state) delta = runner_manager.reconcile(state.runner_config.virtual_machines) @@ -1107,6 +1108,8 @@ def _on_debug_ssh_relation_changed(self, _: ops.RelationChangedEvent) -> None: state = self._setup_state() if state.instance_type == InstanceType.OPENSTACK: + if not self._get_set_image_ready_status(): + return runner_manager = self._get_openstack_runner_manager(state) # 2024/04/12: Should be flush idle. runner_manager.flush() @@ -1122,6 +1125,38 @@ def _on_debug_ssh_relation_changed(self, _: ops.RelationChangedEvent) -> None: state.runner_config.virtual_machine_resources, ) + @catch_charm_errors + def _on_image_relation_changed(self, _: ops.RelationChangedEvent) -> None: + """Handle image relation changed event.""" + state = self._setup_state() + + if state.instance_type != InstanceType.OPENSTACK: + return + if not self._get_set_image_ready_status(): + return + + runner_manager = self._get_openstack_runner_manager(state) + # 2024/04/12: Should be flush idle. + runner_manager.flush() + runner_manager.reconcile(state.runner_config.virtual_machines) + self.unit.status = ActiveStatus() + return + + def _get_set_image_ready_status(self) -> bool: + """Check if image is ready for Openstack and charm status accordingly. + + Returns: + Whether the Openstack image is ready via image integration. + """ + openstack_image = OpenstackImage.from_charm(self) + if openstack_image is None: + self.unit.status = BlockedStatus("Please provide image integration.") + return False + if not openstack_image.id: + self.unit.status = WaitingStatus("Waiting for image over integration.") + return False + return True + def _get_openstack_runner_manager( self, state: CharmState, token: str | None = None, path: GithubPath | None = None ) -> OpenstackRunnerManager: @@ -1144,13 +1179,21 @@ def _get_openstack_runner_manager( if path is None: path = state.charm_config.path + # Empty image can be passed down due to a delete only case where deletion of runners do not + # depend on the image ID being available. Make sure that the charm goes to blocked status + # in hook where a runner may be created. TODO: This logic is subject to refactoring. + image = state.runner_config.openstack_image + image_id = image.id if image and image.id else "" + image_labels = image.tags if image and image.tags else [] + app_name, unit = self.unit.name.rsplit("/", 1) openstack_runner_manager_config = OpenstackRunnerManagerConfig( charm_state=state, path=path, token=token, - labels=state.charm_config.labels, + labels=(*state.charm_config.labels, *image_labels), flavor=state.runner_config.openstack_flavor, + image=image_id, network=state.runner_config.openstack_network, dockerhub_mirror=state.charm_config.dockerhub_mirror, ) diff --git a/src/charm_state.py b/src/charm_state.py index ecdaac4ad..45af52556 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -42,7 +42,6 @@ OPENSTACK_CLOUDS_YAML_CONFIG_NAME = "openstack-clouds-yaml" OPENSTACK_NETWORK_CONFIG_NAME = "openstack-network" OPENSTACK_FLAVOR_CONFIG_NAME = "openstack-flavor" -OPENSTACK_IMAGE_BUILD_UNIT_CONFIG_NAME = "experimental-openstack-image-build-unit" PATH_CONFIG_NAME = "path" RECONCILE_INTERVAL_CONFIG_NAME = "reconcile-interval" # bandit thinks this is a hardcoded password @@ -58,6 +57,10 @@ VM_MEMORY_CONFIG_NAME = "vm-memory" VM_DISK_CONFIG_NAME = "vm-disk" +# Integration names +COS_AGENT_INTEGRATION_NAME = "cos-agent" +DEBUG_SSH_INTEGRATION_NAME = "debug-ssh" +IMAGE_INTEGRATION_NAME = "image" StorageSize = str """Representation of storage size with KiB, MiB, GiB, TiB, PiB, EiB as unit.""" @@ -207,10 +210,6 @@ class Arch(str, Enum): X64 = "x64" -COS_AGENT_INTEGRATION_NAME = "cos-agent" -DEBUG_SSH_INTEGRATION_NAME = "debug-ssh" - - class RunnerStorage(str, Enum): """Supported storage as runner disk. @@ -573,6 +572,44 @@ def from_charm(cls, charm: CharmBase) -> "BaseImage": return cls(image_name) +class OpenstackImage(BaseModel): + """OpenstackImage from image builder relation data. + + Attributes: + id: The OpenStack image ID. + tags: Image tags, e.g. jammy + """ + + id: str | None + tags: list[str] | None + + @classmethod + def from_charm(cls, charm: CharmBase) -> "OpenstackImage | None": + """Initialize the OpenstackImage info from relation data. + + None represents relation not established. + None values for id/tags represent image not yet ready but the relation exists. + + Args: + charm: The charm instance. + + Returns: + OpenstackImage metadata from charm relation data. + """ + relations = charm.model.relations[IMAGE_INTEGRATION_NAME] + if not relations or not (relation := relations[0]).units: + return None + for unit in relation.units: + relation_data = relation.data[unit] + if not relation_data: + continue + return OpenstackImage( + id=relation_data.get("id", None), + tags=[tag.strip() for tag in relation_data.get("tags", "").split(",") if tag], + ) + return OpenstackImage(id=None, tags=None) + + class OpenstackRunnerConfig(BaseModel): """Runner configuration for OpenStack Instances. @@ -580,13 +617,13 @@ class OpenstackRunnerConfig(BaseModel): virtual_machines: Number of virtual machine-based runner to spawn. openstack_flavor: flavor on openstack to use for virtual machines. openstack_network: Network on openstack to use for virtual machines. - build_image: Whether to build the image on this juju unit. + openstack_image: Openstack image to use for virtual machines. """ virtual_machines: int openstack_flavor: str openstack_network: str - build_image: bool + openstack_image: OpenstackImage | None @classmethod def from_charm(cls, charm: CharmBase) -> "OpenstackRunnerConfig": @@ -611,16 +648,13 @@ def from_charm(cls, charm: CharmBase) -> "OpenstackRunnerConfig": openstack_flavor = charm.config[OPENSTACK_FLAVOR_CONFIG_NAME] openstack_network = charm.config[OPENSTACK_NETWORK_CONFIG_NAME] - - openstack_image_build_unit = str(charm.config[OPENSTACK_IMAGE_BUILD_UNIT_CONFIG_NAME]) - _, unit_num = charm.unit.name.rsplit("/", 1) - build_image = openstack_image_build_unit == unit_num + openstack_image = OpenstackImage.from_charm(charm) return cls( virtual_machines=virtual_machines, openstack_flavor=cast(str, openstack_flavor), openstack_network=cast(str, openstack_network), - build_image=build_image, + openstack_image=openstack_image, ) diff --git a/src/errors.py b/src/errors.py index 9ab0f8fd3..ed720f7cf 100644 --- a/src/errors.py +++ b/src/errors.py @@ -162,11 +162,3 @@ class OpenStackInvalidConfigError(OpenStackError): class OpenStackUnauthorizedError(OpenStackError): """Represents an unauthorized connection to OpenStack.""" - - -class OpenstackImageBuildError(Exception): - """Exception representing an error during image build process.""" - - -class OpenstackInstanceLaunchError(Exception): - """Exception representing an error during instance launch process.""" diff --git a/src/openstack_cloud/openstack_manager.py b/src/openstack_cloud/openstack_manager.py index 44b4a6867..c41c83ae7 100644 --- a/src/openstack_cloud/openstack_manager.py +++ b/src/openstack_cloud/openstack_manager.py @@ -22,7 +22,7 @@ from dataclasses import dataclass from multiprocessing import Pool from pathlib import Path -from typing import Iterable, Iterator, Literal, NamedTuple, Optional, cast +from typing import Iterable, Iterator, Literal, Optional, cast import invoke import jinja2 @@ -35,17 +35,10 @@ from invoke.runners import Result from openstack.compute.v2.server import Server from openstack.connection import Connection as OpenstackConnection -from openstack.exceptions import OpenStackCloudException, SDKException +from openstack.exceptions import SDKException from paramiko.ssh_exception import NoValidConnectionsError -from charm_state import ( - Arch, - CharmState, - GithubOrg, - ProxyConfig, - SSHDebugConnection, - UnsupportedArchitectureError, -) +from charm_state import CharmState, GithubOrg, ProxyConfig, SSHDebugConnection from errors import ( CreateMetricsStorageError, GetMetricsStorageError, @@ -54,15 +47,11 @@ GithubMetricsError, IssueMetricEventError, OpenStackError, - OpenstackImageBuildError, - OpenstackInstanceLaunchError, - RunnerBinaryError, RunnerCreateError, RunnerStartError, - SubprocessError, ) from github_client import GithubClient -from github_type import GitHubRunnerStatus, RunnerApplication, SelfHostedRunner +from github_type import GitHubRunnerStatus, SelfHostedRunner from metrics import events as metric_events from metrics import github as github_metrics from metrics import runner as runner_metrics @@ -72,13 +61,10 @@ from runner_manager import IssuedMetricEventsStats from runner_manager_type import OpenstackRunnerManagerConfig from runner_type import GithubPath, RunnerByHealth, RunnerGithubInfo -from utilities import execute_command, retry, set_env_var +from utilities import retry, set_env_var logger = logging.getLogger(__name__) -IMAGE_PATH_TMPL = "jammy-server-cloudimg-{architecture}-compressed.img" -# Update the version when the image is not backward compatible. -IMAGE_NAME = "github-runner-jammy-v1" # Update the version when the security group rules are not backward compatible. SECURITY_GROUP_NAME = "github-runner-v1" BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME = "scripts/build-openstack-image.sh" @@ -125,18 +111,18 @@ class InstanceConfig: """The configuration values for creating a single runner instance. Attributes: - name: Name of the image to launch the GitHub runner instance with. + github_path: The GitHub repo/org path to register the runner. + image_id: The Openstack image id to use to boot the instance with. labels: The runner instance labels. + name: Name of the image to launch the GitHub runner instance with. registration_token: Token for registering the runner on GitHub. - github_path: The GitHub repo/org path to register the runner. - openstack_image: The Openstack image to use to boot the instance with. """ - name: str + github_path: GithubPath + image_id: str labels: Iterable[str] + name: str registration_token: str - github_path: GithubPath - openstack_image: str SupportedCloudImageArch = Literal["amd64", "arm64"] @@ -157,8 +143,8 @@ class _CloudInitUserData: instance_config: InstanceConfig runner_env: str pre_job_contents: str - proxies: Optional[ProxyConfig] = None dockerhub_mirror: Optional[str] = None + proxies: Optional[ProxyConfig] = None @contextmanager @@ -195,251 +181,36 @@ def _create_connection(cloud_config: dict[str, dict]) -> Iterator[openstack.conn raise OpenStackError("Failed OpenStack API call") from exc -class ProxyStringValues(NamedTuple): - """Wrapper class to proxy values to string. - - Attributes: - http: HTTP proxy address. - https: HTTPS proxy address. - no_proxy: Comma-separated list of hosts that should not be proxied. - """ - - http: str - https: str - no_proxy: str - - -def _get_default_proxy_values(proxies: Optional[ProxyConfig] = None) -> ProxyStringValues: - """Get default proxy string values, empty string if None. - - Used to parse proxy values for file configurations, empty strings if None. - - Args: - proxies: The proxy configuration information. - - Returns: - Proxy strings if set, empty string otherwise. - """ - if not proxies: - return ProxyStringValues(http="", https="", no_proxy="") - return ProxyStringValues( - http=str(proxies.http or ""), - https=str(proxies.https or ""), - no_proxy=proxies.no_proxy or "", - ) - - -def _build_image_command( - runner_info: RunnerApplication, - proxies: Optional[ProxyConfig] = None, -) -> list[str]: - """Get command for building runner image. - - Args: - runner_info: The runner application to fetch runner tar download url. - proxies: HTTP proxy settings. - - Returns: - Command to execute to build runner image. - """ - proxy_values = _get_default_proxy_values(proxies=proxies) - cmd = [ - "/usr/bin/bash", - BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME, - runner_info["download_url"], - proxy_values.http, - proxy_values.https, - proxy_values.no_proxy, - ] - return cmd - - -def _get_supported_runner_arch(arch: str) -> SupportedCloudImageArch: - """Validate and return supported runner architecture. - - The supported runner architecture takes in arch value from Github supported architecture and - outputs architectures supported by ubuntu cloud images. - See: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners\ -/about-self-hosted-runners#architectures - and https://cloud-images.ubuntu.com/jammy/current/ - - Args: - arch: The compute architecture to check support for. - - Raises: - UnsupportedArchitectureError: If an unsupported architecture was passed. - - Returns: - The supported architecture. - """ - match arch: - case Arch.X64: - return "amd64" - case Arch.ARM64: - return "arm64" - case _: - raise UnsupportedArchitectureError(arch) - - -def _get_openstack_architecture(arch: Arch) -> str: - """Get openstack architecture. - - See https://docs.openstack.org/glance/latest/admin/useful-image-properties.html - - Args: - arch: The architecture the runner is running on. - - Raises: - UnsupportedArchitectureError: If an unsupported architecture was passed. - - Returns: - The architecture formatted for openstack image property. - """ - match arch: - case arch.X64: - return "x86_64" - case arch.ARM64: - return "aarch64" - case _: - raise UnsupportedArchitectureError(arch) - - -class OpenstackUpdateImageError(Exception): - """Represents an error while updating image on Openstack.""" - - -@retry(tries=5, delay=5, max_delay=60, backoff=2, local_logger=logger) -def _update_image( - cloud_config: dict[str, dict], ubuntu_image_arch: str, openstack_image_arch: str -) -> int: - """Update the openstack image if it exists, create new otherwise. - - Args: - cloud_config: The cloud configuration to connect OpenStack with. - ubuntu_image_arch: The cloud-image architecture. - openstack_image_arch: The Openstack image architecture. - - Raises: - OpenstackUpdateImageError: If there was an error interacting with images on Openstack. - - Returns: - The created image ID. - """ - 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): - # 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 OpenstackUpdateImageError( - "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=ubuntu_image_arch), - wait=True, - properties={"architecture": openstack_image_arch}, - ) - return image.id - except OpenStackCloudException as exc: - raise OpenstackUpdateImageError("Failed to upload image.") from exc - - -# Ignore the flake8 function too complex (C901). The function does not have much logic, the lint -# is likely triggered with the multiple try-excepts, which are needed. -def build_image( # noqa: C901 - arch: Arch, - cloud_config: dict[str, dict], - github_client: GithubClient, - path: GithubPath, - proxies: Optional[ProxyConfig] = None, -) -> str: - """Build and upload an image to OpenStack. - - Args: - arch: The architecture of the image. - 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. - - Raises: - OpenstackImageBuildError: If there were errors building/creating the image. - - Returns: - The created OpenStack image id. - """ - # Setting the env var to this process and any child process spawned. - # Needed for GitHub API with GhApi used by GithubClient class. - if proxies is not None: - if no_proxy := proxies.no_proxy: - set_env_var("NO_PROXY", no_proxy) - if http_proxy := proxies.http: - set_env_var("HTTP_PROXY", http_proxy) - if https_proxy := proxies.https: - set_env_var("HTTPS_PROXY", https_proxy) - - try: - runner_application = github_client.get_runner_application(path=path, arch=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, - ) - except SubprocessError as exc: - raise OpenstackImageBuildError("Failed to build image.") from exc - - runner_arch = runner_application["architecture"] - try: - image_arch = _get_supported_runner_arch(arch=arch) - except UnsupportedArchitectureError as exc: - raise OpenstackImageBuildError(f"Unsupported architecture {runner_arch}") from exc - - try: - return _update_image( - cloud_config=cloud_config, - ubuntu_image_arch=image_arch, - openstack_image_arch=_get_openstack_architecture(arch), - ) - except OpenstackUpdateImageError as exc: - raise OpenstackImageBuildError(f"Failed to update image, {exc}") from exc - - # Disable too many arguments, as they are needed to create the dataclass. def create_instance_config( # pylint: disable=too-many-arguments app_name: str, unit_num: int, - openstack_image: str, + image_id: str, path: GithubPath, labels: Iterable[str], - github_token: str, + registration_token: str, ) -> InstanceConfig: """Create an instance config from charm data. Args: app_name: The juju application name. unit_num: The juju unit number. - openstack_image: The openstack image object to create the instance with. + image_id: The openstack image id to create the instance with. path: Github organisation or repository path. labels: Addition labels for the runner. - github_token: The Github PAT for interaction with Github API. + registration_token: The Github runner registration token. See \ + https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-a-repository Returns: Instance configuration created. """ - github_client = GithubClient(token=github_token) suffix = secrets.token_hex(12) - registration_token = github_client.get_runner_registration_token(path=path) return InstanceConfig( + github_path=path, + image_id=image_id, + labels=labels, name=f"{app_name}-{unit_num}-{suffix}", - labels=("jammy", *labels), registration_token=registration_token, - github_path=path, - openstack_image=openstack_image, ) @@ -825,17 +596,19 @@ class _CreateRunnerArgs: """Arguments for _create_runner method. Attributes: + app_name: The juju application name. cloud_config: The clouds.yaml containing the OpenStack credentials. The first cloud in the file will be used. - app_name: The juju application name. - unit_num: The juju unit number. config: Configurations related to runner manager. + registration_token: Token for registering the runner on GitHub. + unit_num: The juju unit number. """ - cloud_config: dict[str, dict] app_name: str - unit_num: int + cloud_config: dict[str, dict] config: OpenstackRunnerManagerConfig + registration_token: str + unit_num: int @staticmethod def _create_runner(args: _CreateRunnerArgs) -> None: @@ -863,13 +636,14 @@ def _create_runner(args: _CreateRunnerArgs) -> None: pre_job_contents = OpenstackRunnerManager._render_pre_job_contents( charm_state=args.config.charm_state, templates_env=environment ) + instance_config = create_instance_config( args.app_name, args.unit_num, - IMAGE_NAME, + args.config.image, args.config.path, args.config.labels, - args.config.token, + args.registration_token, ) cloud_user_data = _CloudInitUserData( instance_config=instance_config, @@ -891,7 +665,7 @@ def _create_runner(args: _CreateRunnerArgs) -> None: try: instance = conn.create_server( name=instance_config.name, - image=IMAGE_NAME, + image=instance_config.image_id, key_name=instance_config.name, flavor=args.config.flavor, network=args.config.network, @@ -1538,9 +1312,6 @@ def _scale( runner_by_health: The runner status grouped by health. remove_token: The GitHub runner remove token. - Raises: - OpenstackInstanceLaunchError: Unable to launch OpenStack instance. - Returns: The change in number of runners. """ @@ -1548,27 +1319,18 @@ def _scale( # This is not calculated due to there might be removal failures. servers = self._get_openstack_instances(conn) delta = quantity - len(servers) + registration_token = self._github.get_runner_registration_token(path=self._config.path) # Spawn new runners if delta > 0: - # Skip this reconcile if image not present. - try: - if conn.get_image(name_or_id=IMAGE_NAME) is None: - logger.warning("No OpenStack runner was spawned due to image needed not found") - except openstack.exceptions.SDKException as exc: - # Will be resolved by charm integration with image build charm. - logger.exception("Multiple image named %s found", IMAGE_NAME) - raise OpenstackInstanceLaunchError( - "Multiple image found, unable to determine the image to use" - ) from exc - logger.info("Creating %s OpenStack runners", delta) args = [ OpenstackRunnerManager._CreateRunnerArgs( - cloud_config=self._cloud_config, app_name=self.app_name, - unit_num=self.unit_num, config=self._config, + cloud_config=self._cloud_config, + registration_token=registration_token, + unit_num=self.unit_num, ) for _ in range(delta) ] diff --git a/src/runner_manager.py b/src/runner_manager.py index 02411357c..0ee2cee33 100644 --- a/src/runner_manager.py +++ b/src/runner_manager.py @@ -165,7 +165,7 @@ def update_runner_bin(self, binary: RunnerApplication) -> None: try: # Download the new file - response = self.session.get(binary["download_url"], stream=True) + response = self.session.get(binary["download_url"], stream=True, timeout=10 * 60) logger.info( "Download of runner binary from %s return status code: %i", diff --git a/src/runner_manager_type.py b/src/runner_manager_type.py index 578b2b39b..bc117291d 100644 --- a/src/runner_manager_type.py +++ b/src/runner_manager_type.py @@ -88,8 +88,9 @@ def are_metrics_enabled(self) -> bool: return self.charm_state.is_metrics_logging_available +# This class is subject to refactor. @dataclass -class OpenstackRunnerManagerConfig: +class OpenstackRunnerManagerConfig: # pylint: disable=too-many-instance-attributes """Configuration of runner manager. Attributes: @@ -100,6 +101,7 @@ class OpenstackRunnerManagerConfig: token: GitHub personal access token to register runner to the repository or organization. flavor: OpenStack flavor for defining the runner resources. + image: Openstack image id to boot the runner with. network: OpenStack network for runner network access. dockerhub_mirror: URL of dockerhub mirror to use. """ @@ -109,6 +111,7 @@ class OpenstackRunnerManagerConfig: labels: Iterable[str] token: str flavor: str + image: str network: str dockerhub_mirror: str | None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 892140d61..644bdca63 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -44,7 +44,7 @@ wait_for, ) from tests.integration.helpers.lxd import LXDInstanceHelper, ensure_charm_has_runner -from tests.integration.helpers.openstack import OpenStackInstanceHelper +from tests.integration.helpers.openstack import OpenStackInstanceHelper, PrivateEndpointConfigs from tests.status_name import ACTIVE # The following line is required because we are using request.getfixturevalue in conjunction @@ -169,9 +169,9 @@ def loop_device(pytestconfig: pytest.Config) -> Optional[str]: return pytestconfig.getoption("--loop-device") -@pytest.fixture(scope="module", name="private_endpoint_clouds_yaml") -def private_endpoint_clouds_yaml_fixture(pytestconfig: pytest.Config) -> Optional[str]: - """The openstack private endpoint clouds yaml.""" +@pytest.fixture(scope="module", name="private_endpoint_config") +def private_endpoint_config_fixture(pytestconfig: pytest.Config) -> PrivateEndpointConfigs | None: + """The private endpoint configuration values.""" auth_url = pytestconfig.getoption("--openstack-auth-url") password = pytestconfig.getoption("--openstack-password") project_domain_name = pytestconfig.getoption("--openstack-project-domain-name") @@ -192,17 +192,35 @@ def private_endpoint_clouds_yaml_fixture(pytestconfig: pytest.Config) -> Optiona ) ): return None + return { + "auth_url": auth_url, + "password": password, + "project_domain_name": project_domain_name, + "project_name": project_name, + "user_domain_name": user_domain_name, + "username": user_name, + "region_name": region_name, + } + + +@pytest.fixture(scope="module", name="private_endpoint_clouds_yaml") +def private_endpoint_clouds_yaml_fixture( + private_endpoint_config: PrivateEndpointConfigs | None, +) -> Optional[str]: + """The openstack private endpoint clouds yaml.""" + if not private_endpoint_config: + return None return string.Template( Path("tests/integration/data/clouds.yaml.tmpl").read_text(encoding="utf-8") ).substitute( { - "auth_url": auth_url, - "password": password, - "project_domain_name": project_domain_name, - "project_name": project_name, - "user_domain_name": user_domain_name, - "username": user_name, - "region_name": region_name, + "auth_url": private_endpoint_config["auth_url"], + "password": private_endpoint_config["password"], + "project_domain_name": private_endpoint_config["project_domain_name"], + "project_name": private_endpoint_config["project_name"], + "user_domain_name": private_endpoint_config["user_domain_name"], + "username": private_endpoint_config["username"], + "region_name": private_endpoint_config["region_name"], } ) @@ -303,6 +321,35 @@ async def app_no_runner( return application +@pytest_asyncio.fixture(scope="module", name="image_builder") +async def image_builder_fixture( + model: Model, private_endpoint_config: PrivateEndpointConfigs | None +): + """The image builder application for OpenStack runners.""" + if not private_endpoint_config: + raise ValueError("Private endpoints are required for testing OpenStack runners.") + app = await model.deploy( + "github-runner-image-builder", + channel="latest/edge", + revision=2, + constraints="cores=2 mem=16G root-disk=20G virt-type=virtual-machine", + config={ + "app-channel": "edge", + "build-interval": "12", + "revision-history-limit": "2", + "openstack-auth-url": private_endpoint_config["auth_url"], + # Bandit thinks this is a hardcoded password + "openstack-password": private_endpoint_config["password"], # nosec: B105 + "openstack-project-domain-name": private_endpoint_config["project_domain_name"], + "openstack-project-name": private_endpoint_config["project_name"], + "openstack-user-domain-name": private_endpoint_config["user_domain_name"], + "openstack-user-name": private_endpoint_config["username"], + }, + ) + await model.wait_for_idle(apps=[app.name], wait_for_active=True, timeout=15 * 60) + return app + + @pytest_asyncio.fixture(scope="module", name="app_openstack_runner") async def app_openstack_runner_fixture( model: Model, @@ -317,6 +364,7 @@ async def app_openstack_runner_fixture( network_name: str, flavor_name: str, existing_app: Optional[str], + image_builder: Application, ) -> AsyncIterator[Application]: """Application launching VMs and no runners.""" if existing_app: @@ -348,6 +396,7 @@ async def app_openstack_runner_fixture( wait_idle=False, use_local_lxd=False, ) + await model.integrate(f"{image_builder.name}:image", f"{application.name}:image") await model.wait_for_idle(apps=[application.name], status=ACTIVE, timeout=90 * 60) return application diff --git a/tests/integration/helpers/openstack.py b/tests/integration/helpers/openstack.py index 7e62e1855..b2d7624a6 100644 --- a/tests/integration/helpers/openstack.py +++ b/tests/integration/helpers/openstack.py @@ -2,7 +2,7 @@ # See LICENSE file for licensing details. import logging import secrets -from typing import Optional, cast +from typing import Optional, TypedDict, cast import openstack.connection from juju.application import Application @@ -300,3 +300,25 @@ async def server_is_ready() -> bool: return return_code == 0 and bool(stdout) await wait_for(server_is_ready, timeout=30, check_interval=3) + + +class PrivateEndpointConfigs(TypedDict): + """The Private endpoint configuration values. + + Attributes: + auth_url: OpenStack uthentication URL (Keystone). + password: OpenStack password. + project_domain_name: OpenStack project domain to use. + project_name: OpenStack project to use within the domain. + user_domain_name: OpenStack user domain to use. + username: OpenStack user to use within the domain. + region_name: OpenStack deployment region. + """ + + auth_url: str + password: str + project_domain_name: str + project_name: str + user_domain_name: str + username: str + region_name: str diff --git a/tests/integration/test_charm_no_runner.py b/tests/integration/test_charm_no_runner.py index 9a346c9c7..52891a768 100644 --- a/tests/integration/test_charm_no_runner.py +++ b/tests/integration/test_charm_no_runner.py @@ -4,6 +4,7 @@ """Integration tests for github-runner charm with no runner.""" import functools import json +import logging from datetime import datetime, timezone import pytest @@ -24,6 +25,8 @@ from tests.integration.helpers.lxd import wait_till_num_of_runners from tests.status_name import ACTIVE +logger = logging.getLogger(__name__) + REPO_POLICY_COMPLIANCE_VER_0_2_GIT_SOURCE = ( "git+https://github.com/canonical/" "repo-policy-compliance@48b36c130b207278d20c3847ce651ac13fb9e9d7" @@ -210,16 +213,21 @@ async def test_charm_no_runner_upgrade( act: Upgrade the charm. assert: The upgrade_charm hook ran successfully and the image has not been rebuilt. """ + logger.info("Wait for idlle before test start") + await model.wait_for_idle(apps=[app_no_runner.name]) start_time = datetime.now(tz=timezone.utc) + logger.info("Refreshing runner") await app_no_runner.refresh(path=charm_file) unit = app_no_runner.units[0] + logger.info("Waiting for upgrade event") await wait_for( functools.partial(is_upgrade_charm_event_emitted, unit), timeout=360, check_interval=60 ) await model.wait_for_idle(status=ACTIVE) + logger.info("Running 'lxd image list' in unit") ret_code, stdout, stderr = await run_in_unit( unit=unit, command="/snap/bin/lxc image list --format json" ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 4bb4873fc..a964494f2 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -61,7 +61,7 @@ def mocks(monkeypatch, tmp_path, exec_command, lxd_exec_command, runner_binary_p monkeypatch.setattr( "charm.GithubRunnerCharm.repo_check_systemd_service", tmp_path / "systemd_service" ) - monkeypatch.setattr("charm.openstack_manager", openstack_manager_mock) + monkeypatch.setattr("charm.OpenstackRunnerManager", openstack_manager_mock) monkeypatch.setattr("charm.GithubRunnerCharm.kernel_module_path", tmp_path / "modules") monkeypatch.setattr("charm.GithubRunnerCharm._update_kernel", lambda self, now: None) monkeypatch.setattr("charm.execute_command", exec_command) diff --git a/tests/unit/factories.py b/tests/unit/factories.py index fc802fb8b..2a2aabbe2 100644 --- a/tests/unit/factories.py +++ b/tests/unit/factories.py @@ -25,7 +25,6 @@ LABELS_CONFIG_NAME, OPENSTACK_CLOUDS_YAML_CONFIG_NAME, OPENSTACK_FLAVOR_CONFIG_NAME, - OPENSTACK_IMAGE_BUILD_UNIT_CONFIG_NAME, OPENSTACK_NETWORK_CONFIG_NAME, PATH_CONFIG_NAME, RECONCILE_INTERVAL_CONFIG_NAME, @@ -128,7 +127,6 @@ class Meta: OPENSTACK_CLOUDS_YAML_CONFIG_NAME: "", OPENSTACK_NETWORK_CONFIG_NAME: "external", OPENSTACK_FLAVOR_CONFIG_NAME: "m1.small", - OPENSTACK_IMAGE_BUILD_UNIT_CONFIG_NAME: -1, PATH_CONFIG_NAME: factory.Sequence(lambda n: f"mock_path_{n}"), RECONCILE_INTERVAL_CONFIG_NAME: 10, RUNNER_STORAGE_CONFIG_NAME: "juju-storage", diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 7e748d52c..348eb0352 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -4,6 +4,7 @@ """Test cases for GithubRunnerCharm.""" import os import secrets +import typing import unittest import urllib.error from pathlib import Path @@ -11,10 +12,10 @@ import pytest import yaml -from ops.model import BlockedStatus, MaintenanceStatus +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, StatusBase, WaitingStatus from ops.testing import Harness -from charm import GithubRunnerCharm +from charm import GithubRunnerCharm, catch_action_errors, catch_charm_errors from charm_state import ( GROUP_CONFIG_NAME, OPENSTACK_CLOUDS_YAML_CONFIG_NAME, @@ -28,10 +29,20 @@ Arch, GithubOrg, GithubRepo, + InstanceType, + OpenstackImage, ProxyConfig, VirtualMachineResources, ) -from errors import LogrotateSetupError, RunnerError, SubprocessError +from errors import ( + ConfigurationError, + LogrotateSetupError, + MissingRunnerBinaryError, + OpenStackUnauthorizedError, + RunnerError, + SubprocessError, + TokenError, +) from event_timer import EventTimer, TimerEnableError from firewall import FirewallEntry from github_type import GitHubRunnerStatus @@ -615,7 +626,7 @@ def test_on_config_changed_openstack_clouds_yaml(self, run, wt, mkdir, orm, rm): """ arrange: Setup mocked charm. act: Fire config changed event to use openstack-clouds-yaml. - assert: Charm is in maintenance state. + assert: Charm is in blocked state. """ harness = Harness(GithubRunnerCharm) cloud_yaml = { @@ -644,7 +655,7 @@ def test_on_config_changed_openstack_clouds_yaml(self, run, wt, mkdir, orm, rm): harness.charm.on.config_changed.emit() - assert harness.charm.unit.status == MaintenanceStatus() + assert harness.charm.unit.status == BlockedStatus("Please provide image integration.") @patch("charm.RunnerManager") @patch("pathlib.Path.mkdir") @@ -697,3 +708,187 @@ def test_on_flush_runners_action(self, run, wt, mkdir, rm): harness.charm._on_flush_runners_action(mock_event) mock_event.set_results.assert_called() mock_event.reset_mock() + + +@pytest.mark.parametrize( + "exception, expected_status", + [ + pytest.param(ConfigurationError, BlockedStatus, id="charm config error"), + pytest.param(TokenError, BlockedStatus, id="github token error"), + pytest.param(MissingRunnerBinaryError, MaintenanceStatus, id="runner binary error"), + pytest.param(OpenStackUnauthorizedError, BlockedStatus, id="openstack auth error"), + ], +) +def test_catch_charm_errors( + exception: typing.Type[Exception], expected_status: typing.Type[StatusBase] +): + """ + arrange: given mock charm event handler decorated with catch_charm_errors that raises error. + act: when charm event is fired. + assert: the charm is put into expected status. + """ + + class TestCharm: + """Test charm.""" + + def __init__(self): + """Initialize the test charm.""" + self.unit = MagicMock() + + @catch_charm_errors + def test_event_handler(self, _: typing.Any): + """Test event handler. + + Args: + event: The mock event. + + Raises: + exception: The testing exception. + """ + raise exception + + test_charm = TestCharm() + + test_charm.test_event_handler(MagicMock()) + + assert isinstance(test_charm.unit.status, expected_status) + + +@pytest.mark.parametrize( + "exception, expected_status", + [ + pytest.param(ConfigurationError, BlockedStatus, id="charm config error"), + pytest.param(MissingRunnerBinaryError, MaintenanceStatus, id="runner binary error"), + ], +) +def test_catch_action_errors( + exception: typing.Type[Exception], expected_status: typing.Type[StatusBase] +): + """ + arrange: given mock charm event handler decorated with catch_charm_errors that raises error. + act: when charm event is fired. + assert: the charm is put into expected status. + """ + + class TestCharm: + """Test charm.""" + + def __init__(self): + """Initialize the test charm.""" + self.unit = MagicMock() + + @catch_action_errors + def test_event_handler(self, _: typing.Any): + """Test event handler. + + Args: + event: The mock event. + + Raises: + exception: The testing exception. + """ + raise exception + + test_charm = TestCharm() + + test_charm.test_event_handler(event_mock := MagicMock()) + + assert isinstance(test_charm.unit.status, expected_status) + event_mock.fail.assert_called_once() + + +@pytest.mark.parametrize( + "openstack_image, expected_status, expected_value", + [ + pytest.param(None, BlockedStatus, False, id="Image integration missing."), + pytest.param( + OpenstackImage(id=None, tags=None), WaitingStatus, False, id="Image not ready." + ), + pytest.param( + OpenstackImage(id="test", tags=["test"]), + MaintenanceStatus, + True, + id="Valid image integration.", + ), + ], +) +def test_openstack_image_ready_status( + monkeypatch: pytest.MonkeyPatch, + openstack_image: OpenstackImage | None, + expected_status: typing.Type[StatusBase], + expected_value: bool, +): + """ + arrange: given a monkeypatched OpenstackImage.from_charm that returns different values. + act: when _get_set_image_ready_status is called. + assert: expected unit status is set and expected value is returned. + """ + monkeypatch.setattr(OpenstackImage, "from_charm", MagicMock(return_value=openstack_image)) + harness = Harness(GithubRunnerCharm) + harness.begin() + + is_ready = harness.charm._get_set_image_ready_status() + + assert isinstance(harness.charm.unit.status, expected_status) + assert is_ready == expected_value + + +def test__on_image_relation_changed_lxd(): + """ + arrange: given a charm with LXD instance type. + act: when _on_image_relation_changed is called. + assert: nothing happens. + """ + harness = Harness(GithubRunnerCharm) + harness.begin() + state_mock = MagicMock() + state_mock.instance_type = InstanceType.LOCAL_LXD + harness.charm._setup_state = MagicMock(return_value=state_mock) + + harness.charm._on_image_relation_changed(MagicMock()) + + # the unit is in maintenance status since nothing has happened. + assert harness.charm.unit.status.name == MaintenanceStatus.name + + +def test__on_image_relation_image_not_ready(): + """ + arrange: given a charm with OpenStack instance type and a monkeypatched \ + _get_set_image_ready_status that returns False denoting image not ready. + act: when _on_image_relation_changed is called. + assert: nothing happens since _get_set_image_ready_status should take care of status set. + """ + harness = Harness(GithubRunnerCharm) + harness.begin() + state_mock = MagicMock() + state_mock.instance_type = InstanceType.OPENSTACK + harness.charm._setup_state = MagicMock(return_value=state_mock) + harness.charm._get_set_image_ready_status = MagicMock(return_value=False) + + harness.charm._on_image_relation_changed(MagicMock()) + + # the unit is in maintenance status since nothing has happened. + assert harness.charm.unit.status.name == MaintenanceStatus.name + + +def test__on_image_relation_image_ready(): + """ + arrange: given a charm with OpenStack instance type and a monkeypatched \ + _get_set_image_ready_status that returns True denoting image ready. + act: when _on_image_relation_changed is called. + assert: runner flush and reconcile is called. + """ + harness = Harness(GithubRunnerCharm) + harness.begin() + state_mock = MagicMock() + state_mock.instance_type = InstanceType.OPENSTACK + harness.charm._setup_state = MagicMock(return_value=state_mock) + harness.charm._get_set_image_ready_status = MagicMock(return_value=True) + runner_manager_mock = MagicMock() + harness.charm._get_openstack_runner_manager = MagicMock(return_value=runner_manager_mock) + + harness.charm._on_image_relation_changed(MagicMock()) + + assert harness.charm.unit.status.name == ActiveStatus.name + runner_manager_mock.flush.assert_called_once() + runner_manager_mock.reconcile.assert_called_once() diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index 60705288a..bc2292852 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -18,6 +18,7 @@ DEBUG_SSH_INTEGRATION_NAME, DENYLIST_CONFIG_NAME, DOCKERHUB_MIRROR_CONFIG_NAME, + IMAGE_INTEGRATION_NAME, LABELS_CONFIG_NAME, OPENSTACK_CLOUDS_YAML_CONFIG_NAME, PATH_CONFIG_NAME, @@ -40,6 +41,7 @@ GithubRepo, ImmutableConfigChangedError, LocalLxdRunnerConfig, + OpenstackImage, OpenstackRunnerConfig, ProxyConfig, RunnerStorage, @@ -541,6 +543,67 @@ def test_base_image_from_charm(image_name: str, expected_result: BaseImage): assert result == expected_result +def test_openstack_image_from_charm_no_connections(): + """ + arrange: Mock CharmBase instance without relation. + act: Call OpenstackImage.from_charm method. + assert: Verify that the method returns the expected None value. + """ + mock_charm = MockGithubRunnerCharmFactory() + relation_mock = MagicMock() + relation_mock.units = [] + mock_charm.model.relations[IMAGE_INTEGRATION_NAME] = [] + + image = OpenstackImage.from_charm(mock_charm) + + assert image is None + + +def test_openstack_image_from_charm_data_not_ready(): + """ + arrange: Mock CharmBase instance with no relation data. + act: Call OpenstackImage.from_charm method. + assert: Verify that the method returns the expected None value for id and tags. + """ + mock_charm = MockGithubRunnerCharmFactory() + relation_mock = MagicMock() + unit_mock = MagicMock() + relation_mock.units = [unit_mock] + relation_mock.data = {unit_mock: {}} + mock_charm.model.relations[IMAGE_INTEGRATION_NAME] = [relation_mock] + + image = OpenstackImage.from_charm(mock_charm) + + assert isinstance(image, OpenstackImage) + assert image.id is None + assert image.tags is None + + +def test_openstack_image_from_charm(): + """ + arrange: Mock CharmBase instance with relation data. + act: Call OpenstackImage.from_charm method. + assert: Verify that the method returns the expected image id and tags. + """ + mock_charm = MockGithubRunnerCharmFactory() + relation_mock = MagicMock() + unit_mock = MagicMock() + relation_mock.units = [unit_mock] + relation_mock.data = { + unit_mock: { + "id": (test_id := "test-id"), + "tags": ",".join(test_tags := ["tag1", "tag2"]), + } + } + mock_charm.model.relations[IMAGE_INTEGRATION_NAME] = [relation_mock] + + image = OpenstackImage.from_charm(mock_charm) + + assert isinstance(image, OpenstackImage) + assert image.id == test_id + assert image.tags == test_tags + + @pytest.mark.parametrize("virtual_machines", [(-1), (-5)]) # Invalid value # Invalid value def test_check_virtual_machines_invalid(virtual_machines): """ diff --git a/tests/unit/test_openstack_manager.py b/tests/unit/test_openstack_manager.py index ed19e4718..f0b4f803d 100644 --- a/tests/unit/test_openstack_manager.py +++ b/tests/unit/test_openstack_manager.py @@ -18,7 +18,7 @@ import metrics.storage from charm_state import CharmState, ProxyConfig, RepoPolicyComplianceConfig from errors import OpenStackError, RunnerStartError -from github_type import GitHubRunnerStatus, SelfHostedRunner +from github_type import GitHubRunnerStatus, RunnerApplication, SelfHostedRunner from metrics import events as metric_events from metrics.runner import RUNNER_INSTALLED_TS_FILE_NAME from metrics.storage import MetricsStorage @@ -112,7 +112,7 @@ def patch_ssh_connection_error_fixture(monkeypatch: pytest.MonkeyPatch): def mock_github_client_fixture() -> MagicMock: """Mocked github client that returns runner application.""" mock_github_client = MagicMock(spec=openstack_manager.GithubClient) - mock_github_client.get_runner_application.return_value = openstack_manager.RunnerApplication( + mock_github_client.get_runner_application.return_value = RunnerApplication( os="linux", architecture="x64", download_url="http://test_url", @@ -208,6 +208,7 @@ def openstack_manager_for_reconcile_fixture( labels=[], token=secrets.token_hex(16), flavor=app_name, + image="test-image-id", network=secrets.token_hex(16), dockerhub_mirror=None, ) @@ -270,44 +271,6 @@ def test__create_connection( openstack_connect_mock.assert_called_with(cloud=cloud_name) -@pytest.mark.parametrize( - "arch", - [ - pytest.param("s390x", id="s390x"), - pytest.param("riscv64", id="riscv64"), - pytest.param("ppc64el", id="ppc64el"), - pytest.param("armhf", id="armhf"), - pytest.param("test", id="test"), - ], -) -def test__get_supported_runner_arch_invalid_arch(arch: str): - """ - arrange: given supported architectures. - act: when _get_supported_runner_arch is called. - assert: supported cloud image architecture type is returned. - """ - with pytest.raises(openstack_manager.UnsupportedArchitectureError) as exc: - openstack_manager._get_supported_runner_arch(arch=arch) - - assert arch in str(exc) - - -@pytest.mark.parametrize( - "arch, image_arch", - [ - pytest.param("x64", "amd64", id="x64"), - pytest.param("arm64", "arm64", id="arm64"), - ], -) -def test__get_supported_runner_arch(arch: str, image_arch: str): - """ - arrange: given supported architectures. - act: when _get_supported_runner_arch is called. - assert: supported cloud image architecture type is returned. - """ - assert openstack_manager._get_supported_runner_arch(arch=arch) == image_arch - - @pytest.mark.parametrize( "proxy_config, dockerhub_mirror, ssh_debug_connections, expected_env_contents", [ @@ -448,280 +411,6 @@ def test__generate_runner_env( ) -def test__build_image_command(): - """ - arrange: given a mock Github runner application and proxy config. - act: when _build_image_command is called. - assert: command for build image bash script with args are returned. - """ - test_runner_info = openstack_manager.RunnerApplication( - os="linux", - architecture="x64", - download_url=(test_download_url := "https://testdownloadurl.com"), - filename="test_filename", - temp_download_token=secrets.token_hex(16), - ) - test_proxy_config = openstack_manager.ProxyConfig( - http=(test_http_proxy := "http://proxy.test"), - https=(test_https_proxy := "https://proxy.test"), - no_proxy=(test_no_proxy := "http://no.proxy"), - use_aproxy=False, - ) - - command = openstack_manager._build_image_command( - runner_info=test_runner_info, proxies=test_proxy_config - ) - assert command == [ - "/usr/bin/bash", - openstack_manager.BUILD_OPENSTACK_IMAGE_SCRIPT_FILENAME, - test_download_url, - test_http_proxy, - test_https_proxy, - test_no_proxy, - ], "Unexpected build image command." - - -def test_build_image_runner_binary_error(): - """ - arrange: given a mocked github client get_runner_application function that raises an error. - act: when build_image is called. - assert: ImageBuildError is raised. - """ - mock_github_client = MagicMock(spec=openstack_manager.GithubClient) - mock_github_client.get_runner_application.side_effect = openstack_manager.RunnerBinaryError - - 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(), - ) - - assert "Failed to fetch runner application." in str(exc) - - -def test_build_image_script_error(monkeypatch: pytest.MonkeyPatch): - """ - arrange: given a monkeypatched execute_command function that raises an error. - act: when build_image is called. - assert: ImageBuildError is raised. - """ - monkeypatch.setattr( - openstack_manager, - "execute_command", - MagicMock( - side_effect=openstack_manager.SubprocessError( - cmd=[], return_code=1, stdout="", stderr="" - ) - ), - ) - - 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(), - ) - - assert "Failed to build image." in str(exc) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_runner_arch_error( - monkeypatch: pytest.MonkeyPatch, mock_github_client: MagicMock -): - """ - arrange: given _get_supported_runner_arch that raises unsupported architecture error. - act: when build_image is called. - assert: ImageBuildError error is raised with unsupported arch message. - """ - mock_get_supported_runner_arch = MagicMock( - spec=openstack_manager._get_supported_runner_arch, - side_effect=openstack_manager.UnsupportedArchitectureError(arch="x64"), - ) - monkeypatch.setattr( - openstack_manager, "_get_supported_runner_arch", mock_get_supported_runner_arch - ) - - 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(), - ) - - assert "Unsupported architecture" in str(exc) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_delete_image_error( - mock_github_client: MagicMock, patched_create_connection_context: MagicMock -): - """ - arrange: given a mocked openstack connection that returns existing images and delete_image \ - that returns False (failed to delete image). - act: when build_image is called. - assert: ImageBuildError is raised. - """ - patched_create_connection_context.search_images.return_value = ( - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - ) - patched_create_connection_context.delete_image.return_value = False - - 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(), - ) - - assert "Failed to delete duplicate image on Openstack." in str(exc) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_create_image_error( - patched_create_connection_context: MagicMock, mock_github_client: MagicMock -): - """ - arrange: given a mocked connection that raises OpenStackCloudException on create_image. - act: when build_image is called. - assert: ImageBuildError is raised. - """ - patched_create_connection_context.create_image.side_effect = ( - openstack_manager.OpenStackCloudException - ) - - 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, - ) - - assert "Failed to update image" in str(exc) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image( - patched_create_connection_context: MagicMock, - mock_github_client: MagicMock, -): - """ - arrange: given monkeypatched execute_command and mocked openstack connection. - act: when build_image is called. - assert: Openstack image is successfully created. - """ - patched_create_connection_context.search_images.return_value = ( - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - ) - - openstack_manager.build_image( - arch=openstack_manager.Arch.X64, - cloud_config=MagicMock(), - github_client=mock_github_client, - path=MagicMock(), - ) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_on_arm64( - patched_create_connection_context: MagicMock, mock_github_client: MagicMock -): - """ - arrange: given monkeypatched execute_command and mocked openstack connection. - act: when build_image is called on arm64. - assert: Openstack image is successfully created. - """ - patched_create_connection_context.search_images.return_value = ( - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - ) - - openstack_manager.build_image( - arch=openstack_manager.Arch.ARM64, - cloud_config=MagicMock(), - github_client=mock_github_client, - path=MagicMock(), - ) - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_on_unsupported_arch( - patched_create_connection_context: MagicMock, mock_github_client: MagicMock -): - """ - arrange: given monkeypatched execute_command and mocked openstack connection. - act: when build_image is called on unknown architecture. - assert: UnsupportedArchitectureError is raised. - """ - patched_create_connection_context.search_images.return_value = ( - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - ) - - with pytest.raises(openstack_manager.OpenstackImageBuildError) as exc: - openstack_manager.build_image( - # Use mock to represent unknown architecture. - arch=MagicMock(), - cloud_config=MagicMock(), - github_client=mock_github_client, - path=MagicMock(), - ) - assert str(exc.value) == "Unsupported architecture x64" - - -@pytest.mark.usefixtures("patch_execute_command") -def test_build_image_with_proxy_config( - patched_create_connection_context: MagicMock, mock_github_client: MagicMock -): - """ - arrange: given monkeypatched execute_command and mocked openstack connection. - act: when build_image is called with various valid ProxyConfig objects. - assert: Openstack image is successfully created. - """ - patched_create_connection_context.search_images.return_value = ( - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - MagicMock(spec=openstack_manager.openstack.image.v2.image.Image), - ) - - test_proxy_config = openstack_manager.ProxyConfig( - http=None, - https=None, - no_proxy=None, - use_aproxy=False, - ) - - openstack_manager.build_image( - arch=openstack_manager.Arch.ARM64, - cloud_config=MagicMock(), - github_client=mock_github_client, - path=MagicMock(), - proxies=test_proxy_config, - ) - - test_proxy_config = openstack_manager.ProxyConfig( - http="http://proxy.test", - https="https://proxy.test", - no_proxy="http://no.proxy", - use_aproxy=False, - ) - - openstack_manager.build_image( - arch=openstack_manager.Arch.ARM64, - cloud_config=MagicMock(), - github_client=mock_github_client, - path=MagicMock(), - proxies=test_proxy_config, - ) - - def test_reconcile_issues_runner_installed_event( openstack_manager_for_reconcile: openstack_manager.OpenstackRunnerManager, ):