aws / aws-cdk

The AWS Cloud Development Kit is a framework for defining cloud infrastructure in code
https://aws.amazon.com/cdk
Apache License 2.0
11.7k stars 3.93k forks source link

Route53: ElasticBeanstalkEnvironmentEndpointTarget throws exception for single instance environment #31843

Open ivanovvitaly opened 1 month ago

ivanovvitaly commented 1 month ago

Describe the bug

I'm deploying elastic beanstalk single instance application and getting error when creating an A record alias to the EB environment.

Regression Issue

Last Known Working CDK Version

No response

Expected Behavior

Exception is not thrown during sync/deploy and the alias A record is created

Current Behavior

Unhandled exception. System.Exception: Cannot use an EBS alias as `environmentEndpoint`. You must find your EBS environment endpoint via the AWS console. See the Elastic Beanstalk developer guide: https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/customdomains.html
   at Amazon.JSII.Runtime.Services.Client.TryDeserialize[TResponse](String responseJson)
   at Amazon.JSII.Runtime.Services.Client.ReceiveResponse[TResponse]()
   at Amazon.JSII.Runtime.Services.Client.Send[TRequest,TResponse](TRequest requestObject)
   at Amazon.JSII.Runtime.Services.Client.Create(CreateRequest request)
   at Amazon.JSII.Runtime.Services.Client.Create(String fullyQualifiedName, Object[] arguments, Override[] overrides, String[] interfaces)
   at Amazon.JSII.Runtime.Deputy.DeputyBase..ctor(DeputyProps props)
   at Constructs.Construct..ctor(DeputyProps props)
   at Amazon.CDK.Resource..ctor(DeputyProps props)
   at Amazon.CDK.AWS.Route53.RecordSet..ctor(DeputyProps props)
   at Amazon.CDK.AWS.Route53.ARecord..ctor(Construct scope, String id, IARecordProps props)

Reproduction Steps

CfnEnvironment env = ...
var hostedZone = HostedZone.FromLookup(this, "HostedZone", new HostedZoneProviderProps
{
    DomainName = props.HostedZoneName
});
var aRecord = new ARecord(this, "AliasRecord", new ARecordProps
{
    RecordName = props.DnsName,
    Zone = hostedZone,
    Target = RecordTarget.FromAlias(new ElasticBeanstalkEnvironmentEndpointTarget(env.AttrEndpointUrl))
});

Possible Solution

No response

Additional Information/Context

I know that AttrEndpointUrl returns IP address for the single instance deployment. I tried to resolve the environment url using a custom resource, but that didn't help either, though environmentUrl is accurate in response.

CfnEnvironment apiEnvironment;

var getEnvironmentUrl = new AwsCustomResource(this, "GetEnvironmentUrl", new AwsCustomResourceProps
{
    OnUpdate = new AwsSdkCall
    {
        Service = "elasticbeanstalk",
        Action = "DescribeEnvironments",
        Parameters = new Dictionary<string, object>
        {
            ["EnvironmentNames"] = new[] { apiEnvironment.EnvironmentName }
        },
        PhysicalResourceId = PhysicalResourceId.Of(DateTime.Now.ToString(CultureInfo.InvariantCulture))
    },
    Policy = AwsCustomResourcePolicy.FromSdkCalls(new SdkCallsPolicyOptions
    {
        Resources = AwsCustomResourcePolicy.ANY_RESOURCE
    })
});

getEnvironmentUrl.Node.AddDependency(apiEnvironment);

var environmentUrl = getEnvironmentUrl.GetResponseField("Environments.0.CNAME");
var hostedZone = HostedZone.FromLookup(this, "HostedZone", new HostedZoneProviderProps
{
    DomainName = "example.com"
});
var aRecord = new ARecord(this, "AliasRecord", new ARecordProps
{
    RecordName = "api.example.com",
    Zone = hostedZone,
    Target = RecordTarget.FromAlias(new ElasticBeanstalkEnvironmentEndpointTarget(environmentUrl))
});

aRecord.Node.AddDependency(getEnvironmentUrl);

Unfortunately, this produces the same exception. The only thing that helps is to hardcode the environment url

var aRecord = new ARecord(this, "AliasRecord", new ARecordProps
{
    RecordName = "api.example.com",
    Zone = hostedZone,
    Target = RecordTarget.FromAlias(new ElasticBeanstalkEnvironmentEndpointTarget("hardcoded environment url"))
});

I found in the source code that environment url doesn't support tokens. Why?

I believe if I write that in CloudFormation that would be simple !Ref to the function response value.

CDK CLI Version

2.162.1 (build 10aa526)

Framework Version

No response

Node.js Version

22.9.0

OS

windows 11 - 22H2 (22621.4317)

Language

.NET

Language Version

.net 8

Other information

No response

pahud commented 1 month ago

I checked the document in the inline code comment

https://github.com/aws/aws-cdk/blame/f1e2f3b602899b28610849b8b55748f288f50fcd/packages/aws-cdk-lib/aws-route53-targets/lib/elastic-beanstalk-environment-target.ts#L18

