Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create basic CDK support and minimalist demo stack #6

Merged
merged 18 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
aae5227
Add minimal EC2 instance to host Restate daemon
pcholakov Nov 14, 2023
7ec36bd
Add API Gateway for Lambda backend
pcholakov Nov 14, 2023
9b3d463
Use variable prefix from CDK context or env for stack name
pcholakov Nov 15, 2023
b589066
Add insecure internet-facing ALB exposing the Restate ingress endpoint
pcholakov Nov 15, 2023
c7ee2bd
Add custom resource that registers the Lambda handler via Restate dis…
pcholakov Nov 16, 2023
113c6fb
Fix: log discovery endpoint in custom resource handler
pcholakov Nov 16, 2023
44e7a25
Cleanup: remove some unnecessary outputs
pcholakov Nov 16, 2023
9d3bc7a
Discover the concrete AMI to use
pcholakov Nov 16, 2023
69262bf
Switch to Secrets Manager secret for passing the GitHub token into EC2
pcholakov Nov 16, 2023
1ada6ed
Send Restate JSON logs to CloudWatch logs
pcholakov Nov 17, 2023
b97a0bd
Refactoring: Extract custom Restate constructs for all major components
pcholakov Nov 19, 2023
2983cf7
Update README with deployment & test instructions
pcholakov Nov 20, 2023
642761d
Add esbuild dev dependency (avoids Docker requirement for bundling La…
pcholakov Nov 20, 2023
1da5f47
Minor cleanups and comment+TODO updates
pcholakov Nov 20, 2023
8831b6d
Allow Restate to invoke handlers via explicit version qualifier
pcholakov Nov 20, 2023
4d450f5
Ack warning - and actually silence it
pcholakov Nov 21, 2023
cf1b009
Switch to native Lambda invoke integration, remove API Gateway HTTP m…
pcholakov Nov 21, 2023
4496b93
Remove GitHub token secret as no longer needed
pcholakov Nov 21, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
.idea
*.js
!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out
cdk.out
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"arrowParens": "always",
"printWidth": 120
}
53 changes: 43 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
# Welcome to your CDK TypeScript project
# Restate CDK support

