Skip to content

Commit 93b7c71

Browse files
authored
Change runner to connect to otel collector on pre-job (#781)
1 parent c07a1c9 commit 93b7c71

13 files changed

Lines changed: 267 additions & 4 deletions

File tree

charmcraft.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ config:
240240
description: >-
241241
The log level for the runner manager application. The value can be CRITICAL, FATAL, ERROR,
242242
WARNING, INFO, or DEBUG.
243+
otel-collector-endpoint:
244+
type: string
245+
default: ""
246+
description: >-
247+
The endpoint to send OpenTelemetry metrics to in the format "host:port". If not set, OpenTelemetry
248+
will be disabled.
243249
244250
actions:
245251
check-runners:

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This changelog documents user-relevant changes to the GitHub runner charm.
44

5+
## 2026-04-27
6+
7+
- Added configuration option `otel-collector-endpoint` to enable the otel-collector to export metric. Setting this configuration option will add the environment variable ACTION_OTEL_EXPORTER_OTLP_ENDPOINT to the runner, which allow users to configure their own metrics to be exported.
8+
59
## 2026-04-22
610

711
- Removed `KillMode=process` from the runner manager systemd service, restoring the default `control-group` kill mode. This ensures all child processes in the service's cgroup are properly terminated when the service stops, preventing orphaned runner processes.

github-runner-manager/src/github_runner_manager/configuration/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class SupportServiceConfig(BaseModel):
9191
aproxy_redirect_ports: A list of ports to redirect to the aproxy proxy.
9292
dockerhub_mirror: The dockerhub mirror to use for runners.
9393
ssh_debug_connections: The information on the ssh debug services.
94+
otel_collector_config: The configuration for the OpenTelemetry collector.
9495
custom_pre_job_script: The custom pre-job script to run before the job.
9596
"""
9697

@@ -103,6 +104,7 @@ class SupportServiceConfig(BaseModel):
103104
dockerhub_mirror: str | None
104105
ssh_debug_connections: "list[SSHDebugConnection]"
105106
custom_pre_job_script: str | None
107+
otel_collector_config: Optional["OtelCollectorConfig"] = None
106108

107109
@root_validator(pre=False, skip_on_failure=True)
108110
@classmethod
@@ -127,6 +129,18 @@ def check_use_aproxy(cls, values: dict) -> dict:
127129
return values
128130

129131

132+
class OtelCollectorConfig(BaseModel):
133+
"""Configuration for OpenTelemetry collector.
134+
135+
Attributes:
136+
host: The OpenTelemetry collector hostname.
137+
port: The OpenTelemetry collector port.
138+
"""
139+
140+
host: str
141+
port: int = Field(gt=0, le=65535)
142+
143+
130144
class ProxyConfig(BaseModel):
131145
"""Proxy configuration.
132146

github-runner-manager/src/github_runner_manager/openstack_cloud/openstack_runner_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,26 @@ def _generate_cloud_init(self, runner_context: RunnerContext) -> str:
170170
if service_config.ssh_debug_connections
171171
else None
172172
)
173+
otel_collector_config = service_config.otel_collector_config
174+
otel_collector_endpoint = (
175+
f"{otel_collector_config.host}:{otel_collector_config.port}"
176+
if otel_collector_config
177+
else ""
178+
)
173179
env_contents = jinja.get_template("env.j2").render(
174180
pre_job_script=str(PRE_JOB_SCRIPT),
175181
dockerhub_mirror=service_config.dockerhub_mirror or "",
176182
ssh_debug_info=ssh_debug_info,
177183
tmate_server_proxy=runner_http_proxy,
184+
otel_collector_endpoint=otel_collector_endpoint,
178185
)
179186
pre_job_contents_dict = {
180187
"issue_metrics": True,
181188
"metrics_exchange_path": str(METRICS_EXCHANGE_PATH),
182189
"do_repo_policy_check": False,
183190
"custom_pre_job_script": service_config.custom_pre_job_script,
184191
"allow_external_contributor": self._config.allow_external_contributor,
192+
"otel_collector_endpoint": otel_collector_endpoint,
185193
}
186194

