Skip to content

Commit

Permalink
feat: support multiple connection Uris for SQL databases (#2481)
Browse files Browse the repository at this point in the history
* feat: support multiple connection uris for SQL databases
  • Loading branch information
phani-srikar authored Apr 23, 2024
1 parent 13c2716 commit 7ea8000
Show file tree
Hide file tree
Showing 17 changed files with 268 additions and 29 deletions.
37 changes: 25 additions & 12 deletions packages/amplify-e2e-core/src/utils/rds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,20 +442,33 @@ export const storeDbConnectionConfig = async (options: {
export const storeDbConnectionStringConfig = async (options: {
region: string;
pathPrefix: string;
connectionUri: string;
connectionUri: string | string[];
}): Promise<{
connectionUriSsmPath: string;
connectionUriSsmPath: string | string[];
}> => {
await storeSSMParameters({
region: options.region,
pathPrefix: options.pathPrefix,
parameters: {
connectionUri: options.connectionUri,
},
});
return {
connectionUriSsmPath: `${options.pathPrefix}/connectionUri`,
};
if (typeof options.connectionUri === 'string') {
await storeSSMParameters({
region: options.region,
pathPrefix: options.pathPrefix,
parameters: {
connectionUri: options.connectionUri,
},
});
return {
connectionUriSsmPath: `${options.pathPrefix}/connectionUri`,
};
} else {
await storeSSMParameters({
region: options.region,
pathPrefix: options.pathPrefix,
parameters: {
connectionUri: options.connectionUri[1],
},
});
return {
connectionUriSsmPath: [`${options.pathPrefix}/connectionUri/doesnotexist`, `${options.pathPrefix}/connectionUri`],
};
}
};

export const storeSSMParameters = async (options: { region: string; pathPrefix: string; parameters: Record<string, string> }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,14 @@ describe('CDK GraphQL Transformer deployments with SQL datasources', () => {
await testGraphQLAPI(constructTestOptions('ssm'));
});

test('creates a GraphQL API from SQL-based models using Connection String SSM parameter', async () => {
test('creates a GraphQL API from SQL-based models using Connection Uri SSM path', async () => {
await testGraphQLAPI(constructTestOptions('connectionUri'));
});

test('creates a GraphQL API from SQL-based models using multiple Connection Uri SSM paths', async () => {
await testGraphQLAPI(constructTestOptions('connectionUriMultiple'));
});

const constructTestOptions = (connectionConfigName: string) => ({
projRoot,
region,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,18 @@ export class SqlDatatabaseController {
this.options.dbname,
),
});
const dbConnectionStringConfigMultiple = await storeDbConnectionStringConfig({
region: this.options.region,
pathPrefix,
connectionUri: [
'mysql://username:password@host:3306/dbname',
this.getConnectionUri(engine, this.options.username, dbConfig.password, dbConfig.endpoint, dbConfig.port, this.options.dbname),
],
});
const parameters = {
...dbConnectionConfigSSM,
...dbConnectionStringConfigSSM,
...dbConnectionStringConfigMultiple,
};
if (!dbConnectionConfigSSM) {
throw new Error('Failed to store db connection config for SSM');
Expand All @@ -144,6 +153,7 @@ export class SqlDatatabaseController {
secretArn: dbConfig.managedSecretArn,
},
connectionUri: dbConnectionStringConfigSSM,
connectionUriMultiple: dbConnectionStringConfigMultiple,
},
};

Expand Down Expand Up @@ -179,9 +189,11 @@ export class SqlDatatabaseController {
],
});
} else if (isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig)) {
const { connectionUriSsmPath } = dbConnectionConfig;
const paths = Array.isArray(connectionUriSsmPath) ? connectionUriSsmPath : [connectionUriSsmPath];
return deleteSSMParameters({
region: this.options.region,
parameterNames: [dbConnectionConfig.connectionUriSsmPath],
parameterNames: paths,
});
}
}),
Expand Down
18 changes: 16 additions & 2 deletions packages/amplify-graphql-api-construct/.jsii
Original file line number Diff line number Diff line change
Expand Up @@ -7610,7 +7610,21 @@
},
"name": "connectionUriSsmPath",
"type": {
"primitive": "string"
"union": {
"types": [
{
"primitive": "string"
},
{
"collection": {
"elementtype": {
"primitive": "string"
},
"kind": "array"
}
}
]
}
}
}
],
Expand Down Expand Up @@ -8171,5 +8185,5 @@
}
},
"version": "1.8.1",
"fingerprint": "RYLDuPcXrKkJVdyr+N3cvr80/DnbRL4kKh9QZAl4lUY="
"fingerprint": "hF3IhO8jnMvruVK74WA/JcSZ7FQvl6/6Sl4Y84Vq1qw="
}
2 changes: 1 addition & 1 deletion packages/amplify-graphql-api-construct/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export interface SqlModelDataSourceSsmDbConnectionConfig {

// @public
export interface SqlModelDataSourceSsmDbConnectionStringConfig {
readonly connectionUriSsmPath: string;
readonly connectionUriSsmPath: string | string[];
}

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ describe('SQL bound API definitions', () => {
expect(isSqlModelDataSourceDbConnectionConfig(dbConfig)).toBe(true);
});

it('accepts multiple connection uri SSM paths in DB configuration', () => {
const dbConfig = {
connectionUriSsmPath: ['/ssm/path/connectionUri/1', '/ssm/path/connectionUri/2'],
};
expect(isSqlModelDataSourceDbConnectionConfig(dbConfig)).toBe(true);
});

it('does not accept a connection uri object in DB configuration', () => {
const dbConfig = {
connectionUriSsmPath: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,43 @@ describe('datasource config', () => {
'Invalid data source strategy "mysqlStrategy". Following SSM paths must start with \'/\' in dbConnectionConfig: connectionUriPath.',
);
});

it('validateDataSourceStrategy passes when multiple valid connection string SSM Paths are passed', () => {
expect(() =>
validateDataSourceStrategy({
name: 'mysqlStrategy',
dbType: 'MYSQL',
dbConnectionConfig: {
connectionUriSsmPath: ['/test/connectionUri/1', '/test/connectionUri/2'],
},
}),
).not.toThrow();
});

it('validateDataSourceStrategy fails when any of the connection string SSM Paths is not valid', () => {
expect(() =>
validateDataSourceStrategy({
name: 'mysqlStrategy',
dbType: 'MYSQL',
dbConnectionConfig: {
connectionUriSsmPath: ['/test/connectionUri/1', 'connectionUriPath'],
},
}),
).toThrow(
'Invalid data source strategy "mysqlStrategy". Following SSM paths must start with \'/\' in dbConnectionConfig: connectionUriPath.',
);
});

it('validateDataSourceStrategy fails the connection string SSM Path is an empty array', () => {
expect(() =>
validateDataSourceStrategy({
name: 'mysqlStrategy',
dbType: 'MYSQL',
dbConnectionConfig: {
connectionUriSsmPath: [],
},
}),
).toThrow('Invalid data source strategy "mysqlStrategy". connectionUriSsmPath must be a string or non-empty array.');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,18 @@ export const validateDataSourceStrategy = (strategy: ConstructModelDataSourceStr
isSqlModelDataSourceSsmDbConnectionConfig(dbConnectionConfig) ||
isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig)
) {
const invalidSSMPaths = Object.values(dbConnectionConfig).filter((value) => typeof value === 'string' && !isValidSSMPath(value));
const ssmPaths = Object.values(dbConnectionConfig).filter((value) => typeof value === 'string');
if (isSqlModelDataSourceSsmDbConnectionStringConfig(dbConnectionConfig)) {
const hasMultipleSSMPaths = Array.isArray(dbConnectionConfig?.connectionUriSsmPath);
if (hasMultipleSSMPaths) {
if (dbConnectionConfig?.connectionUriSsmPath?.length < 1) {
throw new Error(`Invalid data source strategy "${strategy.name}". connectionUriSsmPath must be a string or non-empty array.`);
}
ssmPaths.push(...dbConnectionConfig.connectionUriSsmPath);
}
}

const invalidSSMPaths = ssmPaths.filter((value) => !isValidSSMPath(value));
if (invalidSSMPaths.length > 0) {
throw new Error(
`Invalid data source strategy "${
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,13 @@ export type SqlModelDataSourceDbConnectionConfig =
* @experimental
*/
export interface SqlModelDataSourceSsmDbConnectionStringConfig {
/** The SSM Path to the secure connection string used for connecting to the database. **/
readonly connectionUriSsmPath: string;
/**
* The SSM Path to the secure connection string used for connecting to the database. If more than one path is provided,
* the SQL Lambda will attempt to retrieve connection information from each path in order until it finds a valid
* path entry, then stop. If the connection information contained in that path is invalid, the SQL Lambda will not
* attempt to retrieve connection information from subsequent paths in the array.
**/
readonly connectionUriSsmPath: string | string[];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ export const isSqlModelDataSourceSecretsManagerDbConnectionConfig = (
* @returns true if the object is shaped like a SqlModelDataSourceSsmDbConnectionStringConfig
*/
export const isSqlModelDataSourceSsmDbConnectionStringConfig = (obj: any): obj is SqlModelDataSourceSsmDbConnectionStringConfig => {
return (typeof obj === 'object' || typeof obj === 'function') && typeof obj.connectionUriSsmPath === 'string';
return (
(typeof obj === 'object' || typeof obj === 'function') &&
(typeof obj.connectionUriSsmPath === 'string' || Array.isArray(obj.connectionUriSsmPath))
);
};

/**
Expand Down
34 changes: 31 additions & 3 deletions packages/amplify-graphql-model-transformer/rds-lambda/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,34 @@ const getSecretManagerValue = async (secretArn: string | undefined): Promise<{ u
}
}

const resolveConnectionStringValue = async (jsonConnectionString: string): Promise<string> => {
const parsedJsonConnectionString = JSON.parse(jsonConnectionString);
const ssmRequestError = 'Unable to connect to the database. Check the logs for more details.';
const ssmLoggedError = 'Unable to fetch the connection Uri from SSM for the provided paths.';
if (Array.isArray(parsedJsonConnectionString)) {
for (const connectionUriSsmPath of parsedJsonConnectionString) {
try {
return await getSSMValue(connectionUriSsmPath);
}
catch (e) {
// try the next secret path;
continue;
}
}
console.log(ssmLoggedError);
throw new Error(ssmRequestError);
}
else {
try {
return await getSSMValue(parsedJsonConnectionString);
}
catch (e) {
console.log(ssmLoggedError);
throw new Error(ssmRequestError);
}
}
};


const getDBConfig = async (): DBConfig => {
const config: DBConfig = {};
Expand All @@ -153,9 +181,9 @@ const getDBConfig = async (): DBConfig => {
createSSMClient();
}

const connectionString = process.env.connectionString;
if (connectionString) {
config.connectionString = await getSSMValue(connectionString);
const jsonConnectionString = process.env.connectionString;
if (jsonConnectionString) {
config.connectionString = await resolveConnectionStringValue(jsonConnectionString);
return config;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ModelTransformer with SQL data sources: should accept multiple connection uris as input 1`] = `
Object {
"Statement": Array [
Object {
"Action": Array [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
"Effect": "Allow",
"Resource": "arn:aws:logs:*:*:*",
},
Object {
"Action": Array [
"ssm:GetParameter",
"ssm:GetParameters",
],
"Effect": "Allow",
"Resource": Array [
"arn:aws:ssm:*:*:parameter/test/connectionUri/1",
"arn:aws:ssm:*:*:parameter/test/connectionUri/2",
],
},
],
"Version": "2012-10-17",
}
`;

exports[`ModelTransformer with SQL data sources: should assign SSM permissions 1`] = `
Object {
"Statement": Array [
Expand Down
Loading

0 comments on commit 7ea8000

Please sign in to comment.