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

Feat/migrations #8

Merged
merged 4 commits into from
Sep 7, 2023
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
7 changes: 7 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ Run the tests
npm run test
```

### Migrations

In this project migrations are one time jobs, typically executed right after a successful deployment. A migration could for example edit DynamoDB table fields or files in S3. To trigger the migrations use the api endpoint (refer to the smithy model or the ApiStack to find the endpoint) secured by a api key. Examine `./services/functions/migrations` and `./services/test/migrations` for better understanding.

Use the following command in your pipe to trigger the migration after a successful deploy:
`curl -X POST '<replace with api url>/v1/migrations' -H "Authorization: $(aws secretsmanager get-secret-value --secret-id migrations-api-key | jq -r '.SecretString | fromjson | .apiKey')"`

### CDK Assets

CDK is ramping up assets in S3 with each deploy which won't be deleted automatically. Refer to this [issue](https://github.com/aws/aws-cdk-rfcs/issues/64) for further information about the difficulties of deleting CDK assets and to track a future built in feature. This app makes use of a 3. party tool called [Toolkit cleaner](https://github.com/jogold/cloudstructs/blob/master/src/toolkit-cleaner) to determine and delete old and unused CDK assets. It is initialized in [CdkAssetsCleanupStack](./stacks/CdkAssetsCleanupStack.ts). Deploy it once per AWS Account. Either as scheduled job or execute it manually as needed in the cloud console via the step function menu.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface DynamoDBRepository<ItemType extends DDBItem, ReturnType> {
indexName?: string;
limit?: number;
cursor?: DynamoDB.Key;
sortOrder?: 'asc' | 'desc';
},
context: InvocationContext
) => TaskResult<AllDataResponse<ReturnType>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class DynamoDBRepositoryImpl
indexName?: string | undefined;
limit?: number;
cursor?: DynamoDB.Key;
sortOrder?: 'asc' | 'desc';
},
context: InvocationContext
): TaskResult<AllDataResponse<unknown>> => {
Expand All @@ -48,6 +49,12 @@ export class DynamoDBRepositoryImpl
expressionAttributeValues,
Limit: limit,
ExclusiveStartKey: cursor,
ScanIndexForward:
queryParams.sortOrder === undefined
? undefined
: queryParams.sortOrder === 'asc'
? true
: false,
}),
context.logger,
context.tracer
Expand Down
2 changes: 2 additions & 0 deletions services/common/dynamodb/tableKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export const TABLE_KEYS = {
BANKS_TABLE: 'BANKS_TABLE',

LOGINATTEMPTS_TABLE: 'LOGINATTEMPTS_TABLE',

MIGRATIONS_TABLE: 'MIGRATIONS_TABLE',
};
16 changes: 13 additions & 3 deletions services/common/gateway/handler/apiGatewayHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export abstract class ApiGatewayHandler<T extends InvocationContext> {
context: Context
): Either<HttpResponse, T>;

protected createInvocationContext(
static createInvocationContext(
context: Context
): InvocationContext | undefined {
const invocationLogger = this.initLogger(context);
Expand All @@ -69,7 +69,16 @@ export abstract class ApiGatewayHandler<T extends InvocationContext> {
};
}

private initLogger(context: Context) {
static createInvocationContextOrThrow(context: Context): InvocationContext {
const invocationContext = this.createInvocationContext(context);
if (invocationContext) {
return invocationContext;
} else {
throw new Error('Could not create invocation context');
}
}

private static initLogger(context: Context) {
const invocationLogger = buildLogger(context.functionName, logger);
return invocationLogger;
}
Expand Down Expand Up @@ -113,7 +122,8 @@ class ApiGatewayHandlerImpl extends ApiGatewayHandler<InvocationContext> {
_event: APIGatewayProxyEvent,
context: Context
): Either<HttpResponse, InvocationContext> {
const invocationContext = this.createInvocationContext(context);
const invocationContext =
ApiGatewayHandler.createInvocationContext(context);
if (invocationContext === undefined) {
return either.left({
statusCode: 500,
Expand Down
11 changes: 11 additions & 0 deletions services/common/injection/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { UserServiceImpl } from '@functions/users/domain/services/userServiceImp
import { S3Repository } from '@common/s3/domain/interfaces/s3Repository';
import { S3RepositoryImpl } from '@common/s3/infrastructure/s3RepositoryImpl';
import { S3RepositoryMock } from '@common/s3/infrastructure/s3RepositoryMock';
import { MigrationService } from '@functions/migrations/domain/interfaces/migrationService';
import { MigrationServiceImpl } from '@functions/migrations/domain/services/migrationServiceImpl';
import { MigrationRepository } from '@functions/migrations/domain/interfaces/migrationRepository';
import { MigrationRepositoryImpl } from '@functions/migrations/infrastructure/migrationRepositoryImpl';
import { SecretManagerRepository } from '@common/secretmanager/domain/interfaces/secretManagerRepository';
import { SecretManagerRepositoryImpl } from '@common/secretmanager/infrastructure/secretManagerRepositoryImpl';
import { SecretManagerRepositoryMock } from '@common/secretmanager/infrastructure/secretManagerRepositoryMock';
Expand Down Expand Up @@ -53,6 +57,13 @@ const bindStageIndependent = () => {
.bind<LoginAttemptsRepository>(INJECTABLES.LoginAttemptsRepository)
.to(LoginAttemptsRepositoryImpl);

injector
.bind<MigrationService>(INJECTABLES.MigrationService)
.to(MigrationServiceImpl);
injector
.bind<MigrationRepository>(INJECTABLES.MigrationRepository)
.to(MigrationRepositoryImpl);

injector.bind<UserService>(INJECTABLES.UserService).to(UserServiceImpl);
};

Expand Down
3 changes: 3 additions & 0 deletions services/common/injection/injectables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const INJECTABLES = {
LoginAttemptsService: Symbol.for('LoginAttemptsService'),
LoginAttemptsRepository: Symbol.for('LoginAttemptsRepository'),

MigrationService: Symbol.for('MigrationService'),
MigrationRepository: Symbol.for('MigrationRepository'),

UserService: Symbol.for('UserService'),
UserRepository: Symbol.for('UserRepository'),
};
Expand Down
2 changes: 1 addition & 1 deletion services/common/metrics/metricExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class MetricExporter {
export(metricData: MetricData, context: InvocationContext) {
if (!isTestStage(context.stage)) {
const metric: PutMetricDataCommandInput = {
Namespace: `Prolo-${context.stage}`,
Namespace: `Bootstrap-${context.stage}`,
MetricData: metricData,
};
context.logger.info(`Exporting metric`, `${prettyPrint(metric)}`);
Expand Down
4 changes: 4 additions & 0 deletions services/common/secretmanager/domain/models/apiKeySecret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ApiKeySecret {
apiKey: string;
version: number;
}
31 changes: 8 additions & 23 deletions services/common/sqs/application/sqsController.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { tracer } from '@common/gateway/handler/apiGatewayHandler';
import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler';
import { InvocationContext } from '@common/gateway/model/invocationContext';
import { bindInterfaces } from '@common/injection/bindings';
import { buildLogger } from '@common/logging/loggerFactory';
import { prettyPrint } from '@common/logging/prettyPrint';
import { MetricExporter } from '@common/metrics/metricExporter';
import { ErrorResult } from '@common/results/errorResult';
import { SQSBatchItemFailure, SQSHandler } from 'aws-lambda';
import { taskEither } from 'fp-ts';
Expand All @@ -23,26 +21,10 @@ export abstract class SQSController<Message> {
}

public handler: SQSHandler = async (event, context) => {
const invocationLogger = buildLogger(this.identifier);
const invocationContext =
ApiGatewayHandler.createInvocationContextOrThrow(context);

try {
invocationLogger.debug(
`Triggered ${this.identifier}`,
prettyPrint(event)
);
const stage = process.env.SST_STAGE;
if (stage === undefined) {
invocationLogger.error('No stage defined');
return undefined;
}
const invocationContext: InvocationContext = {
...context,
logger: invocationLogger,
metricExporter: new MetricExporter(),
stage: stage,
tracer: tracer,
};

const results = event.Records.map((record) => {
return pipe(
this.handleMessage(
Expand Down Expand Up @@ -73,13 +55,16 @@ export abstract class SQSController<Message> {
itemIdentifier: record?.messageId ?? '',
}));

invocationLogger.debug('batchItemFailures', prettyPrint(failures));
invocationContext.logger.debug(
'batchItemFailures',
prettyPrint(failures)
);

return {
batchItemFailures: failures,
};
} catch (error) {
invocationLogger.warn('Unknown error', prettyPrint(error));
invocationContext.logger.warn('Unknown error', prettyPrint(error));
throw error;
}
};
Expand Down
27 changes: 15 additions & 12 deletions services/functions/alarms/publisher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { getAwsSecret } from '@common/aws/secret';
import { buildLogger } from '@common/logging/loggerFactory';
import { prettyPrint } from '@common/logging/prettyPrint';
import { errorResults } from '@common/results/errorResults';
import { envEnum } from '@sst-env';
import { SNSEventRecord, SNSHandler } from 'aws-lambda';
import axios, { AxiosError } from 'axios';
import { taskEither } from 'fp-ts';
Expand All @@ -15,32 +13,37 @@ import {
} from './domain/models/alarmRecipients';
import { isDeployedStage, isTestStage } from 'stacks/common/isOfStage';
import { Logger } from '@aws-lambda-powertools/logger';
import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler';

export const handler: SNSHandler = async (event) => {
const logger = buildLogger('alarmPublisher');
const stage = process.env[envEnum.SST_STAGE];

if (stage === undefined) {
throw new Error('No stage');
}
export const handler: SNSHandler = async (event, context) => {
const invocationContext =
ApiGatewayHandler.createInvocationContextOrThrow(context);

await pipe(
getAwsSecret<AlarmRecipients>('alarm-recipients', logger),
getAwsSecret<AlarmRecipients>(
'alarm-recipients',
invocationContext.logger
),
taskEither.chain((recipients) =>
taskEither.tryCatch(
async () => {
await Promise.all(
event.Records.map((record) =>
recipients.webhooks.map((webhook) =>
sendToWebhook(record, webhook, stage, logger)
sendToWebhook(
record,
webhook,
invocationContext.stage,
invocationContext.logger
)
)
)
);
return;
},
(error) => {
const axiosError = error as AxiosError;
logger.error(
invocationContext.logger.error(
'Error sending alarms to webhooks',
`${axiosError.response?.status} - ${prettyPrint(
axiosError.response?.data
Expand Down
76 changes: 76 additions & 0 deletions services/functions/auth/application/handler/apiKeyAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { BaseController } from '@common/application/baseController';
import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler';
import { InvocationContext } from '@common/gateway/model/invocationContext';
import { lazyInject } from '@common/injection/decorator';
import { INJECTABLES } from '@common/injection/injectables';
import { StatusCodes, errorResults } from '@common/results/errorResults';
import { TaskResult } from '@common/results/taskResult';
import { SecretManagerRepository } from '@common/secretmanager/domain/interfaces/secretManagerRepository';
import { ApiKeySecret } from '@common/secretmanager/domain/models/apiKeySecret';
import {
APIGatewayAuthorizerResult,
APIGatewayRequestAuthorizerHandler,
} from 'aws-lambda';
import { taskEither } from 'fp-ts';
import { isLeft } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/function';

export const handler: APIGatewayRequestAuthorizerHandler = async (
event,
context
) => {
const ctx = ApiGatewayHandler.createInvocationContextOrThrow(context);

const apiToken =
event.headers?.authorization ?? event.headers?.Authorization ?? '';

const result = await apiKeyAuthorizer.auth(apiToken, ctx)();
if (isLeft(result)) {
if (result.left.statusCode === StatusCodes.UNAUTHORIZED) {
throw new Error('Unauthorized');
} else {
throw new Error('Internal Server Error');
}
} else {
const authResponse: APIGatewayAuthorizerResult = {
principalId: 'api-key-auth',
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: 'ALLOW',
Resource: event.methodArn,
},
],
},
context: {},
};

return authResponse;
}
};

