diff --git a/app/clients/gcloud_client.py b/app/clients/gcloud_client.py index c241939..33b4ce4 100644 --- a/app/clients/gcloud_client.py +++ b/app/clients/gcloud_client.py @@ -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: @@ -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}") @@ -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 = [ diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index 4f8ad9b..dade81a 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -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: diff --git a/gcp/cloud-run.tf b/gcp/cloud-run.tf index efa19e7..59e2a4a 100644 --- a/gcp/cloud-run.tf +++ b/gcp/cloud-run.tf @@ -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 = { diff --git a/gcp/compute-vm.tf b/gcp/compute-vm.tf index d3d53ff..8b58d29 100644 --- a/gcp/compute-vm.tf +++ b/gcp/compute-vm.tf @@ -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 } } diff --git a/gcp/variables.tf b/gcp/variables.tf index 93762c4..7d22cf0 100644 --- a/gcp/variables.tf +++ b/gcp/variables.tf @@ -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 @@ -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" diff --git a/tests/unit/test_gcloud_client.py b/tests/unit/test_gcloud_client.py index f1ce6fc..cca6ff6 100644 --- a/tests/unit/test_gcloud_client.py +++ b/tests/unit/test_gcloud_client.py @@ -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): @@ -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): @@ -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."""