I can't find any relevant restriction about it. A very quick workaround is to first provide a dummy hardcoded URL and then use escape hatches to override relevant prop with the Ref statement as you mentioned. If that workaround works, we might need a PR to get it fixed.

pahud commented 1 month ago

After revisiting the code:

This works:

target: route53.RecordTarget.fromAlias(new route53targets.ElasticBeanstalkEnvironmentEndpointTarget('mysampleenvironment.xyz.us-east-1.elasticbeanstalk.com')),

It's because for some reason a RegionInfo.get() will be invoked here, which does not support unresolved token.

https://github.com/aws/aws-cdk/blame/f1e2f3b602899b28610849b8b55748f288f50fcd/packages/aws-cdk-lib/aws-route53-targets/lib/elastic-beanstalk-environment-target.ts#L25

I don't understand why hostedZoneId has to be retrieved like that. Seems a bug to me but I am not 100% sure.

I think the hostedZoneId should be passed like this rather than looking up from RegionInfo.get()

const ar = new route53.ARecord(this, 'AliasRecord', {
      zone: hostedZone,
      target: route53.RecordTarget.fromAlias(new route53targets.ElasticBeanstalkEnvironmentEndpointTarget(
        ebenv.attrEndpointUrl,
        hostedZone.hostedZoneId, // <----
      )),
    })

And my full code should be like:

  // create a eb CfnEnvironment
    const ebenv = new eb.CfnEnvironment(this, 'EBEnvironment', {
      applicationName: 'my-app',
      solutionStackName: '64bit Amazon Linux 2 v5.5.0 running Node.js 16',
      versionLabel: '1.0.0',
      platformArn: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 16.14.2 running on 64bit Amazon Linux 2',
      optionSettings: [
        {
          namespace: 'aws:autoscaling:launchconfiguration',
          optionName: 'IamInstanceProfile',
          value: 'aws-elasticbeanstalk-ec2-role',
        },
        {
          namespace: 'aws:autoscaling:launchconfiguration',
          optionName: 'InstanceType',
          value: 't3.medium',
        },
      ],
    });

    const hostedZone = route53.PublicHostedZone.fromLookup(this, 'HostedZone', {
      domainName: 'example.com',
    });

    const ar = new route53.ARecord(this, 'AliasRecord', {
      zone: hostedZone,
      target: route53.RecordTarget.fromAlias(new route53targets.ElasticBeanstalkEnvironmentEndpointTarget(
        ebenv.attrEndpointUrl,
        hostedZone.hostedZoneId,
      )),
    })

I am making it a p1 as this doesn't seem to have a easy workaround here. Meanwhile, we welcome community PRs.

ivanovvitaly commented 1 month ago

@pahud Thanks for quick feedback! I like the workaround with escape hatches, unfortunately my CDK experience didn't let me think that way :) Leaving working example

CfnEnvironment environment;

var getEnvironmentUrl = new AwsCustomResource(this, "GetEnvironmentUrl", new AwsCustomResourceProps
{
    OnUpdate = new AwsSdkCall
    {
        Service = "elasticbeanstalk",
        Action = "DescribeEnvironments",
        Parameters = new Dictionary<string, object>
        {
            ["EnvironmentNames"] = new[] { environment.EnvironmentName }
        },
        PhysicalResourceId = PhysicalResourceId.Of($"GetEnvironmentUrl-{environment.ApplicationName}-{environment.EnvironmentName}")
    },
    Policy = AwsCustomResourcePolicy.FromSdkCalls(new SdkCallsPolicyOptions
    {
        Resources = AwsCustomResourcePolicy.ANY_RESOURCE
    })
});
getEnvironmentUrl.Node.AddDependency(environment);

var hostedZone = HostedZone.FromLookup(this, "HostedZone", new HostedZoneProviderProps
{
    DomainName = "example.com"
});

var aRecord = new ARecord(this, "AliasRecord", new ARecordProps
{
    RecordName = "api.example.com",
    Zone = hostedZone,
    Target = RecordTarget.FromAlias(new ElasticBeanstalkEnvironmentEndpointTarget($"http://placeholder.placeholder.{Stack.Of(this).Region}.elasticbeanstalk.com"))
});

var recordSet = aRecord.Node.DefaultChild as CfnRecordSet;
recordSet.AliasTarget = new CfnRecordSet.AliasTargetProperty
{
    HostedZoneId = RegionInfo.Get(Stack.Of(this).Region).EbsEnvEndpointHostedZoneId,
    DnsName = getEnvironmentUrl.GetResponseField("Environments.0.CNAME")
};

which produces correct CF template

AliasRecord2A2FBE8F:
    Type: AWS::Route53::RecordSet
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
            - GetEnvironmentUrl7DB70163
            - Environments.0.CNAME
        HostedZoneId: Z117KPS5GTRQ2G # EB regional hosted zone id
      HostedZoneId: Z1VWNYUQXG1QZO # my hosted zone id
      Name: api.example.com.
      Type: A

The workaround works well, though the lib might need fixes. I'm not strong enough to help with community PR at this point, maybe later when skill up in the framework. Do you want me to close the issue?