diff --git a/readme.md b/readme.md index b2d8e62..7e9fe9e 100644 --- a/readme.md +++ b/readme.md @@ -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 '/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. diff --git a/services/common/dynamodb/domain/interfaces/dynamoDbRepository.ts b/services/common/dynamodb/domain/interfaces/dynamoDbRepository.ts index f5eac47..49f8fc8 100644 --- a/services/common/dynamodb/domain/interfaces/dynamoDbRepository.ts +++ b/services/common/dynamodb/domain/interfaces/dynamoDbRepository.ts @@ -24,6 +24,7 @@ export interface DynamoDBRepository { indexName?: string; limit?: number; cursor?: DynamoDB.Key; + sortOrder?: 'asc' | 'desc'; }, context: InvocationContext ) => TaskResult>; diff --git a/services/common/dynamodb/infrastructure/dynamoDBRepositoryImpl.ts b/services/common/dynamodb/infrastructure/dynamoDBRepositoryImpl.ts index b1e272b..c00bb62 100644 --- a/services/common/dynamodb/infrastructure/dynamoDBRepositoryImpl.ts +++ b/services/common/dynamodb/infrastructure/dynamoDBRepositoryImpl.ts @@ -27,6 +27,7 @@ export class DynamoDBRepositoryImpl indexName?: string | undefined; limit?: number; cursor?: DynamoDB.Key; + sortOrder?: 'asc' | 'desc'; }, context: InvocationContext ): TaskResult> => { @@ -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 diff --git a/services/common/dynamodb/tableKeys.ts b/services/common/dynamodb/tableKeys.ts index e911f6f..6884641 100644 --- a/services/common/dynamodb/tableKeys.ts +++ b/services/common/dynamodb/tableKeys.ts @@ -2,4 +2,6 @@ export const TABLE_KEYS = { BANKS_TABLE: 'BANKS_TABLE', LOGINATTEMPTS_TABLE: 'LOGINATTEMPTS_TABLE', + + MIGRATIONS_TABLE: 'MIGRATIONS_TABLE', }; diff --git a/services/common/gateway/handler/apiGatewayHandler.ts b/services/common/gateway/handler/apiGatewayHandler.ts index 5466a98..88130b5 100644 --- a/services/common/gateway/handler/apiGatewayHandler.ts +++ b/services/common/gateway/handler/apiGatewayHandler.ts @@ -50,7 +50,7 @@ export abstract class ApiGatewayHandler { context: Context ): Either; - protected createInvocationContext( + static createInvocationContext( context: Context ): InvocationContext | undefined { const invocationLogger = this.initLogger(context); @@ -69,7 +69,16 @@ export abstract class ApiGatewayHandler { }; } - 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; } @@ -113,7 +122,8 @@ class ApiGatewayHandlerImpl extends ApiGatewayHandler { _event: APIGatewayProxyEvent, context: Context ): Either { - const invocationContext = this.createInvocationContext(context); + const invocationContext = + ApiGatewayHandler.createInvocationContext(context); if (invocationContext === undefined) { return either.left({ statusCode: 500, diff --git a/services/common/injection/bindings.ts b/services/common/injection/bindings.ts index f072027..b0b68fb 100644 --- a/services/common/injection/bindings.ts +++ b/services/common/injection/bindings.ts @@ -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'; @@ -53,6 +57,13 @@ const bindStageIndependent = () => { .bind(INJECTABLES.LoginAttemptsRepository) .to(LoginAttemptsRepositoryImpl); + injector + .bind(INJECTABLES.MigrationService) + .to(MigrationServiceImpl); + injector + .bind(INJECTABLES.MigrationRepository) + .to(MigrationRepositoryImpl); + injector.bind(INJECTABLES.UserService).to(UserServiceImpl); }; diff --git a/services/common/injection/injectables.ts b/services/common/injection/injectables.ts index dec2276..98bab2b 100644 --- a/services/common/injection/injectables.ts +++ b/services/common/injection/injectables.ts @@ -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'), }; diff --git a/services/common/metrics/metricExporter.ts b/services/common/metrics/metricExporter.ts index 03e8617..9ee83f6 100644 --- a/services/common/metrics/metricExporter.ts +++ b/services/common/metrics/metricExporter.ts @@ -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)}`); diff --git a/services/common/secretmanager/domain/models/apiKeySecret.ts b/services/common/secretmanager/domain/models/apiKeySecret.ts new file mode 100644 index 0000000..22c61a2 --- /dev/null +++ b/services/common/secretmanager/domain/models/apiKeySecret.ts @@ -0,0 +1,4 @@ +export interface ApiKeySecret { + apiKey: string; + version: number; +} diff --git a/services/common/sqs/application/sqsController.ts b/services/common/sqs/application/sqsController.ts index b776daf..d975699 100644 --- a/services/common/sqs/application/sqsController.ts +++ b/services/common/sqs/application/sqsController.ts @@ -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'; @@ -23,26 +21,10 @@ export abstract class SQSController { } 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( @@ -73,13 +55,16 @@ export abstract class SQSController { 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; } }; diff --git a/services/functions/alarms/publisher.ts b/services/functions/alarms/publisher.ts index 42117e3..2ddfc54 100644 --- a/services/functions/alarms/publisher.ts +++ b/services/functions/alarms/publisher.ts @@ -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'; @@ -15,24 +13,29 @@ 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('alarm-recipients', logger), + getAwsSecret( + '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 + ) ) ) ); @@ -40,7 +43,7 @@ export const handler: SNSHandler = async (event) => { }, (error) => { const axiosError = error as AxiosError; - logger.error( + invocationContext.logger.error( 'Error sending alarms to webhooks', `${axiosError.response?.status} - ${prettyPrint( axiosError.response?.data diff --git a/services/functions/auth/application/handler/apiKeyAuth.ts b/services/functions/auth/application/handler/apiKeyAuth.ts new file mode 100644 index 0000000..76b0ea9 --- /dev/null +++ b/services/functions/auth/application/handler/apiKeyAuth.ts @@ -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 { + return pipe( + this.secretManagerRepository.get( + '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(); diff --git a/services/functions/auth/application/handler/cognitoLambdaAuthorizer.ts b/services/functions/auth/application/handler/cognitoLambdaAuthorizer.ts index b80b82d..b26bfe6 100644 --- a/services/functions/auth/application/handler/cognitoLambdaAuthorizer.ts +++ b/services/functions/auth/application/handler/cognitoLambdaAuthorizer.ts @@ -1,4 +1,3 @@ -import { buildLogger } from '@common/logging/loggerFactory'; import { APIGatewayAuthorizerResult, APIGatewayRequestAuthorizerHandler, @@ -6,12 +5,15 @@ import { 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 = diff --git a/services/functions/auth/application/handler/postAuthentication.ts b/services/functions/auth/application/handler/postAuthentication.ts index 621b510..e1e35f4 100644 --- a/services/functions/auth/application/handler/postAuthentication.ts +++ b/services/functions/auth/application/handler/postAuthentication.ts @@ -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()}`); diff --git a/services/functions/auth/application/handler/preAuthentication.ts b/services/functions/auth/application/handler/preAuthentication.ts index 421b5ae..b2e7fbd 100644 --- a/services/functions/auth/application/handler/preAuthentication.ts +++ b/services/functions/auth/application/handler/preAuthentication.ts @@ -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 { PreAuthenticationTriggerHandler } from 'aws-lambda'; import { authController } from '../../../loginAttempts/application/loginAttemptsController'; +import { ApiGatewayHandler } from '@common/gateway/handler/apiGatewayHandler'; export const handler: PreAuthenticationTriggerHandler = 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.preAuthentication(event, { - ...context, - logger: buildLogger('preAuthentication'), - tracer: buildTracer('preAuthentication'), - metricExporter: new MetricExporter(), - stage: stage, - }); + const result = await authController.preAuthentication( + event, + invocationContext + ); if (result != 200) { throw new Error(`Failed with status ${result.toString()}`); diff --git a/services/functions/banks/domain/interfaces/bankRepository.ts b/services/functions/banks/domain/interfaces/bankRepository.ts index 5872e9d..9b08c6f 100644 --- a/services/functions/banks/domain/interfaces/bankRepository.ts +++ b/services/functions/banks/domain/interfaces/bankRepository.ts @@ -1,18 +1,15 @@ -import { InvocationContextWithUser } from '@common/gateway/model/invocationContextWithUser'; import { TaskResult } from '@common/results/taskResult'; import { Bank, BankListOutput } from '../model/bank'; import { ListBanksInput } from '../model/listBanksInput'; +import { InvocationContext } from '@common/gateway/model/invocationContext'; export interface BankRepository { - create(bank: Bank, context: InvocationContextWithUser): TaskResult; - update(bank: Bank, context: InvocationContextWithUser): TaskResult; - get(bankId: string, context: InvocationContextWithUser): TaskResult; + create(bank: Bank, context: InvocationContext): TaskResult; + update(bank: Bank, context: InvocationContext): TaskResult; + get(bankId: string, context: InvocationContext): TaskResult; list( input: ListBanksInput, - context: InvocationContextWithUser + context: InvocationContext ): TaskResult; - delete( - bankId: string, - context: InvocationContextWithUser - ): TaskResult; + delete(bankId: string, context: InvocationContext): TaskResult; } diff --git a/services/functions/migrations/application/handler/trigger.ts b/services/functions/migrations/application/handler/trigger.ts new file mode 100644 index 0000000..3ddd99b --- /dev/null +++ b/services/functions/migrations/application/handler/trigger.ts @@ -0,0 +1,14 @@ +import { APIGatewayProxyHandler } from 'aws-lambda'; +import { + apiGatewayHandler, + tracer, +} from '@common/gateway/handler/apiGatewayHandler'; +import { traceOperation } from '@common/tracing/traceLifecycle'; +import { getTriggerMigrationsHandler } from '@api'; +import { migrationController } from '../migrationController'; + +export const handler: APIGatewayProxyHandler = apiGatewayHandler.handle( + getTriggerMigrationsHandler( + traceOperation(migrationController.trigger, tracer) + ) +); diff --git a/services/functions/migrations/application/migrationController.ts b/services/functions/migrations/application/migrationController.ts new file mode 100644 index 0000000..0bdcf48 --- /dev/null +++ b/services/functions/migrations/application/migrationController.ts @@ -0,0 +1,36 @@ +import { + TriggerMigrationsServerInput, + TriggerMigrationsServerOutput, +} from '@api'; +import { Operation } from '@aws-smithy/server-common'; +import { BaseController } from '@common/application/baseController'; +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { lazyInject } from '@common/injection/decorator'; +import { INJECTABLES } from '@common/injection/injectables'; +import { MigrationService } from '../domain/interfaces/migrationService'; +import { prettyPrint } from '@common/logging/prettyPrint'; +import { pipe } from 'fp-ts/lib/function'; +import { taskEither } from 'fp-ts'; + +class MigrationController extends BaseController { + @lazyInject(INJECTABLES.MigrationService) + private migrationService!: MigrationService; + + public trigger: Operation< + TriggerMigrationsServerInput, + TriggerMigrationsServerOutput, + InvocationContext + > = async (input, context) => { + const { logger } = context; + logger.addContext(context); + logger.logEventIfEnabled(prettyPrint(input)); + + return pipe( + this.migrationService.triggerMigrations(context), + taskEither.map(() => ({})), + this.throwOnLeft(logger) + ); + }; +} + +export const migrationController = new MigrationController(); diff --git a/services/functions/migrations/domain/common/migrate.ts b/services/functions/migrations/domain/common/migrate.ts new file mode 100644 index 0000000..9d52e82 --- /dev/null +++ b/services/functions/migrations/domain/common/migrate.ts @@ -0,0 +1,292 @@ +import { pipe } from 'fp-ts/lib/function'; +import { Migration } from '../models/migration'; +import { task, taskEither } from 'fp-ts'; +import { errorResults } from '@common/results/errorResults'; +import { prettyPrint } from '@common/logging/prettyPrint'; +import { TaskResult } from '@common/results/taskResult'; +import { MigrationJob } from '../models/migrationJob'; +import { formatISO } from 'date-fns'; +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { ErrorResult } from '@common/results/errorResult'; +import * as _ from 'lodash'; +import { isRight } from 'fp-ts/lib/Either'; + +export interface MigrationProps { + allMigrations: MigrationJob[]; + latestMigrationInProgress: Migration | undefined; + latestSuccessfulMigration: Migration | undefined; + onMigrationProgress: ( + migration: Migration, + context: InvocationContext + ) => TaskResult; +} + +interface MigrationsResult { + /** + * Last migration that got executed. + * If it failed all following migrations marked as failed without execution + */ + lastExecutedMigration: Migration | undefined; + /** + * Results of all migrations. + * A migration might be marked as failed without being executed. This happens if a previous migration failed + */ + migrationResults: Migration[]; +} + +interface MigrationsPartialResult { + lastExecutedMigration: Migration; + migrationResults: Migration[]; +} + +export const migrate = (props: MigrationProps, context: InvocationContext) => { + return pipe( + validateForMigrationInProgress( + props.latestMigrationInProgress, + context + ), + taskEither.chain(() => + getNotYetExecuted( + props.allMigrations, + props.latestSuccessfulMigration?.id, + context + ) + ), + taskEither.bindTo('notYetExecuted'), + taskEither.map(({ notYetExecuted }) => { + context.logger.info( + `Not yet executed migrations: ${ + notYetExecuted.length > 0 + ? notYetExecuted.map((m) => m.id).join(', ') + : '-' + }` + ); + const migrations = notYetExecuted + .sort((a, b) => (a.id > b.id ? 1 : -1)) + .map((job) => { + const migration: Migration = { + id: job.id, + startedAt: formatISO(new Date()), + status: 'IN_PROGRESS', + finishedAt: undefined, + }; + return { migration, job: job.migration }; + }); + return migrations; + }), + taskEither.chain((migrations) => + runMigrationsSequentially( + migrations, + props.onMigrationProgress, + context + ) + ) + ); +}; + +export const MIGRATION_ALREADY_IN_PROGRESS_MSG = + 'At least one migration is still running!'; +const validateForMigrationInProgress = ( + migrationInProgress: Migration | undefined, + context: InvocationContext +): TaskResult => { + if (migrationInProgress) { + context.logger.info(MIGRATION_ALREADY_IN_PROGRESS_MSG); + return taskEither.left( + errorResults.conflict(MIGRATION_ALREADY_IN_PROGRESS_MSG) + ); + } else { + return taskEither.right(void 0); + } +}; + +const runMigrationsSequentially = ( + migrations: { + migration: Migration; + job: (context: InvocationContext) => TaskResult; + }[], + onMigrationProgress: ( + migration: Migration, + context: InvocationContext + ) => TaskResult, + context: InvocationContext +): TaskResult => { + return migrations.reduce>( + (prevResults, migration) => { + return pipe( + prevResults, + taskEither.chain( + ({ lastExecutedMigration, migrationResults }) => { + if ( + lastExecutedMigration === undefined || + lastExecutedMigration.status === 'SUCCESS' + ) { + return executeMigration( + migration, + migrationResults, + onMigrationProgress, + context + ); + } else { + return consecutivelyFailMigration( + migration, + lastExecutedMigration, + migrationResults, + onMigrationProgress, + context + ); + } + } + ) + ); + }, + taskEither.right({ + lastExecutedMigration: undefined, + migrationResults: [], + }) + ); +}; + +const executeMigration = ( + migration: { + migration: Migration; + job: (context: InvocationContext) => TaskResult; + }, + migrationResults: Migration[], + onMigrationProgress: ( + migration: Migration, + context: InvocationContext + ) => TaskResult, + context: InvocationContext +): TaskResult => { + context.logger.info(`Executing migration ${migration.migration.id}`); + return pipe( + onMigrationProgress(migration.migration, context), + taskEither.chain(() => + taskEither.fromTask( + pipe( + taskEither.tryCatch( + async () => { + const result = await migration.job(context)(); + if (isRight(result)) { + return result.right; + } else { + throw result.left; + } + }, + (error) => { + const handledError = error as ErrorResult; + if ( + handledError.statusCode !== undefined && + handledError.body !== undefined + ) { + context.logger.warn( + `Handled error while executing migration ${migration.migration.id}`, + prettyPrint(error) + ); + return handledError; + } else { + const msg = `Unhandled error while executing migration ${migration.migration.id}`; + context.logger.warn(msg, prettyPrint(error)); + return errorResults.internalServerError(msg); + } + } + ), + taskEither.match( + () => ({ + ...migration.migration, + finishedAt: formatISO(new Date()), + status: 'FAILED', + }), + () => ({ + ...migration.migration, + finishedAt: formatISO(new Date()), + status: 'SUCCESS', + }) + ), + task.map((migration) => ({ + lastExecutedMigration: migration, + migrationResults: migrationResults.concat([migration]), + })) + ) + ) + ), + taskEither.chainFirst((result) => { + context.logger.info( + `Migration ${migration.migration.id} resolved with status ${result.lastExecutedMigration.status}` + ); + return onMigrationProgress(result.lastExecutedMigration, context); + }) + ); +}; + +const consecutivelyFailMigration = ( + migration: { + migration: Migration; + job: (context: InvocationContext) => TaskResult; + }, + lastExecutedMigration: Migration, + migrationResults: Migration[], + onMigrationProgress: ( + migration: Migration, + context: InvocationContext + ) => TaskResult, + context: InvocationContext +): TaskResult => { + context.logger.info( + `Consecutively fail migration ${migration.migration.id} because migration ${lastExecutedMigration.id} failed` + ); + const processedMigration: Migration = { + ...migration.migration, + finishedAt: formatISO(new Date()), + status: 'FAILED', + }; + return pipe( + onMigrationProgress(processedMigration, context), + taskEither.map(() => ({ + lastExecutedMigration: lastExecutedMigration, + migrationResults: migrationResults.concat([processedMigration]), + })) + ); +}; + +const getNotYetExecuted = ( + migrations: MigrationJob[], + latestExecution: number | undefined, + context: InvocationContext +): TaskResult => { + return pipe( + validateForDuplicates(migrations, context), + taskEither.chain(() => { + if (latestExecution) { + return taskEither.right( + migrations.filter( + (migration) => migration.id > latestExecution + ) + ); + } else { + return taskEither.right(migrations); + } + }) + ); +}; + +export const DUPLICATE_MIGRATION_IDS_MSG = 'Duplicate migration ids found!'; +const validateForDuplicates = ( + migrations: MigrationJob[], + context: InvocationContext +): TaskResult => { + const ids = migrations.map((migration) => migration.id); + const uniqueIds = _.uniq(ids); + if (uniqueIds.length !== ids.length) { + context.logger.warn( + DUPLICATE_MIGRATION_IDS_MSG, + `ids: ${prettyPrint(ids)}` + ); + return taskEither.left( + errorResults.conflict(DUPLICATE_MIGRATION_IDS_MSG) + ); + } else { + return taskEither.right(void 0); + } +}; diff --git a/services/functions/migrations/domain/interfaces/migrationRepository.ts b/services/functions/migrations/domain/interfaces/migrationRepository.ts new file mode 100644 index 0000000..ff2c4d2 --- /dev/null +++ b/services/functions/migrations/domain/interfaces/migrationRepository.ts @@ -0,0 +1,13 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { Migration } from '../models/migration'; +import { TaskResult } from '@common/results/taskResult'; + +export interface MigrationRepository { + upsert(migration: Migration, context: InvocationContext): TaskResult; + getLatestSuccessful( + context: InvocationContext + ): TaskResult; + getLatestInProgress( + context: InvocationContext + ): TaskResult; +} diff --git a/services/functions/migrations/domain/interfaces/migrationService.ts b/services/functions/migrations/domain/interfaces/migrationService.ts new file mode 100644 index 0000000..d7187b0 --- /dev/null +++ b/services/functions/migrations/domain/interfaces/migrationService.ts @@ -0,0 +1,6 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { TaskResult } from '@common/results/taskResult'; + +export interface MigrationService { + triggerMigrations(context: InvocationContext): TaskResult; +} diff --git a/services/functions/migrations/domain/models/migration.ts b/services/functions/migrations/domain/models/migration.ts new file mode 100644 index 0000000..97a13ab --- /dev/null +++ b/services/functions/migrations/domain/models/migration.ts @@ -0,0 +1,16 @@ +import { DDBKey } from '@common/dynamodb/domain/interfaces/dynamoDbRepository'; + +export type MigrationStatus = 'SUCCESS' | 'FAILED' | 'IN_PROGRESS'; + +export interface Migration { + id: number; + status: MigrationStatus; + startedAt: string; + finishedAt: string | undefined; +} + +export type MigrationDDB = Omit; +export type MigrationDDBItem = Omit & { + id: DDBKey; + status: DDBKey; +}; diff --git a/services/functions/migrations/domain/models/migrationJob.ts b/services/functions/migrations/domain/models/migrationJob.ts new file mode 100644 index 0000000..dcdd5a4 --- /dev/null +++ b/services/functions/migrations/domain/models/migrationJob.ts @@ -0,0 +1,9 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { TaskResult } from '@common/results/taskResult'; + +export type Job = (context: InvocationContext) => TaskResult; + +export interface MigrationJob { + id: number; + migration: Job; +} diff --git a/services/functions/migrations/domain/services/migrationServiceImpl.ts b/services/functions/migrations/domain/services/migrationServiceImpl.ts new file mode 100644 index 0000000..0744f76 --- /dev/null +++ b/services/functions/migrations/domain/services/migrationServiceImpl.ts @@ -0,0 +1,60 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { TaskResult } from '@common/results/taskResult'; +import { MigrationService } from '../interfaces/migrationService'; +import { inject, injectable } from 'inversify'; +import { INJECTABLES } from '@common/injection/injectables'; +import { MigrationRepository } from '../interfaces/migrationRepository'; +import { pipe } from 'fp-ts/lib/function'; +import { taskEither } from 'fp-ts'; +import { migrate } from '../common/migrate'; +import { migrations } from './migrations'; +import { errorResults } from '@common/results/errorResults'; +import { Migration } from '../models/migration'; + +@injectable() +export class MigrationServiceImpl implements MigrationService { + @inject(INJECTABLES.MigrationRepository) + private migrationRepository!: MigrationRepository; + + triggerMigrations(context: InvocationContext): TaskResult { + return pipe( + this.migrationRepository.getLatestInProgress(context), + taskEither.bindTo('latestMigrationInProgress'), + taskEither.bind('latestSuccessfulMigration', () => + this.migrationRepository.getLatestInProgress(context) + ), + taskEither.chain( + ({ latestMigrationInProgress, latestSuccessfulMigration }) => + migrate( + { + allMigrations: migrations, + latestMigrationInProgress: + latestMigrationInProgress, + latestSuccessfulMigration: + latestSuccessfulMigration, + onMigrationProgress: ( + migration: Migration, + context: InvocationContext + ) => + this.migrationRepository.upsert( + migration, + context + ), + }, + context + ) + ), + taskEither.chain((result) => { + if (result.lastExecutedMigration?.status === 'SUCCESS') { + return taskEither.right(void 0); + } else { + return taskEither.left( + errorResults.internalServerError( + 'Some migrations failed' + ) + ); + } + }) + ); + } +} diff --git a/services/functions/migrations/domain/services/migrations.ts b/services/functions/migrations/domain/services/migrations.ts new file mode 100644 index 0000000..c2bc19d --- /dev/null +++ b/services/functions/migrations/domain/services/migrations.ts @@ -0,0 +1,11 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { MigrationJob } from '../models/migrationJob'; +import { exampleMigrator } from './migrations/ExampleMigrator'; + +export const migrations: MigrationJob[] = [ + { + id: 1, + migration: (context: InvocationContext) => + exampleMigrator.migrate(context), + }, +]; diff --git a/services/functions/migrations/domain/services/migrations/ExampleMigrator.ts b/services/functions/migrations/domain/services/migrations/ExampleMigrator.ts new file mode 100644 index 0000000..40dd227 --- /dev/null +++ b/services/functions/migrations/domain/services/migrations/ExampleMigrator.ts @@ -0,0 +1,29 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { lazyInject } from '@common/injection/decorator'; +import { INJECTABLES } from '@common/injection/injectables'; +import { TaskResult } from '@common/results/taskResult'; +import { BankRepository } from '@functions/banks/domain/interfaces/bankRepository'; +import { taskEither } from 'fp-ts'; +import { pipe } from 'fp-ts/lib/function'; + +class ExampleMigrator { + @lazyInject(INJECTABLES.BankRepository) + private bankRepository!: BankRepository; + + migrate(context: InvocationContext): TaskResult { + return pipe( + this.bankRepository.list({ queryAll: true }, context), + taskEither.chain((banks) => + taskEither.sequenceArray( + banks.items.map((bank) => + // e.g. update banks to add a new field + this.bankRepository.update(bank, context) + ) + ) + ), + taskEither.map(() => void 0) + ); + } +} + +export const exampleMigrator = new ExampleMigrator(); diff --git a/services/functions/migrations/infrastructure/mapper/ddbToDomain.ts b/services/functions/migrations/infrastructure/mapper/ddbToDomain.ts new file mode 100644 index 0000000..7949a10 --- /dev/null +++ b/services/functions/migrations/infrastructure/mapper/ddbToDomain.ts @@ -0,0 +1,13 @@ +import { + Migration, + MigrationDDB, +} from '@functions/migrations/domain/models/migration'; + +export const mapMigrationDDBToDomain = ( + migration: MigrationDDB +): Migration => ({ + id: migration.id, + status: migration.status, + startedAt: migration.startedAt, + finishedAt: migration.finishedAt, +}); diff --git a/services/functions/migrations/infrastructure/mapper/domainToDDB.ts b/services/functions/migrations/infrastructure/mapper/domainToDDB.ts new file mode 100644 index 0000000..51038da --- /dev/null +++ b/services/functions/migrations/infrastructure/mapper/domainToDDB.ts @@ -0,0 +1,12 @@ +import { DDBKey } from '@common/dynamodb/domain/interfaces/dynamoDbRepository'; +import { + Migration, + MigrationDDBItem, +} from '@functions/migrations/domain/models/migration'; + +export const mapMigrationToDDB = (migration: Migration): MigrationDDBItem => ({ + id: new DDBKey(migration.id), + startedAt: migration.startedAt, + finishedAt: migration.finishedAt, + status: new DDBKey(migration.status), +}); diff --git a/services/functions/migrations/infrastructure/migrationRepositoryImpl.ts b/services/functions/migrations/infrastructure/migrationRepositoryImpl.ts new file mode 100644 index 0000000..014ece9 --- /dev/null +++ b/services/functions/migrations/infrastructure/migrationRepositoryImpl.ts @@ -0,0 +1,92 @@ +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { TaskResult } from '@common/results/taskResult'; +import { MigrationRepository } from '../domain/interfaces/migrationRepository'; +import { + Migration, + MigrationDDB, + MigrationDDBItem, + MigrationStatus, +} from '../domain/models/migration'; +import { inject, injectable } from 'inversify'; +import { INJECTABLES } from '@common/injection/injectables'; +import { + DDBKey, + DynamoDBRepository, +} from '@common/dynamodb/domain/interfaces/dynamoDbRepository'; +import { pipe } from 'fp-ts/lib/function'; +import { TABLE_KEYS } from '@common/dynamodb/tableKeys'; +import { mapMigrationToDDB } from './mapper/domainToDDB'; +import { taskEither } from 'fp-ts'; +import { mapMigrationDDBToDomain } from './mapper/ddbToDomain'; + +@injectable() +export class MigrationRepositoryImpl implements MigrationRepository { + @inject(INJECTABLES.DynamoDBRepository) + private dynamoDBRepository!: DynamoDBRepository< + MigrationDDBItem, + MigrationDDB + >; + + private tableKey: string = TABLE_KEYS.MIGRATIONS_TABLE; + + upsert(migration: Migration, context: InvocationContext): TaskResult { + return pipe( + this.dynamoDBRepository.upsert( + { + tableKey: this.tableKey, + items: [mapMigrationToDDB(migration)], + }, + context + ), + taskEither.map(() => void 0) + ); + } + + getLatestSuccessful( + context: InvocationContext + ): TaskResult { + return pipe( + this.dynamoDBRepository.get( + { + tableKey: this.tableKey, + itemKeys: { + status: new DDBKey('SUCCESS'), + }, + limit: 1, + indexName: 'statusIndex', + sortOrder: 'desc', + }, + context + ), + taskEither.map((result) => + result.items.length > 0 + ? mapMigrationDDBToDomain(result.items[0]) + : undefined + ) + ); + } + + getLatestInProgress( + context: InvocationContext + ): TaskResult { + return pipe( + this.dynamoDBRepository.get( + { + tableKey: this.tableKey, + itemKeys: { + status: new DDBKey('IN_PROGRESS'), + }, + limit: 1, + indexName: 'statusIndex', + sortOrder: 'desc', + }, + context + ), + taskEither.map((result) => + result.items.length > 0 + ? mapMigrationDDBToDomain(result.items[0]) + : undefined + ) + ); + } +} diff --git a/services/test/migrations/migrateFlow.test.ts b/services/test/migrations/migrateFlow.test.ts new file mode 100644 index 0000000..6ef11fd --- /dev/null +++ b/services/test/migrations/migrateFlow.test.ts @@ -0,0 +1,162 @@ +import { Tracer } from '@aws-lambda-powertools/tracer'; +import { InvocationContext } from '@common/gateway/model/invocationContext'; +import { buildLogger } from '@common/logging/loggerFactory'; +import { MetricExporter } from '@common/metrics/metricExporter'; +import { errorResults } from '@common/results/errorResults'; +import { + DUPLICATE_MIGRATION_IDS_MSG, + MIGRATION_ALREADY_IN_PROGRESS_MSG, + migrate, +} from '@functions/migrations/domain/common/migrate'; +import { MigrationJob } from '@functions/migrations/domain/models/migrationJob'; +import { fail } from 'assert'; +import { taskEither } from 'fp-ts'; +import { isLeft, isRight } from 'fp-ts/lib/Either'; +import { describe, expect, it } from 'vitest'; + +const successfulMigration = (id: number): MigrationJob => ({ + id: id, + migration: () => taskEither.right(void 0), +}); +const failedMigration = (id: number): MigrationJob => ({ + id: id, + migration: () => taskEither.left(errorResults.internalServerError('')), +}); + +const onMigrationProgress = () => { + return taskEither.right(void 0); +}; + +const context: InvocationContext = { + awsRequestId: '', + callbackWaitsForEmptyEventLoop: false, + functionName: '', + functionVersion: '', + invokedFunctionArn: '', + getRemainingTimeInMillis: () => 0, + logGroupName: '', + logStreamName: '', + memoryLimitInMB: '', + stage: 'test', + logger: buildLogger('test'), + metricExporter: new MetricExporter(), + tracer: new Tracer(), + done: () => void 0, + fail: () => void 0, + succeed: () => void 0, +}; + +describe('migration flow', () => { + it('should migrate 2', async () => { + const result = await migrate( + { + allMigrations: [successfulMigration(1), successfulMigration(2)], + latestMigrationInProgress: undefined, + latestSuccessfulMigration: undefined, + onMigrationProgress: onMigrationProgress, + }, + context + )(); + if (isRight(result)) { + expect(result.right.lastExecutedMigration?.id).toBe(2); + expect(result.right.lastExecutedMigration?.status).toBe('SUCCESS'); + expect(result.right.migrationResults.length).toBe(2); + } else { + fail('Migration should not fail'); + } + }); + + it('should migrate only one because first one is already migrated', async () => { + const result = await migrate( + { + allMigrations: [successfulMigration(1), successfulMigration(2)], + latestMigrationInProgress: undefined, + latestSuccessfulMigration: { + id: 1, + startedAt: '', + finishedAt: '', + status: 'SUCCESS', + }, + onMigrationProgress: onMigrationProgress, + }, + context + )(); + if (isRight(result)) { + expect(result.right.lastExecutedMigration?.id).toBe(2); + expect(result.right.lastExecutedMigration?.status).toBe('SUCCESS'); + expect(result.right.migrationResults.length).toBe(1); + } else { + fail('Migration should not fail'); + } + }); + + it('should fail consecutive migrations', async () => { + const result = await migrate( + { + allMigrations: [ + successfulMigration(1), + failedMigration(2), + successfulMigration(3), + ], + latestMigrationInProgress: undefined, + latestSuccessfulMigration: undefined, + onMigrationProgress: onMigrationProgress, + }, + context + )(); + if (isRight(result)) { + expect(result.right.lastExecutedMigration?.id).toBe(2); + expect(result.right.lastExecutedMigration?.status).toBe('FAILED'); + expect(result.right.migrationResults.length).toBe(3); + expect(result.right.migrationResults[0].id).toBe(1); + expect(result.right.migrationResults[0].status).toBe('SUCCESS'); + expect(result.right.migrationResults[1].id).toBe(2); + expect(result.right.migrationResults[1].status).toBe('FAILED'); + expect(result.right.migrationResults[2].id).toBe(3); + expect(result.right.migrationResults[2].status).toBe('FAILED'); + } else { + fail('Migration should not fail'); + } + }); + + it('should not migrate for duplicate ids', async () => { + const result = await migrate( + { + allMigrations: [successfulMigration(1), successfulMigration(1)], + latestMigrationInProgress: undefined, + latestSuccessfulMigration: undefined, + onMigrationProgress: onMigrationProgress, + }, + context + )(); + if (isLeft(result)) { + expect(result.left.body.message).toBe(DUPLICATE_MIGRATION_IDS_MSG); + } else { + fail('Should fail because of duplicate ids'); + } + }); + + it('should not migrate if migrations are in progress', async () => { + const result = await migrate( + { + allMigrations: [successfulMigration(1), successfulMigration(2)], + latestMigrationInProgress: { + id: 1, + startedAt: '', + status: 'IN_PROGRESS', + finishedAt: undefined, + }, + latestSuccessfulMigration: undefined, + onMigrationProgress: onMigrationProgress, + }, + context + )(); + if (isLeft(result)) { + expect(result.left.body.message).toBe( + MIGRATION_ALREADY_IN_PROGRESS_MSG + ); + } else { + fail('Should fail because another migration is still running'); + } + }); +}); diff --git a/smithy-codegen/model/Banks.smithy b/smithy-codegen/model/Banks.smithy index 0764288..d2f1d1a 100644 --- a/smithy-codegen/model/Banks.smithy +++ b/smithy-codegen/model/Banks.smithy @@ -1,7 +1,5 @@ $version: "2.0" namespace de.innfactory.bootstrapawsserverless.api -use smithy.framework#ValidationException - resource Bank { identifiers: { @@ -23,7 +21,7 @@ string BankId operation CreateBankRequest { input: CreateBankInput output: BankOutput - errors: [ValidationException, BadRequest] + errors: [BadRequest] } structure CreateBankInput for Bank { @@ -43,7 +41,7 @@ structure BankOutput for Bank { operation GetBankRequest { input: GetBankInput output: BankOutput - errors: [ValidationException, NotFound] + errors: [NotFound] } structure GetBankInput for Bank { @@ -56,7 +54,7 @@ structure GetBankInput for Bank { operation UpdateBankRequest { input: UpdateBankInput output: BankOutput - errors: [ValidationException, BadRequest, NotFound] + errors: [BadRequest, NotFound] } structure UpdateBankInput for Bank { @@ -70,7 +68,7 @@ structure UpdateBankInput for Bank { operation DeleteBankRequest { input: DeleteBankInput output: BankOutput - errors: [ValidationException, NotFound] + errors: [NotFound] } structure DeleteBankInput for Bank { @@ -84,8 +82,6 @@ structure DeleteBankInput for Bank { operation ListBanksRequest { input: BanksRequest output: BanksResponse - errors: [ValidationException] - } structure BanksRequest with [PaginatedInput] { diff --git a/smithy-codegen/model/Migration.smithy b/smithy-codegen/model/Migration.smithy new file mode 100644 index 0000000..d33f1bd --- /dev/null +++ b/smithy-codegen/model/Migration.smithy @@ -0,0 +1,5 @@ +$version: "2.0" +namespace de.innfactory.bootstrapawsserverless.api + +@http(method: "POST", uri: "/v1/migrations", code: 204) +operation TriggerMigrations {} \ No newline at end of file diff --git a/smithy-codegen/model/Users.smithy b/smithy-codegen/model/Users.smithy index 4e20b6b..bdf8083 100644 --- a/smithy-codegen/model/Users.smithy +++ b/smithy-codegen/model/Users.smithy @@ -1,7 +1,5 @@ $version: "2.0" namespace de.innfactory.bootstrapawsserverless.api -use smithy.framework#ValidationException - resource User { identifiers: { @@ -27,7 +25,7 @@ string Password operation CreateUserRequest { input: CreateUserInput output: UserOutput - errors: [ValidationException, BadRequest] + errors: [BadRequest] } structure CreateUserInput for User { @@ -49,7 +47,7 @@ structure UserOutput for User { operation GetUserRequest { input: GetUserInput output: UserOutput - errors: [ValidationException, NotFound] + errors: [NotFound] } structure GetUserInput for User { @@ -63,7 +61,7 @@ structure GetUserInput for User { operation GetUserByMailRequest { input: GetUserByMailInput output: UserOutput - errors: [ValidationException, NotFound] + errors: [NotFound] } structure GetUserByMailInput { @@ -75,7 +73,7 @@ structure GetUserByMailInput { @http(method: "PATCH", uri: "/v1/users/{id}/password", code: 204) operation UpdatePasswordRequest { input: UpdatePasswordInput - errors: [ValidationException, BadRequest, NotFound] + errors: [BadRequest, NotFound] } structure UpdatePasswordInput for User { @@ -90,7 +88,7 @@ structure UpdatePasswordInput for User { @http(method: "DELETE", uri: "/v1/users/{id}", code: 204) operation DeleteUserRequest { input: DeleteUserInput - errors: [ValidationException, NotFound] + errors: [NotFound] } structure DeleteUserInput for User { diff --git a/smithy-codegen/model/main.smithy b/smithy-codegen/model/main.smithy index 850c403..0893ff9 100644 --- a/smithy-codegen/model/main.smithy +++ b/smithy-codegen/model/main.smithy @@ -12,8 +12,8 @@ use smithy.framework#ValidationException service Api { version: "0.0.1", resources: [Bank, User] - operations: [GetUserByMailRequest] - errors: [InternalServerError, NotFound, BadRequest, Unauthorized, Forbidden] + operations: [GetUserByMailRequest, TriggerMigrations] + errors: [ValidationException, InternalServerError, NotFound, BadRequest, Unauthorized, Forbidden] } diff --git a/stacks/ApiStack.ts b/stacks/ApiStack.ts index 7a663e3..6f248d7 100644 --- a/stacks/ApiStack.ts +++ b/stacks/ApiStack.ts @@ -23,11 +23,14 @@ import { createDefaultFunction, defaultFunctionName, } from './common/defaultFunction'; +import { triggerMigrations } from '@resources/migrations/migrationsFunctions'; +import { apiKeyAuthorizer } from '@resources/auth/apiKeyAuthorizer'; export function ApiStack(context: StackContext) { const api = new ApiGatewayV1Api(context.stack, 'api', { authorizers: { cognitoLambdaAuthorizer: cognitoLambdaAuthorizer(context), + apiKeyAuthorizer: apiKeyAuthorizer(context), }, defaults: { authorizer: getCognitoAuthorizer(context.stack.stage), @@ -75,6 +78,11 @@ export function ApiStack(context: StackContext) { authorizer: getCognitoAuthorizer(context.stack.stage), }, + 'POST /v1/migrations': { + function: triggerMigrations(context), + authorizer: 'apiKeyAuthorizer', + }, + 'ANY /{proxy+}': { function: createDefaultFunction(context, 'any-route', { functionName: defaultFunctionName(context, 'any-route'), diff --git a/stacks/DynamoDbStack.ts b/stacks/DynamoDbStack.ts index 199297a..2ee52de 100644 --- a/stacks/DynamoDbStack.ts +++ b/stacks/DynamoDbStack.ts @@ -1,10 +1,12 @@ import { StackContext } from 'sst/constructs'; import createBankTable from '@resources/banks/banksTable'; import createLoginAttemptsTable from '@resources/auth/loginAttemptsTable'; +import createMigrationsTable from '@resources/migrations/migrationsTable'; export function DynamoDbStack({ stack }: StackContext) { const bankTable = createBankTable(stack); const loginAttemptsTable = createLoginAttemptsTable(stack); + const migrationsTable = createMigrationsTable(stack); - return { bankTable, loginAttemptsTable }; + return { bankTable, loginAttemptsTable, migrationsTable }; } diff --git a/stacks/resources/auth/apiKeyAuthFunction.ts b/stacks/resources/auth/apiKeyAuthFunction.ts new file mode 100644 index 0000000..2f9d8ca --- /dev/null +++ b/stacks/resources/auth/apiKeyAuthFunction.ts @@ -0,0 +1,10 @@ +import { StackContext } from 'sst/constructs'; +import { createDefaultFunction } from 'stacks/common/defaultFunction'; + +export const apiKeyAuthFunction = (context: StackContext) => { + return createDefaultFunction(context, 'apikey-auth', { + permissions: ['secretsmanager'], + handler: + 'services/functions/auth/application/handler/apiKeyAuth.handler', + }); +}; diff --git a/stacks/resources/auth/apiKeyAuthorizer.ts b/stacks/resources/auth/apiKeyAuthorizer.ts new file mode 100644 index 0000000..d1ff52e --- /dev/null +++ b/stacks/resources/auth/apiKeyAuthorizer.ts @@ -0,0 +1,13 @@ +import { ApiGatewayV1ApiAuthorizer, StackContext } from 'sst/constructs'; +import { apiKeyAuthFunction } from './apiKeyAuthFunction'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; + +export const apiKeyAuthorizer = ( + context: StackContext +): ApiGatewayV1ApiAuthorizer => { + return { + type: 'lambda_request', + function: apiKeyAuthFunction(context), + identitySources: [apigateway.IdentitySource.header('authorization')], + }; +}; diff --git a/stacks/resources/migrations/migrationsFunctions.ts b/stacks/resources/migrations/migrationsFunctions.ts new file mode 100644 index 0000000..87791d7 --- /dev/null +++ b/stacks/resources/migrations/migrationsFunctions.ts @@ -0,0 +1,20 @@ +import { StackContext, use } from 'sst/constructs'; +import { DynamoDbStack } from 'stacks/DynamoDbStack'; +import { KeysStack } from 'stacks/KeysStack'; +import { createDefaultFunction } from 'stacks/common/defaultFunction'; + +export const triggerMigrations = (context: StackContext) => { + const { migrationsTable, bankTable } = use(DynamoDbStack); + const { withDynamoDBKeyPolicy } = use(KeysStack); + + return createDefaultFunction(context, 'migrations', { + handler: + 'services/functions/migrations/application/handler/trigger.handler', + environment: { + MIGRATIONS_TABLE: migrationsTable.tableName, + BANKS_TABLE: bankTable.tableName, + }, + permissions: withDynamoDBKeyPolicy(['secretsmanager']), + bind: [migrationsTable, bankTable], + }); +}; diff --git a/stacks/resources/migrations/migrationsTable.ts b/stacks/resources/migrations/migrationsTable.ts new file mode 100644 index 0000000..6d4b710 --- /dev/null +++ b/stacks/resources/migrations/migrationsTable.ts @@ -0,0 +1,22 @@ +import { Stack } from 'sst/constructs'; + +import { createEncryptedTable } from 'stacks/common/encryptedTable'; + +const createMigrationsTable = (stack: Stack) => { + return createEncryptedTable(stack, 'migrations', { + fields: { + id: 'number', + status: 'string', + startedAt: 'string', + }, + primaryIndex: { partitionKey: 'id' }, + globalIndexes: { + statusIndex: { + partitionKey: 'status', + sortKey: 'id', + }, + }, + }); +}; + +export default createMigrationsTable;