Custom domain name with AWS AppSync using CDK v2 - amazon-web-services

I try to add a custom domain name to my AWS AppSync API using the AWS CDK (v2).
First, I manually added a certificate for my domain. I did this in the us-east-1 region (while my API is hosted in eu-central-1) as this seems to be necessary. APPSYNC_CERT_ARN refers to this certificate's ARN.
This is the TypeScript code I have in my cdk stack:
import * as cdk from "aws-cdk-lib";
import * as appsync from "#aws-cdk/aws-appsync-alpha";
const APPSYNC_CERT_ARN = "arn:aws:acm:us-east-1:xxxx:certificate/xxxx";
export class ApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const certificate = cdk.aws_certificatemanager.Certificate.fromCertificateArn(
this,
"cert",
APPSYNC_CERT_ARN,
);
const api = new appsync.GraphqlApi(this, "Api", {
name: "my-api",
domainName: {
certificate,
domainName: "my.domain.com",
},
...
});
}
}
However, as I add the domainName member, I get the following error during cdk deploy:
Resource of type 'AWS::AppSync::DomainNameApiAssociation' with identifier 'null' was not found.
The feature to add custom AppSync domains via cdk is rather new, so I did not find any hints on what I do wrong. Any help is appreciated.

In order to create an AppsyncDomainNameApiAssociation (which is the underlying cloudformation resource created by the CDK construct you are using) you have to create both the GraphqlAPI and an AppsyncDomainName prior to creating the association. Although the docs don't really reflect this relationship you can read about "AWS::AppSync::DomainName" here.
Including the "domainName" object in your GraphqlApi instantiation creates the association resources but fails to create the actual domain name resources. You'll need to create it before hand by using the L1 construct for CFNDomainName then manually create the association using the L1 construct CfnDomainNameApiAssociation.
The constructs aren't included in the aws_appsync_alpha library. You'll need to import them from aws_appsync and use them like this:
const certificate = cdk.aws_certificatemanager.Certificate.fromCertificateArn(
this,
"cert",
APPSYNC_CERT_ARN,
);Ï
const appsyncDomainName = new aws_appsync.CfnDomainName(
this,
'AppsyncDomainName',
{
certificateArn: certificate.certificateArn,
domainName: "my.domain.com",
}
);
const api = new appsync.GraphqlApi(this, "Api", {
name: "my-api",
// Omit the domainName object
...
});
const assoc = new aws_appsync.CfnDomainNameApiAssociation(
this,
'MyCfnDomainNameApiAssociation',
{
apiId: api.apiId,
domainName: "my.domain.com",
}
);
// Required to ensure the resources are created in order
assoc.addDependsOn(appsyncDomainName);

