Skip to content

Commit 6e15840

Browse files
committed
feat(aws-lambda): Add API-driven pipeline backend
Add 3 API pipeline steps (ProjectSelect, CloudFormation, InstrumentationStep), that replace the legacy Django template/redirect views behind a feature flag. Both paths coexist — the legacy views remain untouched. Register organizations:integration-api-pipeline-aws-lambda feature flag.
1 parent bb7a185 commit 6e15840

File tree

3 files changed

+492
-2
lines changed

3 files changed

+492
-2
lines changed

src/sentry/features/temporary.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def register_temporary_features(manager: FeatureManager) -> None:
160160
manager.add("organizations:integration-api-pipeline-gitlab", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
161161
manager.add("organizations:integration-api-pipeline-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
162162
manager.add("organizations:integration-api-pipeline-bitbucket", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
163+
manager.add("organizations:integration-api-pipeline-aws-lambda", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
163164
# Project Management Integrations Feature Parity Flags
164165
manager.add("organizations:integrations-github_enterprise-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
165166
manager.add("organizations:integrations-gitlab-project-management", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)

src/sentry/integrations/aws_lambda/integration.py

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
from django.http.request import HttpRequest
99
from django.http.response import HttpResponseBase
1010
from django.utils.translation import gettext_lazy as _
11+
from rest_framework import serializers
12+
from rest_framework.fields import CharField, ChoiceField, IntegerField, ListField
1113

1214
from sentry import analytics, options
1315
from sentry.analytics.events.integration_serverless_setup import IntegrationServerlessSetup
16+
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
1417
from sentry.integrations.base import (
1518
FeatureDescription,
1619
IntegrationData,
@@ -25,7 +28,8 @@
2528
from sentry.integrations.pipeline import IntegrationPipeline
2629
from sentry.organizations.services.organization import organization_service
2730
from sentry.organizations.services.organization.model import RpcOrganization
28-
from sentry.pipeline.views.base import PipelineView, render_react_view
31+
from sentry.pipeline.types import PipelineStepResult
32+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView, render_react_view
2933
from sentry.projects.services.project import project_service
3034
from sentry.silo.base import control_silo_function
3135
from sentry.users.models.user import User
@@ -198,6 +202,209 @@ def update_function_to_latest_version(self, target):
198202
return self.get_serialized_lambda_function(target)
199203

200204

205+
class ProjectSelectSerializer(CamelSnakeSerializer):
206+
project_id = IntegerField(required=True)
207+
208+
209+
class ProjectSelectApiStep:
210+
step_name = "project_select"
211+
212+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]:
213+
return {}
214+
215+
def get_serializer_cls(self) -> type:
216+
return ProjectSelectSerializer
217+
218+
def handle_post(
219+
self,
220+
validated_data: dict[str, Any],
221+
pipeline: IntegrationPipeline,
222+
request: HttpRequest,
223+
) -> PipelineStepResult:
224+
project_id = validated_data["project_id"]
225+
226+
assert pipeline.organization is not None
227+
valid_project_ids = {p.id for p in pipeline.organization.projects}
228+
if project_id not in valid_project_ids:
229+
return PipelineStepResult.error("Invalid project")
230+
231+
pipeline.bind_state("project_id", project_id)
232+
return PipelineStepResult.advance()
233+
234+
235+
class CloudFormationSerializer(CamelSnakeSerializer):
236+
account_number = CharField(required=True)
237+
region = ChoiceField(choices=[(r, r) for r in ALL_AWS_REGIONS], required=True)
238+
aws_external_id = CharField(required=True)
239+
240+
def validate_account_number(self, value: str) -> str:
241+
if not value.isdigit() or len(value) != 12:
242+
raise serializers.ValidationError("Must be a 12-digit AWS account number")
243+
return value
244+
245+
246+
class CloudFormationApiStep:
247+
step_name = "cloudformation"
248+
249+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]:
250+
template_url = options.get("aws-lambda.cloudformation-url")
251+
return {
252+
"baseCloudformationUrl": "https://console.aws.amazon.com/cloudformation/home#/stacks/create/review",
253+
"templateUrl": template_url,
254+
"stackName": "Sentry-Monitoring-Stack",
255+
"regionList": ALL_AWS_REGIONS,
256+
}
257+
258+
def get_serializer_cls(self) -> type:
259+
return CloudFormationSerializer
260+
261+
def handle_post(
262+
self,
263+
validated_data: dict[str, Any],
264+
pipeline: IntegrationPipeline,
265+
request: HttpRequest,
266+
) -> PipelineStepResult:
267+
account_number = validated_data["account_number"]
268+
region = validated_data["region"]
269+
aws_external_id = validated_data["aws_external_id"]
270+
271+
pipeline.bind_state("account_number", account_number)
272+
pipeline.bind_state("region", region)
273+
pipeline.bind_state("aws_external_id", aws_external_id)
274+
275+
try:
276+
gen_aws_client(account_number, region, aws_external_id)
277+
except ClientError:
278+
return PipelineStepResult.error(
279+
"Please validate the Cloudformation stack was created successfully"
280+
)
281+
except ConfigurationError:
282+
raise
283+
except Exception as e:
284+
logger.warning(
285+
"CloudFormationApiStep.unexpected_error",
286+
extra={"error": str(e)},
287+
)
288+
return PipelineStepResult.error("Unknown error")
289+
290+
return PipelineStepResult.advance()
291+
292+
293+
class FunctionSelectSerializer(CamelSnakeSerializer):
294+
enabled_functions = ListField(child=CharField(), required=True)
295+
296+
297+
class InstrumentationApiStep:
298+
step_name = "instrumentation"
299+
300+
def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]:
301+
account_number = pipeline.fetch_state("account_number")
302+
region = pipeline.fetch_state("region")
303+
aws_external_id = pipeline.fetch_state("aws_external_id")
304+
305+
lambda_client = gen_aws_client(account_number, region, aws_external_id)
306+
lambda_functions = get_supported_functions(lambda_client)
307+
lambda_functions.sort(key=lambda x: x["FunctionName"].lower())
308+
309+
return {
310+
"functions": [
311+
{
312+
"name": fn["FunctionName"],
313+
"runtime": fn["Runtime"],
314+
"description": fn.get("Description", ""),
315+
}
316+
for fn in lambda_functions
317+
]
318+
}
319+
320+
def get_serializer_cls(self) -> type:
321+
return FunctionSelectSerializer
322+
323+
def handle_post(
324+
self,
325+
validated_data: dict[str, Any],
326+
pipeline: IntegrationPipeline,
327+
request: HttpRequest,
328+
) -> PipelineStepResult:
329+
assert pipeline.organization is not None
330+
organization = pipeline.organization
331+
332+
account_number = pipeline.fetch_state("account_number")
333+
region = pipeline.fetch_state("region")
334+
project_id = pipeline.fetch_state("project_id")
335+
aws_external_id = pipeline.fetch_state("aws_external_id")
336+
337+
enabled_functions = validated_data["enabled_functions"]
338+
enabled_lambdas = {name: True for name in enabled_functions}
339+
340+
sentry_project_dsn = get_dsn_for_project(organization.id, project_id)
341+
342+
lambda_client = gen_aws_client(account_number, region, aws_external_id)
343+
lambda_functions = get_supported_functions(lambda_client)
344+
lambda_functions.sort(key=lambda x: x["FunctionName"].lower())
345+
346+
lambda_functions = [
347+
fn for fn in lambda_functions if enabled_lambdas.get(fn["FunctionName"])
348+
]
349+
350+
def _enable_lambda(function):
351+
try:
352+
enable_single_lambda(lambda_client, function, sentry_project_dsn)
353+
return (True, function, None)
354+
except Exception as e:
355+
return (False, function, e)
356+
357+
failures: list[dict[str, Any]] = []
358+
success_count = 0
359+
360+
with ContextPropagatingThreadPoolExecutor(
361+
max_workers=options.get("aws-lambda.thread-count")
362+
) as _lambda_setup_thread_pool:
363+
for success, function, e in _lambda_setup_thread_pool.map(
364+
_enable_lambda, lambda_functions
365+
):
366+
name = function["FunctionName"]
367+
if success:
368+
success_count += 1
369+
else:
370+
err_message: str | _StrPromise = str(e)
371+
is_custom_err, err_message = get_sentry_err_message(err_message)
372+
if not is_custom_err:
373+
capture_exception(e)
374+
err_message = _("Unknown Error")
375+
failures.append({"name": name, "error": str(err_message)})
376+
logger.info(
377+
"update_function_configuration.error",
378+
extra={
379+
"organization_id": organization.id,
380+
"lambda_name": name,
381+
"account_number": account_number,
382+
"region": region,
383+
"error": str(e),
384+
},
385+
)
386+
387+
analytics.record(
388+
IntegrationServerlessSetup(
389+
user_id=request.user.id,
390+
organization_id=organization.id,
391+
integration="aws_lambda",
392+
success_count=success_count,
393+
failure_count=len(failures),
394+
)
395+
)
396+
397+
if failures:
398+
return PipelineStepResult.stay(
399+
data={
400+
"failures": failures,
401+
"successCount": success_count,
402+
}
403+
)
404+
405+
return PipelineStepResult.advance()
406+
407+
201408
class AwsLambdaIntegrationProvider(IntegrationProvider):
202409
key = "aws_lambda"
203410
name = "AWS Lambda"
@@ -213,6 +420,13 @@ def get_pipeline_views(self) -> list[PipelineView[IntegrationPipeline]]:
213420
AwsLambdaSetupLayerPipelineView(),
214421
]
215422

423+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
424+
return [
425+
ProjectSelectApiStep(),
426+
CloudFormationApiStep(),
427+
InstrumentationApiStep(),
428+
]
429+
216430
@control_silo_function
217431
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
218432
region = state["region"]

0 commit comments

Comments
 (0)