|
| 1 | +/** |
| 2 | + * MIT No Attribution |
| 3 | + * |
| 4 | + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 5 | + * |
| 6 | + * Permission is hereby granted, free of charge, to any person obtaining a copy of |
| 7 | + * the Software without restriction, including without limitation the rights to |
| 8 | + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
| 9 | + * the Software, and to permit persons to whom the Software is furnished to do so. |
| 10 | + * |
| 11 | + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 12 | + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 13 | + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 14 | + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 15 | + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 16 | + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 17 | + * SOFTWARE. |
| 18 | + */ |
| 19 | + |
| 20 | +import * as path from 'path'; |
| 21 | +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; |
| 22 | +import * as apigw from 'aws-cdk-lib/aws-apigateway'; |
| 23 | +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; |
| 24 | +import * as iam from 'aws-cdk-lib/aws-iam'; |
| 25 | +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; |
| 26 | +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; |
| 27 | +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; |
| 28 | +import { NagSuppressions } from 'cdk-nag'; |
| 29 | +import { Construct } from 'constructs'; |
| 30 | +import { ScreenshotBucket } from './screenshot-bucket'; |
| 31 | + |
| 32 | +/** |
| 33 | + * Properties for GitHubScreenshotIntegration construct. |
| 34 | + */ |
| 35 | +export interface GitHubScreenshotIntegrationProps { |
| 36 | + /** The existing REST API to add the GitHub webhook route to. */ |
| 37 | + readonly api: apigw.RestApi; |
| 38 | + |
| 39 | + /** |
| 40 | + * Existing GitHub PAT secret. The processor reuses ABCA's main GitHub |
| 41 | + * token to (a) look up which PR a deploy SHA belongs to via the |
| 42 | + * Commits API, and (b) post the screenshot comment on that PR. |
| 43 | + * No new GitHub credential is provisioned by this construct. |
| 44 | + */ |
| 45 | + readonly githubTokenSecret: secretsmanager.ISecret; |
| 46 | + |
| 47 | + /** |
| 48 | + * Optional — when provided, the processor also tries to post the |
| 49 | + * screenshot to a linked Linear issue. Resolved from the GitHub PR |
| 50 | + * title/body via a Linear-identifier regex (e.g. `ABCA-42`), then |
| 51 | + * looked up across all `status='active'` workspaces in the registry |
| 52 | + * via Linear's `issueVcsBranchSearch` GraphQL. |
| 53 | + */ |
| 54 | + readonly linearWorkspaceRegistryTable?: dynamodb.ITable; |
| 55 | + |
| 56 | + /** |
| 57 | + * Removal policy for the dedup table + screenshot bucket. Defaults |
| 58 | + * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. |
| 59 | + */ |
| 60 | + readonly removalPolicy?: RemovalPolicy; |
| 61 | + |
| 62 | + /** |
| 63 | + * Override for the GitHub deployment `environment` value we |
| 64 | + * screenshot. Different providers use different conventions: |
| 65 | + * `Preview` (Vercel's per-PR label, the default), branch names |
| 66 | + * (Amplify Hosting), `Deploy Preview <PR#>` (Netlify), or whatever |
| 67 | + * your GitHub Actions workflow passes. Set this when your provider |
| 68 | + * uses a different name and you want per-PR-only screenshots. |
| 69 | + * @default 'Preview' |
| 70 | + */ |
| 71 | + readonly screenshotTargetEnvironment?: string; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * CDK construct that adds the GitHub-deployment-status → screenshot → |
| 76 | + * PR-comment pipeline. |
| 77 | + * |
| 78 | + * Topology mirrors `LinearIntegration`: |
| 79 | + * - Receiver Lambda (HMAC-verifies, dedups, async-invokes processor) |
| 80 | + * - Async processor Lambda (drives AgentCore Browser, uploads PNG, |
| 81 | + * posts the PR comment) |
| 82 | + * - Dedup DynamoDB table (1h TTL — covers GitHub's 5-attempt retry |
| 83 | + * window with slack) |
| 84 | + * - Webhook signing-secret (Secrets Manager placeholder; populated |
| 85 | + * manually when the operator pastes GitHub's value into the secret) |
| 86 | + * - Private screenshot S3 bucket; served anonymously via CloudFront OAC |
| 87 | + * - API Gateway route `POST /v1/github/webhook` |
| 88 | + * |
| 89 | + * Inbound-only adapter — there's no outbound polling or stream |
| 90 | + * consumer, just the webhook → screenshot → comment fan-out. |
| 91 | + */ |
| 92 | +export class GitHubScreenshotIntegration extends Construct { |
| 93 | + /** Private bucket; served via CloudFront OAC. Hosts the screenshot PNGs. */ |
| 94 | + public readonly screenshotBucket: ScreenshotBucket; |
| 95 | + |
| 96 | + /** |
| 97 | + * GitHub webhook signing secret — placeholder. The operator pastes |
| 98 | + * GitHub's signing-secret value here after configuring the webhook |
| 99 | + * in the demo repo's settings; the secret is otherwise empty. |
| 100 | + */ |
| 101 | + public readonly webhookSecret: secretsmanager.Secret; |
| 102 | + |
| 103 | + /** Webhook dedup table (composite key = `repo#deployment_id#status_id`). */ |
| 104 | + public readonly webhookDedupTable: dynamodb.Table; |
| 105 | + |
| 106 | + /** Webhook receiver Lambda (HMAC verifier + dispatcher). */ |
| 107 | + public readonly webhookFn: lambda.NodejsFunction; |
| 108 | + |
| 109 | + /** Async processor Lambda (browser + S3 + PR comment). */ |
| 110 | + public readonly webhookProcessorFn: lambda.NodejsFunction; |
| 111 | + |
| 112 | + constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) { |
| 113 | + super(scope, id); |
| 114 | + |
| 115 | + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; |
| 116 | + |
| 117 | + // --- Screenshot bucket (private; served via CloudFront with OAC) --- |
| 118 | + this.screenshotBucket = new ScreenshotBucket(this, 'ScreenshotBucket', { |
| 119 | + removalPolicy, |
| 120 | + }); |
| 121 | + |
| 122 | + // --- Webhook signing secret (operator-populated placeholder) --- |
| 123 | + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { |
| 124 | + description: 'GitHub deployment-status webhook signing secret — populate manually after configuring the GitHub webhook', |
| 125 | + removalPolicy, |
| 126 | + }); |
| 127 | + |
| 128 | + // --- Dedup table --- |
| 129 | + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { |
| 130 | + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, |
| 131 | + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, |
| 132 | + timeToLiveAttribute: 'ttl', |
| 133 | + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, |
| 134 | + removalPolicy, |
| 135 | + }); |
| 136 | + |
| 137 | + const handlersDir = path.join(__dirname, '..', 'handlers'); |
| 138 | + const commonBundling: lambda.BundlingOptions = { |
| 139 | + externalModules: ['@aws-sdk/*'], |
| 140 | + }; |
| 141 | + |
| 142 | + // --- Async processor (browser + S3 + comment) --- |
| 143 | + // Lambda timeout: 120s. The handler enforces a single wall-clock |
| 144 | + // deadline of 110s (TOTAL_BUDGET_MS in github-webhook-processor.ts) |
| 145 | + // shared across PR-lookup retry + screenshot capture + S3 PUT + |
| 146 | + // comment POST, so no individual sub-step can run past the Lambda |
| 147 | + // timeout even on the worst-case path. The 10s headroom covers |
| 148 | + // SDK auto-retries + the runtime's shutdown grace so a hard timeout |
| 149 | + // never severs an in-flight comment-post. (theagenticguy PR-241 |
| 150 | + // review item B1: previous comment under-counted the 35s retry |
| 151 | + // ladder that runs before captureScreenshot's 60s budget.) |
| 152 | + this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { |
| 153 | + entry: path.join(handlersDir, 'github-webhook-processor.ts'), |
| 154 | + handler: 'handler', |
| 155 | + runtime: Runtime.NODEJS_24_X, |
| 156 | + architecture: Architecture.ARM_64, |
| 157 | + timeout: Duration.seconds(120), |
| 158 | + memorySize: 512, |
| 159 | + environment: { |
| 160 | + SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, |
| 161 | + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, |
| 162 | + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, |
| 163 | + ...(props.linearWorkspaceRegistryTable && { |
| 164 | + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, |
| 165 | + }), |
| 166 | + }, |
| 167 | + bundling: commonBundling, |
| 168 | + }); |
| 169 | + |
| 170 | + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); |
| 171 | + props.githubTokenSecret.grantRead(this.webhookProcessorFn); |
| 172 | + |
| 173 | + // Optional Linear feedback path. Wired only when a registry table |
| 174 | + // is provided. The processor scans the registry for active |
| 175 | + // workspaces, then per-workspace looks up the OAuth token from |
| 176 | + // Secrets Manager (`bgagent-linear-oauth-*` prefix, written by |
| 177 | + // `bgagent linear setup`). |
| 178 | + // |
| 179 | + // PutSecretValue is intentional, not a typo: resolveLinearOauthToken |
| 180 | + // rotates Linear's refresh token in place when it expires (the same |
| 181 | + // pattern as the orchestrator role). This is a write grant by |
| 182 | + // design — see linear-oauth-resolver.ts. |
| 183 | + if (props.linearWorkspaceRegistryTable) { |
| 184 | + props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn); |
| 185 | + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ |
| 186 | + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], |
| 187 | + resources: [ |
| 188 | + Stack.of(this).formatArn({ |
| 189 | + service: 'secretsmanager', |
| 190 | + resource: 'secret', |
| 191 | + arnFormat: ArnFormat.COLON_RESOURCE_NAME, |
| 192 | + resourceName: 'bgagent-linear-oauth-*', |
| 193 | + }), |
| 194 | + ], |
| 195 | + })); |
| 196 | + } |
| 197 | + |
| 198 | + // AgentCore Browser session lifecycle + automation-stream connect. |
| 199 | + // Action set scoped to the three calls the handler actually makes; |
| 200 | + // resource is `*` because Browser sessions are ephemeral and the |
| 201 | + // two `Connect*Stream` data-plane actions in the AWS Service |
| 202 | + // Authorization Reference for `bedrock-agentcore` declare no |
| 203 | + // resource types or condition keys (they require Resource:"*" |
| 204 | + // anyway). cdk-nag IAM5 suppression annotates the resource |
| 205 | + // wildcard. |
| 206 | + // |
| 207 | + // Source: AWS Service Authorization Reference for bedrock-agentcore, |
| 208 | + // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrockagentcore.html |
| 209 | + // - bedrock-agentcore:StartBrowserSession (Write) |
| 210 | + // - bedrock-agentcore:StopBrowserSession (Write) |
| 211 | + // - bedrock-agentcore:ConnectBrowserAutomationStream (Read; no resource scoping) |
| 212 | + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ |
| 213 | + actions: [ |
| 214 | + 'bedrock-agentcore:StartBrowserSession', |
| 215 | + 'bedrock-agentcore:StopBrowserSession', |
| 216 | + 'bedrock-agentcore:ConnectBrowserAutomationStream', |
| 217 | + ], |
| 218 | + resources: ['*'], |
| 219 | + })); |
| 220 | + |
| 221 | + // --- Webhook receiver (verify, dedup, dispatch) --- |
| 222 | + this.webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { |
| 223 | + entry: path.join(handlersDir, 'github-webhook.ts'), |
| 224 | + handler: 'handler', |
| 225 | + runtime: Runtime.NODEJS_24_X, |
| 226 | + architecture: Architecture.ARM_64, |
| 227 | + timeout: Duration.seconds(10), |
| 228 | + environment: { |
| 229 | + GITHUB_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, |
| 230 | + GITHUB_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, |
| 231 | + GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME: this.webhookProcessorFn.functionName, |
| 232 | + ...(props.screenshotTargetEnvironment && { |
| 233 | + SCREENSHOT_TARGET_ENVIRONMENT: props.screenshotTargetEnvironment, |
| 234 | + }), |
| 235 | + }, |
| 236 | + bundling: commonBundling, |
| 237 | + }); |
| 238 | + |
| 239 | + this.webhookSecret.grantRead(this.webhookFn); |
| 240 | + this.webhookDedupTable.grantReadWriteData(this.webhookFn); |
| 241 | + this.webhookProcessorFn.grantInvoke(this.webhookFn); |
| 242 | + |
| 243 | + // --- API Gateway route --- |
| 244 | + const githubResource = props.api.root.addResource('github'); |
| 245 | + const webhookResource = githubResource.addResource('webhook'); |
| 246 | + const webhookMethod = webhookResource.addMethod( |
| 247 | + 'POST', |
| 248 | + new apigw.LambdaIntegration(this.webhookFn), |
| 249 | + { authorizationType: apigw.AuthorizationType.NONE }, |
| 250 | + ); |
| 251 | + |
| 252 | + NagSuppressions.addResourceSuppressions(webhookMethod, [ |
| 253 | + { |
| 254 | + id: 'AwsSolutions-APIG4', |
| 255 | + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', |
| 256 | + }, |
| 257 | + { |
| 258 | + id: 'AwsSolutions-COG4', |
| 259 | + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', |
| 260 | + }, |
| 261 | + ]); |
| 262 | + |
| 263 | + NagSuppressions.addResourceSuppressions(this.webhookFn, [ |
| 264 | + { |
| 265 | + id: 'AwsSolutions-IAM4', |
| 266 | + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', |
| 267 | + }, |
| 268 | + { |
| 269 | + id: 'AwsSolutions-IAM5', |
| 270 | + reason: 'DynamoDB grants from CDK helpers expand to table-arn/index/* wildcards; receiver only writes to the dedup table.', |
| 271 | + }, |
| 272 | + ], true); |
| 273 | + |
| 274 | + NagSuppressions.addResourceSuppressions(this.webhookProcessorFn, [ |
| 275 | + { |
| 276 | + id: 'AwsSolutions-IAM4', |
| 277 | + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', |
| 278 | + }, |
| 279 | + { |
| 280 | + id: 'AwsSolutions-IAM5', |
| 281 | + reason: 'AgentCore Browser sessions are ephemeral and have no per-resource ARN; the data-plane API requires wildcards. S3 PutObject uses CDK grant helpers that expand to bucket/* wildcards.', |
| 282 | + }, |
| 283 | + ], true); |
| 284 | + |
| 285 | + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ |
| 286 | + { |
| 287 | + id: 'AwsSolutions-SMG4', |
| 288 | + reason: 'GitHub webhook signing-secret rotation is owned by GitHub (operator regenerates on the GitHub side and pastes the new value here). No automated rotation Lambda needed.', |
| 289 | + }, |
| 290 | + ]); |
| 291 | + } |
| 292 | +} |
0 commit comments