EventBridge Rule

You are viewing article number 11 of 12 in the series Scalable Self-Hosted GitHub Runners on AWS Cloud

An EventBridge rule will be required to filter for queued workflow jobs, and “react” by invoking an ECS task (target).

Some points to consider when setting up the rule:

  • source event fields to include in rule’s pattern filter
  • the Input path and Template configuration for the Input Transformer ContainerOverrideโ€”for example, source event values to pass to the ECS container as environment variables
  • runner naming convention which ensures that a workflow job is assigned to a unique ephemeral runner

Terraform will be used to create a rule based on a sample workflow job event.

๐Ÿ›ˆThe example Input Transformer ContainerOverride associated with the rule only accommodates for GitHub workflow jobs with a single label assignment.

In cases where the transformation includes multiple labels, consider using a Step Function State Machine.

Before continuing, the following resources must already exist:

GitHub Organisationfoo-organisation
Runner registration secretarn:aws:secretsmanager:us-east-1:xxxxxxxxxxxx:secret:github-actions-runner-registration-token
EventBusgithub-actions-event-bus
ECS ClusterGitHub-Actions-Runners
Runner ECS Taskgithub-actions-self-hosted-runner-debian-task

Workflow Job Event Schema

The schema/structure of the workflow job event is stored in the EventBridge “Discovered schema registry“, with a schema name of “github.com@Workflow_job“.

Eventbridge Discovered Schema Registry
github.com_Workflow_job schema

Event Rule

Sample Event

Expand the content below to view the sample workflow_job event with a status of queued.

