Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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

## 2026-02-03

- Introduce planner-driven pressure reconciler.

## 2026-02-02

- Deprecated `repo-policy-compliance` service.
Expand Down Expand Up @@ -39,7 +43,7 @@ This changelog documents user-relevant changes to the GitHub runner charm.
## 2025-12-10

- Removed apt update step in cloud-init of the VM creation step since it is now applied in the
GitHub runner image builder side.
GitHub runner image builder side.

## 2025-12-05

Expand Down
115 changes: 95 additions & 20 deletions github-runner-manager/src/github_runner_manager/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,54 @@

"""The CLI entrypoint for github-runner-manager application."""

import getpass
import grp
import importlib.metadata
import logging
import os
import signal
import sys
from functools import partial
from io import StringIO
from threading import Lock
from types import FrameType
from typing import TextIO

import click

from github_runner_manager.configuration import ApplicationConfiguration
from github_runner_manager.configuration import ApplicationConfiguration, UserInfo
from github_runner_manager.http_server import FlaskArgs, start_http_server
from github_runner_manager.reconcile_service import start_reconcile_service
from github_runner_manager.manager.pressure_reconciler import (
PressureReconciler,
PressureReconcilerConfig,
)
from github_runner_manager.manager.runner_manager import RunnerManager
from github_runner_manager.openstack_cloud.models import OpenStackServerConfig
from github_runner_manager.openstack_cloud.openstack_runner_manager import (
OpenStackRunnerManager,
OpenStackRunnerManagerConfig,
)
from github_runner_manager.planner_client import PlannerClient, PlannerConfiguration
from github_runner_manager.platform.github_provider import GitHubRunnerPlatform
from github_runner_manager.thread_manager import ThreadManager

version = importlib.metadata.version("github-runner-manager")


def handle_shutdown(
signum: int, _frame: FrameType | None, pressure_reconciler: PressureReconciler
) -> None: # pragma: no cover
"""Stop reconciler threads on shutdown signals.

Args:
signum: Received POSIX signal number.
_frame: Current stack frame when the signal was received.
pressure_reconciler: The reconciler instance to stop.
"""
logging.info("Received signal %s; stopping pressure reconciler", signum)
pressure_reconciler.stop()


@click.command()
@click.option(
"--config-file",
Expand Down Expand Up @@ -61,20 +91,13 @@
default="INFO",
help="The log level for the application.",
)
@click.option(
"--python-path",
type=str,
default="",
help="The PYTHONPATH to the github-runner-manager library.",
)
# The entry point for the CLI will be tested with integration test.
def main( # pylint: disable=too-many-arguments, too-many-positional-arguments
config_file: TextIO,
host: str,
port: int,
debug: bool,
log_level: str,
python_path: str,
) -> None: # pragma: no cover
"""Start the reconcile service.

Expand All @@ -84,28 +107,80 @@ def main( # pylint: disable=too-many-arguments, too-many-positional-arguments
port: The port to listen on the HTTP server.
debug: Whether to start the application in debug mode.
log_level: The log level.
python_path: The PYTHONPATH to access the github-runner-manager library.
"""
python_path_config = python_path if python_path else None
logging.basicConfig(
level=log_level,
stream=sys.stderr,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
logging.info("Starting GitHub runner manager service version: %s", version)

lock = Lock()
config_str = config_file.read()
config = ApplicationConfiguration.from_yaml_file(StringIO(config_str))
http_server_args = FlaskArgs(host=host, port=port, debug=debug)
config = ApplicationConfiguration.from_yaml_file(StringIO(config_file.read()))

thread_manager = ThreadManager()
thread_manager.add_thread(
target=partial(start_http_server, config, lock, http_server_args), daemon=True
target=partial(
start_http_server,
config,
Lock(),
FlaskArgs(host=host, port=port, debug=debug),
),
daemon=True,
)
thread_manager.add_thread(
target=partial(start_reconcile_service, config, python_path_config, lock), daemon=True

pressure_reconciler = PressureReconciler(
manager=RunnerManager(
manager_name=config.name,
platform_provider=GitHubRunnerPlatform.build(
prefix=config.openstack_configuration.vm_prefix,
github_configuration=config.github_config,
),
cloud_runner_manager=OpenStackRunnerManager(
config=OpenStackRunnerManagerConfig(
allow_external_contributor=config.allow_external_contributor,
prefix=config.openstack_configuration.vm_prefix,
credentials=config.openstack_configuration.credentials,
server_config=(
None
if not config.non_reactive_configuration.combinations
else OpenStackServerConfig(
image=config.non_reactive_configuration.combinations[0].image.name,
flavor=config.non_reactive_configuration.combinations[0].flavor.name,
network=config.openstack_configuration.network,
)
),
service_config=config.service_config,
),
user=UserInfo(getpass.getuser(), grp.getgrgid(os.getgid()).gr_name),
),
labels=(
list(config.extra_labels)
+ (
[]
if not config.non_reactive_configuration.combinations
else (
config.non_reactive_configuration.combinations[0].image.labels
+ config.non_reactive_configuration.combinations[0].flavor.labels
)
)
),
),
planner_client=PlannerClient(
PlannerConfiguration(base_url=config.planner_url, token=config.planner_token)
),
config=PressureReconcilerConfig(
flavor_name=(
config.non_reactive_configuration.combinations[0].flavor.name
if config.non_reactive_configuration.combinations
else ""
)
),
)
thread_manager.start()
signal.signal(
signal.SIGTERM, partial(handle_shutdown, pressure_reconciler=pressure_reconciler)
)
signal.signal(signal.SIGINT, partial(handle_shutdown, pressure_reconciler=pressure_reconciler))
thread_manager.add_thread(target=pressure_reconciler.start_create_loop, daemon=True)
thread_manager.add_thread(target=pressure_reconciler.start_delete_loop, daemon=True)

thread_manager.start()
thread_manager.raise_on_error()
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,21 @@ class ApplicationConfiguration(BaseModel):
non_reactive_configuration: Configuration for non-reactive mode.
reactive_configuration: Configuration for reactive mode.
openstack_configuration: Configuration for authorization to a OpenStack host.
planner_url: Base URL of the planner service.
planner_token: Bearer token to authenticate against the planner service.
reconcile_interval: Seconds to wait between reconciliation.
"""

allow_external_contributor: bool = False
name: str
extra_labels: list[str]
github_config: github.GitHubConfiguration | None
github_config: github.GitHubConfiguration
service_config: "SupportServiceConfig"
non_reactive_configuration: "NonReactiveConfiguration"
reactive_configuration: "ReactiveConfiguration | None"
openstack_configuration: OpenStackConfiguration
planner_url: AnyHttpUrl
planner_token: str
reconcile_interval: int

@staticmethod
Expand Down
27 changes: 27 additions & 0 deletions github-runner-manager/src/github_runner_manager/http_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""HTTP client utilities for consistent requests session setup."""

from __future__ import annotations

import requests


def configure_session(adapter: requests.adapters.HTTPAdapter) -> requests.Session:
"""Return a requests session with the provided adapter mounted and proxies disabled.

This standardizes how we create sessions across clients by mounting the same adapter
on both HTTP and HTTPS and ensuring environment proxy variables are ignored.

Args:
adapter: A configured `HTTPAdapter` with retry policy.

Returns:
A configured `requests.Session` instance.
"""
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
session.trust_env = False
return session
Loading
Loading