If you do use Route53:
Keeping the same code you already have you will need to add a CNAME record:
import {
ARecord,
CnameRecord,
HostedZone,
RecordTarget
} from 'aws-cdk-lib/aws-route53'
// Add record
new CnameRecord(this, `ApiAliasRecord`, {
recordName: "my.domain.com", // i.e api.foo.com
zone: HostedZone.fromLookup(this, 'Zone', { domainName: "domain.com", }),
domainName: Fn.select(2, Fn.split('/', api.graphqlUrl))
})
External DNS:
Create a CNAME entry with desired api domain name with the value of the internal API domain name (i.e. https://7sdbasdasad8.appsync-api.us-east-1.amazonaws.com)

Related

AWS CDK and AppSync: Invalid principal in policy: "SERVICE":"appsync"

I'm trying to follow tutorials and use AWS's CDK CLI to deploy a stack using AppSync and some resource creations fail with errors like the following showing under events for the Stack in the CloudFormation console:
Invalid principal in policy: "SERVICE":"appsync" (Service: AmazonIdentityManagement; Status Code: 400; Error Code: MalformedPolicyDocument; Request ID: 8d98f07c-d717-4dfe-af96-14f2d72d993f; Proxy: null)
I suspect what happened is that when cleaning up things on my personal developer account I deleted something I shouldn't have, but due to limited AWS experience I don't know what to create, I suspect it's an IAM policy, but I don't know the exact settings to use.
I'm trying on a new clean project created using cdk init sample-app --language=typescript. Running cdk deploy immediately after the above command works fine.
I originally tried using cdk-appsync-transformer to create GraphQL endpoints to a DynamoDB table and encountered the error.
I tried re-running cdk bootstrap after deleting the CDKToolkit CloudFormation stack, but it's not fixing this problem.
To rule out that it's due to something with the 3rd party library, I tried using AWS's own AppSync Construct Library instead and even following the example there I encountered the same error (although on creation of different resource types).
Reproduction steps
Create a new folder.
In the new folder run cdk init sample-app --language=typescript.
Install the AWS AppSync Construct Library: npm i #aws-cdk/aws-appsync-alpha#2.58.1-alpha.0 --save.
As per AWS's docs:
Create lib/schema.graphql with the following:
type demo {
id: String!
version: String!
}
type Query {
getDemos: [ demo! ]
}
input DemoInput {
version: String!
}
type Mutation {
addDemo(input: DemoInput!): demo
}
Update the lib/<projectName>-stack.ts file to be essentially like the following:
import * as appsync from '#aws-cdk/aws-appsync-alpha';
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subs from 'aws-cdk-lib/aws-sns-subscriptions';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { Construct } from 'constructs';
import * as path from 'path';
export class CdkTest3Stack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const queue = new sqs.Queue(this, 'CdkTest3Queue', {
visibilityTimeout: Duration.seconds(300)
});
const topic = new sns.Topic(this, 'CdkTest3Topic');
topic.addSubscription(new subs.SqsSubscription(queue));
const api = new appsync.GraphqlApi(this, 'Api', {
name: 'demo',
schema: appsync.SchemaFile.fromAsset(path.join(__dirname, 'schema.graphql')),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.IAM,
},
},
xrayEnabled: true,
});
const demoTable = new dynamodb.Table(this, 'DemoTable', {
partitionKey: {
name: 'id',
type: dynamodb.AttributeType.STRING,
},
});
const demoDS = api.addDynamoDbDataSource('demoDataSource', demoTable);
// Resolver for the Query "getDemos" that scans the DynamoDb table and returns the entire list.
// Resolver Mapping Template Reference:
// https://docs.aws.amazon.com/appsync/latest/devguide/resolver-mapping-template-reference-dynamodb. html
demoDS.createResolver('QueryGetDemosResolver', {
typeName: 'Query',
fieldName: 'getDemos',
requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
// Resolver for the Mutation "addDemo" that puts the item into the DynamoDb table.
demoDS.createResolver('MutationAddDemoResolver', {
typeName: 'Mutation',
fieldName: 'addDemo',
requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition('id').auto(),
appsync.Values.projecting('input'),
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
//To enable DynamoDB read consistency with the `MappingTemplate`:
demoDS.createResolver('QueryGetDemosConsistentResolver', {
typeName: 'Query',
fieldName: 'getDemosConsistent',
requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(true),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
}
}
Run cdk deploy.

How to read parameter store from a different region in CDK?

I am using CDK to deploy AWS resources but need to get some values from the parameter store from a different region. I can see this API in CDK's reference page to read a parameter:
ssm.StringParameter.fromStringParameterAttributes
But it doesn't support passing region. How can I make it work across region?
You can find an implementation here:
import { Construct } from 'constructs';
import { AwsCustomResource, AwsCustomResourcePolicy, AwsSdkCall, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
interface SSMParameterReaderProps {
readonly parameterName: string;
readonly region: string;
}
export class SSMParameterReader extends AwsCustomResource {
constructor(scope: Construct, name: string, props: SSMParameterReaderProps) {
const { parameterName, region } = props;
super(scope, name, {
onUpdate: {
action: 'getParameter',
service: 'SSM',
parameters: {
Name: parameterName,
},
region,
physicalResourceId: PhysicalResourceId.of(name),
},
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
}),
});
}
public getParameterValue(): string {
return this.getResponseFieldReference('Parameter.Value').toString();
}
}
Source: https://github.com/Idea-Pool/aws-static-site/blob/main/lib/ssm-parameter-reader.ts
(Based on CloudFormation Cross-Region Reference)
This article explains what you are looking for
How to read parameter store from a different region in CDK?
Summary:
Create an AWS custom resource that takes in the parameterName and the regionName as props and returns the value.
It is not currently possible to access SSM Parameters in a different region.
You would have to set up some process to replicate the parameter across the needed regions and keep them in sync.
You could also get the value using a custom resource backed by a lambda.

Retrieve the LoadBalancer URL for EKS construct in CDK

I'm using the EKS Construct to create an EKS cluster in CDK. I'm adding the NGINX helm chart to the cluster and I want to export the ingress LoadBalancer URL. The EKS Construct exposes a .getServiceLoadBalancer() method, but it wants a service name. I'm not sure how to get the name of the LoadBalancer service to pass it into that method. Feels like I'm missing something. Example:
export class EksClusterStack extends cdk.NestedStack {
elbUrl: string;
constructor(scope: cdk.Construct, id: string, props?: cdk.NestedStackProps) {
super(scope, id, props);
const clusterAdmin = new iam.Role(this, 'AdminRole', {
assumedBy: new iam.AccountRootPrincipal()
});
const cluster = new eks.Cluster(this, 'Cluster', {
mastersRole: clusterAdmin,
version: eks.KubernetesVersion.V1_18,
defaultCapacity: 2,
});
const nginx = cluster.addHelmChart('NginxIngress', {
chart: 'nginx-ingress',
repository: 'https://helm.nginx.com/stable',
});
this.elbUrl = cluster.getServiceLoadBalancerAddress('{Where do I get the service name?}') //<- This is what I can't figure out
}
I looked at the properties on the helm chart, and it doesn't seem to expose anything that fits the bill. Appreciate any insight. Thanks!
What you have deployed is the ingress controller helm chart which it self doesn't expose anything but rather scans any K8S objects of type Ingress. In this case you should deploy an Ingress after you deploy the ingress controller and you can't use the pre-built function to get the LoadBalancerAddress for you.
Example:
self.alb_domain_name = eks.KubernetesObjectValue(
self, 'Query',
cluster=cluster,
object_type='Ingress',
object_name='cluster-ingress', # this is your ingress name
object_namespace='my-ingress-controller', # in which namespace your ingress is deployed
json_path='.status.loadBalancer.ingress[0].hostname' # this json path will get you the hostname for the deployed AWS ELB/ALB
)
In my particular case, I just needed a way to define the name of the ingress controller so it wouldn't be auto-generated. Doing so would then let me query that ingress controller for its ELB address. The fix is to give the the ingress controller helm chart a release name. Once deployed, CDK appends -nginx-ingress to the end of the release name, but given the release name, you can calculate the k8s Service name. Here's a working version:
import * as cdk from '#aws-cdk/core';
import * as eks from '#aws-cdk/aws-eks';
import * as iam from '#aws-cdk/aws-iam';
export class SimpleEks extends cdk.NestedStack {
elbUrl: string;
constructor(scope: cdk.Construct, id: string, props?: cdk.NestedStackProps) {
super(scope, id, props);
const ingressControllerReleaseName = 'ingress-controller'
const clusterAdmin = new iam.Role(this, 'AdminRole', {
assumedBy: new iam.AccountRootPrincipal()
});
const cluster = new eks.Cluster(this, 'cluster', {
clusterName: 'cluster',
mastersRole: clusterAdmin,
version: eks.KubernetesVersion.V1_18,
defaultCapacity: 2,
});
const ingressControllerChart = cluster.addHelmChart('IngressController', {
chart: 'nginx-ingress',
repository: 'https://helm.nginx.com/stable',
release: ingressControllerReleaseName, //This fixes the service name so it's predictable and not auto-generated
});
const albAddress = new eks.KubernetesObjectValue(this, 'elbAddress', {
cluster,
objectType: 'Service',
objectName: `${ingressControllerReleaseName}-nginx-ingress`, //This is what I was missing
jsonPath: '.status.loadBalancer.ingress[0].hostname',
});
// I haven't tried the below code, but I suspect it might work as well as getSvcLBAddress is just a convenience method over `eks.KubernetesObjectValue()`
//const elb = cluster.getServiceLoadBalancerAddress(`${ingressControllerReleaseName}-nginx-ingress`);
const elb = albAddress.value; //This is what I needed to get.
}
}

CDK: How to get apigateway key value (ie x-api-key: *20 Chars*)

I'm unable to find out how to get the api key out of an apigateway key. I can get its ID and its ARN but not the value. I know you can specify the value when creating the key, but not how to retrieve it once created--short of logging into the AWS GUI and finding it that way.
I've looked at the documentation for aws-apigateway.ApiKey and couldn't find any way to get the value. https://docs.aws.amazon.com/cdk/api/latest/docs/#aws-cdk_aws-apigateway.ApiKey.html I've also looked at kms keys since you can get their value, but I don't know if it's usable in the context of an API Gateway usage plan (not included in code below).
Failing the ability to get the value, is there a way to generate a value that won't change, or will persist? I'm using an ephemeral Jenkins node to run the CDK.
const apiGateway = require('#aws-cdk/aws-apigateway');
...
const apiKey = new apiGateway.ApiKey(this, 'api-key', {
apiKeyName: 'my-api-key',
});
...
new cdk.CfnOutput(this, 'x-api-key-apiKey_id', {
value: apiKey.keyId
});
new cdk.CfnOutput(this, 'x-api-key-apiKey_keyArn', {
value: apiKey.keyArn
});
We can't retrieve the auto generated key via cdk/cloudformation without a custom resource. But we can generate the key , store it in a secret manager or an ssm secret and use that to create api key.
const secret = new secretsmanager.Secret(this, 'Secret', {
generateSecretString: {
generateStringKey: 'api_key',
secretStringTemplate: JSON.stringify({ username: 'web_user' }),
excludeCharacters: ' %+~`#$&*()|[]{}:;<>?!\'/#"\\',
},
});
this.restApi.addApiKey('ApiKey', {
apiKeyName: `web-app-key`,
value: secret.secretValueFromJson('api_key').toString(),
});
I'm going to use https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SecretsManager.html#getRandomPassword-property to generate the 20 characters and set the API key. Since nothing outside of my stack needs the key I'm ok with regenerating it and updating my resources every time I do a deploy. However if there are things outside of the stack that need the key then using Balu's answer is the best option.
The reason for this is keeping a secret has a cost associated with it.
The accepted answer is perhaps not the best way to go about this.
It can be solved without creating an extra secret using aws-cdk's custom resources.
Here is a snippet that will get you the value of an api key. The value of this key is generated randomly by the api gateway.
import * as iam from "#aws-cdk/aws-iam";
import { RetentionDays } from "#aws-cdk/aws-logs";
import * as cdk from "#aws-cdk/core";
import {
AwsCustomResource,
AwsCustomResourcePolicy,
AwsSdkCall,
PhysicalResourceId,
} from "#aws-cdk/custom-resources";
import { IApiKey } from "#aws-cdk/aws-apigateway";
export interface GetApiKeyCrProps {
apiKey: IApiKey;
}
export class GetApiKeyCr extends cdk.Construct {
apikeyValue: string;
constructor(scope: cdk.Construct, id: string, props: GetApiKeyCrProps) {
super(scope, id);
const apiKey: AwsSdkCall = {
service: "APIGateway",
action: "getApiKey",
parameters: {
apiKey: props.apiKey.keyId,
includeValue: true,
},
physicalResourceId: PhysicalResourceId.of(`APIKey:${props.apiKey.keyId}`),
};
const apiKeyCr = new AwsCustomResource(this, "api-key-cr", {
policy: AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
resources: [props.apiKey.keyArn],
actions: ["apigateway:GET"],
}),
]),
logRetention: RetentionDays.ONE_DAY,
onCreate: apiKey,
onUpdate: apiKey,
});
apiKeyCr.node.addDependency(props.apiKey);
this.apikeyValue = apiKeyCr.getResponseField("value");
}
}

