diff --git a/metadata.yaml b/metadata.yaml index 3b494e56a..00f1d5aa2 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -25,6 +25,10 @@ provides: cos-agent: interface: cos_agent +requires: + debug-ssh: + interface: debug-ssh + storage: runner: description: Storage for the root disk of LXD instances hosting the runner application. diff --git a/src/charm.py b/src/charm.py index 8e03d3b7b..cba4002f1 100755 --- a/src/charm.py +++ b/src/charm.py @@ -32,7 +32,7 @@ from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus import metrics -from charm_state import CharmConfigInvalidError, RunnerStorage, State +from charm_state import DEBUG_SSH_INTEGRATION_NAME, CharmConfigInvalidError, RunnerStorage, State from errors import ( ConfigurationError, LogrotateSetupError, @@ -188,6 +188,10 @@ def __init__(self, *args, **kargs) -> None: self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.start, self._on_start) self.framework.observe(self.on.stop, self._on_stop) + self.framework.observe( + self.on[DEBUG_SSH_INTEGRATION_NAME].relation_changed, + self._on_debug_ssh_relation_changed, + ) self.framework.observe(self.on.reconcile_runners, self._on_reconcile_runners) @@ -923,6 +927,11 @@ def _apt_install(self, packages: Sequence[str]) -> None: execute_command(["dpkg", "--configure", "-a"]) execute_command(["/usr/bin/apt-get", "install", "-qy"] + list(packages)) + def _on_debug_ssh_relation_changed(self, _: ops.RelationChangedEvent) -> None: + """Handle debug ssh relation changed event.""" + runner_manager = self._get_runner_manager() + runner_manager.flush(flush_busy=False) + if __name__ == "__main__": main(GithubRunnerCharm) diff --git a/src/charm_state.py b/src/charm_state.py index 51ee6101a..ef9f280d9 100644 --- a/src/charm_state.py +++ b/src/charm_state.py @@ -12,7 +12,8 @@ from typing import Optional from ops import CharmBase -from pydantic import AnyHttpUrl, BaseModel, ValidationError, root_validator +from pydantic import AnyHttpUrl, BaseModel, Field, ValidationError, root_validator +from pydantic.networks import IPvAnyAddress from utilities import get_env_var @@ -33,6 +34,7 @@ class ARCH(str, Enum): COS_AGENT_INTEGRATION_NAME = "cos-agent" +DEBUG_SSH_INTEGRATION_NAME = "debug-ssh" class RunnerStorage(str, Enum): @@ -182,6 +184,49 @@ def _get_supported_arch() -> ARCH: raise UnsupportedArchitectureError(arch=arch) +class SSHDebugInfo(BaseModel): + """SSH connection information for debug workflow. + + Attributes: + host: The SSH relay server host IP address inside the VPN. + port: The SSH relay server port. + rsa_fingerprint: The host SSH server public RSA key fingerprint. + ed25519_fingerprint: The host SSH server public ed25519 key fingerprint. + """ + + host: IPvAnyAddress + port: int = Field(0, gt=0, le=65535) + rsa_fingerprint: str = Field(pattern="^SHA256:.*") + ed25519_fingerprint: str = Field(pattern="^SHA256:.*") + + @classmethod + def from_charm(cls, charm: CharmBase) -> Optional["SSHDebugInfo"]: + """Initialize the SSHDebugInfo from charm relation data. + + Args: + charm: The charm instance. + """ + relations = charm.model.relations[DEBUG_SSH_INTEGRATION_NAME] + if not relations or not (relation := relations[0]).units: + return None + target_unit = next(iter(relation.units)) + relation_data = relation.data[target_unit] + if ( + not (host := relation_data.get("host")) + or not (port := relation_data.get("port")) + or not (rsa_fingerprint := relation_data.get("rsa_fingerprint")) + or not (ed25519_fingerprint := relation_data.get("ed25519_fingerprint")) + ): + logger.warning("%s relation data not yet ready.", DEBUG_SSH_INTEGRATION_NAME) + return None + return SSHDebugInfo( + host=host, + port=port, + rsa_fingerprint=rsa_fingerprint, + ed25519_fingerprint=ed25519_fingerprint, + ) + + @dataclasses.dataclass(frozen=True) class State: """The charm state. @@ -191,12 +236,14 @@ class State: proxy_config: Proxy-related configuration. charm_config: Configuration of the juju charm. arch: The underlying compute architecture, i.e. x86_64, amd64, arm64/aarch64. + ssh_debug_info: The SSH debug connection configuration information. """ is_metrics_logging_available: bool proxy_config: ProxyConfig charm_config: CharmConfig arch: ARCH + ssh_debug_info: Optional[SSHDebugInfo] @classmethod def from_charm(cls, charm: CharmBase) -> "State": @@ -244,11 +291,18 @@ def from_charm(cls, charm: CharmBase) -> "State": logger.error("Unsupported architecture: %s", exc.arch) raise CharmConfigInvalidError(f"Unsupported architecture {exc.arch}") from exc + try: + ssh_debug_info = SSHDebugInfo.from_charm(charm) + except ValidationError as exc: + logger.error("Invalid SSH debug info: %s.", exc) + raise CharmConfigInvalidError("Invalid SSH Debug info") from exc + state = cls( is_metrics_logging_available=bool(charm.model.relations[COS_AGENT_INTEGRATION_NAME]), proxy_config=proxy_config, charm_config=charm_config, arch=arch, + ssh_debug_info=ssh_debug_info, ) state_dict = dataclasses.asdict(state) diff --git a/src/runner.py b/src/runner.py index fcf421e17..6ca6d9e5f 100644 --- a/src/runner.py +++ b/src/runner.py @@ -662,7 +662,7 @@ def _configure_runner(self) -> None: # Load `/etc/environment` file. environment_contents = self._clients.jinja.get_template("environment.j2").render( - proxies=self.config.proxies + proxies=self.config.proxies, ssh_debug_info=self.config.ssh_debug_info ) self._put_file("/etc/environment", environment_contents) @@ -671,6 +671,7 @@ def _configure_runner(self) -> None: proxies=self.config.proxies, pre_job_script=str(self.pre_job_script), dockerhub_mirror=self.config.dockerhub_mirror, + ssh_debug_info=self.config.ssh_debug_info, ) self._put_file(str(self.env_file), env_contents) self.instance.execute(["/usr/bin/chown", "ubuntu:ubuntu", str(self.env_file)]) diff --git a/src/runner_manager.py b/src/runner_manager.py index a7afe02bb..187d1590e 100644 --- a/src/runner_manager.py +++ b/src/runner_manager.py @@ -402,13 +402,14 @@ def _spawn_new_runners(self, count: int, resources: VirtualMachineResources): logger.info("Attempting to add %i runner(s).", count) for _ in range(count): config = RunnerConfig( - name=self._generate_runner_name(), app_name=self.app_name, + dockerhub_mirror=self.config.dockerhub_mirror, + issue_metrics=self.config.are_metrics_enabled, + lxd_storage_path=self.config.lxd_storage_path, path=self.config.path, proxies=self.proxies, - lxd_storage_path=self.config.lxd_storage_path, - issue_metrics=self.config.are_metrics_enabled, - dockerhub_mirror=self.config.dockerhub_mirror, + name=self._generate_runner_name(), + ssh_debug_info=self.config.charm_state.ssh_debug_info, ) runner = Runner(self._clients, config, RunnerStatus()) try: @@ -603,13 +604,14 @@ def create_runner_info( busy = getattr(remote_runner, "busy", None) config = RunnerConfig( - name=name, app_name=self.app_name, + dockerhub_mirror=self.config.dockerhub_mirror, + issue_metrics=self.config.are_metrics_enabled, + lxd_storage_path=self.config.lxd_storage_path, + name=name, path=self.config.path, proxies=self.proxies, - lxd_storage_path=self.config.lxd_storage_path, - issue_metrics=self.config.are_metrics_enabled, - dockerhub_mirror=self.config.dockerhub_mirror, + ssh_debug_info=self.config.charm_state.ssh_debug_info, ) return Runner( self._clients, diff --git a/src/runner_type.py b/src/runner_type.py index 6b9c4f0b1..6afed8766 100644 --- a/src/runner_type.py +++ b/src/runner_type.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import NamedTuple, Optional, TypedDict, Union +from charm_state import SSHDebugInfo + @dataclass class RunnerByHealth: @@ -62,27 +64,30 @@ class VirtualMachineResources(NamedTuple): @dataclass -class RunnerConfig: +# The instance attributes are all required and is better standalone each. +class RunnerConfig: # pylint: disable=too-many-instance-attributes """Configuration for runner. Attributes: - name: Name of the runner. app_name: Application name of the charm. + issue_metrics: Whether to issue metrics. + lxd_storage_path: Path to be used as LXD storage. + name: Name of the runner. path: GitHub repository path in the format '/', or the GitHub organization name. proxies: HTTP(S) proxy settings. - lxd_storage_path: Path to be used as LXD storage. - issue_metrics: Whether to issue metrics. dockerhub_mirror: URL of dockerhub mirror to use. + ssh_debug_info: The SSH debug server connection metadata. """ - name: str app_name: str + issue_metrics: bool + lxd_storage_path: Path + name: str path: GithubPath proxies: ProxySetting - lxd_storage_path: Path - issue_metrics: bool dockerhub_mirror: str | None = None + ssh_debug_info: SSHDebugInfo | None = None @dataclass diff --git a/templates/env.j2 b/templates/env.j2 index a68f6e09b..1892883bb 100644 --- a/templates/env.j2 +++ b/templates/env.j2 @@ -17,3 +17,9 @@ CONTAINER_REGISTRY_URL={{dockerhub_mirror}} {% endif %} LANG=C.UTF-8 ACTIONS_RUNNER_HOOK_JOB_STARTED={{pre_job_script}} +{% if ssh_debug_info %} +TMATE_SERVER_HOST={{ssh_debug_info['host']}} +TMATE_SERVER_PORT={{ssh_debug_info['port']}} +TMATE_SERVER_RSA_FINGERPRINT={{ssh_debug_info['rsa_fingerprint']}} +TMATE_SERVER_ED25519_FINGERPRINT={{ssh_debug_info['ed25519_fingerprint']}} +{% endif %} diff --git a/templates/environment.j2 b/templates/environment.j2 index b0b6494e7..dcb38b7ed 100644 --- a/templates/environment.j2 +++ b/templates/environment.j2 @@ -5,3 +5,9 @@ NO_PROXY={{proxies['no_proxy']}} http_proxy={{proxies['http']}} https_proxy={{proxies['https']}} no_proxy={{proxies['no_proxy']}} +{% if ssh_debug_info %} +TMATE_SERVER_HOST={{ssh_debug_info['host']}} +TMATE_SERVER_PORT={{ssh_debug_info['port']}} +TMATE_SERVER_RSA_FINGERPRINT={{ssh_debug_info['rsa_fingerprint']}} +TMATE_SERVER_ED25519_FINGERPRINT={{ssh_debug_info['ed25519_fingerprint']}} +{% endif %} diff --git a/tests/unit/test_charm_state.py b/tests/unit/test_charm_state.py index 3519c5441..c3e147e55 100644 --- a/tests/unit/test_charm_state.py +++ b/tests/unit/test_charm_state.py @@ -2,12 +2,21 @@ # See LICENSE file for licensing details. import os import platform +from collections import defaultdict from unittest.mock import MagicMock, patch +import ops import pytest from charm import GithubRunnerCharm -from charm_state import ARCH, CharmConfigInvalidError, State +from charm_state import ( + ARCH, + COS_AGENT_INTEGRATION_NAME, + DEBUG_SSH_INTEGRATION_NAME, + CharmConfigInvalidError, + SSHDebugInfo, + State, +) def test_metrics_logging_available_true(): @@ -17,7 +26,10 @@ def test_metrics_logging_available_true(): assert: metrics_logging_available returns True. """ charm = MagicMock() - charm.model.relations.__getitem__.return_value = [MagicMock()] + charm.model.relations = { + COS_AGENT_INTEGRATION_NAME: MagicMock(spec=ops.Relation), + DEBUG_SSH_INTEGRATION_NAME: None, + } charm.config = {"runner-storage": "juju-storage"} state = State.from_charm(charm) @@ -103,6 +115,7 @@ def test_from_charm_arch(monkeypatch: pytest.MonkeyPatch, arch: str, expected_ar mock_machine.return_value = arch monkeypatch.setattr(platform, "machine", mock_machine) mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.model.relations = defaultdict(lambda: None) mock_charm.config = {"runner-storage": "juju-storage"} state = State.from_charm(mock_charm) @@ -110,6 +123,110 @@ def test_from_charm_arch(monkeypatch: pytest.MonkeyPatch, arch: str, expected_ar assert state.arch == expected_arch +def test_ssh_debug_info_from_charm_no_relations(): + """ + arrange: given a mocked charm that has no ssh-debug relations. + act: when SSHDebug.from_charm is called. + assert: None is returned. + """ + mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.model.relations = {DEBUG_SSH_INTEGRATION_NAME: []} + + assert SSHDebugInfo.from_charm(mock_charm) is None + + +@pytest.mark.parametrize( + "invalid_relation_data", + [ + pytest.param( + { + "host": "invalidip", + "port": "8080", + "rsa_fingerprint": "SHA:fingerprint_data", + "ed25519_fingerprint": "SHA:fingerprint_data", + }, + id="invalid host IP", + ), + pytest.param( + { + "host": "127.0.0.1", + "port": "invalidport", + "rsa_fingerprint": "SHA:fingerprint_data", + "ed25519_fingerprint": "SHA:fingerprint_data", + }, + id="invalid port", + ), + pytest.param( + { + "host": "127.0.0.1", + "port": "invalidport", + "rsa_fingerprint": "invalid_fingerprint_data", + "ed25519_fingerprint": "invalid_fingerprint_data", + }, + id="invalid fingerprint", + ), + ], +) +def test_from_charm_ssh_debug_info_error(invalid_relation_data: dict): + """ + arrange: Given an mocked charm that has invalid ssh-debug relation data. + act: when from_charm is called. + assert: CharmConfigInvalidError is raised. + """ + mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.config = {} + mock_relation = MagicMock(spec=ops.Relation) + mock_unit = MagicMock(spec=ops.Unit) + mock_unit.name = "tmate-ssh-server-operator/0" + mock_relation.units = {mock_unit} + mock_relation.data = {mock_unit: invalid_relation_data} + mock_charm.model.relations = {DEBUG_SSH_INTEGRATION_NAME: [mock_relation]} + mock_charm.app.planned_units.return_value = 1 + mock_charm.app.name = "github-runner-operator" + mock_charm.unit.name = "github-runner-operator/0" + + with pytest.raises(CharmConfigInvalidError): + State.from_charm(mock_charm) + + +def test_from_charm_ssh_debug_info(): + """ + arrange: Given an mocked charm that has invalid ssh-debug relation data. + act: when from_charm is called. + assert: ssh_debug_info data has been correctly parsed. + """ + mock_charm = MagicMock(spec=GithubRunnerCharm) + mock_charm.config = {} + mock_relation = MagicMock(spec=ops.Relation) + mock_unit = MagicMock(spec=ops.Unit) + mock_unit.name = "tmate-ssh-server-operator/0" + mock_relation.units = {mock_unit} + mock_relation.data = { + mock_unit: ( + mock_relation_data := { + "host": "127.0.0.1", + "port": "8080", + "rsa_fingerprint": "fingerprint_data", + "ed25519_fingerprint": "fingerprint_data", + } + ) + } + mock_charm.model.relations = { + DEBUG_SSH_INTEGRATION_NAME: [mock_relation], + COS_AGENT_INTEGRATION_NAME: None, + } + mock_charm.config = {"runner-storage": "juju-storage"} + mock_charm.app.planned_units.return_value = 1 + mock_charm.app.name = "github-runner-operator" + mock_charm.unit.name = "github-runner-operator/0" + + ssh_debug_info = State.from_charm(mock_charm).ssh_debug_info + assert str(ssh_debug_info.host) == mock_relation_data["host"] + assert str(ssh_debug_info.port) == mock_relation_data["port"] + assert ssh_debug_info.rsa_fingerprint == mock_relation_data["rsa_fingerprint"] + assert ssh_debug_info.ed25519_fingerprint == mock_relation_data["ed25519_fingerprint"] + + def test_invalid_runner_storage(): """ arrange: Setup mocked charm with juju-storage as runner-storage. diff --git a/tests/unit/test_runner_manager.py b/tests/unit/test_runner_manager.py index 2df89da0f..4b7d3a037 100644 --- a/tests/unit/test_runner_manager.py +++ b/tests/unit/test_runner_manager.py @@ -35,6 +35,7 @@ def charm_state_fixture(): mock = MagicMock(spec=State) mock.is_metrics_logging_available = False mock.arch = ARCH.X64 + mock.ssh_debug_info = None return mock