187195
pre_job_contents = jinja.get_template("pre-job.j2").render(pre_job_contents_dict)

github-runner-manager/src/github_runner_manager/templates/env.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ TMATE_SERVER_HOST={{ssh_debug_info.local_proxy_host}}
1515
TMATE_SERVER_PORT={{ssh_debug_info.local_proxy_port}}
1616
{% endif %}
1717
{% endif %}
18+
{% if otel_collector_endpoint %}
19+
ACTION_OTEL_EXPORTER_OTLP_ENDPOINT={{otel_collector_endpoint}}
20+
{% endif %}

github-runner-manager/src/github_runner_manager/templates/pre-job.j2

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,57 @@ jq -n \
131131
logger -s "Contributor check passed - proceeding to execute jobs"
132132
{% endif %}
133133

134+
# Setup the OpenTelemetry collector configurations.
135+
{% if otel_collector_endpoint %}
136+
/usr/bin/logger -s "OpenTelemetry collector is enabled."
137+
/usr/bin/logger -s "Additional OpenTelemetery collector configuration can be added."
138+
/usr/bin/logger -s "The exporter endpoint is at the environment variable ACTION_OTEL_EXPORTER_OTLP_ENDPOINT."
139+
/usr/bin/sudo /usr/bin/mkdir -p /etc/otelcol/config.d
140+
/usr/bin/sudo /usr/bin/touch /etc/otelcol/config.d/github.yaml
141+
/usr/bin/sudo /usr/bin/tee /etc/otelcol/config.d/github.yaml <<EOF
142+
receivers:
143+
hostmetrics:
144+
collection_interval: 10s
145+
scrapers:
146+
cpu:
147+
memory:
148+
disk:
149+
filesystem:
150+
network:
151+
load:
152+
processors:
153+
attributes/github_labels:
154+
actions:
155+
- key: github_runner
156+
action: upsert
157+
value: "$RUNNER_NAME"
158+
- key: github_workflow
159+
action: upsert
160+
value: "$GITHUB_WORKFLOW"
161+
- key: github_job
162+
action: upsert
163+
value: "$GITHUB_JOB"
164+
- key: github_repository
165+
action: upsert
166+
value: "$GITHUB_REPOSITORY"
167+
batch:
168+
exporters:
169+
otlp/mimir:
170+
endpoint: {{ otel_collector_endpoint }}
171+
tls:
172+
insecure: true
173+
service:
174+
pipelines:
175+
metrics:
176+
receivers: [hostmetrics]
177+
processors: [attributes/github_labels, batch]
178+
exporters: [otlp/mimir]
179+
EOF
180+
181+
/usr/bin/sudo /usr/bin/snap enable opentelemetry-collector
182+
/usr/bin/sudo /usr/bin/snap start opentelemetry-collector
183+
{% endif %}
184+
134185
if [[ -n "$DOCKERHUB_MIRROR" ]]; then
135186
logger -s "A private docker registry is setup as a dockerhub mirror for this self-hosted runner."
136187
logger -s "The docker daemon on this self-hosted runner is configured to use the dockerhub mirror."