Sample workflow_job event with a status of queued
{
    "version": "0",
    "id": "fsdfsdfsdf-sdfs-fsdfs-dfsdf-sfdsfsdfsdfsdf",
    "detail-type": "workflow_job",
    "source": "github.com",
    "account": "00000000000",
    "time": "2021-01-27T01:42:33Z",
    "region": "us-east-1",
    "resources": [],
    "detail": {
        "action": "queued",
        "workflow_job": {
            "id": 77777777777,
            "run_id": 6666666666,
            "workflow_name": "CI",
            "head_branch": "main",
            "run_url": "https://api.github.com/repos/foo-organisation/foo-repository/actions/runs/6666666666",
            "run_attempt": 1,
            "node_id": "MU_plDAIHFKJKJFKDJFF9999999",
            "head_sha": "8993uhgf49rg4g4g4gtg4tg4g4tg49tgh49gh94gg",
            "url": "https://api.github.com/repos/foo-organisation/foo-repository/actions/jobs/77777777777",
            "html_url": "https://github.com/foo-organisation/foo-repository/actions/runs/6666666666/job/77777777777",
            "status": "queued",
            "conclusion": null,
            "created_at": "2021-01-27T01:42:30Z",
            "started_at": "2021-01-27T01:42:30Z",
            "completed_at": null,
            "name": "build",
            "steps": [],
            "check_run_url": "https://api.github.com/repos/foo-organisation/foo-repository/check-runs/77777777777",
            "labels": [
                "debian-amd64_foo-organisation_foo-repository_6666666666_1"
            ],
            "runner_id": null,
            "runner_name": null,
            "runner_group_id": null,
            "runner_group_name": null
        },
        "repository": {
            "id": 121212121,
            "node_id": "I_KJHFHJDHFDK",
            "name": "foo-repository",
            "full_name": "foo-organisation/foo-repository",
            "private": true,
            "owner": {
                "login": "foo-organisation",
                "id": 123456789,
                "node_id": "A_JHJFKDHFH",
                "avatar_url": "https://avatars.githubusercontent.com/u/123456789?v=4",
                "gravatar_id": "",
                "url": "https://api.github.com/users/foo-organisation",
                "html_url": "https://github.com/foo-organisation",
                "followers_url": "https://api.github.com/users/foo-organisation/followers",
                "following_url": "https://api.github.com/users/foo-organisation/following{/other_user}",
                "gists_url": "https://api.github.com/users/foo-organisation/gists{/gist_id}",
                "starred_url": "https://api.github.com/users/foo-organisation/starred{/owner}{/repo}",
                "subscriptions_url": "https://api.github.com/users/foo-organisation/subscriptions",
                "organizations_url": "https://api.github.com/users/foo-organisation/orgs",
                "repos_url": "https://api.github.com/users/foo-organisation/repos",
                "events_url": "https://api.github.com/users/foo-organisation/events{/privacy}",
                "received_events_url": "https://api.github.com/users/foo-organisation/received_events",
                "type": "Organization",
                "site_admin": false
            },
            "html_url": "https://github.com/foo-organisation/foo-repository",
            "description": null,
            "fork": false,
            "url": "https://api.github.com/repos/foo-organisation/foo-repository",
            "forks_url": "https://api.github.com/repos/foo-organisation/foo-repository/forks",
            "keys_url": "https://api.github.com/repos/foo-organisation/foo-repository/keys{/key_id}",
            "collaborators_url": "https://api.github.com/repos/foo-organisation/foo-repository/collaborators{/collaborator}",
            "teams_url": "https://api.github.com/repos/foo-organisation/foo-repository/teams",
            "hooks_url": "https://api.github.com/repos/foo-organisation/foo-repository/hooks",
            "issue_events_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues/events{/number}",
            "events_url": "https://api.github.com/repos/foo-organisation/foo-repository/events",
            "assignees_url": "https://api.github.com/repos/foo-organisation/foo-repository/assignees{/user}",
            "branches_url": "https://api.github.com/repos/foo-organisation/foo-repository/branches{/branch}",
            "tags_url": "https://api.github.com/repos/foo-organisation/foo-repository/tags",
            "blobs_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/blobs{/sha}",
            "git_tags_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/tags{/sha}",
            "git_refs_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/refs{/sha}",
            "trees_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/trees{/sha}",
            "statuses_url": "https://api.github.com/repos/foo-organisation/foo-repository/statuses/{sha}",
            "languages_url": "https://api.github.com/repos/foo-organisation/foo-repository/languages",
            "stargazers_url": "https://api.github.com/repos/foo-organisation/foo-repository/stargazers",
            "contributors_url": "https://api.github.com/repos/foo-organisation/foo-repository/contributors",
            "subscribers_url": "https://api.github.com/repos/foo-organisation/foo-repository/subscribers",
            "subscription_url": "https://api.github.com/repos/foo-organisation/foo-repository/subscription",
            "commits_url": "https://api.github.com/repos/foo-organisation/foo-repository/commits{/sha}",
            "git_commits_url": "https://api.github.com/repos/foo-organisation/foo-repository/git/commits{/sha}",
            "comments_url": "https://api.github.com/repos/foo-organisation/foo-repository/comments{/number}",
            "issue_comment_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues/comments{/number}",
            "contents_url": "https://api.github.com/repos/foo-organisation/foo-repository/contents/{+path}",
            "compare_url": "https://api.github.com/repos/foo-organisation/foo-repository/compare/{base}...{head}",
            "merges_url": "https://api.github.com/repos/foo-organisation/foo-repository/merges",
            "archive_url": "https://api.github.com/repos/foo-organisation/foo-repository/{archive_format}{/ref}",
            "downloads_url": "https://api.github.com/repos/foo-organisation/foo-repository/downloads",
            "issues_url": "https://api.github.com/repos/foo-organisation/foo-repository/issues{/number}",
            "pulls_url": "https://api.github.com/repos/foo-organisation/foo-repository/pulls{/number}",
            "milestones_url": "https://api.github.com/repos/foo-organisation/foo-repository/milestones{/number}",
            "notifications_url": "https://api.github.com/repos/foo-organisation/foo-repository/notifications{?since,all,participating}",
            "labels_url": "https://api.github.com/repos/foo-organisation/foo-repository/labels{/name}",
            "releases_url": "https://api.github.com/repos/foo-organisation/foo-repository/releases{/id}",
            "deployments_url": "https://api.github.com/repos/foo-organisation/foo-repository/deployments",
            "created_at": "2021-09-23T09:27:03Z",
            "updated_at": "2021-09-23T09:27:04Z",
            "pushed_at": "2021-01-26T10:35:28Z",
            "git_url": "git://github.com/foo-organisation/foo-repository.git",
            "ssh_url": "git@github.com:foo-organisation/foo-repository.git",
            "clone_url": "https://github.com/foo-organisation/foo-repository.git",
            "svn_url": "https://github.com/foo-organisation/foo-repository",
            "homepage": null,
            "size": 5,
            "stargazers_count": 0,
            "watchers_count": 0,
            "language": null,
            "has_issues": true,
            "has_projects": true,
            "has_downloads": true,
            "has_wiki": false,
            "has_pages": false,
            "has_discussions": false,
            "forks_count": 0,
            "mirror_url": null,
            "archived": false,
            "disabled": false,
            "open_issues_count": 0,
            "license": null,
            "allow_forking": false,
            "is_template": false,
            "web_commit_signoff_required": false,
            "topics": [],
            "visibility": "private",
            "forks": 0,
            "open_issues": 0,
            "watchers": 0,
            "default_branch": "main"
        },
        "organization": {
            "login": "foo-organisation",
            "id": 123456789,
            "node_id": "A_JHJFKDHFH",
            "url": "https://api.github.com/orgs/foo-organisation",
            "repos_url": "https://api.github.com/orgs/foo-organisation/repos",
            "events_url": "https://api.github.com/orgs/foo-organisation/events",
            "hooks_url": "https://api.github.com/orgs/foo-organisation/hooks",
            "issues_url": "https://api.github.com/orgs/foo-organisation/issues",
            "members_url": "https://api.github.com/orgs/foo-organisation/members{/member}",
            "public_members_url": "https://api.github.com/orgs/foo-organisation/public_members{/member}",
            "avatar_url": "https://avatars.githubusercontent.com/u/123456789?v=4",
            "description": null
        },
        "sender": {
            "login": "some-github-user",
            "id": 88888888,
            "node_id": "JKHKJFDKJFKDHFDKJHFg",
            "avatar_url": "https://avatars.githubusercontent.com/u/88888888?v=4",
            "gravatar_id": "",
            "url": "https://api.github.com/users/some-github-user",
            "html_url": "https://github.com/some-github-user",
            "followers_url": "https://api.github.com/users/some-github-user/followers",
            "following_url": "https://api.github.com/users/some-github-user/following{/other_user}",
            "gists_url": "https://api.github.com/users/some-github-user/gists{/gist_id}",
            "starred_url": "https://api.github.com/users/some-github-user/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/some-github-user/subscriptions",
            "organizations_url": "https://api.github.com/users/some-github-user/orgs",
            "repos_url": "https://api.github.com/users/some-github-user/repos",
            "events_url": "https://api.github.com/users/some-github-user/events{/privacy}",
            "received_events_url": "https://api.github.com/users/some-github-user/received_events",
            "type": "User",
            "site_admin": false
        }
    }
}

