diff --git a/src-docs/runner.py.md b/src-docs/runner.py.md index ff58f4aad8..da49e1d8de 100644 --- a/src-docs/runner.py.md +++ b/src-docs/runner.py.md @@ -18,7 +18,7 @@ Single instance of GitHub self-hosted runner. Attrs: app_name (str): Name of the charm. path (GitHubPath): Path to GitHub repo or org. proxies (ProxySetting): HTTP proxy setting for juju charm. name (str): Name of the runner instance. exist (bool): Whether the runner instance exists on LXD. online (bool): Whether GitHub marks this runner as online. busy (bool): Whether GitHub marks this runner as busy. - + ### function `__init__` @@ -46,7 +46,7 @@ Construct the runner instance. --- - + ### function `create` @@ -78,7 +78,7 @@ Create the runner instance on LXD and register it on GitHub. --- - + ### function `remove` diff --git a/src/runner.py b/src/runner.py index f0852ab9b9..8014607344 100644 --- a/src/runner.py +++ b/src/runner.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional, Sequence +from urllib.parse import urljoin, urlsplit, urlunsplit import yaml @@ -499,12 +500,13 @@ def _install_binary(self, binary: Path) -> None: self._apt_install(["docker.io", "npm", "python3-pip", "shellcheck", "jq", "wget"]) self._wget_install( [ - WgetExecutable( - url="https://github.com/mikefarah/yq/releases/download/v4.34.1/yq_linux_amd64", + yq_executable := WgetExecutable( + url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64", cmd="yq", ) ] ) + self._verify_yq_checksum(yq_executable) # Add the user to docker group. self.instance.execute(["/usr/sbin/usermod", "-aG", "docker", "ubuntu"]) @@ -751,6 +753,28 @@ def _apt_install(self, packages: Iterable[str]) -> None: self.instance.execute(["/usr/bin/apt-get", "clean"]) + def _wget_download(self, url: str, path: str) -> None: + """Download the file to path. + + Args: + url: URL to download from. + path: Path to store the file. + """ + if self.instance is None: + raise RunnerError("Runner operation called prior to runner creation.") + + logger.info("Downloading %s via wget to %s...", url, path) + wget_cmd = ["/usr/bin/wget", url, "-O", path] + if self.config.proxies.get("http", None) or self.config.proxies.get("https", None): + wget_cmd += ["-e", "use_proxy=on"] + if self.config.proxies.get("http", None): + wget_cmd += ["-e", f"http_proxy={self.config.proxies['http']}"] + if self.config.proxies.get("https", None): + wget_cmd += ["-e", f"https_proxy={self.config.proxies['https']}"] + if self.config.proxies.get("no_proxy", None): + wget_cmd += ["-e", f"no_proxy={self.config.proxies['no_proxy']}"] + self.instance.execute(wget_cmd) + def _wget_install(self, executables: Iterable[WgetExecutable]) -> None: """Installs the given binaries. @@ -765,15 +789,60 @@ def _wget_install(self, executables: Iterable[WgetExecutable]) -> None: for executable in executables: executable_path = f"/usr/bin/{executable.cmd}" - logger.info("Downloading %s via wget to %s...", executable.url, executable_path) - wget_cmd = ["/usr/bin/wget", executable.url, "-O", executable_path] - if self.config.proxies.get("http", None) or self.config.proxies.get("https", None): - wget_cmd += ["-e", "use_proxy=on"] - if self.config.proxies.get("http", None): - wget_cmd += ["-e", f"http_proxy={self.config.proxies['http']}"] - if self.config.proxies.get("https", None): - wget_cmd += ["-e", f"https_proxy={self.config.proxies['https']}"] - if self.config.proxies.get("no_proxy", None): - wget_cmd += ["-e", f"no_proxy={self.config.proxies['no_proxy']}"] - self.instance.execute(wget_cmd) + self._wget_download(executable.url, executable_path) self.instance.execute(["/usr/bin/chmod", "+x", executable_path]) + + def _execute(self, cmd: list[str], cwd: str | None = None) -> tuple[str, str]: + """Execute command in runner instance. + + Args: + cmd: Commands to be executed. + cwd: Working directory to execute the commands. + + Returns: + Tuple containing the stdout, stderr. + """ + if self.instance is None: + raise RunnerError("Runner operation called prior to runner creation.") + + return_code, stdout, stderr = self.instance.execute(cmd, cwd) + stdout_str = stdout.read().decode("utf-8") + stderr_str = stderr.read().decode("utf-8") + + if return_code != 0: + raise RunnerError( + ( + f"Command {' '.join(cmd)} failed with code {return_code} in runner " + f"{self.config.name}: {stderr_str}" + ) + ) + + return stdout_str, stderr_str + + def _verify_yq_checksum(self, yq_executable: WgetExecutable) -> None: + """Verify the checksum is correct for the yq executable. + + Args: + yq_executable: Information on yq executable to check. + """ + if self.instance is None: + raise RunnerError("Runner operation called prior to runner creation.") + + split_url = urlsplit(yq_executable.url) + # Extract the base download path + base_path, executable_name = split_url.path.rsplit("/", 1) + base_path_parts = split_url._replace(path=base_path) + base_url = urlunsplit(base_path_parts) + + self._wget_download(urljoin(base_url, "checksums"), "checksums") + self._wget_download(urljoin(base_url, "extract-checksum.sh"), "extract-checksum.sh") + + stdout, _ = self._execute(["bash", "extract-checksum.sh", "SHA-256", executable_name]) + expected_checksum = stdout.rsplit(maxsplit=1)[1] + + yq_path, _ = self._execute(["which", yq_executable.cmd]) + stdout, _ = self._execute(["sha256sum", yq_path]) + calculated_checksum = stdout.split(maxsplit=1)[0] + + if expected_checksum != calculated_checksum: + raise RunnerError("Checksum mismatch for yq executable") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fd623b49fc..2d7c391213 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -31,11 +31,11 @@ def mocks(monkeypatch, tmp_path, exec_command): monkeypatch.setattr("firewall.Firewall.refresh_firewall", unittest.mock.MagicMock()) monkeypatch.setattr("runner.execute_command", exec_command) monkeypatch.setattr("runner.shared_fs", unittest.mock.MagicMock()) + monkeypatch.setattr("runner.time", unittest.mock.MagicMock()) + monkeypatch.setattr("runner.Runner._verify_yq_checksum", unittest.mock.MagicMock()) monkeypatch.setattr("metrics.execute_command", exec_command) monkeypatch.setattr("metrics.METRICS_LOG_PATH", Path(tmp_path / "metrics.log")) monkeypatch.setattr("metrics.LOGROTATE_CONFIG", Path(tmp_path / "github-runner-metrics")) - - monkeypatch.setattr("runner.time", unittest.mock.MagicMock()) monkeypatch.setattr("runner_manager.GhApi", MockGhapiClient) monkeypatch.setattr("runner_manager.jinja2", unittest.mock.MagicMock()) monkeypatch.setattr("runner_manager.LxdClient", MockLxdClient)