Skip to content
Open
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
21 changes: 16 additions & 5 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 @@ -79,7 +80,7 @@ def create_runner_instance(self, registration_token, repo_url, template_name, in
if instance_template_resource.name.startswith("dependabot"):
instance_name = f"dependabot-{instance_uuid}"
else:
instance_name = f"runner-{instance_uuid}"
instance_name = f"gcp-runner-{instance_uuid}"

logger.info(f"Creating GCE instance {instance_name} with template {instance_template_resource.self_link}")

Expand All @@ -96,12 +97,22 @@ def create_runner_instance(self, registration_token, repo_url, template_name, in
}

# 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 @@ -104,6 +104,10 @@ def _handle_completed_job(self, workflow_job):
logger.warning("Job completed but no runner_name found in payload.")
return

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)
except Exception as e:
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 @@ -32,6 +32,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 @@ -44,6 +45,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 @@ -75,9 +86,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