This is a blank project for CDK development with TypeScript.
CDK construct library for deploying standalone [Restate](https://restate.dev) and Restate service handlers to AWS.

The `cdk.json` file tells the CDK Toolkit how to execute your app.
## Deploying the demo project

## Useful commands
In order to access the Restate Docker image, you need to make a GitHub access token with permission to retrieve the
Restate beta distribution available in your AWS account.

* `npm run build` compile typescript to js
* `npm run watch` watch for changes and compile
* `npm run test` perform the jest unit tests
* `cdk deploy` deploy this stack to your default AWS account/region
* `cdk diff` compare deployed stack with current state
* `cdk synth` emits the synthesized CloudFormation template
```shell
aws secretsmanager create-secret --name /restate/docker/github-token --secret-string $GITHUB_TOKEN
```

To deploy the self-hosted Restate instance, run:

```shell
npx cdk deploy
```

By default, this will create a stack prefixed with the value of `$USER` - you can override this using the CDK context
parameter `prefix` like this:

```shell
npx cdk deploy --context prefix="dev"
```

You will be prompted to confirm the creation of new security-sensitive resources.

Use the value of the `RestateIngressEndpoint` output of the CloudFormation stack to communicate with the Restate
broker (if you customized the deployment prefix above, you will need to update the stack name):

```shell
export RESTATE_INGRESS_ENDPOINT=$(aws cloudformation describe-stacks --stack-name ${USER}-RestateStack \
--query "Stacks[0].Outputs[?OutputKey=='RestateIngressEndpoint'].OutputValue" --output text)
```

```shell
curl -X POST -w \\n ${RESTATE_INGRESS_ENDPOINT}/Greeter/greet -H 'content-type: application/json' -d '{"key": "Restate"}'
```

You will get output similar to the following:

```json
{
"response": "Hello, Restate! :-)"
}
```
22 changes: 7 additions & 15 deletions bin/restate-cdk-support.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { RestateCdkSupportStack } from '../lib/restate-cdk-support-stack';
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { RestateSelfHostedStack } from "../lib/restate-self-hosted-stack";

const app = new cdk.App();
new RestateCdkSupportStack(app, 'RestateCdkSupportStack', {
/* If you don't specify 'env', this stack will be environment-agnostic.
* Account/Region-dependent features and context lookups will not work,
* but a single synthesized template can be deployed anywhere. */

/* Uncomment the next line to specialize this stack for the AWS Account
* and Region that are implied by the current CLI configuration. */
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
const githubPat = app.node.tryGetContext("githubTokenSecretName");
const prefix = app.node.tryGetContext("prefix") ?? process.env["USER"];

/* Uncomment the next line if you know exactly what Account and Region you
* want to deploy the stack to. */
// env: { account: '123456789012', region: 'us-east-1' },

/* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
new RestateSelfHostedStack(app, [prefix, "RestateStack"].filter(Boolean).join("-"), {
githubTokenSecretName: githubPat,
});
16 changes: 0 additions & 16 deletions lib/restate-cdk-support-stack.ts

This file was deleted.

76 changes: 76 additions & 0 deletions lib/restate-constructs/lambda/register-service-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { CloudFormationCustomResourceResponse } from "aws-lambda";
import { Handler } from "aws-lambda/handler";
import { CloudFormationCustomResourceEvent } from "aws-lambda/trigger/cloudformation-custom-resource";
import fetch from "node-fetch";

interface RegistrationProperties {
ingressEndpoint?: string;
metaEndpoint?: string;
serviceEndpoint?: string;
functionVersion?: number;
}

export const handler: Handler<CloudFormationCustomResourceEvent, Partial<CloudFormationCustomResourceResponse>> =
async function(event) {
console.log({ event });

if (event.RequestType === "Delete") {
return {
// TODO: deregister service on delete (https://github.com/restatedev/restate-cdk-support/issues/5)
Reason: "No-op",
Status: "SUCCESS",
} satisfies Partial<CloudFormationCustomResourceResponse>;
}

try {
const props = event.ResourceProperties as RegistrationProperties;

const controller = new AbortController();
const healthCheckTimeout = setTimeout(() => controller.abort(), 3000);
const healthCheckUrl = `${props.ingressEndpoint}/grpc.health.v1.Health/Check`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should be using the health endpoint on the meta here, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're quite right, for pre-registration the meta health check is probably the one that matters! I'm going to revisit this entire handler to make it more robust but switching over to meta health check for now. (Separately, the ALB is continuously monitoring the ingress health check.)

console.log(`Performing health check against: ${healthCheckUrl}`);
const healthResponse = await fetch(healthCheckUrl,
{
signal: controller.signal,
})
.finally(() => clearTimeout(healthCheckTimeout));
console.log(`Got health response back: ${await healthResponse.text()}`);

if (!(healthResponse.status >= 200 && healthResponse.status < 300)) {
// TODO: retry until service is healthy, or some overall timeout is reached
throw new Error(`Health check failed: ${healthResponse.statusText} (${healthResponse.status})`);
}

const registerCallTimeout = setTimeout(() => controller.abort(), 3000);
const discoveryEndpointUrl = `${props.metaEndpoint}/endpoints`;
const registrationRequest = JSON.stringify({ uri: props.serviceEndpoint });
console.log(`Triggering registration at ${discoveryEndpointUrl}: ${registrationRequest}`);
const discoveryResponse = await fetch(discoveryEndpointUrl,
{
signal: controller.signal,
method: "POST",
body: registrationRequest,
headers: {
"Content-Type": "application/json",
},
})
.finally(() => clearTimeout(registerCallTimeout));
console.log(`Got registration response back: ${await discoveryResponse.text()} (${discoveryResponse.status})`);

if (!(healthResponse.status >= 200 && healthResponse.status < 300)) {
// TODO: retry until successful, or some overall timeout is reached
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if only we had some kind of orchestrator that did this for us ;)

throw new Error(`Health check failed: ${healthResponse.statusText} (${healthResponse.status})`);
}

console.log("Returning success.");
} catch (err) {
console.error("Ignoring unhandled error: " + err);
}

return {
Data: {
// it would be neat if we could return a unique Restate event id back to CloudFormation to close the loop
},
Status: "SUCCESS",
} satisfies Partial<CloudFormationCustomResourceResponse>;
};
79 changes: 79 additions & 0 deletions lib/restate-constructs/restate-lambda-services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as api_gw from "aws-cdk-lib/aws-apigateway";
import { SingleNodeRestateInstance } from "./single-node-restate-instance";
import * as cdk from "aws-cdk-lib";

/**
* A Restate RPC service path. Example: `greeter`.
*/
type RestatePath = string;

/**
* A collection of Lambda Restate RPC Service handlers.
*/
export type LambdaServiceCollectionProps = {
/**
* Mappings from service path to Lambda handler.
*/
serviceHandlers: Record<RestatePath, lambda.Function>;
}

/**
* Creates a Restate service deployment backed by a single EC2 instance,
* suitable for development and testing purposes.
*/
export class RestateLambdaServiceCollection extends Construct {
private readonly serviceHandlers: Record<RestatePath, lambda.Function>;

constructor(scope: Construct, id: string, props: LambdaServiceCollectionProps) {
super(scope, id);
this.serviceHandlers = props.serviceHandlers;
}


public register(restate: SingleNodeRestateInstance) {
for (const [path, handler] of Object.entries(this.serviceHandlers)) {
this.registerHandler(restate, { path, handler });
}
}

private registerHandler(restate: SingleNodeRestateInstance, service: {
path: RestatePath,
handler: lambda.Function
}) {
service.handler.grantInvoke(restate.instanceRole);
service.handler.currentVersion.grantInvoke(restate.instanceRole); // Allow explicit version invocation

const serviceHttpResource = restate.serviceApi.root.addResource(service.path);
serviceHttpResource.addProxy({
defaultIntegration: new api_gw.LambdaIntegration(service.handler),
anyMethod: true,
});

new RestateServiceRegistrar(this, service.handler.node.id + "Discovery", { restate, service });
}
}

class RestateServiceRegistrar extends Construct {
constructor(scope: Construct, id: string, props: {
restate: SingleNodeRestateInstance, service: {
path: RestatePath,
handler: lambda.Function
}
}) {
super(scope, id);

new cdk.CustomResource(this, props.service.handler.node.id + "Discovery", {
serviceToken: props.restate.serviceDiscoveryProvider.serviceToken,
resourceType: "Custom::RestateServiceRegistrar",
properties: {
ingressEndpoint: props.restate.privateIngressEndpoint,
metaEndpoint: props.restate.metaEndpoint,
serviceEndpoint: props.restate.serviceApi.urlForPath(`/${props.service.path}`),
functionVersion: props.service.handler.currentVersion.version,
// TODO: force a refresh on EC2 instance configuration changes, too (https://github.com/restatedev/restate-cdk-support/issues/4)
},
});
}
}
Loading