The event includes the following JSON key:value pairs:

"source": "github.com"

Identifies the source/service that generated the event
"detail-type": "workflow_job"

The Github webhook event that triggered the hook. In this case, workflow_job
"detail.workflow_job.status": "queued"

Status of the workflow job
"detail.organization.login": "foo-organization"

Github organization name
"detail.workflow_job.labels": "debian-amd64_123456789_121212121_6666666666_1"

Runner label, as assigned to the “runs-on:” statement for the workflow job. The assumption is that workflow jobs use a single label, with a prefix of debian-amd64, and combination of GitHub context values, to uniquely identify the ephemeral runner, i.e.,

runs-on: "debian-amd64_${{github.repository_owner_id}}_${{github.repository_id}}_${{github.run_id}}_${{github.run_attempt}}"

The following event pattern can be used to filter for events matching the above criteria:

{
  "detail": {
    "organization": {
      "login": ["foo-organisation"]
    },
    "workflow_job": {
      "labels": [{
        "prefix": "debian-amd64"
      }],
      "status": ["queued"]
    }
  },
  "detail-type": ["workflow_job"],
  "source": ["github.com"]
}

The Terraform source for deploying the rule is included in the next section.

Deploy EventBridge Rule using Terraform

Create the Terraform files below, placing them in a common directory. Change names of variables to match your account/region, etc.

variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "aws_account" {
  description = "AWS account ID"
  type        = string
  default     = "xxxxxxxxxxxx"
}

variable "aws_profile" {
  description = "AWS profile"
  type        = string
  default     = "my-aws-account-creds"
}

