Skip to content
Merged
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
25 changes: 19 additions & 6 deletions app/clients/gcloud_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(self):
"""Initialize GCloudClient with project and zone configuration."""
self.project_id = os.environ.get('GOOGLE_CLOUD_PROJECT')
self.zone = os.environ.get('GOOGLE_CLOUD_ZONE', 'us-central1-a')
self.github_runner_group = os.environ.get('GITHUB_RUNNER_GROUP', '').strip()
self.region = '-'.join(self.zone.split('-')[:-1])

if not self.project_id:
Expand Down Expand Up @@ -92,11 +93,13 @@ def create_runner_instance(
)
return None

# Name must start with a lowercase letter followed by up to 62 lowercase letters,
# numbers, or hyphens, and cannot end with a hyphen.
instance_uuid = uuid.uuid4().hex[:16]
if instance_template_resource.name.startswith("dependabot"):
instance_name = f"dependabot-{instance_uuid}"
instance_name = f"gcp-runner-dependabot-{instance_uuid}"
else:
instance_name = f"runner-{instance_uuid}"
instance_name = f"gcp-runner-{instance_uuid}"

logger.info(
"Creating GCE instance %s with template %s, delivery_id: %s",
Expand All @@ -118,12 +121,22 @@ def create_runner_instance(
}

# Set metadata (startup script) - use shlex.quote to prevent command injection
runner_group_flag = ""
if self.github_runner_group:
runner_group_flag = f" --runnergroup {shlex.quote(self.github_runner_group)}"

startup_script = (
f"sudo -u runner /actions-runner/config.sh --url {shlex.quote(repo_url)} "
"cd /actions-runner && "
f"sudo -u runner ./config.sh --url {shlex.quote(repo_url)} "
f"--token {shlex.quote(registration_token)} "
f"--name {shlex.quote(instance_name)} --labels {shlex.quote(template_name)} "
"--ephemeral --unattended --no-default-labels --disableupdate && "
"sudo -u runner /actions-runner/run.sh"
f"--name {shlex.quote(instance_name)} "
f"--labels {shlex.quote(template_name)} "
f"{runner_group_flag} "
"--ephemeral "
"--unattended "
"--no-default-labels "
"--disableupdate && "
"sudo -u runner ./run.sh"
)
metadata = compute_v1.Metadata()
metadata.items = [
Expand Down
4 changes: 4 additions & 0 deletions app/services/webhook_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ def _handle_completed_job(self, workflow_job, delivery_id=None):
)
return None

if not runner_name.startswith('gcp-runner-'):
logger.warning("gcp-runner prefix not found in runner name %s. Ignoring job.", runner_name)
return

try:
self.gcloud_client.delete_runner_instance(
runner_name, delivery_id=delivery_id
Expand Down
1 change: 1 addition & 0 deletions gcp/cloud-run.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module "cloud_run_github_runners_manager" {
env = {
GOOGLE_CLOUD_PROJECT = var.project_id
GOOGLE_CLOUD_ZONE = "${var.region}-${var.zone}"
GITHUB_RUNNER_GROUP = var.github_runner_group
}
env_from_key = {
GITHUB_APP_ID = {
Expand Down
2 changes: 1 addition & 1 deletion gcp/compute-vm.tf
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ module "github-runners-vm-templates" {
termination_action = "DELETE"
# https://docs.github.com/en/actions/reference/limits#existing-system-limits
max_run_duration = {
seconds = (86400 * 5) + 300 # Terminate Instance after 5 days, 5 minutes
seconds = var.github_runners_max_run_duration
}
}

Expand Down
19 changes: 19 additions & 0 deletions gcp/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ variable "zone" {
}
}

variable "github_runner_group" {
description = "GitHub Actions runner group name passed to the Cloud Run service; blank disables --runnergroup"
type = string
default = ""
nullable = false
}

variable "github_runners_internal_cidr" {
description = "The Internal IP Range used for the GitHub Actions Runners"
type = string
Expand Down Expand Up @@ -100,6 +107,18 @@ variable "github_runners_manager_max_instance_count" {
}
}

# Maximum runtime for GitHub Actions runner VMs before Compute Engine force-deletes them
variable "github_runners_max_run_duration" {
description = "Maximum runtime in seconds for GitHub Actions runner VMs before termination"
type = number
default = (86400 * 5) + 300

validation {
condition = var.github_runners_max_run_duration > 0
error_message = "Maximum run duration must be greater than 0 seconds."
}
}

# Map of default VM images for GitHub Actions Runners by architecture
variable "github_runners_default_image" {
description = "Default GitHub Actions Runners images (family images) for different CPU architectures"
Expand Down
49 changes: 48 additions & 1 deletion tests/unit/test_gcloud_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def test_init_with_env_vars(self, mock_env_vars, mock_compute_clients, mock_gclo

assert client.project_id == 'test-project'
assert client.zone == 'us-central1-a'
assert client.github_runner_group == ''
assert client.region == 'us-central1'

def test_init_default_zone(self, monkeypatch, mock_compute_clients, mock_gcloud_auth):
Expand All @@ -45,6 +46,16 @@ def test_init_default_zone(self, monkeypatch, mock_compute_clients, mock_gcloud_
assert client.zone == 'us-central1-a'
assert client.region == 'us-central1'

def test_init_with_runner_group(self, monkeypatch, mock_compute_clients, mock_gcloud_auth):
"""Test GCloudClient initialization with runner group."""
monkeypatch.setenv('GOOGLE_CLOUD_PROJECT', 'test-project')
monkeypatch.setenv('GOOGLE_CLOUD_ZONE', 'us-central1-a')
monkeypatch.setenv('GITHUB_RUNNER_GROUP', 'platform-runners')

client = GCloudClient()

assert client.github_runner_group == 'platform-runners'

def test_init_missing_project_id(self, mock_compute_clients, mock_gcloud_auth):
"""Test GCloudClient initialization with missing project ID."""
with patch.dict('os.environ', {}, clear=True):
Expand Down Expand Up @@ -76,9 +87,45 @@ def test_create_runner_instance(self, mock_compute, mock_env_vars):
'gcp-ubuntu-24.04'
)

assert instance_name.startswith('runner-')
assert instance_name.startswith('gcp-runner-')
mock_instance_client.insert.assert_called_once()

startup_script = mock_compute.Items.call_args_list[0].kwargs['value']
assert startup_script.startswith('cd /actions-runner && ')
assert 'sudo -u runner ./config.sh' in startup_script
assert 'sudo -u runner ./run.sh' in startup_script
assert '--runnergroup' not in startup_script

@patch('app.clients.gcloud_client.compute_v1')
def test_create_runner_instance_with_runner_group(self, mock_compute, monkeypatch, mock_env_vars):
"""Test creating a runner instance with runner group."""
monkeypatch.setenv('GITHUB_RUNNER_GROUP', 'platform-runners')

mock_instance_client = MagicMock()
mock_operation = MagicMock()
mock_operation.name = 'operation-123'
mock_instance_client.insert.return_value = mock_operation
mock_compute.InstancesClient.return_value = mock_instance_client

mock_templates_client = MagicMock()
mock_template = MagicMock()
mock_template.name = 'gcp-ubuntu-24-04-12345678901234'
mock_template.self_link = ('https://www.googleapis.com/compute/v1/projects/test-project/regions/us-central1/'
'instanceTemplates/gcp-ubuntu-24-04-12345678901234')
mock_templates_client.list.return_value = [mock_template]
mock_compute.RegionInstanceTemplatesClient.return_value = mock_templates_client

client = GCloudClient()
client.create_runner_instance(
'fake-token-12345678',
'https://github.com/owner/repo',
'gcp-ubuntu-24.04'
)

startup_script = mock_compute.Items.call_args_list[0].kwargs['value']
assert startup_script.startswith('cd /actions-runner && ')
assert '--runnergroup platform-runners' in startup_script

@patch('app.clients.gcloud_client.compute_v1')
def test_create_runner_instance_error(self, mock_compute, mock_env_vars):
"""Test error handling when creating instance fails."""
Expand Down
20 changes: 10 additions & 10 deletions tests/unit/test_webhook_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def test_handle_queued_job_with_matching_label(self, mock_gh_client_class, mock_
mock_gh_client_class.return_value = mock_gh_client

mock_gc_client = Mock()
mock_gc_client.create_runner_instance.return_value = "runner-abc123"
mock_gc_client.create_runner_instance.return_value = "gcp-runner-abc123"
mock_gc_client_class.return_value = mock_gc_client

service = WebhookService()
Expand All @@ -32,7 +32,7 @@ def test_handle_queued_job_with_matching_label(self, mock_gh_client_class, mock_

result = service.handle_workflow_job(payload, delivery_id="delivery-001")

assert result == {"action": "created", "runner_name": "runner-abc123"}
assert result == {"action": "created", "runner_name": "gcp-runner-abc123"}
mock_gh_client.get_registration_token.assert_called_once_with(
repo_name='owner/repo', delivery_id="delivery-001"
)
Expand All @@ -53,7 +53,7 @@ def test_handle_queued_job_for_org(self, mock_gh_client_class, mock_gc_client_cl
mock_gh_client_class.return_value = mock_gh_client

mock_gc_client = Mock()
mock_gc_client.create_runner_instance.return_value = "runner-org456"
mock_gc_client.create_runner_instance.return_value = "gcp-runner-org456"
mock_gc_client_class.return_value = mock_gc_client

service = WebhookService()
Expand All @@ -74,7 +74,7 @@ def test_handle_queued_job_for_org(self, mock_gh_client_class, mock_gc_client_cl

result = service.handle_workflow_job(payload, delivery_id="delivery-org-001")

assert result == {"action": "created", "runner_name": "runner-org456"}
assert result == {"action": "created", "runner_name": "gcp-runner-org456"}
mock_gh_client.get_registration_token.assert_called_once_with(
org_name='my-org', delivery_id="delivery-org-001"
)
Expand Down Expand Up @@ -125,17 +125,17 @@ def test_handle_completed_job(self, mock_gh_client_class, mock_gc_client_class):
payload = {
'action': 'completed',
'workflow_job': {
'runner_name': 'runner-12345'
'runner_name': 'gcp-runner-12345'
}
}

result = service.handle_workflow_job(
payload, delivery_id="delivery-completed-001"
)

assert result == {"action": "deleted", "runner_name": "runner-12345"}
assert result == {"action": "deleted", "runner_name": "gcp-runner-12345"}
mock_gc_client.delete_runner_instance.assert_called_once_with(
'runner-12345', delivery_id="delivery-completed-001"
'gcp-runner-12345', delivery_id="delivery-completed-001"
)

@patch('app.services.webhook_service.GCloudClient')
Expand Down Expand Up @@ -230,16 +230,16 @@ def test_handle_completed_job_with_error(self, mock_gh_client_class, mock_gc_cli
payload = {
'action': 'completed',
'workflow_job': {
'runner_name': 'runner-12345'
'runner_name': 'gcp-runner-12345'
}
}

# Should not raise exception, just log error and return runner_name
result = service.handle_workflow_job(payload, delivery_id="delivery-delerr-001")

assert result == {"action": "deleted", "runner_name": "runner-12345"}
assert result == {"action": "deleted", "runner_name": "gcp-runner-12345"}
mock_gc_client.delete_runner_instance.assert_called_once_with(
'runner-12345', delivery_id="delivery-delerr-001"
'gcp-runner-12345', delivery_id="delivery-delerr-001"
)


Expand Down
Loading