Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
ff1a35d
feat: parallel deletion of runners
yanksyoon Jul 2, 2025
8a86673
chore: minor refactor for imports
yanksyoon Jul 3, 2025
f70eb62
feat: add delete vms and extract metrics interface
yanksyoon Jul 3, 2025
2094b7b
feat: add delete runners interface
yanksyoon Jul 3, 2025
a4a4120
feat: delete runner controller implementation
yanksyoon Jul 3, 2025
f0dcf64
feat: delete runner implementation for GitHub platform
yanksyoon Jul 3, 2025
1f1f730
feat: delete runner implementation on jobmanager
yanksyoon Jul 3, 2025
cb4cbfc
feat: parallel vm deletion and metrics extraction
yanksyoon Jul 3, 2025
fbb96d5
feat: cloud manager + cloud delete vms implementation
yanksyoon Jul 3, 2025
aa7c2c1
chore: reorder helper func
yanksyoon Jul 3, 2025
d9032e1
test: runner pull metrics
yanksyoon Jul 3, 2025
9ca696b
feat: replace singular delete with parallel deletes
yanksyoon Jul 3, 2025
d7a0496
chore: ignore docstring errors in abc
yanksyoon Jul 3, 2025
e6064dd
chore: lint fixes
yanksyoon Jul 3, 2025
f91a533
Merge branch 'main' into feat/parallel-instance-deletion
yanksyoon Jul 10, 2025
49f1a0a
test: redo tests
yanksyoon Jul 13, 2025
6697dd1
test: fix openstack fixture
yanksyoon Jul 13, 2025
04b4b9d
Merge branch 'main' into feat/parallel-instance-deletion
yanksyoon Jul 13, 2025
afc5c03
chore: log extracted metric s
yanksyoon Jul 13, 2025
e952f64
debug
yanksyoon Jul 13, 2025
2dae730
chore: debug log
yanksyoon Jul 13, 2025
986b8a1
chore: try logging with file
yanksyoon Jul 14, 2025
cf076e9
general exception catch w/ traceback
yanksyoon Jul 14, 2025
b23b08e
fix: construct connection on subprocess call
yanksyoon Jul 14, 2025
ebd09d2
chore: minor lint fixes
yanksyoon Jul 14, 2025
b3d7ec0
chore: remove comments
yanksyoon Jul 14, 2025
4bb1a6f
chore: revert debug workflow
yanksyoon Jul 14, 2025
01f6f90
chore: tidy up factory comments
yanksyoon Jul 14, 2025
48aba37
Merge branch 'main' into feat/parallel-instance-deletion
yanksyoon Jul 16, 2025
ceb68ee
chore: remove reusing connection
yanksyoon Jul 16, 2025
41d9ff2
fix: pyproject merge conflict fix
yanksyoon Jul 16, 2025
ed7d185
fix: adapt changes to SelfHostedRunnerLabels
yanksyoon Jul 16, 2025
a37bb2d
fix: pyproject toml conflicting test ignores
yanksyoon Jul 16, 2025
0cf5a98
chore: remove todo comments
yanksyoon Jul 16, 2025
f5848e8
feat: use thread pool for concurrency
yanksyoon Jul 16, 2025
9bad113
feat: limit max workers
yanksyoon Jul 16, 2025
c528e83
feat: concurrent github runner delete request using threadpool
yanksyoon Jul 16, 2025
2b55893
feat: concurrent metrics fetching using multithreading
yanksyoon Jul 16, 2025
e76f9a9
chore: rename DeleteVMError
yanksyoon Jul 17, 2025
d219e0e
chore: update unused argument comment
yanksyoon Jul 17, 2025
5692a66
chore: rename mock to fake
yanksyoon Jul 17, 2025
6dabc41
fix: lint issues w docstring
yanksyoon Jul 17, 2025
65fc56d
debug
yanksyoon Jul 21, 2025
79437f6
fix: instantiate vars before assignment
yanksyoon Jul 21, 2025
722b383
chore: delete unused test bin
yanksyoon Jul 21, 2025
53df57c
chore: pass down delete timeout
yanksyoon Jul 21, 2025
346a038
chore: update keypair delete to fire and forget
yanksyoon Jul 21, 2025
7283628
chore: undo debug
yanksyoon Jul 21, 2025
e009961
fix: lint rules
yanksyoon Jul 21, 2025
2975bf1
chore: fix meta docstring
yanksyoon Jul 21, 2025
fe27cb7
Merge remote-tracking branch 'origin' into feat/parallel-instance-del…
yanksyoon Jul 21, 2025
a500b86
Merge remote-tracking branch 'origin' into feat/parallel-instance-del…
yanksyoon Jul 23, 2025
c13faec
chore: remove unused platform argument after multiplexer ejection
yanksyoon Jul 23, 2025
bc2d740
Merge remote-tracking branch 'origin' into feat/parallel-instance-del…
yanksyoon Jul 23, 2025
fcf7cb1
fix: result returns none
yanksyoon Jul 24, 2025
b06bf0b
fix: only return truely deleted openstack IDs
yanksyoon Jul 24, 2025
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
12 changes: 9 additions & 3 deletions github-runner-manager/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ select = ["E", "W", "F", "C", "N", "R", "D", "H"]
# Ignore W503,E203 because using black creates errors with this
# Ignore D107 Missing docstring in __init__
ignore = ["W503", "D107", "E203"]
# D100, D101, D102, D103, D104: Ignore docstring style issues in tests
# DCO020, DCO030, DCO050: Ignore docstring argument,returns,raises sections in tests
per-file-ignores = ["tests/*:D100,D101,D102,D103,D104,D205,D212, DCO020, DCO030, DCO050"]
per-file-ignores = [
# Ignore factory methods attributes docstring
"tests/unit/factories/*:DCO060",
# Ignore no return values (DCO031) in docstring for abstract methods
"src/github_runner_manager/manager/cloud_runner_manager.py:DCO031",
# DCO020, DCO030, DCO050, DCO060: Ignore docstring argument, returns, raises, attribute
# sections in tests
"tests/*:D100,D101,D102,D103,D104,D205,D212,DCO020,DCO030,DCO050,DCO060",
]
docstring-convention = "google"
# Check for properly formatted copyright header in each file
copyright-check = "True"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@
from requests import RequestException
from typing_extensions import assert_never