class ApiKeyAuthorizer extends BaseController {
@lazyInject(INJECTABLES.SecretManagerRepository)
private secretManagerRepository!: SecretManagerRepository;

auth(apiToken: string, context: InvocationContext): TaskResult<void> {
return pipe(
this.secretManagerRepository.get<ApiKeySecret>(
'migrations-api-key',
context
),
taskEither.chain((secret) => {
if (secret.apiKey === apiToken) {
return taskEither.right(void 0);
} else {
return taskEither.left(
errorResults.unauthorized('Invalid api key')
);
}
})
);
}
}
const apiKeyAuthorizer = new ApiKeyAuthorizer();
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { buildLogger } from '@common/logging/loggerFactory';
import {
APIGatewayAuthorizerResult,
APIGatewayRequestAuthorizerHandler,
} from 'aws-lambda';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import jwt_decode from 'jwt-decode';
import { prettyPrint } from '@common/logging/prettyPrint';
import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler';

export const handler: APIGatewayRequestAuthorizerHandler = async (
event,
context
) => {
const logger = buildLogger('CognitoLambdaAuthorizer');
const invocationContext =
ApiGatewayHandler.createInvocationContextOrThrow(context);
const { logger } = invocationContext;
logger.addContext(context);

const authHeader =
Expand Down
23 changes: 7 additions & 16 deletions services/functions/auth/application/handler/postAuthentication.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,18 @@
import { buildLogger } from '@common/logging/loggerFactory';
import { MetricExporter } from '@common/metrics/metricExporter';
import { buildTracer } from '@common/tracing/tracerFactory';
import { envEnum } from '@sst-env';
import { PostAuthenticationTriggerHandler } from 'aws-lambda';
import { authController } from '../../../loginAttempts/application/loginAttemptsController';
import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler';

export const handler: PostAuthenticationTriggerHandler = async (
event,
context
) => {
const stage = process.env[envEnum.SST_STAGE];
const invocationContext =
ApiGatewayHandler.createInvocationContextOrThrow(context);

if (!stage) {
throw new Error('No stage');
}

const result = await authController.postAuthentication(event, {
...context,
logger: buildLogger('postAuthentication'),
tracer: buildTracer('postAuthentication'),
metricExporter: new MetricExporter(),
stage: stage,
});
const result = await authController.postAuthentication(
event,
invocationContext
);

if (result != 200) {
throw new Error(`Failed with status ${result.toString()}`);
Expand Down
Loading