src/charm_state.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import yaml
1919
from github_runner_manager.configuration import ProxyConfig, SSHDebugConnection
20+
from github_runner_manager.configuration.base import OtelCollectorConfig
2021
from github_runner_manager.configuration.github import (
2122
GitHubAppAuth,
2223
GitHubAuth,
@@ -69,6 +70,7 @@
6970
VIRTUAL_MACHINES_CONFIG_NAME = "virtual-machines"
7071
CUSTOM_PRE_JOB_SCRIPT_CONFIG_NAME = "pre-job-script"
7172
RUNNER_MANAGER_LOG_LEVEL_CONFIG_NAME = "runner-manager-log-level"
73+
OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME = "otel-collector-endpoint"
7274

7375
# Integration names
7476
COS_AGENT_INTEGRATION_NAME = "cos-agent"
@@ -840,6 +842,39 @@ def _build_ssh_debug_connection_from_charm(charm: CharmBase) -> list[SSHDebugCon
840842
return ssh_debug_connections
841843

842844

845+
def _build_otel_collector_config_from_charm(charm: CharmBase) -> OtelCollectorConfig | None:
846+
"""Initialize the OtelCollectorConfig from charm configuration.
847+
848+
Args:
849+
charm: The charm instance.
850+
851+
Returns:
852+
OtelCollectorConfig if endpoint config is set; otherwise None.
853+
"""
854+
endpoint = cast(str, charm.config.get(OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME, ""))
855+
if not endpoint:
856+
return None
857+
858+
parsed_endpoint = urlsplit(f"//{endpoint}")
859+
if not parsed_endpoint.hostname or parsed_endpoint.port is None:
860+
raise CharmConfigInvalidError(
861+
f"Invalid {OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME} config, expected host:port"
862+
)
863+
864+
if (
865+
parsed_endpoint.username
866+
or parsed_endpoint.password
867+
or parsed_endpoint.path
868+
or parsed_endpoint.query
869+
or parsed_endpoint.fragment
870+
):
871+
raise CharmConfigInvalidError(
872+
f"Invalid {OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME} config, expected host:port"
873+
)
874+
875+
return OtelCollectorConfig(host=parsed_endpoint.hostname, port=parsed_endpoint.port)
876+
877+
843878
def _build_planner_config_from_charm(charm: CharmBase) -> PlannerConfig | None:
844879
"""Initialize planner endpoint and token from relation data.
845880
@@ -896,6 +931,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
896931
runner_proxy_config: Proxy-related configuration for the runner.
897932
runner_config: The charm configuration related to runner VM configuration.
898933
ssh_debug_connections: SSH debug connections configuration information.
934+
otel_collector_config: OpenTelemetry collector configuration information.
899935
planner_config: Planner endpoint and token from relation data.
900936
"""
901937

@@ -905,6 +941,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
905941
charm_config: CharmConfig
906942
runner_config: OpenstackRunnerConfig
907943
ssh_debug_connections: list[SSHDebugConnection]
944+
otel_collector_config: OtelCollectorConfig | None
908945
planner_config: PlannerConfig | None
909946

910947
@classmethod
@@ -923,6 +960,11 @@ def _store_state(cls, state: "CharmState") -> None:
923960
state_dict["ssh_debug_connections"] = [
924961
debug_info.json() for debug_info in state_dict["ssh_debug_connections"]
925962
]
963+
state_dict["otel_collector_config"] = (
964+
json.loads(state_dict["otel_collector_config"].json())
965+
if state_dict["otel_collector_config"]
966+
else None
967+
)
926968
json_data = json.dumps(state_dict, ensure_ascii=False)
927969
CHARM_STATE_PATH.write_text(json_data, encoding="utf-8")
928970

@@ -975,6 +1017,12 @@ def from_charm(cls, charm: CharmBase) -> "CharmState": # noqa: C901
9751017
logger.error("Invalid SSH debug info: %s.", exc)
9761018
raise CharmConfigInvalidError("Invalid SSH Debug info") from exc
9771019

1020+
try:
1021+
otel_collector_config = _build_otel_collector_config_from_charm(charm)
1022+
except (ValidationError, ValueError) as exc:
1023+
logger.error("Invalid OpenTelemetry collector config: %s.", exc)
1024+
raise CharmConfigInvalidError("Invalid OpenTelemetry collector config") from exc
1025+
9781026
planner_config = _build_planner_config_from_charm(charm)
9791027

9801028
state = cls(
@@ -984,6 +1032,7 @@ def from_charm(cls, charm: CharmBase) -> "CharmState": # noqa: C901
9841032
charm_config=charm_config,
9851033
runner_config=runner_config,
9861034
ssh_debug_connections=ssh_debug_connections,
1035+
otel_collector_config=otel_collector_config,
9871036
planner_config=planner_config,
9881037
)
9891038

src/factories.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def create_application_configuration(
5353
runner_proxy_config=state.runner_proxy_config,
5454
dockerhub_mirror=state.charm_config.dockerhub_mirror,
5555
ssh_debug_connections=state.ssh_debug_connections,
56+
otel_collector_config=state.otel_collector_config,
5657
use_aproxy=state.charm_config.use_aproxy,
5758
aproxy_exclude_addresses=state.charm_config.aproxy_exclude_addresses,
5859
aproxy_redirect_ports=state.charm_config.aproxy_redirect_ports,

tests/integration/helpers/openstack.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,11 @@ def run_in_instance(
125125
exit_code, _, _ = run_in_unit(self.juju, unit_name, f"ls {key_path}")
126126
assert exit_code == 0, f"Unable to find key file {key_path}"
127127
ssh_cmd = f'ssh -i {key_path} -o "StrictHostKeyChecking no" ubuntu@{ip} {command}'
128-
ssh_cmd_as_ubuntu_user = f"su - ubuntu -c '{ssh_cmd}'"
129-
logging.warning("ssh_cmd: %s", ssh_cmd_as_ubuntu_user)
128+
# The SSH command needs to be run as the manager user to have access to the SSH keys.
129+
ssh_cmd_as_manager_user = f"su - {constants.RUNNER_MANAGER_USER} -c '{ssh_cmd}'"
130+
logging.warning("ssh_cmd: %s", ssh_cmd_as_manager_user)
130131
exit_code, stdout, stderr = run_in_unit(
131-
self.juju, unit_name, ssh_cmd_as_ubuntu_user, timeout
132+
self.juju, unit_name, ssh_cmd_as_manager_user, timeout
132133
)
133134
logger.info(
134135
"Run command '%s' in runner with result %s: '%s' '%s'",

tests/integration/test_charm_runner.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from github.Branch import Branch
1111
from github.Repository import Repository
1212

13-
from charm_state import BASE_VIRTUAL_MACHINES_CONFIG_NAME, CUSTOM_PRE_JOB_SCRIPT_CONFIG_NAME
13+
from charm_state import (
14+
BASE_VIRTUAL_MACHINES_CONFIG_NAME,
15+
CUSTOM_PRE_JOB_SCRIPT_CONFIG_NAME,
16+
OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME,
17+
)
1418
from tests.integration.helpers.common import (
1519
DISPATCH_TEST_WORKFLOW_FILENAME,
1620
DISPATCH_WAIT_TEST_WORKFLOW_FILENAME,
@@ -186,3 +190,47 @@ def test_custom_pre_job_script(
186190
logs = get_job_logs(workflow_run.jobs("latest")[0])
187191
assert "SSH config" in logs
188192
assert "proxycommand socat - PROXY:squid.internal:%h:%p,proxyport=3128" in logs
193+
194+
195+
@pytest.mark.openstack
196+
@pytest.mark.abort_on_fail
197+
def test_otel_collector_endpoint_pre_job_installs_config(
198+
juju: jubilant.Juju,
199+
app: str,
200+
github_repository: Repository,
201+
test_github_branch: Branch,
202+
instance_helper: OpenStackInstanceHelper,
203+
) -> None:
204+
"""
205+
arrange: A working application with one runner and otel collector endpoint configured.
206+
act: Dispatch a workflow to run pre-job script.
207+
assert: The workflow writes otel collector config to /etc/otelcol/config.d/github.yaml.
208+
"""
209+
endpoint = "10.10.0.12:4317"
210+
juju.config(
211+
app,
212+
values={
213+
BASE_VIRTUAL_MACHINES_CONFIG_NAME: "1",
214+
OTEL_COLLECTOR_ENDPOINT_CONFIG_NAME: endpoint,
215+
},
216+
)
217+
wait_for_runner_ready(juju, app)
218+
219+
dispatch_workflow(
220+
app_name=app,
221+
branch=test_github_branch,
222+
github_repository=github_repository,
223+
conclusion="success",
224+
workflow_id_or_name=DISPATCH_TEST_WORKFLOW_FILENAME,
225+
dispatch_input={"runner": app},
226+
)
227+
228+
exit_code, stdout, stderr = instance_helper.run_in_instance(
229+
unit_name=f"{app}/0",
230+
command="sudo cat /etc/otelcol/config.d/github.yaml",
231+
)
232+
233+
assert exit_code == 0, stderr
234+
assert stdout is not None
235+
assert "exporters:" in stdout
236+
assert f"endpoint: {endpoint}" in stdout

0 commit comments

Comments
 (0)