from github_runner_manager.configuration.github import (
GitHubOrg,
GitHubPath,
GitHubRepo,
)
from github_runner_manager.configuration.github import GitHubOrg, GitHubPath, GitHubRepo
from github_runner_manager.manager.models import InstanceID
from github_runner_manager.platform.platform_provider import (
DeleteRunnerBusyError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,6 @@ class HealthState(Enum):
UNHEALTHY = auto()
UNKNOWN = auto()

@staticmethod
Comment thread
yanksyoon marked this conversation as resolved.
def from_value(health: bool | None) -> "HealthState":
"""Create from a health value.

Args:
health: The health value as boolean or None.

Returns:
The health state.
"""
if health is None:
return HealthState.UNKNOWN
return HealthState.HEALTHY if health else HealthState.UNHEALTHY


class CloudRunnerState(str, Enum):
"""Represent state of the instance hosting the runner.
Expand Down Expand Up @@ -275,11 +261,25 @@ def get_runners(self) -> Sequence[CloudRunnerInstance]:
"""Get cloud self-hosted runners."""

@abc.abstractmethod
def delete_runner(self, instance_id: InstanceID) -> RunnerMetrics | None:
"""Delete self-hosted runner.
def delete_vms(self, instance_ids: Sequence[InstanceID]) -> list[InstanceID]:
"""Delete cloud VM instances.

Args:
instance_ids: The ID of the VMs to request deletion.

Returns:
The deleted instance IDs.
"""

@abc.abstractmethod
def extract_metrics(self, instance_ids: Sequence[InstanceID]) -> list[RunnerMetrics]:
"""Extract metrics from cloud VMs.

Args:
instance_id: The instance id of the runner to delete.
instance_ids: The VM instance IDs to fetch the metrics from.

Returns:
The fetched runner metrics.
"""

@abc.abstractmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ class InstanceID:
"""

prefix: str
reactive: bool | None
suffix: str
reactive: bool | None = None
Comment thread
yanksyoon marked this conversation as resolved.

@property
def name(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@
from github_runner_manager.metrics import events as metric_events
from github_runner_manager.metrics import github as github_metrics
from github_runner_manager.metrics import runner as runner_metrics
from github_runner_manager.metrics.reconcile import CLEANED_RUNNERS_TOTAL
from github_runner_manager.metrics.runner import RunnerMetrics
from github_runner_manager.openstack_cloud.constants import CREATE_SERVER_TIMEOUT
from github_runner_manager.platform.platform_provider import (
DeleteRunnerBusyError,
PlatformApiError,
PlatformProvider,
PlatformRunnerHealth,
Expand Down Expand Up @@ -82,27 +80,33 @@ class RunnerInstance:
platform_state: PlatformRunnerState | None
cloud_state: CloudRunnerState

def __init__(
self,
@classmethod
def from_cloud_and_platform_health(
Comment thread
yanksyoon marked this conversation as resolved.
cls,
cloud_instance: CloudRunnerInstance,
platform_health_state: PlatformRunnerHealth | None,
):
) -> "RunnerInstance":
"""Construct an instance.

Args:
cloud_instance: Information on the cloud instance.
platform_health_state: Health state in the platform provider.

Returns:
The RunnerInstance instantiated from cloud instance and platform state.
"""
self.name = cloud_instance.name
self.instance_id = cloud_instance.instance_id
self.metadata = cloud_instance.metadata
self.health = cloud_instance.health
self.platform_state = (
PlatformRunnerState.from_platform_health(platform_health_state)
if platform_health_state is not None
else None
return cls(
name=cloud_instance.name,
instance_id=cloud_instance.instance_id,
metadata=cloud_instance.metadata,
health=cloud_instance.health,
platform_state=(
PlatformRunnerState.from_platform_health(platform_health_state)
if platform_health_state is not None
else None
),
cloud_state=cloud_instance.state,
)
self.cloud_state = cloud_instance.state


class RunnerManager:
Expand Down Expand Up @@ -183,7 +187,7 @@ def get_runners(self) -> tuple[RunnerInstance, ...]:
health_runners_map = {runner.identity.instance_id: runner for runner in runners_health}
for cloud_runner in cloud_runners:
if cloud_runner.instance_id not in health_runners_map:
runner_instance = RunnerInstance(cloud_runner, None)
Comment thread
yanksyoon marked this conversation as resolved.
runner_instance = RunnerInstance.from_cloud_and_platform_health(cloud_runner, None)
runner_instance.health = HealthState.UNKNOWN
runner_instances.append(runner_instance)
continue
Expand All @@ -194,7 +198,9 @@ def get_runners(self) -> tuple[RunnerInstance, ...]:
cloud_runner.health = HealthState.HEALTHY
else:
cloud_runner.health = HealthState.UNHEALTHY
runner_instance = RunnerInstance(cloud_runner, health_runner)
runner_instance = RunnerInstance.from_cloud_and_platform_health(
cloud_runner, health_runner
)
runner_instances.append(runner_instance)
return cast(tuple[RunnerInstance], tuple(runner_instances))

Expand Down Expand Up @@ -268,7 +274,9 @@ def _cleanup_resources(
)
cloud_runners = self._cloud.get_runners()
logger.info("cleanup cloud_runners %s", cloud_runners)
runners_health_response = self._platform.get_runners_health(cloud_runners)
runners_health_response = self._platform.get_runners_health(
requested_runners=cloud_runners
)
logger.info("cleanup health_response %s", runners_health_response)

# Clean dangling resources in the cloud
Expand All @@ -295,7 +303,7 @@ def _cleanup_resources(
)
)

if maximum_runners_to_delete:
if maximum_runners_to_delete is not None:
Comment thread
yanksyoon marked this conversation as resolved.
cloud_runners_to_delete.sort(
key=partial(_runner_deletion_sort_key, health_runners_map)
)
Expand All @@ -313,58 +321,70 @@ def _delete_cloud_runners(
runners_health: Sequence[PlatformRunnerHealth],
delete_busy_runners: bool = False,
) -> Iterable[runner_metrics.RunnerMetrics]:
"""Delete runners in the platform ant the cloud.
"""Delete runners in the platform and the cloud.

If delete_busy_runners is False, when the platform provider fails in deleting the
runner because it can be busy, will mean that that runner should not be deleted.

Runners without health information should not be deleted.
"""
extracted_runner_metrics = []
health_runners_map = {health.identity.instance_id: health for health in runners_health}
for cloud_runner in cloud_runners:
logging.info("Trying to delete cloud_runner %s", cloud_runner)
runner_health = health_runners_map.get(cloud_runner.instance_id)
if runner_health and runner_health.runner_in_platform:
try:
self._platform.delete_runner(runner_health.identity)
except DeleteRunnerBusyError:
if not delete_busy_runners:
logger.warning(
"Skipping deletion as the runner is busy. %s", cloud_runner.instance_id
)
continue
logger.info("Deleting busy runner: %s", cloud_runner.instance_id)
except PlatformApiError as exc:
if not delete_busy_runners:
logger.warning(
"Failed to delete platform runner %s. %s. Skipping.",
cloud_runner.instance_id,
exc,
)
continue
logger.warning(
"Deleting runner: %s after platform failure %s.",
cloud_runner.instance_id,
exc,
)
if not cloud_runners:
return []

logging.info("Delete runner in cloud: %s", cloud_runner.instance_id)
runner_metric = self._cloud.delete_runner(cloud_runner.instance_id)
CLEANED_RUNNERS_TOTAL.labels(self.manager_name).inc(1)
if not runner_metric:
logger.error("No metrics returned after deleting %s", cloud_runner.instance_id)
else:
extracted_runner_metrics.append(runner_metric)
return extracted_runner_metrics
runner_identity_map = {
health_info.identity.instance_id: health_info.identity
for health_info in runners_health
}
platform_runner_ids_to_delete = [
# The runner_id cannot be None due to the if condition. the type system
# isn't able to catch that.
cast(str, runner_identity_map[runner.instance_id].metadata.runner_id)
for runner in cloud_runners
if runner.instance_id in runner_identity_map
and runner_identity_map[runner.instance_id].metadata.runner_id
]
logger.info("Deleting runners from platform: %s", platform_runner_ids_to_delete)
deleted_runner_ids = self._platform.delete_runners(
runner_ids=platform_runner_ids_to_delete
)
logger.info(
"Deleted runners from platform: %s (diff: %s)",
deleted_runner_ids,
set(platform_runner_ids_to_delete) - set(deleted_runner_ids),
)

logger.info("Cloud runners: %s", cloud_runners)
cloud_vm_ids_to_delete = [
runner.instance_id
for runner in cloud_runners
# We can delete all VMs if delete_busy_runners is True
if delete_busy_runners
# We can delete the VM if no runner is associated with it
Comment thread
yanksyoon marked this conversation as resolved.
or not runner.metadata.runner_id
# We can delete the VM if it has been deleted from the Platform provider.
or runner.metadata.runner_id in deleted_runner_ids
]
logger.info("Extracting metrics from cloud VMs: %s", cloud_vm_ids_to_delete)
extracted_metrics = self._cloud.extract_metrics(instance_ids=cloud_vm_ids_to_delete)
logger.info("Extracted metrics from cloud VMs: %s", extracted_metrics)
logger.info("Deleting VMs %s", cloud_vm_ids_to_delete)
deleted_vm_ids = self._cloud.delete_vms(instance_ids=cloud_vm_ids_to_delete)
logger.info(
"Deleted VMs: %s, (diff: %s)",
deleted_vm_ids,
set(cloud_vm_ids_to_delete) - set(deleted_vm_ids),
)
return tuple(extracted_metrics)

def _clean_platform_runners(self, runners: list[RunnerIdentity]) -> None:
"""Clean the specified runners in the platform."""
for runner in runners:
try:
self._platform.delete_runner(runner)
except DeleteRunnerBusyError:
logger.warning("Tried to delete busy runner in cleanup %s", runner)
except PlatformApiError:
logger.warning("Failed to delete platform runner %s", runner)
if not runners:
return

runner_ids_to_delete = [
runner.metadata.runner_id for runner in runners if runner.metadata.runner_id
]
self._platform.delete_runners(runner_ids=runner_ids_to_delete)

@staticmethod
def _spawn_runners(
Expand Down Expand Up @@ -525,7 +545,7 @@ def _create_runner(args: _CreateRunnerArgs) -> InstanceID:
)
except RunnerError:
logger.warning("Deleting runner %s from platform after creation failed", instance_id)
args.platform_provider.delete_runner(runner_info.identity)
args.platform_provider.delete_runners(runner_ids=[args.metadata.runner_id])
raise
return instance_id

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class _ReconcileMetricData:
start_timestamp: float
end_timestamp: float
metric_stats: IssuedMetricEventsStats
runner_list: tuple[RunnerInstance]
runner_list: tuple[RunnerInstance, ...]
Comment thread
yanksyoon marked this conversation as resolved.
flavor: str
expected_runner_quantity: int

Expand Down Expand Up @@ -375,7 +375,7 @@ def _reconcile_non_reactive(self, expected_quantity: int) -> _ReconcileResult:
return _ReconcileResult(runner_diff=runner_diff, metric_stats=metric_stats)

@staticmethod
def _log_runners(runner_list: tuple[RunnerInstance]) -> None:
def _log_runners(runner_list: tuple[RunnerInstance, ...]) -> None:
"""Log information about the runners found.

Args:
Expand Down Expand Up @@ -446,7 +446,6 @@ def _issue_reconciliation_metric(
IDLE_RUNNERS_COUNT.labels(manager_name).set(len(idle_runners))

try:

metric_events.issue_event(
metric_events.Reconciliation(
timestamp=time.time(),
Expand Down
Loading
Loading