diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl index b4043fb5eb..61e1824697 100644 --- a/.terraform.lock.hcl +++ b/.terraform.lock.hcl @@ -3,7 +3,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "5.31.0" - constraints = "~> 5.27" + constraints = "~> 5.0, ~> 5.27" hashes = [ "h1:ltxyuBWIy9cq0kIKDJH1jeWJy/y7XJLjS4QrsQK4plA=", "zh:0cdb9c2083bf0902442384f7309367791e4640581652dda456f2d6d7abf0de8d", @@ -24,6 +24,26 @@ provider "registry.terraform.io/hashicorp/aws" { ] } +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.3" + constraints = "~> 3.2" + hashes = [ + "h1:I0Um8UkrMUb81Fxq/dxbr3HLP2cecTH2WMJiwKSrwQY=", + "zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2", + "zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d", + "zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3", + "zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f", + "zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301", + "zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670", + "zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed", + "zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65", + "zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd", + "zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5", + ] +} + provider "registry.terraform.io/hashicorp/random" { version = "3.6.0" constraints = "~> 3.0" diff --git a/README.md b/README.md index 2c2283f711..2d734da30d 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,8 @@ Talk to the forestkeepers in the `runners-channel` on Slack. | [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | `false` | no | | [enable\_user\_data\_debug\_logging\_runner](#input\_enable\_user\_data\_debug\_logging\_runner) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no | -| [enable\_workflow\_job\_events\_queue](#input\_enable\_workflow\_job\_events\_queue) | Enabling this experimental feature will create a secondory sqs queue to which a copy of the workflow\_job event will be delivered. | `bool` | `false` | no | +| [enable\_workflow\_job\_events\_queue](#input\_enable\_workflow\_job\_events\_queue) | Enabling this experimental feature will create a secondary SQS queue to which a copy of the workflow\_job event will be delivered. | `bool` | `false` | no | +| [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, false)
accept_events = optional(list(string), null)
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). |
object({
key_base64 = string
id = string
webhook_secret = string
})
| n/a | yes | diff --git a/docs/assets/aws-architecture.dark.png b/docs/assets/aws-architecture.dark.png index ea6d3f38a6..ae5869760e 100644 Binary files a/docs/assets/aws-architecture.dark.png and b/docs/assets/aws-architecture.dark.png differ diff --git a/docs/assets/aws-architecture.light.png b/docs/assets/aws-architecture.light.png index 8fc065cf0b..79dbce4b91 100644 Binary files a/docs/assets/aws-architecture.light.png and b/docs/assets/aws-architecture.light.png differ diff --git a/docs/configuration.md b/docs/configuration.md index 4b5f33507e..6d74b1fe6a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,7 @@ To be able to support a number of use-cases, the module has quite a lot of confi - Org vs Repo level. You can configure the module to connect the runners in GitHub on an org level and share the runners in your org, or set the runners on repo level and the module will install the runner to the repo. There can be multiple repos but runners are not shared between repos. - Multi-Runner module. This modules allows you to create multiple runner configurations with a single webhook and single GitHub App to simplify deployment of different types of runners. Check the detailed module [documentation](modules/public/multi-runner.md) for more information or checkout the [multi-runner example](examples/multi-runner.md). -- Workflow job event. You can configure the webhook in GitHub to send workflow job events to the webhook. Workflow job events were introduced by GitHub in September 2021 and are designed to support scalable runners. We advise using the workflow job event when possible. +- Webhook mode, the module can be deployed in `direct` mode or `EventBridge` (Experimental) mode. The `direct` mode is the default and will directly distribute to SQS for the scale-up lambda. The `EventBridge` mode will publish the events to a eventbus, the rule then directs the received events to a dispatch lambda. The dispatch lambda will send the event to the SQS queue. The `EventBridge` mode is useful when you want to have more control over the events and potentially filter them. The `EventBridge` mode is disabled by default. An example of what the `EventBridge` mode could be used for is building a data lake, build metrics, act on `workflow_job` job started events, etc. - Linux vs Windows. You can configure the OS types linux and win. Linux will be used by default. - Re-use vs Ephemeral. By default runners are re-used, until detected idle. Once idle they will be removed from the pool. To improve security we are introducing ephemeral runners. Those runners are only used for one job. Ephemeral runners only work in combination with the workflow job event. For ephemeral runners the lambda requests a JIT (just in time) configuration via the GitHub API to register the runner. [JIT configuration](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-just-in-time-runners) is limited to ephemeral runners (and currently not supported by GHES). For non-ephemeral runners, a registration token is always requested. In both cases the configuration is made available to the instance via the same SSM parameter. To disable JIT configuration for ephemeral runners set `enable_jit_config` to `false`. We also suggest using a pre-build AMI to improve the start time of jobs for ephemeral runners. - Job retry (**Beta**). By default the scale-up lambda will discard the message when it is handled. Meaning in the ephemeral use-case an instance is created. The created runner will ask GitHub for a job, no guarantee it will run the job for which it was scaling. Result could be that with small system hick-up the job is keeping waiting for a runner. Enable a pool (org runners) is one option to avoid this problem. Another option is to enable the job retry function. Which will retry the job after a delay for a configured number of times. @@ -259,8 +259,83 @@ Below an example of the the log messages created. } ``` +### EventBridge + +This module can be deployed in using the mode `EventBridge` (Experimental). The `EventBridge` mode will publish an event to a eventbus. Within the eventbus, there is a target rule set, sending events to the dispatch lambda. The `EventBridge` mode is disabled by default. + +Example to use the EventBridge: + +```hcl + +module "runners" { + source = "philips-labs/github-runners/aws" + + ... + eventbridge = { + enable = true + } + ... +} + +locals { + event_bus_name = module.runners.webhook.eventbridge.event_bus.name +} + +resource "aws_cloudwatch_event_rule" "example" { + name = "${local.prefix}-github-events-all" + description = "Caputure all GitHub events" + event_bus_name = local.event_bus_name + event_pattern = < + event_bus_name = local.event_bus_name + role_arn = aws_iam_role.event_rule_firehose_role.arn +} + +data "aws_iam_policy_document" "event_rule_firehose_role" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "event_rule_role" { + name = "${local.prefix}-eventbridge-github-rule" + assume_role_policy = data.aws_iam_policy_document.event_rule_firehose_role.json +} + +data aws_iam_policy_document firehose_stream { + statement { + INSER_YOUR_POIICY_HERE_TO_ACCESS_THE_TARGET + } +} + +resource "aws_iam_role_policy" "event_rule_firehose_role" { + name = "target-event-rule-firehose" + role = aws_iam_role.event_rule_firehose_role.name + policy = data.aws_iam_policy_document.firehose_stream.json +} +``` + ### Queue to publish workflow job events +!!! warning "Deprecated + + This fearure will be removed since we introducing the EventBridge. Same functinallity can be implemented by adding a rule to the EventBridge to forward `workflow_job` events to the SQS queue. + This queue is an experimental feature to allow you to receive a copy of the wokflow_jobs events sent by the GitHub App. This can be used to calculate a matrix or monitor the system. To enable the feature set `enable_workflow_job_events_queue = true`. Be aware though, this feature is experimental! diff --git a/docs/index.md b/docs/index.md index 959b2465f3..ffc5326f83 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,7 +31,7 @@ The diagram below shows the architecture of the module, groups are indicating th ### Webhook -The moment a GitHub action workflow requiring a `self-hosted` runner is triggered, GitHub will try to find a runner which can execute the workload. See [additional notes](additional_notes.md) for how the selection is made. This module reacts to GitHub's [`workflow_job` event](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#workflow_job) for the triggered workflow and creates a new runner if necessary. +The moment a GitHub action workflow requiring a `self-hosted` runner is triggered, GitHub will try to find a runner which can execute the workload. See [additional notes](additional_notes.md) for how the selection is made. The module can be deployed in two modes. One mode called `direct`, after accepting the [`workflow_job` event](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads#workflow_job) event the module will dispatch the event to a SQS queue on which the scale-up function will act. The second mode, `eventbridge` will funnel events via the AWS EventBridge. the EventBridge enables act on other events then only the `workflow_job` event with status `queued`. besides that the EventBridge supports replay functionality. For future extensions to act on events or create a data lake we will relay on the EventBridge. For receiving the `workflow_job` event by the webhook (lambda), a webhook needs to be created in GitHub. The same app as for API calls can be used to create the webhook. Or a dedicated webhook can be defined. diff --git a/examples/default/main.tf b/examples/default/main.tf index 90c889319b..873786683b 100644 --- a/examples/default/main.tf +++ b/examples/default/main.tf @@ -97,8 +97,21 @@ module "runners" { # prefix GitHub runners with the environment name runner_name_prefix = "${local.environment}_" + # webhook supports two modes, either direct or via the eventbridge, uncomment to enable eventbridge + # eventbridge = { + # enable = true + # # adjust the allow events to only allow specific events, like workflow_job + # # allowed_events = ['workflow_job'] + # } + # Enable debug logging for the lambda functions - log_level = "info" + # log_level = "debug" + + # tracing_config = { + # mode = "Active" + # capture_error = true + # capture_http_requests = true + # } enable_ami_housekeeper = true ami_housekeeper_cleanup_config = { diff --git a/examples/multi-runner/main.tf b/examples/multi-runner/main.tf index 2c53ff47cb..48e94631e1 100644 --- a/examples/multi-runner/main.tf +++ b/examples/multi-runner/main.tf @@ -77,6 +77,14 @@ module "runners" { id = var.github_app.id webhook_secret = random_id.random.hex } + + # Deploy webhook using the EventBridge + eventbridge = { + enable = true + # adjust the allow events to only allow specific events, like workflow_job + accept_events = ["workflow_job"] + } + # enable this section for tracing # tracing_config = { # mode = "Active" diff --git a/lambdas/functions/ami-housekeeper/package.json b/lambdas/functions/ami-housekeeper/package.json index e0bb38e0a1..038ed0443f 100644 --- a/lambdas/functions/ami-housekeeper/package.json +++ b/lambdas/functions/ami-housekeeper/package.json @@ -23,7 +23,7 @@ "@typescript-eslint/eslint-plugin": "^8.9.0", "@typescript-eslint/parser": "^8.11.0", "@vercel/ncc": "^0.38.1", - "aws-sdk-client-mock": "^4.0.2", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-jest": "^4.1.0", "eslint": "^8.57.0", "eslint-plugin-prettier": "5.2.1", diff --git a/lambdas/functions/control-plane/package.json b/lambdas/functions/control-plane/package.json index a0e55dc538..44ce8391ca 100644 --- a/lambdas/functions/control-plane/package.json +++ b/lambdas/functions/control-plane/package.json @@ -23,7 +23,7 @@ "@typescript-eslint/eslint-plugin": "^8.9.0", "@typescript-eslint/parser": "^8.11.0", "@vercel/ncc": "^0.38.1", - "aws-sdk-client-mock": "^4.0.2", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-jest": "^4.1.0", "eslint": "^8.57.0", "eslint-plugin-prettier": "5.2.1", diff --git a/lambdas/functions/gh-agent-syncer/package.json b/lambdas/functions/gh-agent-syncer/package.json index b5f59a327c..75885782e8 100644 --- a/lambdas/functions/gh-agent-syncer/package.json +++ b/lambdas/functions/gh-agent-syncer/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/eslint-plugin": "^8.9.0", "@typescript-eslint/parser": "^8.11.0", "@vercel/ncc": "^0.38.1", - "aws-sdk-client-mock": "^4.0.2", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-jest": "^4.1.0", "eslint": "^8.57.0", "eslint-plugin-prettier": "5.2.1", diff --git a/lambdas/functions/termination-watcher/package.json b/lambdas/functions/termination-watcher/package.json index 4cd6f64d57..d303722dc0 100644 --- a/lambdas/functions/termination-watcher/package.json +++ b/lambdas/functions/termination-watcher/package.json @@ -21,7 +21,7 @@ "@typescript-eslint/eslint-plugin": "^8.9.0", "@typescript-eslint/parser": "^8.11.0", "@vercel/ncc": "^0.38.1", - "aws-sdk-client-mock": "^4.0.2", + "aws-sdk-client-mock": "^4.1.0", "aws-sdk-client-mock-jest": "^4.1.0", "eslint": "^8.57.0", "eslint-plugin-prettier": "5.2.1", diff --git a/lambdas/functions/webhook/jest.config.ts b/lambdas/functions/webhook/jest.config.ts index 8511e3baa5..03dc453a00 100644 --- a/lambdas/functions/webhook/jest.config.ts +++ b/lambdas/functions/webhook/jest.config.ts @@ -6,10 +6,10 @@ const config: Config = { ...defaultConfig, coverageThreshold: { global: { - statements: 99.2, + statements: 99.58, branches: 100, functions: 100, - lines: 99.25, + lines: 99.57, }, }, }; diff --git a/lambdas/functions/webhook/package.json b/lambdas/functions/webhook/package.json index 43ea3f191c..8726d66030 100644 --- a/lambdas/functions/webhook/package.json +++ b/lambdas/functions/webhook/package.json @@ -16,6 +16,7 @@ "all": "yarn build && yarn format && yarn lint && yarn test" }, "devDependencies": { + "@aws-sdk/client-eventbridge": "^3.670.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/aws-lambda": "^8.10.145", "@types/express": "^4.17.21", diff --git a/lambdas/functions/webhook/src/ConfigLoader.test.ts b/lambdas/functions/webhook/src/ConfigLoader.test.ts new file mode 100644 index 0000000000..b99ff7a951 --- /dev/null +++ b/lambdas/functions/webhook/src/ConfigLoader.test.ts @@ -0,0 +1,288 @@ +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { ConfigWebhook, ConfigWebhookEventBridge, ConfigDispatcher } from './ConfigLoader'; +import { mocked } from 'jest-mock'; +import { logger } from '@aws-github-runner/aws-powertools-util'; +import { RunnerMatcherConfig } from './sqs'; + +jest.mock('@aws-github-runner/aws-ssm-util'); + +describe('ConfigLoader Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + ConfigWebhook.reset(); + ConfigWebhookEventBridge.reset(); + ConfigDispatcher.reset(); + logger.setLogLevel('DEBUG'); + + // clear process.env + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + }); + + describe('Check base object', () => { + function setupConfiguration(): void { + process.env.EVENT_BUS_NAME = 'event-bus'; + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; + const matcherConfig = [ + { + id: '1', + arn: 'arn:aws:sqs:us-east-1:123456789012:queue1', + fifo: false, + matcherConfig: { + labelMatchers: [['label1', 'label2']], + exactMatch: true, + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + if (paramPath === '/path/to/webhook/secret') { + return 'secret'; + } + return ''; + }); + } + + it('should return the same instance of ConfigWebhook (singleton)', async () => { + setupConfiguration(); + const config1 = await ConfigWebhook.load(); + const config2 = await ConfigWebhook.load(); + + expect(config1).toBe(config2); + expect(getParameter).toHaveBeenCalledTimes(2); + }); + + it('should return the same instance of ConfigWebhookEventBridge (singleton)', async () => { + setupConfiguration(); + const config1 = await ConfigWebhookEventBridge.load(); + const config2 = await ConfigWebhookEventBridge.load(); + + expect(config1).toBe(config2); + expect(getParameter).toHaveBeenCalledTimes(1); + }); + + it('should return the same instance of ConfigDispatcher (singleton)', async () => { + setupConfiguration(); + const config1 = await ConfigDispatcher.load(); + const config2 = await ConfigDispatcher.load(); + + expect(config1).toBe(config2); + expect(getParameter).toHaveBeenCalledTimes(1); + }); + + it('should filter secrets from being logged', async () => { + setupConfiguration(); + const spy = jest.spyOn(logger, 'debug'); + + await ConfigWebhook.load(); + + expect(spy).toHaveBeenCalledWith( + 'Config loaded', + expect.objectContaining({ + config: expect.objectContaining({ + webhookSecret: '***', + }), + }), + ); + }); + }); + + describe('ConfigWebhook', () => { + it('should load config successfully', async () => { + process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; + process.env.SQS_WORKFLOW_JOB_QUEUE = 'secondary-queue'; + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; + const matcherConfig = [ + { + id: '1', + arn: 'arn:aws:sqs:us-east-1:123456789012:queue1', + fifo: false, + matcherConfig: { + labelMatchers: [['label1', 'label2']], + exactMatch: true, + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + if (paramPath === '/path/to/webhook/secret') { + return 'secret'; + } + return ''; + }); + + const config: ConfigWebhook = await ConfigWebhook.load(); + + expect(config.repositoryAllowList).toEqual(['repo1', 'repo2']); + expect(config.workflowJobEventSecondaryQueue).toBe('secondary-queue'); + expect(config.matcherConfig).toEqual(matcherConfig); + expect(config.webhookSecret).toBe('secret'); + }); + + it('should load config successfully', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; + const matcherConfig = [ + { + id: '1', + arn: 'arn:aws:sqs:us-east-1:123456789012:queue1', + fifo: false, + matcherConfig: { + labelMatchers: [['label1', 'label2']], + exactMatch: true, + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + if (paramPath === '/path/to/webhook/secret') { + return 'secret'; + } + return ''; + }); + + const config: ConfigWebhook = await ConfigWebhook.load(); + + expect(config.repositoryAllowList).toEqual([]); + expect(config.workflowJobEventSecondaryQueue).toBe(''); + expect(config.matcherConfig).toEqual(matcherConfig); + expect(config.webhookSecret).toBe('secret'); + }); + + it('should throw error if config loading fails', async () => { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + throw new Error('Failed to load matcher config'); + } + return ''; + }); + + await expect(ConfigWebhook.load()).rejects.toThrow( + 'Failed to load config: Failed to load parameter for matcherConfig from path /path/to/matcher/config: Failed to load matcher config', // eslint-disable-line max-len + ); + }); + }); + + describe('ConfigWebhookEventBridge', () => { + it('should load config successfully', async () => { + process.env.ACCEPT_EVENTS = '["push", "pull_request"]'; + process.env.EVENT_BUS_NAME = 'event-bus'; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; + + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/webhook/secret') { + return 'secret'; + } + return ''; + }); + + const config: ConfigWebhookEventBridge = await ConfigWebhookEventBridge.load(); + + expect(config.allowedEvents).toEqual(['push', 'pull_request']); + expect(config.eventBusName).toBe('event-bus'); + expect(config.webhookSecret).toBe('secret'); + }); + + it('should throw error if config loading fails', async () => { + mocked(getParameter).mockImplementation(async (paramPath: string) => { + throw new Error(`Parameter ${paramPath} not found`); + }); + + await expect(ConfigWebhookEventBridge.load()).rejects.toThrow( + 'Failed to load config: Environment variable for eventBusName is not set and no default value provided., Failed to load parameter for webhookSecret from path undefined: Parameter undefined not found', // eslint-disable-line max-len + ); + }); + }); + + describe('ConfigDispatcher', () => { + it('should load config successfully', async () => { + process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + + const matcherConfig: RunnerMatcherConfig[] = [ + { + arn: 'arn:aws:sqs:eu-central-1:123456:npalm-default-queued-builds', + fifo: true, + id: 'https://sqs.eu-central-1.amazonaws.com/123456/npalm-default-queued-builds', + matcherConfig: { + exactMatch: true, + labelMatchers: [['default', 'example', 'linux', 'self-hosted', 'x64']], + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + return ''; + }); + + const config: ConfigDispatcher = await ConfigDispatcher.load(); + + expect(config.repositoryAllowList).toEqual(['repo1', 'repo2']); + expect(config.matcherConfig).toEqual(matcherConfig); + }); + + it('should throw error if config loading fails', async () => { + mocked(getParameter).mockImplementation(async (paramPath: string) => { + throw new Error(`Parameter ${paramPath} not found`); + }); + + await expect(ConfigDispatcher.load()).rejects.toThrow( + 'Failed to load config: Failed to load parameter for matcherConfig from path undefined: Parameter undefined not found', // eslint-disable-line max-len + ); + }); + + it('should rely on default when optionals are not set.', async () => { + process.env.ACCEPT_EVENTS = 'null'; + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + const matcherConfig: RunnerMatcherConfig[] = [ + { + arn: 'arn:aws:sqs:eu-central-1:123456:npalm-default-queued-builds', + fifo: true, + id: 'https://sqs.eu-central-1.amazonaws.com/123456/npalm-default-queued-builds', + matcherConfig: { + exactMatch: true, + labelMatchers: [['default', 'example', 'linux', 'self-hosted', 'x64']], + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + return ''; + }); + + const config: ConfigDispatcher = await ConfigDispatcher.load(); + + expect(config.repositoryAllowList).toEqual([]); + expect(config.matcherConfig).toEqual(matcherConfig); + }); + + it('should throw an error if runner matcher config is empty.', async () => { + process.env.REPOSITORY_ALLOW_LIST = '["repo1", "repo2"]'; + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(''); + } + return ''; + }); + + await expect(ConfigDispatcher.load()).rejects.toThrow('ailed to load config: Matcher config is empty'); + }); + }); +}); diff --git a/lambdas/functions/webhook/src/ConfigLoader.ts b/lambdas/functions/webhook/src/ConfigLoader.ts new file mode 100644 index 0000000000..7dc3a3b695 --- /dev/null +++ b/lambdas/functions/webhook/src/ConfigLoader.ts @@ -0,0 +1,155 @@ +import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { RunnerMatcherConfig } from './sqs'; +import { logger } from '@aws-github-runner/aws-powertools-util'; + +/** + * Base class for loading configuration from environment variables and SSM parameters. + * + * @remarks + * To avoid usages or checking values can be undefined we assume that configuration is + * set to + * - empty string if the property is not relevant + * - empty list if the property is not relevant + */ +abstract class BaseConfig { + static instance: BaseConfig | null = null; + configLoadingErrors: string[] = []; + + static async load(): Promise { + if (!this.instance) { + this.instance = new (this as unknown as { new (): T })(); + await this.instance.loadConfig(); + + if (this.instance.configLoadingErrors.length > 0) { + logger.debug('Failed to load config', { + config: this.instance.logOjbect, + errors: this.instance.configLoadingErrors, + }); + throw new Error(`Failed to load config: ${this.instance.configLoadingErrors.join(', ')}`); + } + + logger.debug('Config loaded', { config: this.instance.logOjbect() }); + } else { + logger.debug('Config already loaded', { config: this.instance.logOjbect() }); + } + + return this.instance as T; + } + + static reset(): void { + this.instance = null; + } + + abstract loadConfig(): Promise; + + protected loadEnvVar(envVar: string, propertyName: keyof this, defaultValue?: T): void { + logger.debug(`Loading env var for ${String(propertyName)}`, { envVar }); + if (!(envVar == undefined || envVar === 'null')) { + this.loadProperty(propertyName, envVar); + } else if (defaultValue !== undefined) { + this[propertyName] = defaultValue as unknown as this[keyof this]; + } else { + const errorMessage = `Environment variable for ${String(propertyName)} is not set and no default value provided.`; + this.configLoadingErrors.push(errorMessage); + } + } + + protected async loadParameter(paramPath: string, propertyName: keyof this): Promise { + logger.debug(`Loading parameter for ${String(propertyName)} from path ${paramPath}`); + await getParameter(paramPath) + .then((value) => { + this.loadProperty(propertyName, value); + }) + .catch((error) => { + const errorMessage = `Failed to load parameter for ${String(propertyName)} from path ${paramPath}: ${(error as Error).message}`; // eslint-disable-line max-len + this.configLoadingErrors.push(errorMessage); + }); + } + + private loadProperty(propertyName: keyof this, value: string) { + try { + this[propertyName] = JSON.parse(value) as unknown as this[keyof this]; + } catch { + this[propertyName] = value as unknown as this[keyof this]; + } + } + + // create a log object without secrets + protected logOjbect(): this { + const config = { ...this }; + for (const key in config) { + if (key.toLowerCase().includes('secret') && config[key]) { + config[key as keyof this] = '***' as unknown as this[keyof this]; + } + } + + return config; + } +} + +export class ConfigWebhook extends BaseConfig { + repositoryAllowList: string[] = []; + matcherConfig: RunnerMatcherConfig[] = []; + webhookSecret: string = ''; + workflowJobEventSecondaryQueue: string = ''; + + async loadConfig(): Promise { + this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []); + this.loadEnvVar(process.env.SQS_WORKFLOW_JOB_QUEUE, 'workflowJobEventSecondaryQueue', ''); + + await Promise.all([ + this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig'), + this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'), + ]); + + validateWebhookSecret(this); + validateRunnerMatcherConfig(this); + } +} + +export class ConfigWebhookEventBridge extends BaseConfig { + eventBusName: string | undefined; + allowedEvents: string[] = []; + webhookSecret: string = ''; + + async loadConfig(): Promise { + this.loadEnvVar(process.env.ACCEPT_EVENTS, 'allowedEvents', []); + this.loadEnvVar(process.env.EVENT_BUS_NAME, 'eventBusName'); + await this.loadParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET, 'webhookSecret'); + + validateEventBusName(this); + validateWebhookSecret(this); + } +} + +export class ConfigDispatcher extends BaseConfig { + repositoryAllowList: string[] = []; + matcherConfig: RunnerMatcherConfig[] = []; + workflowJobEventSecondaryQueue: string = ''; // Deprecated + + async loadConfig(): Promise { + this.loadEnvVar(process.env.REPOSITORY_ALLOW_LIST, 'repositoryAllowList', []); + this.loadEnvVar(process.env.SQS_WORKFLOW_JOB_QUEUE, 'workflowJobEventSecondaryQueue', ''); + await this.loadParameter(process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH, 'matcherConfig'); + + validateRunnerMatcherConfig(this); + } +} + +function validateEventBusName(config: ConfigWebhookEventBridge): void { + if (!config.eventBusName) { + config.configLoadingErrors.push('Environment variable for eventBusName is not set and no default value provided.'); + } +} + +function validateWebhookSecret(config: ConfigWebhookEventBridge | ConfigWebhook): void { + if (!config.webhookSecret) { + config.configLoadingErrors.push('Environment variable for webhookSecret is not set and no default value provided.'); + } +} + +function validateRunnerMatcherConfig(config: ConfigDispatcher | ConfigWebhook): void { + if (config.matcherConfig.length === 0) { + config.configLoadingErrors.push('Matcher config is empty'); + } +} diff --git a/lambdas/functions/webhook/src/ConfigResolver.ts b/lambdas/functions/webhook/src/ConfigResolver.ts deleted file mode 100644 index 91b8d0379f..0000000000 --- a/lambdas/functions/webhook/src/ConfigResolver.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getParameter } from '@aws-github-runner/aws-ssm-util'; -import { RunnerMatcherConfig } from './sqs'; -import { logger } from '@aws-github-runner/aws-powertools-util'; - -export class Config { - repositoryAllowList: Array; - static matcherConfig: Array | undefined; - static webhookSecret: string | undefined; - workflowJobEventSecondaryQueue: string | undefined; - - constructor(repositoryAllowList: Array, workflowJobEventSecondaryQueue: string | undefined) { - this.repositoryAllowList = repositoryAllowList; - - this.workflowJobEventSecondaryQueue = workflowJobEventSecondaryQueue; - } - - static async load(): Promise { - const repositoryAllowListEnv = process.env.REPOSITORY_ALLOW_LIST ?? '[]'; - const repositoryAllowList = JSON.parse(repositoryAllowListEnv) as Array; - // load parallel config if not cached - if (!Config.matcherConfig) { - const matcherConfigPath = - process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH ?? '/github-runner/runner-matcher-config'; - const [matcherConfigVal, webhookSecret] = await Promise.all([ - getParameter(matcherConfigPath), - getParameter(process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET), - ]); - Config.webhookSecret = webhookSecret; - Config.matcherConfig = JSON.parse(matcherConfigVal) as Array; - logger.debug('Loaded queues config', { matcherConfig: Config.matcherConfig }); - } - const workflowJobEventSecondaryQueue = process.env.SQS_WORKFLOW_JOB_QUEUE || undefined; - return new Config(repositoryAllowList, workflowJobEventSecondaryQueue); - } - - static reset(): void { - Config.matcherConfig = undefined; - } -} diff --git a/lambdas/functions/webhook/src/eventbridge/index.test.ts b/lambdas/functions/webhook/src/eventbridge/index.test.ts new file mode 100644 index 0000000000..cb705c713b --- /dev/null +++ b/lambdas/functions/webhook/src/eventbridge/index.test.ts @@ -0,0 +1,78 @@ +import { EventBridgeClient, PutEventsCommandOutput, PutEventsRequestEntry } from '@aws-sdk/client-eventbridge'; +import nock from 'nock'; + +import { publish } from '.'; + +jest.mock('@aws-sdk/client-eventbridge'); + +const cleanEnv = process.env; + +beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env = { ...cleanEnv }; + nock.disableNetConnect(); +}); + +describe('Test EventBridge adapter', () => { + test('Test publish without errors', async () => { + // Arrange + const output: PutEventsCommandOutput = { + $metadata: { + httpStatusCode: 200, + }, + Entries: [], + FailedEntryCount: 0, + }; + + EventBridgeClient.prototype.send = jest.fn().mockResolvedValue(output); + + // Act + const result = await publish({ + EventBusName: 'test', + Source: 'test', + DetailType: 'test', + Detail: 'test', + } as PutEventsRequestEntry); + + // Assert + expect(result).toBe(undefined); + }); + + test('Test publish with errors', async () => { + // Arrange + const output: PutEventsCommandOutput = { + $metadata: { + httpStatusCode: 200, + }, + Entries: [], + FailedEntryCount: 1, + }; + + EventBridgeClient.prototype.send = jest.fn().mockResolvedValue(output); + + await expect( + publish({ + EventBusName: 'test', + Source: 'test', + DetailType: 'test', + Detail: 'test', + } as PutEventsRequestEntry), + ).rejects.toThrowError('Event failed to send to EventBridge.'); + }); + + test('Test publish with exceptions', async () => { + // Arrange + const error = new Error('test'); + EventBridgeClient.prototype.send = jest.fn().mockRejectedValue(error); + + await expect( + publish({ + EventBusName: 'test', + Source: 'test', + DetailType: 'test', + Detail: 'test', + } as PutEventsRequestEntry), + ).rejects.toThrow(); + }); +}); diff --git a/lambdas/functions/webhook/src/eventbridge/index.ts b/lambdas/functions/webhook/src/eventbridge/index.ts new file mode 100644 index 0000000000..375128cf1c --- /dev/null +++ b/lambdas/functions/webhook/src/eventbridge/index.ts @@ -0,0 +1,29 @@ +import { EventBridgeClient, PutEventsCommand, PutEventsRequestEntry } from '@aws-sdk/client-eventbridge'; + +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; + +const logger = createChildLogger('eventbridge.ts'); + +export async function publish(entry: PutEventsRequestEntry): Promise { + const client = new EventBridgeClient({ region: process.env.AWS_REGION }); + const command = new PutEventsCommand({ + Entries: [entry], + }); + + let result; + try { + result = await client.send(command); + } catch (e) { + logger.debug(`Failed to send event to EventBridge`, { error: e }); + throw new Error('Failed to send event to EventBridge'); + } + + logger.debug(`Event sent to EventBridge${result.FailedEntryCount === 0 ? '' : ' with ERRORS'}.`, { + command: command, + result: result, + }); + + if (result.FailedEntryCount !== 0) { + throw new Error('Event failed to send to EventBridge.'); + } +} diff --git a/lambdas/functions/webhook/src/lambda.test.ts b/lambdas/functions/webhook/src/lambda.test.ts index 599c6594db..1174e52dae 100644 --- a/lambdas/functions/webhook/src/lambda.test.ts +++ b/lambdas/functions/webhook/src/lambda.test.ts @@ -1,11 +1,14 @@ import { logger } from '@aws-github-runner/aws-powertools-util'; import { APIGatewayEvent, Context } from 'aws-lambda'; import { mocked } from 'jest-mock'; +import { WorkflowJobEvent } from '@octokit/webhooks-types'; -import { githubWebhook } from './lambda'; -import { handle } from './webhook'; +import { dispatchToRunners, eventBridgeWebhook, directWebhook } from './lambda'; +import { publishForRunners, publishOnEventBridge } from './webhook'; import ValidationError from './ValidationError'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import { dispatch } from './runners/dispatch'; +import { EventWrapper } from './types'; const event: APIGatewayEvent = { body: JSON.stringify(''), @@ -73,40 +76,129 @@ const context: Context = { }, }; +jest.mock('./runners/dispatch'); jest.mock('./webhook'); jest.mock('@aws-github-runner/aws-ssm-util'); -describe('Test scale up lambda wrapper.', () => { +describe('Test webhook lambda wrapper.', () => { beforeEach(() => { + // We mock all SSM request to resolve to a non empty array. Since we mock all implemeantions + // relying on the config opbject that is enought to test the handlers. const mockedGet = mocked(getParameter); - mockedGet.mockResolvedValue('[]'); + mockedGet.mockResolvedValue('["abc"]'); + jest.clearAllMocks(); }); - it('Happy flow, resolve.', async () => { - const mock = mocked(handle); - mock.mockImplementation(() => { - return new Promise((resolve) => { - resolve({ body: 'test', statusCode: 200 }); + + describe('Test webhook lambda wrapper.', () => { + it('Happy flow, resolve.', async () => { + const mock = mocked(publishForRunners); + mock.mockImplementation(() => { + return new Promise((resolve) => { + resolve({ body: 'test', statusCode: 200 }); + }); }); + + const result = await directWebhook(event, context); + expect(result).toEqual({ body: 'test', statusCode: 200 }); + }); + + it('An expected error, resolve.', async () => { + const mock = mocked(publishForRunners); + mock.mockRejectedValue(new ValidationError(400, 'some error')); + + const result = await directWebhook(event, context); + expect(result).toMatchObject({ body: 'some error', statusCode: 400 }); }); - const result = await githubWebhook(event, context); - expect(result).toEqual({ body: 'test', statusCode: 200 }); + it('Errors are not thrown.', async () => { + const mock = mocked(publishForRunners); + const logSpy = jest.spyOn(logger, 'error'); + mock.mockRejectedValue(new Error('some error')); + const result = await directWebhook(event, context); + expect(result).toMatchObject({ body: 'Check the Lambda logs for the error details.', statusCode: 500 }); + expect(logSpy).toHaveBeenCalledTimes(1); + }); }); - it('An expected error, resolve.', async () => { - const mock = mocked(handle); - mock.mockRejectedValue(new ValidationError(400, 'some error')); + describe('Lmmbda eventBridgeWebhook.', () => { + beforeEach(() => { + process.env.EVENT_BUS_NAME = 'test'; + }); + + it('Happy flow, resolve.', async () => { + const mock = mocked(publishOnEventBridge); + mock.mockImplementation(() => { + return new Promise((resolve) => { + resolve({ body: 'test', statusCode: 200 }); + }); + }); + + const result = await eventBridgeWebhook(event, context); + expect(result).toEqual({ body: 'test', statusCode: 200 }); + }); + + it('Reject events .', async () => { + const mock = mocked(publishOnEventBridge); + mock.mockRejectedValue(new Error('some error')); + + mock.mockRejectedValue(new ValidationError(400, 'some error')); + + const result = await eventBridgeWebhook(event, context); + expect(result).toMatchObject({ body: 'some error', statusCode: 400 }); + }); - const result = await githubWebhook(event, context); - expect(result).toMatchObject({ body: 'some error', statusCode: 400 }); + it('Errors are not thrown.', async () => { + const mock = mocked(publishOnEventBridge); + const logSpy = jest.spyOn(logger, 'error'); + mock.mockRejectedValue(new Error('some error')); + const result = await eventBridgeWebhook(event, context); + expect(result).toMatchObject({ body: 'Check the Lambda logs for the error details.', statusCode: 500 }); + expect(logSpy).toHaveBeenCalledTimes(1); + }); }); - it('Errors are not thrown.', async () => { - const mock = mocked(handle); - const logSpy = jest.spyOn(logger, 'error'); - mock.mockRejectedValue(new Error('some error')); - const result = await githubWebhook(event, context); - expect(result).toMatchObject({ body: 'Check the Lambda logs for the error details.', statusCode: 500 }); - expect(logSpy).toHaveBeenCalledTimes(1); + describe('Lambda dispatchToRunners.', () => { + it('Happy flow, resolve.', async () => { + const mock = mocked(dispatch); + mock.mockImplementation(() => { + return new Promise((resolve) => { + resolve({ body: 'test', statusCode: 200 }); + }); + }); + + const testEvent = { + 'detail-type': 'workflow_job', + } as unknown as EventWrapper; + + await expect(dispatchToRunners(testEvent, context)).resolves.not.toThrow(); + }); + + it('Rejects non workflow_job events.', async () => { + const mock = mocked(dispatch); + mock.mockImplementation(() => { + return new Promise((resolve) => { + resolve({ body: 'test', statusCode: 200 }); + }); + }); + + const testEvent = { + 'detail-type': 'non_workflow_job', + } as unknown as EventWrapper; + + await expect(dispatchToRunners(testEvent, context)).rejects.toThrow( + 'Incorrect Event detail-type only workflow_job is accepted', + ); + }); + + it('Rejects any event causing an error.', async () => { + const mock = mocked(dispatch); + mock.mockRejectedValue(new Error('some error')); + + const testEvent = { + 'detail-type': 'workflow_job', + } as unknown as EventWrapper; + + await expect(dispatchToRunners(testEvent, context)).rejects.toThrow(); + }); }); }); diff --git a/lambdas/functions/webhook/src/lambda.ts b/lambdas/functions/webhook/src/lambda.ts index df064604fb..1f7cb0c830 100644 --- a/lambdas/functions/webhook/src/lambda.ts +++ b/lambdas/functions/webhook/src/lambda.ts @@ -2,28 +2,54 @@ import middy from '@middy/core'; import { logger, setContext, captureLambdaHandler, tracer } from '@aws-github-runner/aws-powertools-util'; import { APIGatewayEvent, Context } from 'aws-lambda'; -import { handle } from './webhook'; -import { Config } from './ConfigResolver'; +import { publishForRunners, publishOnEventBridge } from './webhook'; import { IncomingHttpHeaders } from 'http'; import ValidationError from './ValidationError'; +import { EventWrapper } from './types'; +import { WorkflowJobEvent } from '@octokit/webhooks-types'; +import { ConfigDispatcher, ConfigWebhook, ConfigWebhookEventBridge } from './ConfigLoader'; +import { dispatch } from './runners/dispatch'; export interface Response { statusCode: number; body: string; } -middy(githubWebhook).use(captureLambdaHandler(tracer)); +middy(directWebhook).use(captureLambdaHandler(tracer)); -export async function githubWebhook(event: APIGatewayEvent, context: Context): Promise { +export async function directWebhook(event: APIGatewayEvent, context: Context): Promise { setContext(context, 'lambda.ts'); - const config = await Config.load(); + logger.logEventIfEnabled(event); + + let result: Response; + try { + const config: ConfigWebhook = await ConfigWebhook.load(); + result = await publishForRunners(headersToLowerCase(event.headers), event.body as string, config); + } catch (e) { + logger.error(`Failed to handle webhook event`, { error: e }); + if (e instanceof ValidationError) { + result = { + statusCode: e.statusCode, + body: e.message, + }; + } else { + result = { + statusCode: 500, + body: 'Check the Lambda logs for the error details.', + }; + } + } + return result; +} +export async function eventBridgeWebhook(event: APIGatewayEvent, context: Context): Promise { + setContext(context, 'lambda.ts'); logger.logEventIfEnabled(event); - logger.debug('Loading config', { config }); let result: Response; try { - result = await handle(headersToLowerCase(event.headers), event.body as string, config); + const config: ConfigWebhookEventBridge = await ConfigWebhookEventBridge.load(); + result = await publishOnEventBridge(headersToLowerCase(event.headers), event.body as string, config); } catch (e) { logger.error(`Failed to handle webhook event`, { error: e }); if (e instanceof ValidationError) { @@ -41,6 +67,25 @@ export async function githubWebhook(event: APIGatewayEvent, context: Context): P return result; } +export async function dispatchToRunners(event: EventWrapper, context: Context): Promise { + setContext(context, 'lambda.ts'); + logger.logEventIfEnabled(event); + + const eventType = event['detail-type']; + if (eventType != 'workflow_job') { + logger.debug('Wrong event type received. Unable to process event', { event }); + throw new Error('Incorrect Event detail-type only workflow_job is accepted'); + } + + try { + const config: ConfigDispatcher = await ConfigDispatcher.load(); + await dispatch(event.detail, eventType, config); + } catch (e) { + logger.error(`Failed to handle webhook event`, { error: e }); + throw e; + } +} + // ensure header keys lower case since github headers can contain capitals. function headersToLowerCase(headers: IncomingHttpHeaders): IncomingHttpHeaders { for (const key in headers) { diff --git a/lambdas/functions/webhook/src/local.ts b/lambdas/functions/webhook/src/local.ts index ddedb552f4..f4f10e24be 100644 --- a/lambdas/functions/webhook/src/local.ts +++ b/lambdas/functions/webhook/src/local.ts @@ -1,16 +1,16 @@ import bodyParser from 'body-parser'; import express from 'express'; -import { handle } from './webhook'; -import { Config } from './ConfigResolver'; +import { publishForRunners } from './webhook'; +import { ConfigWebhook } from './ConfigLoader'; const app = express(); -const config = Config.load(); app.use(bodyParser.json()); app.post('/event_handler', async (req, res) => { - handle(req.headers, JSON.stringify(req.body), await config) + const config: ConfigWebhook = await ConfigWebhook.load(); + publishForRunners(req.headers, JSON.stringify(req.body), config) .then((c) => res.status(c.statusCode).end()) .catch((e) => { console.log(e); diff --git a/lambdas/functions/webhook/src/webhook/modules.d.ts b/lambdas/functions/webhook/src/modules.d.ts similarity index 58% rename from lambdas/functions/webhook/src/webhook/modules.d.ts rename to lambdas/functions/webhook/src/modules.d.ts index 7ca0bb65e4..a3bc22c3e0 100644 --- a/lambdas/functions/webhook/src/webhook/modules.d.ts +++ b/lambdas/functions/webhook/src/modules.d.ts @@ -1,8 +1,12 @@ declare namespace NodeJS { export interface ProcessEnv { ENVIRONMENT: string; + EVENT_BUS_NAME: string; PARAMETER_GITHUB_APP_WEBHOOK_SECRET: string; + PARAMETER_RUNNER_MATCHER_CONFIG_PATH: string; REPOSITORY_ALLOW_LIST: string; RUNNER_LABELS: string; + ACCEPT_EVENTS: string; + SQS_WORKFLOW_JOB_QUEUE: string; } } diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index 4a003c594b..88d6aa3f55 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -8,7 +8,8 @@ import runnerConfig from '../../test/resources/multi_runner_configurations.json' import { RunnerConfig, sendActionRequest } from '../sqs'; import { canRunJob, dispatch } from './dispatch'; -import { Config } from '../ConfigResolver'; +import { ConfigDispatcher } from '../ConfigLoader'; +import { logger } from '@aws-github-runner/aws-powertools-util'; jest.mock('../sqs'); jest.mock('@aws-github-runner/aws-ssm-util'); @@ -20,9 +21,10 @@ const cleanEnv = process.env; describe('Dispatcher', () => { let originalError: Console['error']; - let config: Config; + let config: ConfigDispatcher; beforeEach(async () => { + logger.setLogLevel('DEBUG'); process.env = { ...cleanEnv }; nock.disableNetConnect(); @@ -235,6 +237,7 @@ describe('Dispatcher', () => { }); function mockSSMResponse(runnerConfigInput?: RunnerConfig) { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/github-runner/runner-matcher-config'; const mockedGet = mocked(getParameter); mockedGet.mockImplementation((parameter_name) => { const value = @@ -245,11 +248,11 @@ function mockSSMResponse(runnerConfigInput?: RunnerConfig) { }); } -async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise { +async function createConfig(repositoryAllowList?: string[], runnerConfig?: RunnerConfig): Promise { if (repositoryAllowList) { process.env.REPOSITORY_ALLOW_LIST = JSON.stringify(repositoryAllowList); } - Config.reset(); + ConfigDispatcher.reset(); mockSSMResponse(runnerConfig); - return await Config.load(); + return await ConfigDispatcher.load(); } diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts index 52985e7bf3..91f9126413 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -3,21 +3,25 @@ import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { Response } from '../lambda'; import { RunnerMatcherConfig, sendActionRequest, sendWebhookEventToWorkflowJobQueue } from '../sqs'; -import { Config } from '../ConfigResolver'; import ValidationError from '../ValidationError'; +import { ConfigDispatcher, ConfigWebhook } from '../ConfigLoader'; const logger = createChildLogger('handler'); -export async function dispatch(event: WorkflowJobEvent, eventType: string, config: Config): Promise { +export async function dispatch( + event: WorkflowJobEvent, + eventType: string, + config: ConfigDispatcher | ConfigWebhook, +): Promise { validateRepoInAllowList(event, config); - const result = await handleWorkflowJob(event, eventType, Config.matcherConfig!); + const result = await handleWorkflowJob(event, eventType, config.matcherConfig!); await sendWebhookEventToWorkflowJobQueue({ workflowJobEvent: event }, config); return result; } -function validateRepoInAllowList(event: WorkflowJobEvent, config: Config) { +function validateRepoInAllowList(event: WorkflowJobEvent, config: ConfigDispatcher) { if (config.repositoryAllowList.length > 0 && !config.repositoryAllowList.includes(event.repository.full_name)) { logger.info(`Received event from unauthorized repository ${event.repository.full_name}`); throw new ValidationError(403, `Received event from unauthorized repository ${event.repository.full_name}`); diff --git a/lambdas/functions/webhook/src/sqs/index.test.ts b/lambdas/functions/webhook/src/sqs/index.test.ts index 5756ec2074..f8fe64f2e4 100644 --- a/lambdas/functions/webhook/src/sqs/index.test.ts +++ b/lambdas/functions/webhook/src/sqs/index.test.ts @@ -2,7 +2,6 @@ import { SendMessageCommandInput } from '@aws-sdk/client-sqs'; import { ActionRequestMessage, GithubWorkflowEvent, sendActionRequest, sendWebhookEventToWorkflowJobQueue } from '.'; import workflowjob_event from '../../test/resources/github_workflowjob_event.json'; -import { Config } from '../ConfigResolver'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; import { mocked } from 'jest-mock'; @@ -17,6 +16,7 @@ jest.mock('@aws-sdk/client-sqs', () => ({ jest.mock('@aws-github-runner/aws-ssm-util'); import { SQS } from '@aws-sdk/client-sqs'; +import { ConfigDispatcher, ConfigWebhook } from '../ConfigLoader'; describe('Test sending message to SQS.', () => { const queueUrl = 'https://sqs.eu-west-1.amazonaws.com/123456789/queued-builds'; @@ -82,8 +82,9 @@ describe('Test sending message to SQS.', () => { MessageBody: JSON.stringify(message), }; beforeEach(() => { + ConfigDispatcher.reset(); const mockedGet = mocked(getParameter); - mockedGet.mockResolvedValue('[]'); + mockedGet.mockResolvedValue('["abc"]'); }); afterEach(() => { jest.clearAllMocks(); @@ -91,8 +92,8 @@ describe('Test sending message to SQS.', () => { it('sends webhook events to workflow job queue', async () => { // Arrange - process.env.SQS_WORKFLOW_JOB_QUEUE = sqsMessage.QueueUrl; - const config = await Config.load(); + process.env.SQS_WORKFLOW_JOB_QUEUE = sqsMessage.QueueUrl || ''; + const config: ConfigWebhook = await ConfigWebhook.load(); // Act const result = sendWebhookEventToWorkflowJobQueue(message, config); @@ -103,22 +104,10 @@ describe('Test sending message to SQS.', () => { }); it('Does not send webhook events to workflow job event copy queue when job queue is not in environment', async () => { - // Arrange - delete process.env.SQS_WORKFLOW_JOB_QUEUE; - const config = await Config.load(); - - // Act - await sendWebhookEventToWorkflowJobQueue(message, config); - - // Assert - expect(SQS).not.toHaveBeenCalled(); - }); - - // eslint-disable-next-line max-len - it('Does not send webhook events to workflow job event copy queue when job queue is set to empty string', async () => { // Arrange process.env.SQS_WORKFLOW_JOB_QUEUE = ''; - const config = await Config.load(); + const config: ConfigDispatcher = await ConfigDispatcher.load(); + // Act await sendWebhookEventToWorkflowJobQueue(message, config); @@ -128,8 +117,8 @@ describe('Test sending message to SQS.', () => { it('Catch the exception when even copy queue throws exception', async () => { // Arrange - process.env.SQS_WORKFLOW_JOB_QUEUE = sqsMessage.QueueUrl; - const config = await Config.load(); + process.env.SQS_WORKFLOW_JOB_QUEUE = sqsMessage.QueueUrl || ''; + const config: ConfigDispatcher = await ConfigDispatcher.load(); const mockSQS = { sendMessage: jest.fn(() => { diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index a61679215a..14f61f40e3 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -1,7 +1,7 @@ import { SQS, SendMessageCommandInput } from '@aws-sdk/client-sqs'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; -import { Config } from '../ConfigResolver'; +import { ConfigDispatcher } from '../ConfigLoader'; const logger = createChildLogger('sqs'); @@ -50,7 +50,10 @@ export const sendActionRequest = async (message: ActionRequestMessage): Promise< await sqs.sendMessage(sqsMessage); }; -export async function sendWebhookEventToWorkflowJobQueue(message: GithubWorkflowEvent, config: Config): Promise { +export async function sendWebhookEventToWorkflowJobQueue( + message: GithubWorkflowEvent, + config: ConfigDispatcher, +): Promise { if (!config.workflowJobEventSecondaryQueue) { return; } @@ -61,7 +64,7 @@ export async function sendWebhookEventToWorkflowJobQueue(message: GithubWorkflow MessageBody: JSON.stringify(message), }; - logger.debug(`Sending Webhook events to the workflow job queue: ${config.workflowJobEventSecondaryQueue}`); + logger.info(`Sending event to the workflow job queue: ${config.workflowJobEventSecondaryQueue}`); try { await sqs.sendMessage(sqsMessage); diff --git a/lambdas/functions/webhook/src/types.ts b/lambdas/functions/webhook/src/types.ts new file mode 100644 index 0000000000..fd3479e3b9 --- /dev/null +++ b/lambdas/functions/webhook/src/types.ts @@ -0,0 +1,11 @@ +export interface EventWrapper { + version: string; + id: string; + 'detail-type': 'workflow_job'; + source: string; + account: string; + time: Date; + region: string; + resources: string[]; + detail: T; +} diff --git a/lambdas/functions/webhook/src/webhook/index.test.ts b/lambdas/functions/webhook/src/webhook/index.test.ts index 9144d23b51..c202361369 100644 --- a/lambdas/functions/webhook/src/webhook/index.test.ts +++ b/lambdas/functions/webhook/src/webhook/index.test.ts @@ -4,15 +4,15 @@ import { mocked } from 'jest-mock'; import nock from 'nock'; import workFlowJobEvent from '../../test/resources/github_workflowjob_event.json'; -import runnerConfig from '../../test/resources/multi_runner_configurations.json'; -import { RunnerConfig } from '../sqs'; -import { checkBodySize, handle } from '.'; -import { Config } from '../ConfigResolver'; +import { checkBodySize, publishForRunners, publishOnEventBridge } from '.'; import { dispatch } from '../runners/dispatch'; import { IncomingHttpHeaders } from 'http'; +import { ConfigWebhook, ConfigWebhookEventBridge } from '../ConfigLoader'; +import { publish } from '../eventbridge'; jest.mock('../sqs'); +jest.mock('../eventbridge'); jest.mock('../runners/dispatch'); jest.mock('@aws-github-runner/aws-ssm-util'); @@ -25,88 +25,234 @@ const webhooks = new Webhooks({ }); describe('handle GitHub webhook events', () => { - let originalError: Console['error']; - let config: Config; - beforeEach(async () => { process.env = { ...cleanEnv }; nock.disableNetConnect(); - originalError = console.error; - console.error = jest.fn(); jest.clearAllMocks(); - jest.resetAllMocks(); mockSSMResponse(); - config = await Config.load(); }); - afterEach(() => { - console.error = originalError; - }); + describe('handle and dispatch webhook events to build queues', () => { + let config: ConfigWebhook; + beforeEach(async () => { + ConfigWebhook.reset(); + process.env.EVENT_BUS_NAME = 'test'; + config = await ConfigWebhook.load(); + }); - it('should return 500 if no signature available', async () => { - await expect(handle({}, '', config)).rejects.toMatchObject({ - statusCode: 500, + it('should return 500 if no signature available', async () => { + await expect(publishForRunners({}, '', config)).rejects.toMatchObject({ + statusCode: 500, + }); }); - }); - it('should accept large events', async () => { - // setup - mocked(dispatch).mockImplementation(() => { - return Promise.resolve({ body: 'test', statusCode: 201 }); + it('should accept large events', async () => { + // setup + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); + }); + + const event = JSON.stringify(workFlowJobEvent); + + // act and assert + const result = publishForRunners( + { + 'X-Hub-Signature-256': await webhooks.sign(event), + 'X-GitHub-Event': 'workflow_job', + 'content-length': (1024 * 256 + 1).toString(), + }, + event, + config, + ); + expect(result).resolves.toMatchObject({ + statusCode: 201, + }); }); - const event = JSON.stringify(workFlowJobEvent); + it('should reject with 403 if invalid signature', async () => { + const event = JSON.stringify(workFlowJobEvent); + const other = JSON.stringify({ ...workFlowJobEvent, action: 'mutated' }); - // act and assert - const result = handle( - { - 'X-Hub-Signature-256': await webhooks.sign(event), - 'X-GitHub-Event': 'workflow_job', - 'content-length': (1024 * 256 + 1).toString(), - }, - event, - config, - ); - expect(result).resolves.toMatchObject({ - statusCode: 201, + await expect( + publishForRunners( + { 'X-Hub-Signature-256': await webhooks.sign(other), 'X-GitHub-Event': 'workflow_job' }, + event, + config, + ), + ).rejects.toMatchObject({ + statusCode: 401, + }); }); - }); - it('should reject with 403 if invalid signature', async () => { - const event = JSON.stringify(workFlowJobEvent); - const other = JSON.stringify({ ...workFlowJobEvent, action: 'mutated' }); + it('should reject with 202 if event type is not supported', async () => { + const event = JSON.stringify(workFlowJobEvent); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(other), 'X-GitHub-Event': 'workflow_job' }, event, config), - ).rejects.toMatchObject({ - statusCode: 401, + await expect( + publishForRunners( + { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'invalid' }, + event, + config, + ), + ).rejects.toMatchObject({ + statusCode: 202, + }); }); - }); - it('should reject with 202 if event type is not supported', async () => { - const event = JSON.stringify(workFlowJobEvent); + it('should accept with 201 if valid signature', async () => { + const event = JSON.stringify(workFlowJobEvent); + + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); + }); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'invalid' }, event, config), - ).rejects.toMatchObject({ - statusCode: 202, + await expect( + publishForRunners( + { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, + event, + config, + ), + ).resolves.toMatchObject({ + statusCode: 201, + }); }); }); - it('should accept with 201 if valid signature', async () => { - const event = JSON.stringify(workFlowJobEvent); + describe('handle webhook events and forward to eventbridge', () => { + let config: ConfigWebhookEventBridge; + beforeEach(async () => { + ConfigWebhookEventBridge.reset(); + process.env.EVENT_BUS_NAME = 'test'; + config = await ConfigWebhookEventBridge.load(); + }); + + it('should return 500 if no signature available', async () => { + await expect(publishOnEventBridge({}, '', config)).rejects.toMatchObject({ + statusCode: 500, + }); + }); + + it('should publish too large events on an error channel.,', async () => { + // setup + mocked(publish).mockImplementation(async () => { + return Promise.resolve(); + }); + + const event = JSON.stringify(workFlowJobEvent); + + // act and assert + await publishOnEventBridge( + { + 'X-Hub-Signature-256': await webhooks.sign(event), + 'X-GitHub-Event': 'workflow_job', + 'content-length': (1024 * 256 + 1).toString(), + }, + event, + config, + ); - mocked(dispatch).mockImplementation(() => { - return Promise.resolve({ body: 'test', statusCode: 201 }); + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + Source: 'runners.webhook', + EventBusName: 'test', + DetailType: 'error.workflow_job', + Detail: expect.objectContaining({ + error: 'Body size exceeded 256KB', + size: expect.any(Number), + }), + }), + ); }); - await expect( - handle({ 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': 'workflow_job' }, event, config), - ).resolves.toMatchObject({ - statusCode: 201, + it('should reject with 403 if invalid signature', async () => { + const event = JSON.stringify(workFlowJobEvent); + const other = JSON.stringify({ ...workFlowJobEvent, action: 'mutated' }); + + await expect( + publishOnEventBridge( + { 'X-Hub-Signature-256': await webhooks.sign(other), 'X-GitHub-Event': 'workflow_job' }, + event, + config, + ), + ).rejects.toMatchObject({ + statusCode: 401, + }); }); + + interface TestInput { + events: string[]; + eventType: string; + } + + it.each([ + { events: [], eventType: 'workflow_job' }, + { events: ['workflow_job', 'workflow_run'], eventType: 'workflow_run' }, + ])('should accept $eventType for allowed events list $events', async (input: TestInput) => { + const event = JSON.stringify(workFlowJobEvent); + + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); + }); + + ConfigWebhookEventBridge.reset(); + process.env.ACCEPT_EVENTS = JSON.stringify(input.events); + config = await ConfigWebhookEventBridge.load(); + + await expect( + publishOnEventBridge( + { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': input.eventType }, + event, + config, + ), + ).resolves.toMatchObject({ + statusCode: 201, + }); + }); + + it('should throw if publish to bridge failes.,', async () => { + // setup + mocked(publish).mockRejectedValue(new Error('test')); + const event = JSON.stringify(workFlowJobEvent); + + // act and assert + await expect( + publishOnEventBridge( + { + 'X-Hub-Signature-256': await webhooks.sign(event), + 'X-GitHub-Event': 'workflow_job', + 'content-length': (1024 * 256 + 1).toString(), + }, + event, + config, + ), + ).rejects.toThrow('test'); + }); + + it.each([{ events: ['workflow_job', 'workflow_run'], eventType: 'push' }])( + 'should reject $eventType when not in allowed events list $events', + async (input: TestInput) => { + const event = JSON.stringify(workFlowJobEvent); + + mocked(dispatch).mockImplementation(() => { + return Promise.resolve({ body: 'test', statusCode: 201 }); + }); + + ConfigWebhookEventBridge.reset(); + process.env.ACCEPT_EVENTS = JSON.stringify(input.events); + config = await ConfigWebhookEventBridge.load(); + + await expect( + publishOnEventBridge( + { 'X-Hub-Signature-256': await webhooks.sign(event), 'X-GitHub-Event': input.eventType }, + event, + config, + ), + ).rejects.toMatchObject({ + statusCode: 202, + }); + }, + ); }); }); @@ -137,13 +283,27 @@ describe('Check message size (checkBodySize)', () => { }); }); -function mockSSMResponse(runnerConfigInput?: RunnerConfig) { - const mockedGet = mocked(getParameter); - mockedGet.mockImplementation((parameter_name) => { - const value = - parameter_name == '/github-runner/runner-matcher-config' - ? JSON.stringify(runnerConfigInput ?? runnerConfig) - : GITHUB_APP_WEBHOOK_SECRET; - return Promise.resolve(value); +function mockSSMResponse() { + process.env.PARAMETER_RUNNER_MATCHER_CONFIG_PATH = '/path/to/matcher/config'; + process.env.PARAMETER_GITHUB_APP_WEBHOOK_SECRET = '/path/to/webhook/secret'; + const matcherConfig = [ + { + id: '1', + arn: 'arn:aws:sqs:us-east-1:123456789012:queue1', + fifo: false, + matcherConfig: { + labelMatchers: [['label1', 'label2']], + exactMatch: true, + }, + }, + ]; + mocked(getParameter).mockImplementation(async (paramPath: string) => { + if (paramPath === '/path/to/matcher/config') { + return JSON.stringify(matcherConfig); + } + if (paramPath === '/path/to/webhook/secret') { + return GITHUB_APP_WEBHOOK_SECRET; + } + throw new Error('Parameter not found'); }); } diff --git a/lambdas/functions/webhook/src/webhook/index.ts b/lambdas/functions/webhook/src/webhook/index.ts index f0f58b31aa..ddea935773 100644 --- a/lambdas/functions/webhook/src/webhook/index.ts +++ b/lambdas/functions/webhook/src/webhook/index.ts @@ -5,15 +5,19 @@ import { IncomingHttpHeaders } from 'http'; import { Response } from '../lambda'; import ValidationError from '../ValidationError'; -import { Config } from '../ConfigResolver'; import { dispatch } from '../runners/dispatch'; -const supportedEvents = ['workflow_job']; +import { publish } from '../eventbridge'; +import { ConfigWebhook, ConfigWebhookEventBridge } from '../ConfigLoader'; const logger = createChildLogger('handler'); -export async function handle(headers: IncomingHttpHeaders, body: string, config: Config): Promise { +export async function publishForRunners( + headers: IncomingHttpHeaders, + body: string, + config: ConfigWebhook, +): Promise { init(headers); - await verifySignature(headers, body); + await verifySignature(headers, body, config.webhookSecret); const checkBodySizeResult = checkBodySize(body, headers); @@ -26,10 +30,63 @@ export async function handle(headers: IncomingHttpHeaders, body: string, config: return await dispatch(event, eventType, config); } -async function verifySignature(headers: IncomingHttpHeaders, body: string): Promise { +export async function publishOnEventBridge( + headers: IncomingHttpHeaders, + body: string, + config: ConfigWebhookEventBridge, +): Promise { + init(headers); + + await verifySignature(headers, body, config.webhookSecret); + + const eventType = headers['x-github-event'] as string; + checkEventIsSupported(eventType, config.allowedEvents); + + const checkBodySizeResult = checkBodySize(body, headers); + + logger.info( + `Github event ${headers['x-github-event'] as string} accepted for ` + + `${headers['x-github-hook-installation-target-id'] as string}`, + ); + + let response: Response = { body: '', statusCode: 201 }; + if (!checkBodySizeResult.sizeExceeded) { + await publishEvent(config.eventBusName, `github`, eventType, body); + response = { statusCode: 201, body: 'Event sent successfully to EventBridge successfully.' }; + } else { + await publishEvent(config.eventBusName, 'runners.webhook', `error.${eventType}`, checkBodySizeResult.message); + logger.warn('Github event body size exceeded 256KB'); + response = { statusCode: 400, body: checkBodySizeResult.message.error }; + } + return response; +} + +async function publishEvent(eventBusName: string | undefined, eventSource: string, eventType: string, body: string) { + try { + const result = await publish({ + EventBusName: eventBusName, + Source: eventSource, + DetailType: eventType, + Detail: body, + }); + logger.debug(`Event sent to EventBridge`, { + message: { + Source: eventSource, + DetailType: eventType, + Detail: body, + }, + result: result, + }); + } catch (e) { + logger.warn(`Failed to send event to EventBridge`, { error: e }); + throw e; + } +} + +async function verifySignature(headers: IncomingHttpHeaders, body: string, secret: string): Promise { const signature = headers['x-hub-signature-256'] as string; const webhooks = new Webhooks({ - secret: Config.webhookSecret!, + secret, }); if ( @@ -50,21 +107,26 @@ function init(headers: IncomingHttpHeaders) { headers[key.toLowerCase()] = headers[key]; } - logger.addPersistentLogAttributes({ + logger.appendPersistentKeys({ github: { - 'github-event': headers['x-github-event'], 'github-delivery': headers['x-github-delivery'], + 'github-event': headers['x-github-event'], + 'github-hook-id': headers['x-github-hook-id'], + 'github-hook-installation-target-id': headers['x-github-hook-installation-target-id'], }, }); } -function readEvent(headers: IncomingHttpHeaders, body: string): { event: WorkflowJobEvent; eventType: string } { - const eventType = headers['x-github-event'] as string; - - if (!supportedEvents.includes(eventType)) { +function checkEventIsSupported(eventType: string, allowedEvents: string[]): void { + if (allowedEvents.length > 0 && !allowedEvents.includes(eventType)) { logger.warn(`Unsupported event type: ${eventType}`); throw new ValidationError(202, `Unsupported event type: ${eventType}`); } +} + +function readEvent(headers: IncomingHttpHeaders, body: string): { event: WorkflowJobEvent; eventType: string } { + const eventType = headers['x-github-event'] as string; + checkEventIsSupported(eventType, ['workflow_job']); const event = JSON.parse(body) as WorkflowJobEvent; logger.appendPersistentKeys({ diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index b20304ab61..3526567e2e 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -113,7 +113,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.9.0" "@typescript-eslint/parser": "npm:^8.11.0" "@vercel/ncc": "npm:^0.38.1" - aws-sdk-client-mock: "npm:^4.0.2" + aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" cron-parser: "npm:^4.9.0" eslint: "npm:^8.57.0" @@ -212,7 +212,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.9.0" "@typescript-eslint/parser": "npm:^8.11.0" "@vercel/ncc": "npm:^0.38.1" - aws-sdk-client-mock: "npm:^4.0.2" + aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" cron-parser: "npm:^4.9.0" eslint: "npm:^8.57.0" @@ -248,7 +248,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.9.0" "@typescript-eslint/parser": "npm:^8.11.0" "@vercel/ncc": "npm:^0.38.1" - aws-sdk-client-mock: "npm:^4.0.2" + aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" axios: "npm:^1.7.7" eslint: "npm:^8.57.0" @@ -277,7 +277,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.9.0" "@typescript-eslint/parser": "npm:^8.11.0" "@vercel/ncc": "npm:^0.38.1" - aws-sdk-client-mock: "npm:^4.0.2" + aws-sdk-client-mock: "npm:^4.1.0" aws-sdk-client-mock-jest: "npm:^4.1.0" eslint: "npm:^8.57.0" eslint-plugin-prettier: "npm:5.2.1" @@ -299,6 +299,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-sdk/client-eventbridge": "npm:^3.670.0" "@aws-sdk/client-sqs": "npm:^3.677.0" "@middy/core": "npm:^4.7.0" "@octokit/rest": "npm:20.1.1" @@ -460,6 +461,56 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-eventbridge@npm:^3.670.0": + version: 3.678.0 + resolution: "@aws-sdk/client-eventbridge@npm:3.678.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/client-sso-oidc": "npm:3.678.0" + "@aws-sdk/client-sts": "npm:3.678.0" + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/credential-provider-node": "npm:3.678.0" + "@aws-sdk/middleware-host-header": "npm:3.667.0" + "@aws-sdk/middleware-logger": "npm:3.667.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.667.0" + "@aws-sdk/middleware-user-agent": "npm:3.678.0" + "@aws-sdk/region-config-resolver": "npm:3.667.0" + "@aws-sdk/signature-v4-multi-region": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-endpoints": "npm:3.667.0" + "@aws-sdk/util-user-agent-browser": "npm:3.675.0" + "@aws-sdk/util-user-agent-node": "npm:3.678.0" + "@smithy/config-resolver": "npm:^3.0.9" + "@smithy/core": "npm:^2.4.8" + "@smithy/fetch-http-handler": "npm:^3.2.9" + "@smithy/hash-node": "npm:^3.0.7" + "@smithy/invalid-dependency": "npm:^3.0.7" + "@smithy/middleware-content-length": "npm:^3.0.9" + "@smithy/middleware-endpoint": "npm:^3.1.4" + "@smithy/middleware-retry": "npm:^3.0.23" + "@smithy/middleware-serde": "npm:^3.0.7" + "@smithy/middleware-stack": "npm:^3.0.7" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/node-http-handler": "npm:^3.2.4" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/url-parser": "npm:^3.0.7" + "@smithy/util-base64": "npm:^3.0.0" + "@smithy/util-body-length-browser": "npm:^3.0.0" + "@smithy/util-body-length-node": "npm:^3.0.0" + "@smithy/util-defaults-mode-browser": "npm:^3.0.23" + "@smithy/util-defaults-mode-node": "npm:^3.0.23" + "@smithy/util-endpoints": "npm:^2.1.3" + "@smithy/util-middleware": "npm:^3.0.7" + "@smithy/util-retry": "npm:^3.0.7" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/33220266079b1354b7cd824d258a5abd002ba3be4a18ae60a45bd2eff843e4276cb24d7ca0dd7e1bbbf36f7d9b4120ac97b78b76304f028387a99a1e41284e4d + languageName: node + linkType: hard + "@aws-sdk/client-s3@npm:^3.677.0": version: 3.677.0 resolution: "@aws-sdk/client-s3@npm:3.677.0" @@ -678,6 +729,55 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso-oidc@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/client-sso-oidc@npm:3.678.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/credential-provider-node": "npm:3.678.0" + "@aws-sdk/middleware-host-header": "npm:3.667.0" + "@aws-sdk/middleware-logger": "npm:3.667.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.667.0" + "@aws-sdk/middleware-user-agent": "npm:3.678.0" + "@aws-sdk/region-config-resolver": "npm:3.667.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-endpoints": "npm:3.667.0" + "@aws-sdk/util-user-agent-browser": "npm:3.675.0" + "@aws-sdk/util-user-agent-node": "npm:3.678.0" + "@smithy/config-resolver": "npm:^3.0.9" + "@smithy/core": "npm:^2.4.8" + "@smithy/fetch-http-handler": "npm:^3.2.9" + "@smithy/hash-node": "npm:^3.0.7" + "@smithy/invalid-dependency": "npm:^3.0.7" + "@smithy/middleware-content-length": "npm:^3.0.9" + "@smithy/middleware-endpoint": "npm:^3.1.4" + "@smithy/middleware-retry": "npm:^3.0.23" + "@smithy/middleware-serde": "npm:^3.0.7" + "@smithy/middleware-stack": "npm:^3.0.7" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/node-http-handler": "npm:^3.2.4" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/url-parser": "npm:^3.0.7" + "@smithy/util-base64": "npm:^3.0.0" + "@smithy/util-body-length-browser": "npm:^3.0.0" + "@smithy/util-body-length-node": "npm:^3.0.0" + "@smithy/util-defaults-mode-browser": "npm:^3.0.23" + "@smithy/util-defaults-mode-node": "npm:^3.0.23" + "@smithy/util-endpoints": "npm:^2.1.3" + "@smithy/util-middleware": "npm:^3.0.7" + "@smithy/util-retry": "npm:^3.0.7" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + peerDependencies: + "@aws-sdk/client-sts": ^3.678.0 + checksum: 10c0/0b3f8d2448417ce28ba5bb6b8d61b2c85774d9066b4b344bd832bc994f064f702bc34074201db66a574ef78c5a24a541763295aa1108b01e1453962d7e13f902 + languageName: node + linkType: hard + "@aws-sdk/client-sso@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/client-sso@npm:3.677.0" @@ -724,6 +824,52 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sso@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/client-sso@npm:3.678.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/middleware-host-header": "npm:3.667.0" + "@aws-sdk/middleware-logger": "npm:3.667.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.667.0" + "@aws-sdk/middleware-user-agent": "npm:3.678.0" + "@aws-sdk/region-config-resolver": "npm:3.667.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-endpoints": "npm:3.667.0" + "@aws-sdk/util-user-agent-browser": "npm:3.675.0" + "@aws-sdk/util-user-agent-node": "npm:3.678.0" + "@smithy/config-resolver": "npm:^3.0.9" + "@smithy/core": "npm:^2.4.8" + "@smithy/fetch-http-handler": "npm:^3.2.9" + "@smithy/hash-node": "npm:^3.0.7" + "@smithy/invalid-dependency": "npm:^3.0.7" + "@smithy/middleware-content-length": "npm:^3.0.9" + "@smithy/middleware-endpoint": "npm:^3.1.4" + "@smithy/middleware-retry": "npm:^3.0.23" + "@smithy/middleware-serde": "npm:^3.0.7" + "@smithy/middleware-stack": "npm:^3.0.7" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/node-http-handler": "npm:^3.2.4" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/url-parser": "npm:^3.0.7" + "@smithy/util-base64": "npm:^3.0.0" + "@smithy/util-body-length-browser": "npm:^3.0.0" + "@smithy/util-body-length-node": "npm:^3.0.0" + "@smithy/util-defaults-mode-browser": "npm:^3.0.23" + "@smithy/util-defaults-mode-node": "npm:^3.0.23" + "@smithy/util-endpoints": "npm:^2.1.3" + "@smithy/util-middleware": "npm:^3.0.7" + "@smithy/util-retry": "npm:^3.0.7" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/d0d03d8bc9d42be12c8c487730b8c50e4230362511763a8a82c717b698f43bd68b13eddd57352405afe6fa27c08b96381ffc3995d7879d073a682e355845f653 + languageName: node + linkType: hard + "@aws-sdk/client-sts@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/client-sts@npm:3.677.0" @@ -772,6 +918,54 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/client-sts@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/client-sts@npm:3.678.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/client-sso-oidc": "npm:3.678.0" + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/credential-provider-node": "npm:3.678.0" + "@aws-sdk/middleware-host-header": "npm:3.667.0" + "@aws-sdk/middleware-logger": "npm:3.667.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.667.0" + "@aws-sdk/middleware-user-agent": "npm:3.678.0" + "@aws-sdk/region-config-resolver": "npm:3.667.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-endpoints": "npm:3.667.0" + "@aws-sdk/util-user-agent-browser": "npm:3.675.0" + "@aws-sdk/util-user-agent-node": "npm:3.678.0" + "@smithy/config-resolver": "npm:^3.0.9" + "@smithy/core": "npm:^2.4.8" + "@smithy/fetch-http-handler": "npm:^3.2.9" + "@smithy/hash-node": "npm:^3.0.7" + "@smithy/invalid-dependency": "npm:^3.0.7" + "@smithy/middleware-content-length": "npm:^3.0.9" + "@smithy/middleware-endpoint": "npm:^3.1.4" + "@smithy/middleware-retry": "npm:^3.0.23" + "@smithy/middleware-serde": "npm:^3.0.7" + "@smithy/middleware-stack": "npm:^3.0.7" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/node-http-handler": "npm:^3.2.4" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/url-parser": "npm:^3.0.7" + "@smithy/util-base64": "npm:^3.0.0" + "@smithy/util-body-length-browser": "npm:^3.0.0" + "@smithy/util-body-length-node": "npm:^3.0.0" + "@smithy/util-defaults-mode-browser": "npm:^3.0.23" + "@smithy/util-defaults-mode-node": "npm:^3.0.23" + "@smithy/util-endpoints": "npm:^2.1.3" + "@smithy/util-middleware": "npm:^3.0.7" + "@smithy/util-retry": "npm:^3.0.7" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/7ddbbe40611ccb47cc4567185a985e33a2fb626390e07feccebc3f64f87bc748de5b3432ff2a0bcc8e27cd5e705393f811e4e194906e2e11f1a24c2bc6914635 + languageName: node + linkType: hard + "@aws-sdk/core@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/core@npm:3.677.0" @@ -791,6 +985,25 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/core@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/core@npm:3.678.0" + dependencies: + "@aws-sdk/types": "npm:3.667.0" + "@smithy/core": "npm:^2.4.8" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/signature-v4": "npm:^4.2.0" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/util-middleware": "npm:^3.0.7" + fast-xml-parser: "npm:4.4.1" + tslib: "npm:^2.6.2" + checksum: 10c0/7113ccd670e76527d6a720cfb9f7ccd3113a63665bc5048af0b3477ffc5dbc62968b9b02fe4689a56390444a94fa446837f147e65ba091fb24ed095e4fe1f3d4 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-env@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-env@npm:3.677.0" @@ -804,6 +1017,19 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-env@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/09de3d9d31a4c22d18ceca8818fcbb911c93eb4337477f4eb2e169f4877525a109b1110b264271b39914a5a13cfcb4e85e0cd8d20bf8006723dfadfe82b09ea9 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-http@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-http@npm:3.677.0" @@ -822,6 +1048,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-http@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/fetch-http-handler": "npm:^3.2.9" + "@smithy/node-http-handler": "npm:^3.2.4" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/util-stream": "npm:^3.1.9" + tslib: "npm:^2.6.2" + checksum: 10c0/e03375673f97ce220c0c9b2bc8db73debd32200f59711bd4064f50b37cb5ba691816481bc12bc684670f4a721676c7171a95ce06c5e573718b4d5b6de0f48b6d + languageName: node + linkType: hard + "@aws-sdk/credential-provider-ini@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-ini@npm:3.677.0" @@ -844,6 +1088,28 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-ini@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/credential-provider-env": "npm:3.678.0" + "@aws-sdk/credential-provider-http": "npm:3.678.0" + "@aws-sdk/credential-provider-process": "npm:3.678.0" + "@aws-sdk/credential-provider-sso": "npm:3.678.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/credential-provider-imds": "npm:^3.2.4" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/shared-ini-file-loader": "npm:^3.1.8" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + peerDependencies: + "@aws-sdk/client-sts": ^3.678.0 + checksum: 10c0/48da167b454a1564c2e4b0e9e417f093b666f8815001be157320d3e1bc052d65e54430a8de186fb00315f9ef6859224244924db6e70d86995d28e05ddf5d9d1c + languageName: node + linkType: hard + "@aws-sdk/credential-provider-node@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-node@npm:3.677.0" @@ -864,6 +1130,26 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-node@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.678.0" + dependencies: + "@aws-sdk/credential-provider-env": "npm:3.678.0" + "@aws-sdk/credential-provider-http": "npm:3.678.0" + "@aws-sdk/credential-provider-ini": "npm:3.678.0" + "@aws-sdk/credential-provider-process": "npm:3.678.0" + "@aws-sdk/credential-provider-sso": "npm:3.678.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/credential-provider-imds": "npm:^3.2.4" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/shared-ini-file-loader": "npm:^3.1.8" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/5cf7b7197ba45ce217acd51c4e04bff601804e0a447e31103df3d5842ce9771cbed679b45e1750769f58cf9c64b3a7d27e9b0cc77e42f7680037a8c84a531719 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-process@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-process@npm:3.677.0" @@ -878,6 +1164,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-process@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/shared-ini-file-loader": "npm:^3.1.8" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/793681c4234aadae9992debfdd8a5bb1dd2958c2f12c808789c450ed315fec3cc242a57538a8ba6a731439995b04fa8da2e4c5449274f33618ac61f288de5431 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-sso@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-sso@npm:3.677.0" @@ -894,6 +1194,22 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-sso@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.678.0" + dependencies: + "@aws-sdk/client-sso": "npm:3.678.0" + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/token-providers": "npm:3.667.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/shared-ini-file-loader": "npm:^3.1.8" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ef1db0dc1b5a62b3c450d3ab5a1e86fb7083d2df855b7b312c5986c6fccfc97f10bfcdc31bf4f64482e8b5ef9c945f9ac82b23d6d3b79f8cfbc1b16fb5415f89 + languageName: node + linkType: hard + "@aws-sdk/credential-provider-web-identity@npm:3.677.0": version: 3.677.0 resolution: "@aws-sdk/credential-provider-web-identity@npm:3.677.0" @@ -909,6 +1225,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/credential-provider-web-identity@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/property-provider": "npm:^3.1.7" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + peerDependencies: + "@aws-sdk/client-sts": ^3.678.0 + checksum: 10c0/68194b54ad7396e6dafc4f451d94b0d647d33a410bc9a3b9525e4b6e203177a2e77c6cdd359445875612d6499689359c762fffb46eda2f58fff2f55b06c8cac9 + languageName: node + linkType: hard + "@aws-sdk/lib-storage@npm:^3.677.0": version: 3.677.0 resolution: "@aws-sdk/lib-storage@npm:3.677.0" @@ -1056,6 +1387,28 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-sdk-s3@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-arn-parser": "npm:3.568.0" + "@smithy/core": "npm:^2.4.8" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/signature-v4": "npm:^4.2.0" + "@smithy/smithy-client": "npm:^3.4.0" + "@smithy/types": "npm:^3.5.0" + "@smithy/util-config-provider": "npm:^3.0.0" + "@smithy/util-middleware": "npm:^3.0.7" + "@smithy/util-stream": "npm:^3.1.9" + "@smithy/util-utf8": "npm:^3.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/f137d1ed8319f8f7d0f4270b92e971cfc6576a6fd97d6984ad36e02d0d0764c40f25cc8c8818917c8ea638780bd59f2cdf3e6b48f6396ca423e94f6841662018 + languageName: node + linkType: hard + "@aws-sdk/middleware-sdk-sqs@npm:3.667.0": version: 3.667.0 resolution: "@aws-sdk/middleware-sdk-sqs@npm:3.667.0" @@ -1096,6 +1449,21 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/middleware-user-agent@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.678.0" + dependencies: + "@aws-sdk/core": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@aws-sdk/util-endpoints": "npm:3.667.0" + "@smithy/core": "npm:^2.4.8" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/c2b86d7e5abb956a52ae3386f6e5243f1913f08b3593bdc5f97b1f0a7eb5089bcd5d92ed7e5c6ddfd20d09a05540e20c87d566b12462e8f70b714618065ff72f + languageName: node + linkType: hard + "@aws-sdk/region-config-resolver@npm:3.667.0": version: 3.667.0 resolution: "@aws-sdk/region-config-resolver@npm:3.667.0" @@ -1124,6 +1492,20 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/signature-v4-multi-region@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.678.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/protocol-http": "npm:^4.1.4" + "@smithy/signature-v4": "npm:^4.2.0" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + checksum: 10c0/12afa7d5e0a8f05ac5d8aef85c2cb18300d460a0706e5fd2ac6c65e37a964fb0694a927ef22b88690576ee18ace6e94a27e9e4db2f1f5999e82fd6dd3133276a + languageName: node + linkType: hard + "@aws-sdk/token-providers@npm:3.667.0": version: 3.667.0 resolution: "@aws-sdk/token-providers@npm:3.667.0" @@ -1139,7 +1521,7 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:3.667.0": +"@aws-sdk/types@npm:3.667.0, @aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.4.1, @aws-sdk/types@npm:^3.664.0": version: 3.667.0 resolution: "@aws-sdk/types@npm:3.667.0" dependencies: @@ -1149,26 +1531,6 @@ __metadata: languageName: node linkType: hard -"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.4.1": - version: 3.609.0 - resolution: "@aws-sdk/types@npm:3.609.0" - dependencies: - "@smithy/types": "npm:^3.3.0" - tslib: "npm:^2.6.2" - checksum: 10c0/293249118c2fc3cdc79ff9712e3a9f757a2f38e7d5d770507b3bb31d22b8c67ed6f9bdd83c1b6319236b8257d5cc7e2882c15e076200021e8bbf41e4780d430c - languageName: node - linkType: hard - -"@aws-sdk/types@npm:^3.664.0": - version: 3.664.0 - resolution: "@aws-sdk/types@npm:3.664.0" - dependencies: - "@smithy/types": "npm:^3.5.0" - tslib: "npm:^2.6.2" - checksum: 10c0/0f56e2dfc2990ded7fe3c3344a3ae0e21f835b4a251d309def04bf122b1da2336baf66fa78d6b9c4a82166d6ccd9cadcd4186f0c7bf7423e4db973dac63f2d74 - languageName: node - linkType: hard - "@aws-sdk/util-arn-parser@npm:3.568.0": version: 3.568.0 resolution: "@aws-sdk/util-arn-parser@npm:3.568.0" @@ -1241,6 +1603,24 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/util-user-agent-node@npm:3.678.0": + version: 3.678.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.678.0" + dependencies: + "@aws-sdk/middleware-user-agent": "npm:3.678.0" + "@aws-sdk/types": "npm:3.667.0" + "@smithy/node-config-provider": "npm:^3.1.8" + "@smithy/types": "npm:^3.5.0" + tslib: "npm:^2.6.2" + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10c0/c17b09c247c11c7ff14a7c3fd8b8976e3b28f339a5dbe5f0f4717096e7c1bd8a1132e494d7ef709871988b06528a8742c0af4407b8650276e135aa1671c7b865 + languageName: node + linkType: hard + "@aws-sdk/xml-builder@npm:3.662.0": version: 3.662.0 resolution: "@aws-sdk/xml-builder@npm:3.662.0" @@ -4670,15 +5050,6 @@ __metadata: languageName: node linkType: hard -"@smithy/types@npm:^3.3.0": - version: 3.3.0 - resolution: "@smithy/types@npm:3.3.0" - dependencies: - tslib: "npm:^2.6.2" - checksum: 10c0/ab2c2d621384a2bbdd31d5c90809395cb5c2a726afd69758895d5a630f932f6ae9a53ca7a9cd5d8c195df9278869b2420a2fb4fada47dee9e8c9d4e3c80a349e - languageName: node - linkType: hard - "@smithy/types@npm:^3.5.0": version: 3.5.0 resolution: "@smithy/types@npm:3.5.0" @@ -5291,16 +5662,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*": - version: 22.4.1 - resolution: "@types/node@npm:22.4.1" - dependencies: - undici-types: "npm:~6.19.2" - checksum: 10c0/e42607438fcbd3a6aebd09084868fa0b22a4b0daf9eda79ed615df7ff8ae95e35ea56e090e1f3140ebae76b640abe42d4a6d5b60c0819eadf499adca737305b6 - languageName: node - linkType: hard - -"@types/node@npm:^22.5.4": +"@types/node@npm:*, @types/node@npm:^22.5.4": version: 22.5.4 resolution: "@types/node@npm:22.5.4" dependencies: @@ -5925,14 +6287,14 @@ __metadata: languageName: node linkType: hard -"aws-sdk-client-mock@npm:^4.0.2": - version: 4.0.2 - resolution: "aws-sdk-client-mock@npm:4.0.2" +"aws-sdk-client-mock@npm:^4.1.0": + version: 4.1.0 + resolution: "aws-sdk-client-mock@npm:4.1.0" dependencies: "@types/sinon": "npm:^17.0.3" sinon: "npm:^18.0.1" tslib: "npm:^2.1.0" - checksum: 10c0/3028bb4997e51efa8669f96e01b6c3841f0c5cfb4ae8e3087e31b556dc34193619cd7152f4c30eb65f5c851e384709f915a2d6ea6dd52f7ece7216a9942c213b + checksum: 10c0/045caad0cff0ffeb08e69849dcae51aac8999163c58d71220bf47a82c237aabab2abf92bf6bf3bd7666e6e8984513c628e01a89eafa46fb230201d6587bc01e9 languageName: node linkType: hard @@ -5968,18 +6330,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.7.2, axios@npm:^1.7.4": - version: 1.7.5 - resolution: "axios@npm:1.7.5" - dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.0" - proxy-from-env: "npm:^1.1.0" - checksum: 10c0/1d5daeb28b3d1bb2a7b9f0743433c4bfbeaddc15461e50ebde487eec6c009af2515749d5261096dd430c90cd891bd310bcba5ec3967bae2033c4a307f58a6ad3 - languageName: node - linkType: hard - -"axios@npm:^1.7.7": +"axios@npm:^1.7.2, axios@npm:^1.7.4, axios@npm:^1.7.7": version: 1.7.7 resolution: "axios@npm:1.7.7" dependencies: @@ -6346,17 +6697,7 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.2": - version: 1.0.2 - resolution: "call-bind@npm:1.0.2" - dependencies: - function-bind: "npm:^1.1.1" - get-intrinsic: "npm:^1.0.2" - checksum: 10c0/74ba3f31e715456e22e451d8d098779b861eba3c7cac0d9b510049aced70d75c231ba05071f97e1812c98e34e2bee734c0c6126653e0088c2d9819ca047f4073 - languageName: node - linkType: hard - -"call-bind@npm:^1.0.7": +"call-bind@npm:^1.0.2, call-bind@npm:^1.0.7": version: 1.0.7 resolution: "call-bind@npm:1.0.7" dependencies: @@ -7669,7 +8010,7 @@ __metadata: languageName: node linkType: hard -"function-bind@npm:^1.1.1, function-bind@npm:^1.1.2": +"function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 @@ -7690,18 +8031,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.3": - version: 1.2.0 - resolution: "get-intrinsic@npm:1.2.0" - dependencies: - function-bind: "npm:^1.1.1" - has: "npm:^1.0.3" - has-symbols: "npm:^1.0.3" - checksum: 10c0/7c564f6b1061e6ca9eb1abab424a2cf80b93e75dcde65229d504e4055aa0ea54f88330e9b75d10e41c72bca881a947e84193b3549a4692d836f304239a178d63 - languageName: node - linkType: hard - -"get-intrinsic@npm:^1.2.4": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4": version: 1.2.4 resolution: "get-intrinsic@npm:1.2.4" dependencies: @@ -7875,15 +8205,6 @@ __metadata: languageName: node linkType: hard -"has@npm:^1.0.3": - version: 1.0.3 - resolution: "has@npm:1.0.3" - dependencies: - function-bind: "npm:^1.1.1" - checksum: 10c0/e1da0d2bd109f116b632f27782cf23182b42f14972ca9540e4c5aa7e52647407a0a4a76937334fddcb56befe94a3494825ec22b19b51f5e5507c3153fd1a5e1b - languageName: node - linkType: hard - "hasown@npm:^2.0.0": version: 2.0.1 resolution: "hasown@npm:2.0.1" @@ -10630,16 +10951,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": - version: 7.6.2 - resolution: "semver@npm:7.6.2" - bin: - semver: bin/semver.js - checksum: 10c0/97d3441e97ace8be4b1976433d1c32658f6afaff09f143e52c593bae7eef33de19e3e369c88bd985ce1042c6f441c80c6803078d1de2a9988080b66684cbb30c - languageName: node - linkType: hard - -"semver@npm:^7.6.3": +"semver@npm:^7.0.0, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" bin: diff --git a/main.tf b/main.tf index 25346b30bd..12f0bd96bb 100644 --- a/main.tf +++ b/main.tf @@ -123,6 +123,7 @@ module "ssm" { module "webhook" { source = "./modules/webhook" + ssm_paths = { root = local.ssm_root_path webhook = var.ssm_paths.webhook @@ -130,6 +131,7 @@ module "webhook" { prefix = var.prefix tags = local.tags kms_key_arn = var.kms_key_arn + eventbridge = var.eventbridge runner_matcher_config = { (aws_sqs_queue.queued_builds.id) = { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 1e399a1a1c..d47ea60c6f 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -131,7 +131,8 @@ module "multi-runner" { | [enable\_ami\_housekeeper](#input\_enable\_ami\_housekeeper) | Option to disable the lambda to clean up old AMIs. | `bool` | `false` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [enable\_metrics\_control\_plane](#input\_enable\_metrics\_control\_plane) | (Experimental) Enable or disable the metrics for the module. Feature can change or renamed without a major release. | `bool` | `false` | no | -| [enable\_workflow\_job\_events\_queue](#input\_enable\_workflow\_job\_events\_queue) | Enabling this experimental feature will create a secondory sqs queue to which a copy of the workflow\_job event will be delivered. | `bool` | `false` | no | +| [enable\_workflow\_job\_events\_queue](#input\_enable\_workflow\_job\_events\_queue) | Enabling this experimental feature will create a secondary SQS queue to which a copy of the workflow\_job event will be delivered. | `bool` | `false` | no | +| [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, false)
accept_events = optional(list(string), [])
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB | `string` | `null` | no | | [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. Ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). |
object({
key_base64 = string
id = string
webhook_secret = string
})
| n/a | yes | diff --git a/modules/multi-runner/outputs.tf b/modules/multi-runner/outputs.tf index bc6624be27..0a7b99243f 100644 --- a/modules/multi-runner/outputs.tf +++ b/modules/multi-runner/outputs.tf @@ -38,6 +38,9 @@ output "webhook" { lambda_log_group = module.webhook.lambda_log_group lambda_role = module.webhook.role endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" + webhook = module.webhook.webhook + dispatcher = var.eventbridge.enable ? module.webhook.dispatcher : null + eventbridge = var.eventbridge.enable ? module.webhook.eventbridge : null } } diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 553cc04594..a9d0a9f906 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -551,7 +551,7 @@ variable "pool_lambda_reserved_concurrent_executions" { } variable "enable_workflow_job_events_queue" { - description = "Enabling this experimental feature will create a secondory sqs queue to which a copy of the workflow_job event will be delivered." + description = "Enabling this experimental feature will create a secondary SQS queue to which a copy of the workflow_job event will be delivered." type = bool default = false } @@ -683,3 +683,13 @@ variable "metrics" { }) default = {} } + +variable "eventbridge" { + description = "Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling." + type = object({ + enable = optional(bool, false) + accept_events = optional(list(string), []) + }) + + default = {} +} diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 573ebafbe0..9e70ca81a2 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -1,11 +1,12 @@ module "webhook" { - source = "../webhook" - prefix = var.prefix - tags = local.tags - kms_key_arn = var.kms_key_arn - + source = "../webhook" + prefix = var.prefix + tags = local.tags + kms_key_arn = var.kms_key_arn + eventbridge = var.eventbridge runner_matcher_config = local.runner_config matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier + ssm_paths = { root = local.ssm_root_path webhook = var.ssm_paths.webhook diff --git a/modules/webhook/README.md b/modules/webhook/README.md index 4419b109d0..4408bc56af 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -2,11 +2,11 @@ > This module is treated as internal module, breaking changes will not trigger a major release bump. -This module creates an API gateway endpoint and lambda function to handle GitHub App webhook events. +The module can be deployed in two modes. 'Direct' messages, are delivered directly to the runner queues. 'EventBridge' messages are delivered to an EventBridge bus and then dispatched to the runner queues. ## Lambda Function -The Lambda function is written in [TypeScript](https://www.typescriptlang.org/) and requires Node 12.x and yarn. Sources are located in [./lambdas/webhook]. +The Lambda function is written in [TypeScript](https://www.typescriptlang.org/) and requires Node and yarn. Sources are located in [./lambdas/webhook]. Check see `lambda.ts` for the different handler functions available. ### Install @@ -44,11 +44,13 @@ yarn run dist | Name | Version | |------|---------| | [aws](#provider\_aws) | ~> 5.27 | -| [null](#provider\_null) | ~> 3 | ## Modules -No modules. +| Name | Source | Version | +|------|--------|---------| +| [direct](#module\_direct) | ./direct | n/a | +| [eventbridge](#module\_eventbridge) | ./eventbridge | n/a | ## Resources @@ -58,26 +60,14 @@ No modules. | [aws_apigatewayv2_integration.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_integration) | resource | | [aws_apigatewayv2_route.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_route) | resource | | [aws_apigatewayv2_stage.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/apigatewayv2_stage) | resource | -| [aws_cloudwatch_log_group.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_iam_role.webhook_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | -| [aws_iam_role_policy.webhook_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.webhook_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.webhook_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.webhook_workflow_job_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy.xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | -| [aws_iam_role_policy_attachment.webhook_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_lambda_function.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | -| [aws_lambda_permission.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | | [aws_ssm_parameter.runner_matcher_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | -| [null_resource.github_app_parameters](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | -| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | -| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no | +| [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, false)
accept_events = optional(list(string), null)
})
| n/a | yes | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
webhook_secret = map(string)
})
| n/a | yes | | [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | | [lambda\_architecture](#input\_lambda\_architecture) | AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions. | `string` | `"arm64"` | no | @@ -110,9 +100,12 @@ No modules. | Name | Description | |------|-------------| +| [dispatcher](#output\_dispatcher) | n/a | | [endpoint\_relative\_path](#output\_endpoint\_relative\_path) | n/a | +| [eventbridge](#output\_eventbridge) | n/a | | [gateway](#output\_gateway) | n/a | | [lambda](#output\_lambda) | n/a | | [lambda\_log\_group](#output\_lambda\_log\_group) | n/a | | [role](#output\_role) | n/a | +| [webhook](#output\_webhook) | n/a | diff --git a/modules/webhook/direct/README.md b/modules/webhook/direct/README.md new file mode 100644 index 0000000000..be9390c3dc --- /dev/null +++ b/modules/webhook/direct/README.md @@ -0,0 +1,52 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | ~> 5.27 | +| [null](#requirement\_null) | ~> 3.2 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | ~> 5.27 | +| [null](#provider\_null) | ~> 3.2 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_log_group.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.webhook_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.webhook_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_workflow_job_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.webhook_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [null_resource.github_app_parameters](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | +| [null_resource.ssm_parameter_runner_matcher_config](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | +| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
sqs_workflow_job_queue = optional(object({
id = string
arn = string
}), null)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs20.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = object({
name = string
arn = string
version = string
})
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [webhook](#output\_webhook) | n/a | +| [webhook\_lambda\_function](#output\_webhook\_lambda\_function) | n/a | + \ No newline at end of file diff --git a/modules/webhook/direct/main.tf b/modules/webhook/direct/main.tf new file mode 100644 index 0000000000..9937792f5e --- /dev/null +++ b/modules/webhook/direct/main.tf @@ -0,0 +1,6 @@ + +resource "null_resource" "ssm_parameter_runner_matcher_config" { + triggers = { + version = var.config.ssm_parameter_runner_matcher_config.version + } +} diff --git a/modules/webhook/direct/outputs.tf b/modules/webhook/direct/outputs.tf new file mode 100644 index 0000000000..891821c727 --- /dev/null +++ b/modules/webhook/direct/outputs.tf @@ -0,0 +1,12 @@ +output "webhook_lambda_function" { + value = aws_lambda_function.webhook +} + + +output "webhook" { + value = { + lambda = aws_lambda_function.webhook + log_group = aws_cloudwatch_log_group.webhook + role = aws_iam_role.webhook_lambda + } +} diff --git a/modules/webhook/policies.tf b/modules/webhook/direct/policies.tf similarity index 83% rename from modules/webhook/policies.tf rename to modules/webhook/direct/policies.tf index 454d943b4b..23bfea7d24 100644 --- a/modules/webhook/policies.tf +++ b/modules/webhook/direct/policies.tf @@ -1,5 +1,5 @@ data "aws_iam_policy_document" "lambda_xray" { - count = var.tracing_config.mode != null ? 1 : 0 + count = var.config.tracing_config.mode != null ? 1 : 0 statement { actions = [ "xray:BatchGetTraces", diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf new file mode 100644 index 0000000000..dabad516a9 --- /dev/null +++ b/modules/webhook/direct/variables.tf @@ -0,0 +1,54 @@ +variable "config" { + description = "Configuration object for all variables." + type = object({ + prefix = string + archive = optional(object({ + enable = optional(bool, true) + retention_days = optional(number, 7) + }), {}) + tags = optional(map(string), {}) + + lambda_subnet_ids = optional(list(string), []) + lambda_security_group_ids = optional(list(string), []) + sqs_job_queues_arns = list(string) + sqs_workflow_job_queue = optional(object({ + id = string + arn = string + }), null) + lambda_zip = optional(string, null) + lambda_memory_size = optional(number, 256) + lambda_timeout = optional(number, 10) + role_permissions_boundary = optional(string, null) + role_path = optional(string, null) + logging_retention_in_days = optional(number, 180) + logging_kms_key_id = optional(string, null) + lambda_s3_bucket = optional(string, null) + lambda_s3_key = optional(string, null) + lambda_s3_object_version = optional(string, null) + lambda_apigateway_access_log_settings = optional(object({ + destination_arn = string + format = string + }), null) + repository_white_list = optional(list(string), []) + kms_key_arn = optional(string, null) + log_level = optional(string, "info") + lambda_runtime = optional(string, "nodejs20.x") + aws_partition = optional(string, "aws") + lambda_architecture = optional(string, "arm64") + github_app_parameters = object({ + webhook_secret = map(string) + }) + tracing_config = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }), {}) + lambda_tags = optional(map(string), {}) + api_gw_source_arn = string + ssm_parameter_runner_matcher_config = object({ + name = string + arn = string + version = string + }) + }) +} diff --git a/modules/webhook/direct/versions.tf b/modules/webhook/direct/versions.tf new file mode 100644 index 0000000000..d780c7775c --- /dev/null +++ b/modules/webhook/direct/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.27" + } + + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + } +} diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf new file mode 100644 index 0000000000..9fd24e3d30 --- /dev/null +++ b/modules/webhook/direct/webhook.tf @@ -0,0 +1,149 @@ +locals { + lambda_zip = var.config.lambda_zip == null ? "${path.module}/../../../lambdas/functions/webhook/webhook.zip" : var.config.lambda_zip +} + +resource "aws_lambda_function" "webhook" { + s3_bucket = var.config.lambda_s3_bucket != null ? var.config.lambda_s3_bucket : null + s3_key = var.config.lambda_s3_key != null ? var.config.lambda_s3_key : null + s3_object_version = var.config.lambda_s3_object_version != null ? var.config.lambda_s3_object_version : null + filename = var.config.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.config.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.config.prefix}-webhook" + role = aws_iam_role.webhook_lambda.arn + handler = "index.directWebhook" + runtime = var.config.lambda_runtime + memory_size = var.config.lambda_memory_size + timeout = var.config.lambda_timeout + architectures = [var.config.lambda_architecture] + + environment { + variables = { + for k, v in { + LOG_LEVEL = var.config.log_level + POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" + POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name + REPOSITORY_ALLOW_LIST = jsonencode(var.config.repository_white_list) + SQS_WORKFLOW_JOB_QUEUE = try(var.config.sqs_workflow_job_queue.id, null) + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name + } : k => v if v != null + } + } + + dynamic "vpc_config" { + for_each = var.config.lambda_subnet_ids != null && var.config.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.config.lambda_security_group_ids + subnet_ids = var.config.lambda_subnet_ids + } + } + + tags = merge(var.config.tags, var.config.lambda_tags) + + dynamic "tracing_config" { + for_each = var.config.tracing_config.mode != null ? [true] : [] + content { + mode = var.config.tracing_config.mode + } + } + + lifecycle { + replace_triggered_by = [null_resource.ssm_parameter_runner_matcher_config, null_resource.github_app_parameters] + } +} + +resource "aws_cloudwatch_log_group" "webhook" { + name = "/aws/lambda/${aws_lambda_function.webhook.function_name}" + retention_in_days = var.config.logging_retention_in_days + kms_key_id = var.config.logging_kms_key_id + tags = var.config.tags +} + +resource "aws_lambda_permission" "webhook" { + statement_id = "AllowExecutionFromAPIGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.webhook.function_name + principal = "apigateway.amazonaws.com" + source_arn = var.config.api_gw_source_arn + lifecycle { + replace_triggered_by = [null_resource.ssm_parameter_runner_matcher_config, null_resource.github_app_parameters] + } +} + +resource "null_resource" "github_app_parameters" { + triggers = { + github_app_webhook_secret = var.config.github_app_parameters.webhook_secret.name + } +} + +data "aws_iam_policy_document" "lambda_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "webhook_lambda" { + name = "${var.config.prefix}-direct-webhook-lambda-role" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = var.config.role_path + permissions_boundary = var.config.role_permissions_boundary + tags = var.config.tags +} + +resource "aws_iam_role_policy" "webhook_logging" { + name = "logging-policy" + role = aws_iam_role.webhook_lambda.name + policy = templatefile("${path.module}/../policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.webhook.arn + }) +} + +resource "aws_iam_role_policy_attachment" "webhook_vpc_execution_role" { + count = length(var.config.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.webhook_lambda.name + policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "webhook_sqs" { + name = "publish-sqs-policy" + role = aws_iam_role.webhook_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-publish-sqs-policy.json", { + sqs_resource_arns = jsonencode(var.config.sqs_job_queues_arns) + kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" + }) +} + +resource "aws_iam_role_policy" "webhook_workflow_job_sqs" { + count = var.config.sqs_workflow_job_queue != null ? 1 : 0 + name = "publish-workflow-job-sqs-policy" + role = aws_iam_role.webhook_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-publish-sqs-policy.json", { + sqs_resource_arns = jsonencode([var.config.sqs_workflow_job_queue.arn]) + kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" + }) +} + +resource "aws_iam_role_policy" "webhook_ssm" { + name = "publish-ssm-policy" + role = aws_iam_role.webhook_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn, var.config.ssm_parameter_runner_matcher_config.arn]) + }) +} + +resource "aws_iam_role_policy" "xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.webhook_lambda.name +} diff --git a/modules/webhook/eventbridge/README.md b/modules/webhook/eventbridge/README.md new file mode 100644 index 0000000000..6426772d3d --- /dev/null +++ b/modules/webhook/eventbridge/README.md @@ -0,0 +1,65 @@ + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | ~> 5.0 | +| [null](#requirement\_null) | ~> 3.2 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | ~> 5.0 | +| [null](#provider\_null) | ~> 3.2 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_event_archive.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_archive) | resource | +| [aws_cloudwatch_event_bus.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_bus) | resource | +| [aws_cloudwatch_event_rule.workflow_job](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | +| [aws_cloudwatch_event_target.github_welcome](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_log_group.dispatcher](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_cloudwatch_log_group.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | +| [aws_iam_role.dispatcher_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.webhook_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role_policy.dispatcher_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dispatcher_sqs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dispatcher_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.dispatcher_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_logging](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.webhook_ssm](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.dispatcher_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_iam_role_policy_attachment.webhook_vpc_execution_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lambda_function.dispatcher](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_function.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function) | resource | +| [aws_lambda_permission.allow_cloudwatch_to_call_lambda](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.webhook](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [null_resource.github_app_parameters](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | +| [null_resource.ssm_parameter_runner_matcher_config](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource) | resource | +| [aws_iam_policy_document.lambda_assume_role_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.lambda_xray](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [config](#input\_config) | Configuration object for all variables. |
object({
prefix = string
archive = optional(object({
enable = optional(bool, true)
retention_days = optional(number, 7)
}), {})
tags = optional(map(string), {})

lambda_subnet_ids = optional(list(string), [])
lambda_security_group_ids = optional(list(string), [])
sqs_job_queues_arns = list(string)
sqs_workflow_job_queue = optional(object({
id = string
arn = string
}), null)
lambda_zip = optional(string, null)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 10)
role_permissions_boundary = optional(string, null)
role_path = optional(string, null)
logging_retention_in_days = optional(number, 180)
logging_kms_key_id = optional(string, null)
lambda_s3_bucket = optional(string, null)
lambda_s3_key = optional(string, null)
lambda_s3_object_version = optional(string, null)
lambda_apigateway_access_log_settings = optional(object({
destination_arn = string
format = string
}), null)
repository_white_list = optional(list(string), [])
kms_key_arn = optional(string, null)
log_level = optional(string, "info")
lambda_runtime = optional(string, "nodejs20.x")
aws_partition = optional(string, "aws")
lambda_architecture = optional(string, "arm64")
github_app_parameters = object({
webhook_secret = map(string)
})
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
lambda_tags = optional(map(string), {})
api_gw_source_arn = string
ssm_parameter_runner_matcher_config = object({
name = string
arn = string
version = string
})
accept_events = optional(list(string), null)
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [dispatcher](#output\_dispatcher) | n/a | +| [eventbridge](#output\_eventbridge) | n/a | +| [webhook](#output\_webhook) | n/a | + \ No newline at end of file diff --git a/modules/webhook/eventbridge/dispatcher.tf b/modules/webhook/eventbridge/dispatcher.tf new file mode 100644 index 0000000000..93d9af84e1 --- /dev/null +++ b/modules/webhook/eventbridge/dispatcher.tf @@ -0,0 +1,137 @@ +resource "aws_cloudwatch_event_rule" "workflow_job" { + name = "${var.config.prefix}-workflow_job" + description = "Workflow job event ruule for job queued." + event_bus_name = aws_cloudwatch_event_bus.main.name + + event_pattern = < v if v != null + } + } + + dynamic "vpc_config" { + for_each = var.config.lambda_subnet_ids != null && var.config.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.config.lambda_security_group_ids + subnet_ids = var.config.lambda_subnet_ids + } + } + + tags = merge(var.config.tags, var.config.lambda_tags) + + dynamic "tracing_config" { + for_each = var.config.tracing_config.mode != null ? [true] : [] + content { + mode = var.config.tracing_config.mode + } + } + + lifecycle { + replace_triggered_by = [null_resource.ssm_parameter_runner_matcher_config, null_resource.github_app_parameters] + } +} + +resource "aws_cloudwatch_log_group" "dispatcher" { + name = "/aws/lambda/${aws_lambda_function.dispatcher.function_name}" + retention_in_days = var.config.logging_retention_in_days + kms_key_id = var.config.logging_kms_key_id + tags = var.config.tags +} + +resource "aws_lambda_permission" "allow_cloudwatch_to_call_lambda" { + statement_id = "AllowExecutionFromCloudWatch" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.dispatcher.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.workflow_job.arn +} + +resource "aws_iam_role" "dispatcher_lambda" { + name = "${var.config.prefix}-dispatcher-lambda-role" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = var.config.role_path + permissions_boundary = var.config.role_permissions_boundary + tags = var.config.tags +} + +resource "aws_iam_role_policy" "dispatcher_logging" { + name = "logging-policy" + role = aws_iam_role.dispatcher_lambda.name + policy = templatefile("${path.module}/../policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.dispatcher.arn + }) +} + +resource "aws_iam_role_policy_attachment" "dispatcher_vpc_execution_role" { + count = length(var.config.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.dispatcher_lambda.name + policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "dispatcher_sqs" { + name = "publish-sqs-policy" + role = aws_iam_role.dispatcher_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-publish-sqs-policy.json", { + sqs_resource_arns = jsonencode(var.config.sqs_job_queues_arns) + kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" + }) +} + +resource "aws_iam_role_policy" "dispatcher_ssm" { + name = "publish-ssm-policy" + role = aws_iam_role.dispatcher_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + resource_arns = jsonencode([var.config.ssm_parameter_runner_matcher_config.arn]) + }) +} + +resource "aws_iam_role_policy" "dispatcher_xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.dispatcher_lambda.name +} diff --git a/modules/webhook/eventbridge/main.tf b/modules/webhook/eventbridge/main.tf new file mode 100644 index 0000000000..79ba724c51 --- /dev/null +++ b/modules/webhook/eventbridge/main.tf @@ -0,0 +1,21 @@ +locals { + name = "${var.config.prefix}-runners" + lambda_zip = var.config.lambda_zip == null ? "${path.module}/../../../lambdas/functions/webhook/webhook.zip" : var.config.lambda_zip +} + +resource "aws_cloudwatch_event_bus" "main" { + name = local.name + tags = var.config.tags +} + +resource "aws_cloudwatch_event_archive" "main" { + name = "${local.name}-archive" + event_source_arn = aws_cloudwatch_event_bus.main.arn + retention_days = var.config.archive.retention_days +} + +resource "null_resource" "ssm_parameter_runner_matcher_config" { + triggers = { + version = var.config.ssm_parameter_runner_matcher_config.version + } +} diff --git a/modules/webhook/eventbridge/outputs.tf b/modules/webhook/eventbridge/outputs.tf new file mode 100644 index 0000000000..3f7c4fde1d --- /dev/null +++ b/modules/webhook/eventbridge/outputs.tf @@ -0,0 +1,22 @@ +output "eventbridge" { + value = { + event_bus = aws_cloudwatch_event_bus.main + archive = aws_cloudwatch_event_archive.main + } +} + +output "webhook" { + value = { + lambda = aws_lambda_function.webhook + log_group = aws_cloudwatch_log_group.webhook + role = aws_iam_role.webhook_lambda + } +} + +output "dispatcher" { + value = { + lambda = aws_lambda_function.dispatcher + log_group = aws_cloudwatch_log_group.dispatcher + role = aws_iam_role.dispatcher_lambda + } +} diff --git a/modules/webhook/eventbridge/policies.tf b/modules/webhook/eventbridge/policies.tf new file mode 100644 index 0000000000..23bfea7d24 --- /dev/null +++ b/modules/webhook/eventbridge/policies.tf @@ -0,0 +1,16 @@ +data "aws_iam_policy_document" "lambda_xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + statement { + actions = [ + "xray:BatchGetTraces", + "xray:GetTraceSummaries", + "xray:PutTelemetryRecords", + "xray:PutTraceSegments" + ] + effect = "Allow" + resources = [ + "*" + ] + sid = "AllowXRay" + } +} diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf new file mode 100644 index 0000000000..8980c6b5bc --- /dev/null +++ b/modules/webhook/eventbridge/variables.tf @@ -0,0 +1,55 @@ +variable "config" { + description = "Configuration object for all variables." + type = object({ + prefix = string + archive = optional(object({ + enable = optional(bool, true) + retention_days = optional(number, 7) + }), {}) + tags = optional(map(string), {}) + + lambda_subnet_ids = optional(list(string), []) + lambda_security_group_ids = optional(list(string), []) + sqs_job_queues_arns = list(string) + sqs_workflow_job_queue = optional(object({ + id = string + arn = string + }), null) + lambda_zip = optional(string, null) + lambda_memory_size = optional(number, 256) + lambda_timeout = optional(number, 10) + role_permissions_boundary = optional(string, null) + role_path = optional(string, null) + logging_retention_in_days = optional(number, 180) + logging_kms_key_id = optional(string, null) + lambda_s3_bucket = optional(string, null) + lambda_s3_key = optional(string, null) + lambda_s3_object_version = optional(string, null) + lambda_apigateway_access_log_settings = optional(object({ + destination_arn = string + format = string + }), null) + repository_white_list = optional(list(string), []) + kms_key_arn = optional(string, null) + log_level = optional(string, "info") + lambda_runtime = optional(string, "nodejs20.x") + aws_partition = optional(string, "aws") + lambda_architecture = optional(string, "arm64") + github_app_parameters = object({ + webhook_secret = map(string) + }) + tracing_config = optional(object({ + mode = optional(string, null) + capture_http_requests = optional(bool, false) + capture_error = optional(bool, false) + }), {}) + lambda_tags = optional(map(string), {}) + api_gw_source_arn = string + ssm_parameter_runner_matcher_config = object({ + name = string + arn = string + version = string + }) + accept_events = optional(list(string), null) + }) +} diff --git a/modules/webhook/eventbridge/versions.tf b/modules/webhook/eventbridge/versions.tf new file mode 100644 index 0000000000..c53c0999f4 --- /dev/null +++ b/modules/webhook/eventbridge/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + } +} diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf new file mode 100644 index 0000000000..7c47a5d19b --- /dev/null +++ b/modules/webhook/eventbridge/webhook.tf @@ -0,0 +1,135 @@ +resource "aws_lambda_function" "webhook" { + s3_bucket = var.config.lambda_s3_bucket != null ? var.config.lambda_s3_bucket : null + s3_key = var.config.lambda_s3_key != null ? var.config.lambda_s3_key : null + s3_object_version = var.config.lambda_s3_object_version != null ? var.config.lambda_s3_object_version : null + filename = var.config.lambda_s3_bucket == null ? local.lambda_zip : null + source_code_hash = var.config.lambda_s3_bucket == null ? filebase64sha256(local.lambda_zip) : null + function_name = "${var.config.prefix}-webhook" + role = aws_iam_role.webhook_lambda.arn + handler = "index.eventBridgeWebhook" + runtime = var.config.lambda_runtime + memory_size = var.config.lambda_memory_size + timeout = var.config.lambda_timeout + architectures = [var.config.lambda_architecture] + + environment { + variables = { + for k, v in { + LOG_LEVEL = var.config.log_level + POWERTOOLS_LOGGER_LOG_EVENT = var.config.log_level == "debug" ? "true" : "false" + POWERTOOLS_SERVICE_NAME = "webhook" + POWERTOOLS_TRACE_ENABLED = var.config.tracing_config.mode != null ? true : false + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS = var.config.tracing_config.capture_http_requests + POWERTOOLS_TRACER_CAPTURE_ERROR = var.config.tracing_config.capture_error + # Parameters required for lambda configuration + ACCEPT_EVENTS = jsonencode(var.config.accept_events) + EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name + PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name + PARAMETER_RUNNER_MATCHER_CONFIG_PATH = var.config.ssm_parameter_runner_matcher_config.name + } : k => v if v != null + } + } + + dynamic "vpc_config" { + for_each = var.config.lambda_subnet_ids != null && var.config.lambda_security_group_ids != null ? [true] : [] + content { + security_group_ids = var.config.lambda_security_group_ids + subnet_ids = var.config.lambda_subnet_ids + } + } + + tags = merge(var.config.tags, var.config.lambda_tags) + + dynamic "tracing_config" { + for_each = var.config.tracing_config.mode != null ? [true] : [] + content { + mode = var.config.tracing_config.mode + } + } + + lifecycle { + replace_triggered_by = [null_resource.ssm_parameter_runner_matcher_config, null_resource.github_app_parameters] + } +} + +resource "aws_cloudwatch_log_group" "webhook" { + name = "/aws/lambda/${aws_lambda_function.webhook.function_name}" + retention_in_days = var.config.logging_retention_in_days + kms_key_id = var.config.logging_kms_key_id + tags = var.config.tags +} + +resource "aws_lambda_permission" "webhook" { + statement_id = "AllowExecutionFromAPIGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.webhook.function_name + principal = "apigateway.amazonaws.com" + source_arn = var.config.api_gw_source_arn + lifecycle { + replace_triggered_by = [null_resource.ssm_parameter_runner_matcher_config, null_resource.github_app_parameters] + } +} + +resource "null_resource" "github_app_parameters" { + triggers = { + github_app_webhook_secret = var.config.github_app_parameters.webhook_secret.name + } +} + +data "aws_iam_policy_document" "lambda_assume_role_policy" { + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "webhook_lambda" { + name = "${var.config.prefix}-eventbridge-webhook-lambda-role" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json + path = var.config.role_path + permissions_boundary = var.config.role_permissions_boundary + tags = var.config.tags +} + +resource "aws_iam_role_policy" "webhook_logging" { + name = "logging-policy" + role = aws_iam_role.webhook_lambda.name + policy = templatefile("${path.module}/../policies/lambda-cloudwatch.json", { + log_group_arn = aws_cloudwatch_log_group.webhook.arn + }) +} + +resource "aws_iam_role_policy_attachment" "webhook_vpc_execution_role" { + count = length(var.config.lambda_subnet_ids) > 0 ? 1 : 0 + role = aws_iam_role.webhook_lambda.name + policy_arn = "arn:${var.config.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_iam_role_policy" "webhook_eventbridge" { + name = "publish-eventbridge-policy" + role = aws_iam_role.webhook_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-publish-eventbridge-policy.json", { + resource_arns = jsonencode(aws_cloudwatch_event_bus.main.arn) + }) +} + +resource "aws_iam_role_policy" "webhook_ssm" { + name = "publish-ssm-policy" + role = aws_iam_role.webhook_lambda.name + + policy = templatefile("${path.module}/../policies/lambda-ssm.json", { + resource_arns = jsonencode([var.config.github_app_parameters.webhook_secret.arn]) + }) +} + +resource "aws_iam_role_policy" "xray" { + count = var.config.tracing_config.mode != null ? 1 : 0 + name = "xray-policy" + policy = data.aws_iam_policy_document.lambda_xray[0].json + role = aws_iam_role.webhook_lambda.name +} diff --git a/modules/webhook/main.tf b/modules/webhook/main.tf index 71fc36fea1..2dd0624b48 100644 --- a/modules/webhook/main.tf +++ b/modules/webhook/main.tf @@ -1,7 +1,6 @@ locals { webhook_endpoint = "webhook" role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path - lambda_zip = var.lambda_zip == null ? "${path.module}/../../lambdas/functions/webhook/webhook.zip" : var.lambda_zip } resource "aws_apigatewayv2_api" "webhook" { @@ -63,13 +62,5 @@ resource "aws_apigatewayv2_integration" "webhook" { connection_type = "INTERNET" description = "GitHub App webhook for receiving build events." integration_method = "POST" - integration_uri = aws_lambda_function.webhook.invoke_arn -} - - -resource "aws_ssm_parameter" "runner_matcher_config" { - name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config" - type = "String" - value = jsonencode(local.runner_matcher_config_sorted) - tier = var.matcher_config_parameter_store_tier + integration_uri = !var.eventbridge.enable ? module.direct[0].webhook.lambda.invoke_arn : module.eventbridge[0].webhook.lambda.invoke_arn } diff --git a/modules/webhook/outputs.tf b/modules/webhook/outputs.tf index f368c0448c..b89cc10f92 100644 --- a/modules/webhook/outputs.tf +++ b/modules/webhook/outputs.tf @@ -2,18 +2,32 @@ output "gateway" { value = aws_apigatewayv2_api.webhook } +output "endpoint_relative_path" { + value = local.webhook_endpoint +} + +output "webhook" { + value = !var.eventbridge.enable ? module.direct[0].webhook : module.eventbridge[0].webhook +} + +output "dispatcher" { + value = var.eventbridge.enable ? module.eventbridge[0].dispatcher : null +} + +output "eventbridge" { + value = var.eventbridge.enable ? module.eventbridge[0].eventbridge : null +} + +### For backwards compatibility + output "lambda" { - value = aws_lambda_function.webhook + value = !var.eventbridge.enable ? module.direct[0].webhook.lambda : module.eventbridge[0].webhook.lambda } output "lambda_log_group" { - value = aws_cloudwatch_log_group.webhook + value = !var.eventbridge.enable ? module.direct[0].webhook.log_group : module.eventbridge[0].webhook.log_group } output "role" { - value = aws_iam_role.webhook_lambda -} - -output "endpoint_relative_path" { - value = local.webhook_endpoint + value = !var.eventbridge.enable ? module.direct[0].webhook.role : module.eventbridge[0].webhook.role } diff --git a/modules/webhook/policies/lambda-publish-eventbridge-policy.json b/modules/webhook/policies/lambda-publish-eventbridge-policy.json new file mode 100644 index 0000000000..6ed365f3e6 --- /dev/null +++ b/modules/webhook/policies/lambda-publish-eventbridge-policy.json @@ -0,0 +1,10 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["events:PutEvents"], + "Resource": ${resource_arns} + } + ] +} diff --git a/modules/webhook/policies/lambda-ssm.json b/modules/webhook/policies/lambda-ssm.json index 23864db305..9e33d1ca0a 100644 --- a/modules/webhook/policies/lambda-ssm.json +++ b/modules/webhook/policies/lambda-ssm.json @@ -4,10 +4,7 @@ { "Effect": "Allow", "Action": ["ssm:GetParameter"], - "Resource": [ - "${github_app_webhook_secret_arn}", - "${parameter_runner_matcher_config_arn}" - ] + "Resource": ${resource_arns} } ] } diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index f427fa3412..182f42053c 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -210,3 +210,16 @@ variable "matcher_config_parameter_store_tier" { error_message = "`matcher_config_parameter_store_tier` value is not valid, valid values are: `Standard`, and `Advanced`." } } + +variable "eventbridge" { + description = < v if v != null - } - } - - dynamic "vpc_config" { - for_each = var.lambda_subnet_ids != null && var.lambda_security_group_ids != null ? [true] : [] - content { - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids - } - } - - tags = merge(var.tags, var.lambda_tags) - - dynamic "tracing_config" { - for_each = var.tracing_config.mode != null ? [true] : [] - content { - mode = var.tracing_config.mode - } - } - lifecycle { - replace_triggered_by = [aws_ssm_parameter.runner_matcher_config, null_resource.github_app_parameters] - } -} - -resource "aws_cloudwatch_log_group" "webhook" { - name = "/aws/lambda/${aws_lambda_function.webhook.function_name}" - retention_in_days = var.logging_retention_in_days - kms_key_id = var.logging_kms_key_id - tags = var.tags -} - -resource "aws_lambda_permission" "webhook" { - statement_id = "AllowExecutionFromAPIGateway" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.webhook.function_name - principal = "apigateway.amazonaws.com" - source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}" - lifecycle { - replace_triggered_by = [aws_ssm_parameter.runner_matcher_config, null_resource.github_app_parameters] - } +resource "aws_ssm_parameter" "runner_matcher_config" { + name = "${var.ssm_paths.root}/${var.ssm_paths.webhook}/runner-matcher-config" + type = "String" + value = jsonencode(local.runner_matcher_config_sorted) + tier = var.matcher_config_parameter_store_tier } -resource "null_resource" "github_app_parameters" { - triggers = { - github_app_webhook_secret = var.github_app_parameters.webhook_secret.name +module "direct" { + count = var.eventbridge.enable ? 0 : 1 + source = "./direct" + + config = { + lambda_subnet_ids = var.lambda_subnet_ids, + lambda_security_group_ids = var.lambda_security_group_ids, + prefix = var.prefix, + tags = var.tags, + runner_matcher_config = var.runner_matcher_config, + sqs_job_queues_arns = [for k, v in var.runner_matcher_config : v.arn] + sqs_workflow_job_queue = var.sqs_workflow_job_queue, + lambda_zip = var.lambda_zip, + lambda_memory_size = var.lambda_memory_size, + lambda_timeout = var.lambda_timeout, + role_permissions_boundary = var.role_permissions_boundary, + role_path = local.role_path, + logging_retention_in_days = var.logging_retention_in_days, + logging_kms_key_id = var.logging_kms_key_id, + lambda_s3_bucket = var.lambda_s3_bucket, + lambda_s3_key = var.webhook_lambda_s3_key, + lambda_s3_object_version = var.webhook_lambda_s3_object_version, + lambda_apigateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings, + repository_white_list = var.repository_white_list, + kms_key_arn = var.kms_key_arn, + log_level = var.log_level, + lambda_runtime = var.lambda_runtime, + aws_partition = var.aws_partition, + lambda_architecture = var.lambda_architecture, + github_app_parameters = var.github_app_parameters, + tracing_config = var.tracing_config, + lambda_tags = var.lambda_tags, + matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier, + api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}" + ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config } } -data "aws_iam_policy_document" "lambda_assume_role_policy" { - statement { - actions = ["sts:AssumeRole"] - - principals { - type = "Service" - identifiers = ["lambda.amazonaws.com"] - } +module "eventbridge" { + count = var.eventbridge.enable ? 1 : 0 + source = "./eventbridge" + + config = { + lambda_subnet_ids = var.lambda_subnet_ids, + lambda_security_group_ids = var.lambda_security_group_ids, + prefix = var.prefix, + tags = var.tags, + sqs_job_queues_arns = [for k, v in var.runner_matcher_config : v.arn] + sqs_workflow_job_queue = var.sqs_workflow_job_queue, + lambda_zip = var.lambda_zip, + lambda_memory_size = var.lambda_memory_size, + lambda_timeout = var.lambda_timeout, + role_permissions_boundary = var.role_permissions_boundary, + role_path = local.role_path, + logging_retention_in_days = var.logging_retention_in_days, + logging_kms_key_id = var.logging_kms_key_id, + lambda_s3_bucket = var.lambda_s3_bucket, + lambda_s3_key = var.webhook_lambda_s3_key, + lambda_s3_object_version = var.webhook_lambda_s3_object_version, + lambda_apigateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings, + repository_white_list = var.repository_white_list, + kms_key_arn = var.kms_key_arn, + log_level = var.log_level, + lambda_runtime = var.lambda_runtime, + aws_partition = var.aws_partition, + lambda_architecture = var.lambda_architecture, + github_app_parameters = var.github_app_parameters, + tracing_config = var.tracing_config, + lambda_tags = var.lambda_tags, + api_gw_source_arn = "${aws_apigatewayv2_api.webhook.execution_arn}/*/*/${local.webhook_endpoint}" + ssm_parameter_runner_matcher_config = aws_ssm_parameter.runner_matcher_config + accept_events = var.eventbridge.accept_events } -} - -resource "aws_iam_role" "webhook_lambda" { - name = "${var.prefix}-action-webhook-lambda-role" - assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json - path = local.role_path - permissions_boundary = var.role_permissions_boundary - tags = var.tags -} - -resource "aws_iam_role_policy" "webhook_logging" { - name = "logging-policy" - role = aws_iam_role.webhook_lambda.name - policy = templatefile("${path.module}/policies/lambda-cloudwatch.json", { - log_group_arn = aws_cloudwatch_log_group.webhook.arn - }) -} - -resource "aws_iam_role_policy_attachment" "webhook_vpc_execution_role" { - count = length(var.lambda_subnet_ids) > 0 ? 1 : 0 - role = aws_iam_role.webhook_lambda.name - policy_arn = "arn:${var.aws_partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" -} - -resource "aws_iam_role_policy" "webhook_sqs" { - name = "publish-sqs-policy" - role = aws_iam_role.webhook_lambda.name - - policy = templatefile("${path.module}/policies/lambda-publish-sqs-policy.json", { - sqs_resource_arns = jsonencode([for k, v in var.runner_matcher_config : v.arn]) - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - }) -} - -resource "aws_iam_role_policy" "webhook_workflow_job_sqs" { - count = var.sqs_workflow_job_queue != null ? 1 : 0 - name = "publish-workflow-job-sqs-policy" - role = aws_iam_role.webhook_lambda.name - - policy = templatefile("${path.module}/policies/lambda-publish-sqs-policy.json", { - sqs_resource_arns = jsonencode([var.sqs_workflow_job_queue.arn]) - kms_key_arn = var.kms_key_arn != null ? var.kms_key_arn : "" - }) -} - -resource "aws_iam_role_policy" "webhook_ssm" { - name = "publish-ssm-policy" - role = aws_iam_role.webhook_lambda.name - - policy = templatefile("${path.module}/policies/lambda-ssm.json", { - github_app_webhook_secret_arn = var.github_app_parameters.webhook_secret.arn, - parameter_runner_matcher_config_arn = aws_ssm_parameter.runner_matcher_config.arn - }) -} -resource "aws_iam_role_policy" "xray" { - count = var.tracing_config.mode != null ? 1 : 0 - name = "xray-policy" - policy = data.aws_iam_policy_document.lambda_xray[0].json - role = aws_iam_role.webhook_lambda.name } diff --git a/outputs.tf b/outputs.tf index 2bc58caa20..ce49e8927c 100644 --- a/outputs.tf +++ b/outputs.tf @@ -37,6 +37,9 @@ output "webhook" { lambda_log_group = module.webhook.lambda_log_group lambda_role = module.webhook.role endpoint = "${module.webhook.gateway.api_endpoint}/${module.webhook.endpoint_relative_path}" + webhook = module.webhook.webhook + dispatcher = var.eventbridge.enable ? module.webhook.dispatcher : null + eventbridge = var.eventbridge.enable ? module.webhook.eventbridge : null } } diff --git a/variables.tf b/variables.tf index 0eccb5a3c0..86535107af 100644 --- a/variables.tf +++ b/variables.tf @@ -724,7 +724,7 @@ variable "lambda_architecture" { } variable "enable_workflow_job_events_queue" { - description = "Enabling this experimental feature will create a secondory sqs queue to which a copy of the workflow_job event will be delivered." + description = "Enabling this experimental feature will create a secondary SQS queue to which a copy of the workflow_job event will be delivered." type = bool default = false } @@ -944,3 +944,19 @@ variable "job_retry" { }) default = {} } + + +variable "eventbridge" { + description = <