variable "cluster_name" {
  type        = string
  description = "Target cluster where ECS runner task will execute"
  default     = "GitHub-Actions-Runners"
}

variable "event_bus_name" {
  description = "Eventbridge bus name where github events are received"
  type        = string
  default     = "github-actions-event-bus"
}
data.tf
data "aws_cloudwatch_event_bus" "this" {
  name = var.event_bus_name
}
iam.tf
# IAM role trust relationship
resource "aws_iam_role" "this" {
  name               = "event_rule_role"
  description        = "IAM policy for event"
  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "events.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

# IAM policy allowing eventbridge rule to trigger ecs task
resource "aws_iam_policy" "this" {
  name        = "event_rule_trust_policy"
  description = "IAM policy for event target"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Action" : [
          "ecs:RunTask"
        ],
        "Resource" : [
          "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}:*",
          "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}"
        ],
        "Condition" : {
          "ArnLike" : {
            "ecs:cluster" : "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:cluster/${var.cluster_name}"
          }
        }
      },
      {
        "Effect" : "Allow",
        "Action" : "iam:PassRole",
        "Resource" : [
          "*"
        ],
        "Condition" : {
          "StringLike" : {
            "iam:PassedToService" : "ecs-tasks.amazonaws.com"
          }
        }
      }
    ]
  })
}


# Attach policy to IAM role
resource "aws_iam_role_policy_attachment" "this" {
  role       = aws_iam_role.this.name
  policy_arn = aws_iam_policy.this.arn
}
main.tf
provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

locals {
  runner_label_prefix         = "debian_amd64"
  target_ecs_task_name        = "github-actions-self-hosted-runner-debian-task"
  git_org                     = "foo-organisation"
}

resource "aws_cloudwatch_event_rule" "this" {
  name        = "github-actions-trigger-runner"
  description = "Rule to trigger ephemeral Github runner when a workflow queued event is received"
  event_bus_name = data.aws_cloudwatch_event_bus.this.name
  event_pattern  = <<EOF
{
  "detail": {
    "organization": {
      "login": ["${local.git_org}"]
    },
    "workflow_job": {
      "labels": [{
        "prefix": "${local.runner_label_prefix}"
      }],
      "status": ["queued"]
    }
  },
  "source": ["github.com"]
}
EOF
}

resource "aws_cloudwatch_event_target" "this" {
  target_id      = local.target_ecs_task_name
  event_bus_name = data.aws_cloudwatch_event_bus.this.name
  rule           = aws_cloudwatch_event_rule.this.name
  arn            = "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:cluster/${var.cluster_name}"
  role_arn       = aws_iam_role.this.arn

  ecs_target {
    task_count              = 1
    task_definition_arn     = "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/${local.target_ecs_task_name}"
    enable_execute_command  = false
    enable_ecs_managed_tags = false   
  }

  input_transformer {
    input_paths = {
		detail_org = "$.detail.organization.login",
		detail_org_id = "$.detail.organization.id",
		detail_repo_id = "$.detail.repository.id",
		wf_job_run_id = "$.detail.workflow_job.run_id",
		wf_job_run_attempt = "$.detail.workflow_job.run_attempt"
    }
    input_template = <<EOF
{
    "containerOverrides": [{
            "name": "github-actions-self-hosted-runner-debian-container",
            "environment": [{
                    "name": "ORGANIZATION",
                    "value": "<detail_org>"
                }, {
                    "name": "RUNNER_CONFIG_ARGS",
                    "value": "--unattended --replace --disableupdate --no-default-labels --name ${local.runner_label_prefix}_<detail_org_id>_<detail_repo_id>_<wf_job_run_id>_<wf_job_run_attempt> --ephemeral"
                }, {
                    "name": "RUNNER_LABELS",
                    "value": "${local.runner_label_prefix}_<detail_org_id>_<detail_repo_id>_<wf_job_run_id>_<wf_job_run_attempt>"
                }                              
            ]
        }
    ]
}
EOF
  }
}

Initialise Terraform:

terraform init

Generate plan:

terraform plan

Apply the plan:

terraform apply
Series Navigation<< GitHub WebhookTesting the Final Infrastructure >>