CloudFormation Cross-Region Reference

When you are running multiple CloudFormation stacks within the same region, you are able to share references across stacks using CloudFormation Outputs
However, outputs cannot be used for cross region references as that documentation highlights.
You can't create cross-stack references across regions. You can use the intrinsic function Fn::ImportValue to import only values that have been exported within the same region.
How do you reference values across regions in CloudFormation?
For an example to follow, I have a Route 53 hosted zone deployed in us-east-1. However, I have a backend in us-west-2 that I want to create a DNS-validated ACM certificate which requires a reference to the hosted zone in order to be able to create the appropriate CNAME for prove ownership.
How would I go about referencing that hosted zone id created in us-east-1 from within us-west-2?
The easiest way I have found of doing this is writing the reference you want to share (i.e. your hosted zone id in this case) to the Systems Manager Parameter Store and then referencing that value in your "child" stack in the separate region using a custom resource.
Fortunately, this is incredibly easy if your templates are created using Cloud Development Kit (CDK).
For the custom resource to read from SSM, you can use something like this:
// ssm-parameter-reader.ts
import { Construct } from '#aws-cdk/core';
import { AwsCustomResource, AwsSdkCall } from '#aws-cdk/custom-resources';
interface SSMParameterReaderProps {
parameterName: string;
region: string;
}
export class SSMParameterReader extends AwsCustomResource {
constructor(scope: Construct, name: string, props: SSMParameterReaderProps) {
const { parameterName, region } = props;
const ssmAwsSdkCall: AwsSdkCall = {
service: 'SSM',
action: 'getParameter',
parameters: {
Name: parameterName
},
region,
physicalResourceId: Date.now().toString() // Update physical id to always fetch the latest version
};
super(scope, name, { onUpdate: ssmAwsSdkCall });
}
public getParameterValue(): string {
return this.getData('Parameter.Value').toString();
}
}
To write the hosted zone id to parameter store, you can simply do this:
// route53.ts (deployed in us-east-1)
import { PublicHostedZone } from '#aws-cdk/aws-route53';
import { StringParameter } from '#aws-cdk/aws-ssm';
export const ROUTE_53_HOSTED_ZONE_ID_SSM_PARAM = 'ROUTE_53_HOSTED_ZONE_ID_SSM_PARAM';
/**
* Other Logic
*/
const hostedZone = new PublicHostedZone(this, 'WebsiteHostedZone', { zoneName: 'example.com' });
new StringParameter(this, 'Route53HostedZoneIdSSMParam', {
parameterName: ROUTE_53_HOSTED_ZONE_ID_SSM_PARAM,
description: 'The Route 53 hosted zone id for this account',
stringValue: hostedZone.hostedZoneId
});
Lastly, you can read that value from the parameter store in that region using the custom resource we just created and use that to create a certificate in us-west-2.
// acm.ts (deployed in us-west-2)
import { DnsValidatedCertificate } from '#aws-cdk/aws-certificatemanager';
import { PublicHostedZone } from '#aws-cdk/aws-route53';
import { ROUTE_53_HOSTED_ZONE_ID_SSM_PARAM } from './route53';
import { SSMParameterReader } from './ssm-parameter-reader';
/**
* Other Logic
*/
const hostedZoneIdReader = new SSMParameterReader(this, 'Route53HostedZoneIdReader', {
parameterName: ROUTE_53_HOSTED_ZONE_ID_SSM_PARAM,
region: 'us-east-1'
});
const hostedZoneId: string = hostedZoneIdReader.getParameterValue();
const hostedZone = PublicHostedZone.fromPublicHostedZoneId(this, 'Route53HostedZone', hostedZoneId);
const certificate = new DnsValidatedCertificate(this, 'ApiGatewayCertificate', { 'pdx.example.com', hostedZone });
The cdk library has been updated, the code avove needs to be changed to the following:
import { Construct } from '#aws-cdk/core';
import { AwsCustomResource, AwsSdkCall } from '#aws-cdk/custom-resources';
import iam = require("#aws-cdk/aws-iam");
interface SSMParameterReaderProps {
parameterName: string;
region: string;
}
export class SSMParameterReader extends AwsCustomResource {
constructor(scope: Construct, name: string, props: SSMParameterReaderProps) {
const { parameterName, region } = props;
const ssmAwsSdkCall: AwsSdkCall = {
service: 'SSM',
action: 'getParameter',
parameters: {
Name: parameterName
},
region,
physicalResourceId: {id:Date.now().toString()} // Update physical id to always fetch the latest version
};
super(scope, name, { onUpdate: ssmAwsSdkCall,policy:{
statements:[new iam.PolicyStatement({
resources : ['*'],
actions : ['ssm:GetParameter'],
effect:iam.Effect.ALLOW,
}
)]
}});
}
public getParameterValue(): string {
return this.getResponseField('Parameter.Value').toString();
}
}
CDK 2.x
There is a new Stack property called crossRegionReferences which you can enable to add cross region references. It's as simple as this:
const stack = new Stack(app, 'Stack', {
crossRegionReferences: true,
});
Under the hood, this does something similar to the above answers by using custom resources and Systems Manager. From the CDK docs:
crossRegionReferences?
Enable this flag to allow native cross region stack references.
Enabling this will create a CloudFormation custom resource in both the producing stack and consuming stack in order to perform the export/import
This feature is currently experimental
More details from the CDK core package README:
You can enable the Stack property crossRegionReferences
in order to access resources in a different stack and region. With this feature flag
enabled it is possible to do something like creating a CloudFront distribution in us-east-2 and
an ACM certificate in us-east-1.
When the AWS CDK determines that the resource is in a different stack and is in a different
region, it will "export" the value by creating a custom resource in the producing stack which
creates SSM Parameters in the consuming region for each exported value. The parameters will be
created with the name '/cdk/exports/${consumingStackName}/${export-name}'.
In order to "import" the exports into the consuming stack a SSM Dynamic reference
is used to reference the SSM parameter which was created.
In order to mimic strong references, a Custom Resource is also created in the consuming
stack which marks the SSM parameters as being "imported". When a parameter has been successfully
imported, the producing stack cannot update the value.
CDK 1.x
If you are on CDK 1.x, continue using the workaround that others have shared.
Update 2023-01-16 with cdkv2 version 2.56.0 in a projen generated projects (hence respecting eslint rules and best practices for formatting etc.) :
import {
aws_iam as iam,
custom_resources as cr,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
interface SSMParameterReaderProps {
parameterName: string;
region: string;
}
export class SSMParameterReader extends cr.AwsCustomResource {
constructor(scope: Construct, name: string, props: SSMParameterReaderProps) {
const { parameterName, region } = props;
const ssmAwsSdkCall: cr.AwsSdkCall = {
service: 'SSM',
action: 'getParameter',
parameters: {
Name: parameterName,
},
region,
physicalResourceId: { id: Date.now().toString() }, // Update physical id to always fetch the latest version
};
super(scope, name, {
onUpdate: ssmAwsSdkCall,
policy: {
statements: [
new iam.PolicyStatement({
resources: ['*'],
actions: ['ssm:GetParameter'],
effect: iam.Effect.ALLOW,
}),
],
},
});
}
public getParameterValue(): string {
return this.getResponseField('Parameter.Value').toString();
}
};
Could not edit the post above ...