Skip to content

Commit

Permalink
feat: initial ssh-debug integration (#176)
Browse files Browse the repository at this point in the history
* feat: initial ssh-debug integration

* fix: linting and test fixes

* test: add debug info tests

* fix: relation data & tests

* test: fix unit test

* feat: flush runners on relation changed

* fix: lint

* test: validate SHA fingerprint format

* fix: flush only idle runners

* fix: remove remote unit filter

* fix: use underscore for env vars
  • Loading branch information
yanksyoon committed Jan 17, 2024
1 parent 2072097 commit 8b6ff08
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 20 deletions.
4 changes: 4 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
56 changes: 55 additions & 1 deletion src/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -33,6 +34,7 @@ class ARCH(str, Enum):


COS_AGENT_INTEGRATION_NAME = "cos-agent"
DEBUG_SSH_INTEGRATION_NAME = "debug-ssh"


class RunnerStorage(str, Enum):
Expand Down Expand Up @@ -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.
Expand All @@ -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":
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)])
Expand Down
18 changes: 10 additions & 8 deletions src/runner_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 12 additions & 7 deletions src/runner_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pathlib import Path
from typing import NamedTuple, Optional, TypedDict, Union

from charm_state import SSHDebugInfo


@dataclass
class RunnerByHealth:
Expand Down Expand Up @@ -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 '<owner>/<repo>', 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
Expand Down
6 changes: 6 additions & 0 deletions templates/env.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
6 changes: 6 additions & 0 deletions templates/environment.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Loading

0 comments on commit 8b6ff08

Please sign in to comment.