@alexa/ask-cdk
v0.0.2
Published
Alexa Skills Kit CDK Construct Library
Downloads
10
Readme
@alexa/ask-cdk
This package provides CDK Constructs for managing and deploying Alexa Skills with the AWS Cloud Development Kit. It integrates with the Alexa Conversations Description Language (ACDL) Compiler to prepare a Skill Package during build time and then deploy it via a Custom CloudFormation Resource.
The Skill Package is built locally during the CDK synthesize step (see https://docs.aws.amazon.com/cdk/latest/guide/apps.html). That Skill Package ZIP is then uploaded to S3 and later processed within a Custom Resource to modify the Skill Manifest and then imported into SMAPI (see https://developer.amazon.com/en-US/docs/alexa/smapi/skill-package-api-reference.html).
Setup
TODO: provide a template for getting started, e.g. ask new --cdk
.
The Skill Deployer requires a one-time setup process for each AWS Account and Region you wish to deploy and manage Alexa Skills with. We assume some basic knowledge of the AWS Cloud Development Kit and AWS CLI, so please refer to the following reference documentation:
First, make sure you've configured the ASK CLI, AWS CLI and bootstrapped your AWS accounts with the CDK:
# login with amazon and configure the ASK CLI
ask configure
# configure your AWS CLI with access to your AWS account
aws configure
# run the CDK bootstrap process for each AWS account and region you wish to use
cdk bootstrap aws://<your-aws-account>/<aws-region>
In order to deploy skills you need to store your ASK Login-With-Amazon credentials in a Secret so that they can be accessed by the Custom Resource. The acc
CLI (available in NPM: @alexa/acdl
) provides a light-weight utility to help with this process:
npx acc bootstrap
This command creates a CloudFormation Stack with a AWS Secrets Manager Secret. By default it creates a Stack and Secret with the name ask-config-<vendor-id>
, where vendor-id
is loaded from your ASK profile. After the Stack is created, the command then uploads you ASK config from ~/.ask/cli_config
to the Secret.
You may also specify profiles and a different name for the Stack and AWS Secret:
TODO: put this script in the ask-cli
.
npx acc bootstrap \
--secret-name <secret-name>\
--profile <your-ask-profile>\
--aws-profile <your-aws-profile>\
--regions <comma-separated-list-of-regions>
Skill Construct
You can use the Skill
Construct to configure and deploy Skills within an ordinary AWS Cloud Development Kit Application.
We recommend you create a class extending cdk.Stack
for your Skill:
// src/skill-stack.ts
import * as path from "path";
import * as ask from "@alexa/ask-cdk";
import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as node from "@aws-cdk/aws-lambda-nodejs";
export interface MySkillStackProps extends cdk.StackProps {
skillName: string;
vendorId: string;
}
export class MySkillStack extends cdk.Stack {
readonly skill: ask.Skill;
readonly endpoint: lambda.Function;
constructor(scope: cdk.App, id: string, props: MySkillStackProps) {
super(scope, id, props);
// create a Lambda Function referencing your Skill Handler code
this.endpoint = new node.NodejsFunction(this, "Endpoint", {
// point to the file that contains your skill's handler logic
entry: path.resolve(__dirname, "pizzabot-handler.ts"),
// name of the method within your code that Lambda calls
handler: "index.handler",
runtime: lambda.Runtime.NODEJS_14_X,
memorySize: 512,
});
// create an ask.Binding to the Lambda Function
const binding = new ask.LambdaBinding(this.endpoint);
// create and deploy an Alexa Skill Resource - this will Create and Update a Skill with SMAPI.
this.skill = new ask.Skill(this, "MySkill", {
// instance of the Skill Deployer to use when managing this Skill
skillDeployer: props.skillDeployer,
// name of your Alexa Skill
skillName: props.skillName,
// path to the Alexa Skills Project - this is the root directory containing `package.json`.
projectPath: path.resolve(__dirname, "../"),
// the default binding to use to handle requests
defaultBinding: binding,
// your amazon developer vendor id
vendorId: props.vendorId,
});
}
}
Alternatively, instead of using the ACDL compiler and referencing a project path directly, you can reference a pre-compiled (or hand-written) Skill Package Path.
this.skill = new ask.Skill(this, "MySkill", {
// ...
// path to your skill package
skillPackagePath: path.join(__dirname, "path/to/skill-package"),
});
AWS Secret
The Skill
Construct needs your ASK LWA credentials in order to deploy the skill. As mentioned earlier, acc bootstrap
will place your LWA credentials in a secure AWS Secret.
By default the Skill Construct will import a secret from ask-config-<vendor-id>
, which is the default name of the secret from acc bootstrap
. If you create a secret with a custom name, you can use the secretName
prop in the Skill
construct, and it will import the secret from secretName
instead.
this.skill = new ask.Skill(this, "MySkill", {
// ...
// changing the default secret import name
secretName: "<your-secret-name>",
});
You may also provide your own Secret instead of having the Skill
Construct import it.
import * as secrets from "@aws-cdk/aws-secretsmanager";
const secret = secrets.Secret.fromSecretNameV2(
this,
`MySecret`,
`<secret-name>`
);
this.skill = new ask.Skill(this, "MySkill", {
// ...
// providing a secret
secret: secret,
});
Note: If you provide a secret without bootstrapping the secret or uploading your ASK credentials, cdk deploy
will fail.
Instantiating a Skill Stack
Now that we have written MySkillStack
, we can create an Alexa Skill for each AWS Account and Region:
// src/app.ts
import * as secrets from "@aws-cdk/secretsmanager";
import { MySkillStack } from "./my-skill-stack";
const app = new cdk.App();
// deploy a dev skill
const devSkill = new MySkillStack(app, "MySkill-dev", {
skillName: "MySkill-dev",
vendorId: "<your-vendor-id>",
env: {
account: "<dev-account>",
region: "us-west-2",
},
});
// and a production skill
const prodSkill = new MySkillStack(app, "MySkill-prod", {
skillName: "MySkill",
vendorId: "<your-vendor-id>",
env: {
account: "<prod-account>",
region: "us-east-1",
},
});
Note: you can find <your-vendor-id>
on the console or the CLI (cat ~/.ask/cli_config
):
Finally, deploy the stacks to AWS:
# deploy all stacks
cdk deploy
# just the dev skill
cdk deploy MySkill-dev
# just the prod skill
cdk deploy MySkill-prod
Note how easy it is to deploy copies of the same Skill Package to different AWS Accounts and Regions. In this example, we created two Skills with SMAPI - one for a dev account and another for prod. The Skill Resource is designed to encapsulate all the information for a Skill in a repeatable/re-usable way. Rather than fixing a single Skill project to a specific Skill ID (checked into code), the same Skill configuration can be used to create/update/delete in many Amazon Developer Accounts.
For example, we plan to support deploying a Skill on demand to test a GitHub Pull Request in isolation before approving the merge. The Skill would be updated on each commit and deleted once the pull request is merged. See (TODO: link issue).
Multi-Region Skills
TOOD: add support for deploying a Skill with endpoints configured in NA
, FE
and EU
regions. See (TODO: link issue).
Build Timeout
Sometimes an Alexa Conversations build can take more than an hour, exceeding the maximum timeout for an AWS CloudFormation Custom Resource (60 minutes). For now, our resource deals with this by waiting for up to 55 minutes before assuming the build will succeed. The low fidelity build usually completes much sooner, in a few minutes, so if the build hasn't failed within 55 minutes, it is safe to assume the build has succeeded.
55 minutes is the default and 5 minutes is the minimum wait time. To specify, use the maxBuildTimeMinutes
property when instantiating the Skill
:
new ask.Skill(this, "MySkill", {
// ...
maxBuildTimeMinutes: 10,
});
Skill.deployer
The Skill
Construct contains a nested SkillDeployer
construct for managing the AWS Resources that will Create/Update/Delete your skill. In your CloudFormation Stack you will see the following resources for managing your skill:
- CloudFormation Handler - Lambda Function that creates/deletes a barebones Skill to retrieve a Skill ID and then triggers a Step Function workflow to import a SKill Package.
- SMAPI Deploy Workflow - Standard Step Function Workflow to import a Skill Package and wait for the build to complete. Builds can be long running (sometimes exceeding Lambda's 15 minute timeout) especially for Alexa Conversations, so a Step Function workflow is used to reliably wait for the state of the build to succeed or fail.
- SMAPI Lambda Function - called by the Step Function to interact with SMAPI to import a Skill Package and check its status.
CloudFormation Workflow
A Skill is orchestrated within CloudFormation with two Custom Resources which safely orchestrate the creation of a Skill, configuration of the Lambda and the import of a Skill Package into SMAPI.
Custom::AlexaSkillId
Custom::AlexaSkillId
creates a bare-bones Skill by calling the createSkillForVendorV1
API with a template Skill Manifest. The successful creation of this Resource creates a Skill ID so that we can properly configure the Lambda Trigger for our Alexa Skill before importing the Skill Package and pointing it at your Lambda Function.
This is analogous to using the Skill Developer Console to create a new skill from an empty template.
Custom::AlexaSkill
Custom::AlexaSkill
downloads your Skill Package from S3 (compiled with the @alexa/acdl
compiler and uploaded by the CDK toolchain), modifies the endpoint in skill.json
and calls the importSkillPackage
SMAPI API to initiate the Skill Package import. A Standard Step Function workflow will then wait for the build to complete before calling back to CloudFormation.
In between the creation of Custom::AlexaSkillId
and Custom::AlexaSkill
, the Lambda trigger is configured, allowing the service principal, alexa-appkit.amazon.com
, to invoke the Skill. Without this, the Skill Package Import will fail with a Skill Manifest error.
To monitor the progress of a build, you can go to the Step Function AWS console to see the state transitions and current state of the build. You can also view the logs of the Step Function and the associated Lambda Functions.
CI/CD with CDK Pipelines
To create a CI/CD pipeline, we suggest you use the CDK Pipeline library. With CDK Pipelines, it’s as straightforward to deploy to a different account and Region as it is to deploy to the same account.
Initial GitHub setup
To enable your CI/CD pipeline to react to GitHub changes, we need to create an oauth token in AWS Secrets. For creating an oauth token please follow this GitHub tutorial. Ensure the oauth token has repo
and admin:repo_hook
scopes.
After creating your token, create an AWS secret with the name github-token
(You may specify another name if you wish) in the region you want to deploy your pipeline(s). You can use the AWS console or the AWS CLI to do this.
aws create-secret --name github-token --secret-string <oauth-token>
Creating a Pipeline Stack
// src/pipeline.ts
import * as cdk from "@aws-cdk/core";
import * as pipelines from "@aws-cdk/pipelines";
interface PipelineProps extends cdk.StackProps {
branch: string;
}
export class Pipeline extends cdk.Stack {
public readonly pipeline: pipelines.CodePipeline;
constructor(scope: cdk.Construct, id: string, props: PipelineProps) {
super(scope, id, props);
// Connects your pipeline to GitHub to listen for changes.
const source = pipelines.CodePipelineSource.gitHub(
"<Your-Name>/<Your-Repo>",
props.branch
);
this.pipeline = new pipelines.CodePipeline(this, "Pipeline", {
synth: new pipelines.ShellStep("Synth", {
input: source,
commands: ["npm i", "npm run build", "npx cdk synth"],
}),
dockerEnabledForSynth: true, // allows for bundling of assets
selfMutation: true, // allows the pipeline to change itself
});
}
}
You may also use the ask.Pipeline
Stack which provides the same functionality:
const myPipeline = new ask.Pipeline(app, "SkillPipeline", {
owner: "<github-account-name>",
repo: "<repo-name>",
branch: "<branch>",
vendorId: "<vendor-id>"
});
// access the underlying CDK CodePipeline using the pipeline property
myPipeline.pipeline.addStage(...);
Creating the Skill Stage
First we need to create a Stage which wraps our MySkillStack
. This represents an entire skill application. Doing this lets us easily deploy our skill to different stages in a pipeline.
// src/skill.ts
export interface SkillStageProps extends cdk.StageProps {
skillName: string;
}
export class SkillStage extends cdk.Stage {
public readonly skillStack: MySkillStack;
constructor(scope: cdk.Construct, id: string, props: SkillStageProps) {
super(scope, id, props);
this.skillStack = new MySkillStack(this, props.skillName, {
env: props.env,
skillName: props.skillName,
});
}
}
Instantiating the Pipeline
Create your pipeline by setting the environment you want it created in and by choosing a GitHub branch you want to listen to updates for. For example, we can create a pipeline for managing the main
(or your choice) branch of our GitHub repo.
import * as cdk from "@aws-cdk/core";
import * as ask from "@alexa/ask-cdk";
import { SkillStage } from "./skill";
import { Pipeline } from "./pipeline";
const app = new cdk.App();
const devEnvironment = {
account: "<dev-aws-account>",
region: "<dev-aws-region>",
};
const prodEnvironment = {
account: "<prod-aws-account>",
region: "<prod-aws-region>",
};
const pipeline = new Pipeline(app, "Pipeline", {
branch: "main", // connects your pipeline to the main branch
env: devEnvironment, // a different environment can be used
});
// Your development skill
const devSkill = new SkillStage(devPipeline, "Development", {
skillName: "<your-dev-skill-name>",
env: devEnvironment,
});
const devStage = devPipeline.pipeline.addStage(mySkill);
// Your production skill
const prodSkill = new SkillStage(prodPipeline, "Production", {
skillName: "<prod-skill-name>",
env: prodEnvironment,
});
const prodStage = prodPipeline.pipeline.addStage(prodSkill);
prodStage.addPre(new pipelines.ManualApprovalStep("ManualApproval"));
To handle certifications and publications #695, you currently need to use the Alexa Developer Console.
Deploying a Pipeline
When deploying your pipeline(s) for the first time, we must ensure our repo has the latest updates before running cdk deploy
.
git add .
git commit -m "creating our first skill pipeline"
git push
npx cdk deploy
Our pipeline will be created and configure itself to listen for GitHub changes. We now can rely on the GitHub push
command to make changes and the pipeline will self mutate and deploy changes.
Local development
Often when working in teams, developers want to use their own lambda for testing. The CDK makes this easier than ever. Simply add the following code into your source code.
new SkillStage(app, "Dev", {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
skillName: "<skill-name>",
});
Now developers can simply run npx cdk deploy Dev/*
and this will deploy a skill and lambda to the account currently configured with the CDK CLI. Developers will have an entire skill replica/lambda created in their personal AWS account.
npx cdk deploy Dev/*
Note: If you don't want your vendor account to be overcrowded with skills, you can make the vendor-id
prop in MySkillStack
dynamic and allow developers to pass in their personal vendor-id
so the skill gets deployed to their vendor profile.
Pipeline simulation testing
To ensure your skill is working properly, you can add a Validation Step that provides End-to-End testing by using the SMAPI Simulation API. To use this action, simply create an array of type ask.Simulation
and provide it to ask.SimulationStep
.
// some file where you want to generate simulations
import { Simulation, Locale } as ask from "@alexa/ask-cdk";
// Generate your simulations however you like. These may be dynamic values.
export const simulations: Simulation[] = [
{
utterance: "<your-utterance>",
expect: ["possible-alexa-response", "possible-en-US-alexa-response-two"],
newSession: true, // default value: false
locale: Locale.EN_US // default value: Locale.en_US
},
...
]
Now you can add add a new SimulationStep
to your pipeline.
//src/app.ts
import { simulations } from "../simulations.ts"
...
myStage.addPost(new ask.SimulationStep("StageSimulations", {
skillId: "<your-skill-id>"
secret: mySecret, // you may also pass a vendorId or secretName instead.
simulations: simulations,
}));
This Step creates a temporary JSON file and uploads it to s3. After the pipeline deploys your CFN stacks, it will then invoke a custom lambda function that calls the SMAPI simulation API with your test cases. Detailed logs of the tests are provided in CloudWatch, and any failure of your required tests will block the pipeline from continuing.
Note: When creating skills with the CDK, we often have multiple skills within a vendor account. For example, you may have a development
skill, beta
skill, and a production
skill. Due to a limitation with Alexa NLU, having multiple skills with the same invocation name may cause the simulations to not recognize your skill intents. To fix this, you can provide a skillInvocationPrefix to the Skill
construct which prepends the prefix to each interaction model invocation name at deployment time. This allows you to test the different stages of your skill by each stage using it's own invocation prefix.
For example, if we had a beta
skill within our pipeline we could do this:
new ask.Skill(this, "MySkill", {
...
// prepends "beta " to each interaction model's invocation name
skillInvocationPrefix: "beta ", // "hello world" invocation name is now "beta hello world"
});
// when generating your simulations, be sure that invocation simulations use the prefix
const betaSimulations: Simulation[] = [
{
// The en-US interaction model invocation name that is originally "hello world"
utterance: "open beta hello world",
expect: ["possible-en-US-alexa-response", "possible-en-US-alexa-response-two"],
locale: Locale.en_US,
newSession: true
},
{
// The fr-FR interaction model invocation name that is originally "bonjur world"
utterance: "ouvert beta bonjur monde", // test a different locale
expect: ["possible-fr-FR-locale-response"],
locale: Locale.fr_FR,
newSession: true
}
]
Testing with Jest
When developing with CDK you may want to test your local skill, or the team's development skill. ask-cdk
provides a SimulationClient
(#703) that allows you to easily run simulations against your skill. This client can easily be integrated with jest or any testing library you prefer.
With jest, we can test components of our skill by using describe
blocks. This allows us to break our test suite into multiple components that will run sequentially. In the snippet below, once the en-US locale
tests are complete, it will begin running the en-CA locale
tests. Note that the SMAPI Simulation API doesn't allow for concurrent requests per user, so you MUST run these tests sequentially.
By default, jest tests your files in parallel, which is not supported for alexa simulations. In order to fix this, you can add the --runInBand
option to your package.json
testing command which will run the tests serially. Please visit jestjs.io/docs for viewing other options you can set. You may also set the maxWorkers
property in your jest.config.js
setup which has the same effect.
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
maxWorkers: 1,
};
Running jest simulation tests in the pipeline is not currently supported #696. See Epic Testing for future testing options.
import { getTestingClient } from "@alexa/ask-cdk";
// set a custom jest timeout duration
jest.setTimeout(10000);
const client = getTestingClient("<skill-id-to-test>", "<cli-profile>");
// Any stage specific invocation prefix.
const prefix = "<my-prefix>";
// testing the en-US
describe("en-US locale", () => {
test("the skill is invoked", async () => {
const alexaResponse = await client.simulate(
`open ${prefix} <my en-US invocation>`,
"FORCE_NEW_SESSION"
);
expect(alexaResponse).toContain(
"Welcome, you can say Hello or Help. Which would you like to try?"
);
});
test("hello", async () => {
const alexaResponse = await client.simulate("help");
expect(alexaResponse).toContain("Hello World!");
});
});
describe("en-CA locale", () => {
test("the en-CA locale is invoked", async () => {
const alexaResponse = await client.simulate(
`open ${prefix} <my en-CA invocation>`,
"FORCE_NEW_SESSION",
"en-CA"
);
expect(alexaResponse).toContain(
"Welcome, you can say Hello or Help. Which would you like to try?"
);
});
});