Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ For building and deploying Next.js apps to the system we created a CLI tool call
It is a npm package that can be installed with:

```sh
npm i -g tf-next
npm i -g tf-next@canary
```

Next, we need to build the Next.js so that it can run in a serverless environment (with AWS Lambda).
Expand Down
4 changes: 3 additions & 1 deletion packages/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface paths {
/** Only list aliases that are associated with the specified deployment. */
deploymentId: string;
/** Beginning index from where to get the aliases. */
startIndex?: string;
startAt?: string;
};
};
responses: {
Expand All @@ -25,6 +25,7 @@ export interface paths {
};
};
400: components['responses']['InvalidParamsError'];
404: components['responses']['NotFound'];
};
};
post: {
Expand Down Expand Up @@ -145,6 +146,7 @@ export interface components {
Alias: {
id: string;
deployment: string;
createDate: string;
};
/** @enum {string} */
DeploymentStatus:
Expand Down
7 changes: 6 additions & 1 deletion packages/api/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ components:
type: string
deployment:
type: string
createDate:
type: string
required:
- id
- deployment
- createDate

DeploymentStatus:
type: string
Expand Down Expand Up @@ -124,7 +127,7 @@ paths:
required: true
description: Only list aliases that are associated with the specified deployment.
- in: query
name: startIndex
name: startAt
schema:
type: string
description: Beginning index from where to get the aliases.
Expand All @@ -145,6 +148,8 @@ paths:
required:
- metadata
- items
'404':
$ref: '#/components/responses/NotFound'
'400':
$ref: '#/components/responses/InvalidParamsError'

Expand Down
1 change: 1 addition & 0 deletions packages/api/src/actions/alias/create-or-update-alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ async function createOrUpdateAlias(
return {
id: generateAliasId(createdAlias),
deployment: createdAlias.DeploymentId,
createDate: createdAlias.CreateDate,
};
}

Expand Down
29 changes: 26 additions & 3 deletions packages/api/src/actions/alias/list-aliases.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { listAliasesForDeployment } from '@millihq/tfn-dynamodb-actions';
import {
getDeploymentById,
listAliasesForDeployment,
} from '@millihq/tfn-dynamodb-actions';
import { Request, Response } from 'lambda-api';

import { paths } from '../../../schema';
import { DynamoDBServiceType } from '../../services/dynamodb';
import { generateAliasId } from './alias-utils';

type NotFoundResponse =
paths['/aliases']['get']['responses']['404']['content']['application/json'];
type ErrorResponse =
paths['/aliases']['get']['responses']['400']['content']['application/json'];
type SuccessResponse =
Expand All @@ -19,7 +24,7 @@ const START_AT_KEY_SPLIT_CHAR = '#';
async function listAliases(
req: Request,
res: Response
): Promise<SuccessResponse | ErrorResponse> {
): Promise<SuccessResponse | ErrorResponse | NotFoundResponse> {
const { deploymentId, startAt } = req.query;
let startKey:
| {
Expand Down Expand Up @@ -51,11 +56,28 @@ async function listAliases(

const dynamoDB = req.namespace.dynamoDB as DynamoDBServiceType;

// Check if the deployment exists
const deployment = await getDeploymentById({
dynamoDBClient: dynamoDB.getDynamoDBClient(),
deploymentTableName: dynamoDB.getDeploymentTableName(),
deploymentId,
});

if (!deployment) {
const notFoundResponse: NotFoundResponse = {
code: 'DEPLOYMENT_NOT_FOUND',
status: 404,
message: 'Deployment does not exist.',
};
res.sendStatus(404);
return notFoundResponse;
}

const { meta, items } = await listAliasesForDeployment({
dynamoDBClient: dynamoDB.getDynamoDBClient(),
aliasTableName: dynamoDB.getAliasTableName(),
limit: PAGE_LIMIT,
deploymentId,
deploymentId: deployment.DeploymentId,
startKey,
});

Expand All @@ -77,6 +99,7 @@ async function listAliases(
return {
id: generateAliasId(alias),
deployment: alias.DeploymentId,
createDate: alias.CreateDate,
};
}),
};
Expand Down
26 changes: 25 additions & 1 deletion packages/api/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import LambdaApi from 'lambda-api';
import LambdaApi, { ErrorHandlingMiddleware } from 'lambda-api';

import { createOrUpdateAlias } from './actions/alias/create-or-update-alias';
import { deleteAliasById } from './actions/alias/delete-alias-by-id';
Expand Down Expand Up @@ -31,6 +31,30 @@ function createApi() {
api.get('/deployments', listDeployments);
api.post('/deployments', createDeployment);

// Since all errors should be handled in the the actions in the first place
// errors that caught here are unhandled exceptions.
// The exceptions can contain sensitive information so we ensure that they
// are only logged but not exposed through the API.
const errorHandler: ErrorHandlingMiddleware = (err, _req, res, next) => {
// Log the error message to CloudWatch
console.error(err);

const cloudWatchLogGroupName = process.env.AWS_LAMBDA_LOG_GROUP_NAME;
const cloudWatchLogStreamName = process.env.AWS_LAMBDA_LOG_STREAM_NAME;
const awsRegion = process.env.AWS_REGION;

res.status(500).json({
status: 500,
code: 'INTERNAL_ERROR',
message: `Internal error. Contact your administrator for a detailed error message. The error message is logged to CloudWatch LogGroup: '${cloudWatchLogGroupName}', LogStream: '${cloudWatchLogStreamName}' in AWS region '${awsRegion}'.`,
cloudWatchLogGroupName,
cloudWatchLogStreamName,
awsRegion,
});
next();
};
api.use(errorHandler);

return api;
}

Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ declare namespace NodeJS {
TABLE_NAME_ALIASES: string;
UPLOAD_BUCKET_ID: string;
UPLOAD_BUCKET_REGION: string;

// Reserved environment variables from AWS Lambda
// @see {@link https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime}
AWS_LAMBDA_LOG_GROUP_NAME: string;
AWS_LAMBDA_LOG_STREAM_NAME: string;
AWS_REGION: string;
}
}
35 changes: 33 additions & 2 deletions packages/api/test/actions/alias/list-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createAlias } from '@millihq/tfn-dynamodb-actions';
import { createAlias, createDeployment } from '@millihq/tfn-dynamodb-actions';
import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { API } from 'lambda-api';

Expand Down Expand Up @@ -61,9 +61,15 @@ describe('ListAliases', () => {
});

test('No aliases with deploymentId', async () => {
await createDeployment({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
deploymentTableName: dynamoDBService.getDeploymentTableName(),
deploymentId: 'deploymentIdWithoutAliases',
});

const event = createAPIGatewayProxyEventV2({
uri: `/aliases?deploymentId=${encodeURIComponent(
'notExistingDeploymentId'
'deploymentIdWithoutAliases'
)}`,
});

Expand All @@ -85,7 +91,32 @@ describe('ListAliases', () => {
});
});

test('Provided deployment id does not exist', async () => {
const event = createAPIGatewayProxyEventV2({
uri: `/aliases?deploymentId=${encodeURIComponent(
'notExistingDeployment'
)}`,
});

const result = (await api.run(
event as any,
{} as any
)) as APIGatewayProxyStructuredResultV2;

expect(result).toMatchObject({
headers: { 'content-type': 'application/json' },
statusCode: 404,
isBase64Encoded: false,
});
});

test('Pagination', async () => {
await createDeployment({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
deploymentTableName: dynamoDBService.getDeploymentTableName(),
deploymentId: 'paginationDeployment',
});

// Create some entries first
for (let index = 1; index <= 30; index++) {
await createAlias({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ describe('DeleteDeployment', () => {
beforeAll(async () => {
api = createApi();

// Insert mocks
cloudFormationService = mockCloudFormationService();
api.app('cloudFormation', cloudFormationService);

const s3Mock = await mockS3Service();
api.app('s3', s3Mock[0]);
s3CleanupCallback = s3Mock[1];
Expand All @@ -42,6 +38,12 @@ describe('DeleteDeployment', () => {
dynamoDBCleanupCallback = dynamoDBMock[1];
});

beforeEach(() => {
// Insert mocks
cloudFormationService = mockCloudFormationService();
api.app('cloudFormation', cloudFormationService);
});

afterAll(async () => {
await s3CleanupCallback();
await dynamoDBCleanupCallback();
Expand All @@ -51,6 +53,72 @@ describe('DeleteDeployment', () => {
jest.clearAllMocks();
});

test('CloudFormation fail', async () => {
console.error = jest.fn();
const stackDeleteError = new Error('Throw from deleteStack');

/**
* Mock for the cloudFormationService that fails when
*/
function mockFailedCloudFormationService(): CloudFormationServiceType {
return class CloudFormationServiceMock {
static deleteStack(_stackName: string) {
return Promise.reject(stackDeleteError);
}
};
}
api.app('cloudFormation', mockFailedCloudFormationService());

const deployment = await createDeployment({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
deploymentTableName: dynamoDBService.getDeploymentTableName(),
deploymentId: 'deploymentDeletionFails',
});

// Status: INITIALIZED -> CREATE_IN-PROGRESS
await updateDeploymentStatusCreateInProgress({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
deploymentTableName: dynamoDBService.getDeploymentTableName(),
deploymentId: {
PK: deployment.PK,
SK: deployment.SK,
},
prerenders: 'foo',
routes: 'bar',
cloudFormationStack:
'arn:aws:cloudformation:eu-central-1:123456789123:stack/tfn-d35de1a94815e0562689b89b6225cd85/319a93a0-c3df-11ec-9e1a-0a226e11de6a',
});

// Status: CREATE_IN-PROGRESS -> FINISHED
await updateDeploymentStatusFinished({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
deploymentTableName: dynamoDBService.getDeploymentTableName(),
deploymentId: {
PK: deployment.PK,
SK: deployment.SK,
},
});

const event = createAPIGatewayProxyEventV2({
uri: '/deployments/deploymentDeletionFails',
method: 'DELETE',
});
const response = (await api.run(
event as any,
{} as any
)) as APIGatewayProxyStructuredResultV2;
expect(response).toMatchObject({
headers: { 'content-type': 'application/json' },
statusCode: 500,
isBase64Encoded: false,
});
expect(JSON.parse(response.body!)).toMatchObject({
status: 500,
code: 'INTERNAL_ERROR',
});
expect(console.error).toHaveBeenCalledWith(stackDeleteError);
});

test('Deployment without aliases', async () => {
await createDeployment({
dynamoDBClient: dynamoDBService.getDynamoDBClient(),
Expand Down
8 changes: 4 additions & 4 deletions packages/tf-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This covers only the CLI part of the tool, for a full step-by-step tutorial plea
1. Install the CLI tool

```plain
npm i -g tf-next
npm i -g tf-next@canary
```

2. Build the project
Expand All @@ -24,9 +24,9 @@ This covers only the CLI part of the tool, for a full step-by-step tutorial plea
```plain
tf-next deploy --endpoint https://<api-id>.execute-api.<region>.amazonaws.com

> ✅ Upload complete.
> Deployment complete.
> Available at: https://1e02d46975338b63651b8587ea6a8475.example.com
> success Deployment package uploaded
> success Deployment ready
> Available at: https://1e02d46975338b63651b8587ea6a8475.example.com/
```

## Commands
Expand Down
6 changes: 6 additions & 0 deletions packages/tf-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,17 @@
"ansi-escapes": "4.3.2",
"archiver": "^5.3.0",
"chalk": "4.1.2",
"clipboardy": "2.3.0",
"find-yarn-workspace-root": "^2.0.0",
"form-data-encoder": "^1.7.2",
"formdata-node": "^4.3.2",
"fs-extra": "^9.0.1",
"glob": "^7.1.6",
"ms": "2.1.3",
"node-fetch": "2.6.7",
"ora": "^5.4.1",
"p-wait-for": "3.2.0",
"text-table": "0.2.0",
"tmp": "^0.2.1",
"yargs": "17.5.1"
},
Expand All @@ -47,7 +51,9 @@
"@types/archiver": "^5.1.0",
"@types/fs-extra": "^9.0.1",
"@types/glob": "^7.1.2",
"@types/ms": "0.7.31",
"@types/node-fetch": "^2.0.0",
"@types/text-table": "0.2.2",
"@types/tmp": "^0.2.0",
"@types/yargs": "17.0.10",
"typescript": "*"
Expand Down
Loading