Skip to content

Commit

Permalink
Update CDK constructs for Restate 0.7.0 release (#22)
Browse files Browse the repository at this point in the history
* Update terminology and API endpoints for restate 0.7.0 release

* Add support for custom Restate Docker images, useful for testing pre-release builds

* Simplify the LambdaServiceRegistry API

* Increase admin service health check timeout for better first-run experience

* Add support for legacy registration endpoint fallback

* Bump CDK version to 2.121.0
  • Loading branch information
pcholakov authored Jan 15, 2024
1 parent 05c1955 commit cc600ff
Show file tree
Hide file tree
Showing 10 changed files with 577 additions and 555 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@ see [Getting started with the AWS CDK](https://docs.aws.amazon.com/cdk/v2/guide/
- [`LambdaServiceRegistry`](./lib/restate-constructs/lambda-service-registry.ts) - A collection of Lambda-deployed
Restate services, this construct automatically registers the latest function version as a new deployment revision in a
Restate instance
- [`SingleNodeRestateInstance`](./lib/restate-constructs/single-node-restate-instance.ts) - Deploys a self-hosted
- [`SingleNodeRestateDeployment`](./lib/restate-constructs/single-node-restate-deployment.ts) - Deploys a self-hosted
Restate instance on EC2; note this is a single-node deployment targeted at development and testing
- [`RestateCloudEndpoint`](./lib/restate-constructs/restate-cloud-endpoint.ts) - A Restate Cloud instance
- [`RestateCloudEnvironment`](./lib/restate-constructs/restate-cloud-environment.ts) - A Restate Cloud instance

For a more detailed overview, please see the [Restate CDK documentation](https://docs.restate.dev/services/deployment/cdk).
For a more detailed overview, please see
the [Restate CDK documentation](https://docs.restate.dev/services/deployment/cdk).

### Examples

You can use the following examples as references for your own CDK projects:

- [hello-world-lambda-cdk](https://github.com/restatedev/examples/tree/main/kotlin/hello-world-lambda-cdk) - provides a
simple example of a Lambda-deployed Kotlin handler
- [hello-world-lambda-cdk](https://github.com/restatedev/examples/tree/main/kotlin/hello-world-lambda-cdk) - Kotlin
service deployed to AWS Lambda
- [Restate Holiday](https://github.com/restatedev/restate-holiday) - a more complex example of a fictional reservation
service demonstrating the Saga orchestration pattern
6 changes: 3 additions & 3 deletions lib/restate-constructs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@

export * from "./lambda-service-registry";
export * from "./registration-provider";
export * from "./restate-cloud-endpoint";
export * from "./restate-instance";
export * from "./single-node-restate-instance";
export * from "./restate-cloud-environment";
export * from "./restate-environment";
export * from "./single-node-restate-deployment";
64 changes: 34 additions & 30 deletions lib/restate-constructs/lambda-service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,56 +15,50 @@ import * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import { RegistrationProperties } from "./register-service-handler";

import { RestateInstance } from "./restate-instance";
import { RestateEnvironment } from "./restate-environment";

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

export interface RestateInstanceRef {
readonly metaEndpoint: string;
readonly invokerRoleArn: string;
readonly authTokenSecretArn?: string;
}
export type RestatePath = string;

/**
* A collection of Lambda Restate RPC Service handlers.
* Manage registration of a set of Lambda-deployed Restate RPC Service handlers with a Restate environment.
*/
export type LambdaServiceRegistryProps = {
/**
* Mappings from service path to Lambda handler.
* Custom resource provider token required for service discovery.
*/
serviceHandlers: Record<RestatePath, lambda.Function>;
environment: RestateEnvironment;

/**
* Custom resource provider token required for service discovery.
* Lambda Restate service handlers to deploy.
*/
restate: RestateInstance;
handlers: Record<RestatePath, lambda.Function>;
}

/**
* Represents a collection of Lambda-based Restate RPC services. This component is used to register
* them with a single Restate instance. This creates a custom resource which will trigger service
* discovery on any handler changes deployed through CDK/CloudFormation.
* Manage registration of a set of Lambda-deployed Restate RPC Service handlers with a Restate environment. This
* construct creates a custom resource which will trigger Restate service discovery on handler function changes.
*/
export class LambdaServiceRegistry extends Construct {
private readonly registrationProviderToken: cdk.CfnOutput;
private readonly serviceHandlers: Record<RestatePath, lambda.Function>;
private readonly registrationProviderToken: string;

constructor(scope: Construct, id: string, props: LambdaServiceRegistryProps) {
super(scope, id);

if (Object.values(props.serviceHandlers).length == 0) {
if (Object.values(props.handlers).length == 0) {
throw new Error("Please specify at least one service handler.");
}

this.serviceHandlers = props.serviceHandlers;
this.registrationProviderToken = props.restate.registrationProviderToken.value;
this.serviceHandlers = props.handlers;
this.registrationProviderToken = props.environment.registrationProviderToken;
this.registerServices(props.environment);
}

public register(restate: RestateInstanceRef) {
const invokerRole = iam.Role.fromRoleArn(this, "InvokerRole", restate.invokerRoleArn);
private registerServices(environment: RestateEnvironment) {
const invokerRole = iam.Role.fromRoleArn(this, "InvokerRole", environment.invokerRole.roleArn);

const allowInvokeFunction = new iam.Policy(this, "AllowInvokeFunction", {
statements: [
Expand All @@ -80,18 +74,22 @@ export class LambdaServiceRegistry extends Construct {
invokerRole.attachInlinePolicy(allowInvokeFunction);

for (const [path, handler] of Object.entries(this.serviceHandlers)) {
this.registerHandler(restate, { path, handler }, allowInvokeFunction);
this.registerHandler({
adminUrl: environment.adminUrl,
invokerRoleArn: invokerRole.roleArn,
authTokenSecretArn: environment.authToken?.secretArn,
}, { path, handler }, allowInvokeFunction);
}
}

private registerHandler(restate: RestateInstanceRef, service: {
private registerHandler(restate: EnvironmentDetails, service: {
path: RestatePath,
handler: lambda.Function
}, allowInvokeFunction: iam.Policy) {
const registrar = new RestateServiceRegistrar(this, service.handler.node.id + "Discovery", {
restate,
environment: restate,
service,
serviceToken: this.registrationProviderToken,
serviceToken: this.registrationProviderToken.value,
});

// CloudFormation doesn't know that Restate depends on this role to call services; we must ensure that Lambda
Expand All @@ -103,7 +101,7 @@ export class LambdaServiceRegistry extends Construct {
class RestateServiceRegistrar extends Construct {
constructor(scope: Construct, id: string,
props: {
restate: RestateInstanceRef,
environment: EnvironmentDetails,
service: {
path: RestatePath,
handler: lambda.Function
Expand All @@ -118,12 +116,18 @@ class RestateServiceRegistrar extends Construct {
resourceType: "Custom::RestateServiceRegistrar",
properties: {
servicePath: props.service.path,
metaEndpoint: props.restate.metaEndpoint,
authTokenSecretArn: props.restate.authTokenSecretArn,
adminUrl: props.environment.adminUrl,
authTokenSecretArn: props.environment.authTokenSecretArn,
serviceLambdaArn: props.service.handler.currentVersion.functionArn,
invokeRoleArn: props.restate.invokerRoleArn,
invokeRoleArn: props.environment.invokerRoleArn,
removalPolicy: cdk.RemovalPolicy.RETAIN,
} satisfies RegistrationProperties,
});
}
}

interface EnvironmentDetails {
readonly adminUrl: string;
readonly invokerRoleArn: string;
readonly authTokenSecretArn?: string;
}
47 changes: 28 additions & 19 deletions lib/restate-constructs/register-service-handler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,35 @@ import fetch from "node-fetch";
import * as cdk from "aws-cdk-lib";
import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import * as https from "https";
import { randomInt } from "crypto";

export interface RegistrationProperties {
servicePath?: string;
metaEndpoint?: string;
serviceEndpoint?: string;
adminUrl?: string;
serviceLambdaArn?: string;
invokeRoleArn?: string;
removalPolicy?: cdk.RemovalPolicy;
authTokenSecretArn?: string;
}

type EndpointResponse = {
type RegisterDeploymentResponse = {
id?: string;
services?: { name?: string; revision?: number }[];
};

const MAX_HEALTH_CHECK_ATTEMPTS = 3;
const MAX_HEALTH_CHECK_ATTEMPTS = 5; // This is intentionally quite long to allow some time for first-run EC2 and Docker boot up
const MAX_REGISTRATION_ATTEMPTS = 3;

const INSECURE = true;

const DEPLOYMENTS_PATH = "deployments";
const DEPLOYMENTS_PATH_LEGACY = "endpoints"; // temporarily fall back for legacy clusters

/**
* Custom Resource event handler for Restate service registration. This handler backs the custom resources created by
* {@link LambdaServiceRegistry} to facilitate Lambda service handler discovery.
*/
export const handler: Handler<CloudFormationCustomResourceEvent, void> = async function (event) {
export const handler: Handler<CloudFormationCustomResourceEvent, void> = async function(event) {
console.log({ event });

if (event.RequestType === "Delete") {
Expand All @@ -55,15 +58,15 @@ export const handler: Handler<CloudFormationCustomResourceEvent, void> = async f
// const controller = new AbortController();
// const id = btoa(props.serviceLambdaArn!); // TODO: we should be treating service ids as opaque
// const deleteCallTimeout = setTimeout(() => controller.abort("timeout"), 5_000);
// const deleteResponse = await fetch(`${props.metaEndpoint}/endpoints/${id}?force=true`, {
// const deleteResponse = await fetch(`${props.adminUrl}/${DEPLOYMENTS_PATH}/${id}?force=true`, {
// signal: controller.signal,
// method: "DELETE",
// agent: INSECURE ? new https.Agent({ rejectUnauthorized: false }) : undefined,
// }).finally(() => clearTimeout(deleteCallTimeout));
//
// console.log(`Got delete response back: ${deleteResponse.status}`);
// if (deleteResponse.status != 202) {
// throw new Error(`Deleting service endpoint failed: ${deleteResponse.statusText} (${deleteResponse.status})`);
// throw new Error(`Removing service deployment failed: ${deleteResponse.statusText} (${deleteResponse.status})`);
// }
// }

Expand All @@ -77,7 +80,7 @@ export const handler: Handler<CloudFormationCustomResourceEvent, void> = async f
let attempt;
const controller = new AbortController();

const healthCheckUrl = `${props.metaEndpoint}/health`;
const healthCheckUrl = `${props.adminUrl}/health`;

console.log(`Performing health check against: ${healthCheckUrl}`);
attempt = 1;
Expand All @@ -104,30 +107,30 @@ export const handler: Handler<CloudFormationCustomResourceEvent, void> = async f
console.error(`Restate health check failed: "${errorMessage}" (attempt ${attempt})`);
}

if (attempt > MAX_HEALTH_CHECK_ATTEMPTS) {
console.error(`Meta health check still failing after ${attempt} attempts.`);
if (attempt >= MAX_HEALTH_CHECK_ATTEMPTS) {
console.error(`Admin service health check failing after ${attempt} attempts.`);
throw new Error(errorMessage ?? `${healthResponse?.statusText} (${healthResponse?.status})`);
}
attempt += 1;

const waitTimeMillis = 2 ** attempt * 1_000;
const waitTimeMillis = randomInt(2_000) + 2 ** attempt * 1_000; // 3s -> 6s -> 10s -> 18s -> 34s
console.log(`Retrying after ${waitTimeMillis} ms...`);
await sleep(waitTimeMillis);
}

const endpointsUrl = `${props.metaEndpoint}/endpoints`;
let deploymentsUrl = `${props.adminUrl}/${DEPLOYMENTS_PATH}`;
const registrationRequest = JSON.stringify({
arn: props.serviceLambdaArn,
assume_role_arn: props.invokeRoleArn,
});

let failureReason;
console.log(`Triggering registration at ${endpointsUrl}: ${registrationRequest}`);
console.log(`Triggering registration at ${deploymentsUrl}: ${registrationRequest}`);
attempt = 1;
while (true) {
try {
const registerCallTimeout = setTimeout(() => controller.abort("timeout"), 10_000);
const discoveryResponse = await fetch(endpointsUrl, {
const registerDeploymentResponse = await fetch(deploymentsUrl, {
signal: controller.signal,
method: "POST",
body: registrationRequest,
Expand All @@ -138,10 +141,15 @@ export const handler: Handler<CloudFormationCustomResourceEvent, void> = async f
agent: INSECURE ? new https.Agent({ rejectUnauthorized: false }) : undefined,
}).finally(() => clearTimeout(registerCallTimeout));

console.log(`Got registration response back: ${discoveryResponse.status}`);
console.log(`Got registration response back: ${registerDeploymentResponse.status}`);

if (registerDeploymentResponse.status == 404 && attempt == 1) {
deploymentsUrl = `${props.adminUrl}/${DEPLOYMENTS_PATH_LEGACY}`;
console.log(`Got 404, falling back to <0.7.0 legacy endpoint registration at: ${deploymentsUrl}`);
}

if (discoveryResponse.status >= 200 && discoveryResponse.status < 300) {
const response = (await discoveryResponse.json()) as EndpointResponse;
if (registerDeploymentResponse.status >= 200 && registerDeploymentResponse.status < 300) {
const response = (await registerDeploymentResponse.json()) as RegisterDeploymentResponse;

if (response?.services?.[0]?.name !== props.servicePath) {
failureReason =
Expand All @@ -156,14 +164,15 @@ export const handler: Handler<CloudFormationCustomResourceEvent, void> = async f
}
} catch (e) {
console.error(`Service registration call failed: ${(e as Error)?.message} (attempt ${attempt})`);
failureReason = `Restate service registration failed: ${(e as Error)?.message}`;
}

if (attempt > MAX_REGISTRATION_ATTEMPTS) {
if (attempt >= MAX_REGISTRATION_ATTEMPTS) {
console.error(`Service registration failed after ${attempt} attempts.`);
break;
}
attempt += 1;
const waitTimeMillis = 2_000 + 2 ** attempt * 1_000; // 3s -> 6s -> 10s
const waitTimeMillis = randomInt(2_000) + 2 ** attempt * 1_000; // 3s -> 6s -> 10s
console.log(`Retrying registration after ${waitTimeMillis} ms...`);
await sleep(waitTimeMillis);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/restate-constructs/registration-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import * as cdk from "aws-cdk-lib";
import * as cr from "aws-cdk-lib/custom-resources";
import * as ec2 from "aws-cdk-lib/aws-ec2";

const DEFAULT_TIMEOUT = cdk.Duration.seconds(120);
const DEFAULT_TIMEOUT = cdk.Duration.seconds(180);

export class RegistrationProvider extends Construct {
readonly serviceToken: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,48 @@ import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ssm from "aws-cdk-lib/aws-secretsmanager";
import { RestateInstance } from "./restate-instance";
import { RestateEnvironment } from "./restate-environment";
import { RegistrationProvider } from "./registration-provider";

const RESTATE_INGRESS_PORT = 8080;
const RESTATE_META_PORT = 9070;
const RESTATE_ADMIN_PORT = 9070;

export interface ManagedRestateProps {
export interface RestateCloudEnvironmentProps {
/** Prefix for resources created by this construct that require unique names. */
prefix?: string;

/** ID of the Restate service cluster to which this service will be registered. */
clusterId: string;

/** Auth token to use with Restate cluster. Used to authenticate access to the meta endpoint for registration. */
/** Auth token for Restate environment. Used with the admin service for service deployment registration. */
authTokenSecretArn: string;
}

/**
* Models a Restate managed service cluster provided to the application. In the case of a managed service, this
* construct only creates an appropriately configured registration provider custom component for use by the service
* registry elsewhere, and creates the role assumed by the cluster. An appropriate trust policy will be added to this
* role that allows Restate to assume it from outside the deployment AWS account.
* Restate environment hosted on Restate Cloud. This construct manages the role in the deployment environment that
* Restate Cloud assumes to call registered services, and provides the service registration helper for Lambda-based
* handlers. An appropriate trust policy will be added to this role that allows Restate to assume it from outside the
* deployment AWS account.
*/
export class RestateCloudEndpoint extends Construct implements RestateInstance {
export class RestateCloudEnvironment extends Construct implements RestateEnvironment {
readonly invokerRole: iam.Role;
readonly ingressEndpoint: string;
readonly metaEndpoint: string;
readonly ingressUrl: string;
readonly adminUrl: string;
readonly authToken: ssm.ISecret;
readonly registrationProviderToken: cdk.CfnOutput;

constructor(scope: Construct, id: string, props: ManagedRestateProps) {
constructor(scope: Construct, id: string, props: RestateCloudEnvironmentProps) {
super(scope, id);

this.invokerRole = new iam.Role(this, "ManagedServiceRole", {
description: "Role assumed by the Restate managed service to invoke our services",
// This role should be easier to customize or override completely: https://github.com/restatedev/cdk/issues/21
this.invokerRole = new iam.Role(this, "RestateServiceInvokerRole", {
description: "Role assumed by Restate Cloud when invoking Lambda service handlers",
assumedBy: new iam.ArnPrincipal("arn:aws:iam::663487780041:role/restate-dev"),
externalIds: [props.clusterId],
});

this.ingressEndpoint = `https://${props.clusterId}.dev.restate.cloud:${RESTATE_INGRESS_PORT}`;
this.metaEndpoint = `https://${props.clusterId}.dev.restate.cloud:${RESTATE_META_PORT}`;
this.ingressUrl = `https://${props.clusterId}.dev.restate.cloud:${RESTATE_INGRESS_PORT}`;
this.adminUrl = `https://${props.clusterId}.dev.restate.cloud:${RESTATE_ADMIN_PORT}`;
this.authToken = ssm.Secret.fromSecretCompleteArn(this, "ClusterAuthToken", props.authTokenSecretArn);

const registrationProvider = new RegistrationProvider(this, "RegistrationProvider", { authToken: this.authToken });
Expand Down
Loading

0 comments on commit cc600ff

Please sign in to comment.