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
20 changes: 20 additions & 0 deletions src/v2/components/database/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export class DatabaseBuilder {
private kmsKeyId?: Database.Args['kmsKeyId'];
private parameterGroupName?: Database.Args['parameterGroupName'];
private tags?: Database.Args['tags'];
private createReplica?: Database.Args['createReplica'];
private replicaConfig?: Database.Args['replicaConfig'];

constructor(name: string) {
this.name = name;
Expand Down Expand Up @@ -75,6 +77,13 @@ export class DatabaseBuilder {
return this;
}

public withReplica(replicaConfig: Database.Args['replicaConfig'] = {}): this {
this.createReplica = true;
this.replicaConfig = replicaConfig;

return this;
}

public build(opts: pulumi.ComponentResourceOptions = {}): Database {
if (!this.snapshotIdentifier && !this.instanceConfig?.dbName) {
throw new Error(
Expand All @@ -96,6 +105,15 @@ export class DatabaseBuilder {
throw new Error(`You can't set username when using snapshotIdentifier.`);
}

if (this.createReplica && this.replicaConfig?.enableMonitoring) {
if (!this.enableMonitoring && !this.replicaConfig.monitoringRole) {
throw new Error(
`If you want enable monitoring on the replica instance either provide monitoring role or
enable monitoring on the primary instance to reuse the same monitoring role.`,
);
}
}

if (!this.vpc) {
throw new Error(
'VPC not provided. Make sure to call DatabaseBuilder.withVpc().',
Expand All @@ -114,6 +132,8 @@ export class DatabaseBuilder {
kmsKeyId: this.kmsKeyId,
parameterGroupName: this.parameterGroupName,
tags: this.tags,
createReplica: this.createReplica,
replicaConfig: this.replicaConfig,
},
opts,
);
Expand Down
110 changes: 110 additions & 0 deletions src/v2/components/database/database-replica.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as aws from '@pulumi/aws-v7';
import * as pulumi from '@pulumi/pulumi';
import { commonTags } from '../../shared/common-tags';
import { mergeWithDefaults } from '../../shared/merge-with-defaults';

export namespace DatabaseReplica {
export type Instance = {
engineVersion?: pulumi.Input<string>;
multiAz?: pulumi.Input<boolean>;
instanceClass?: pulumi.Input<string>;
allowMajorVersionUpgrade?: pulumi.Input<boolean>;
autoMinorVersionUpgrade?: pulumi.Input<boolean>;
applyImmediately?: pulumi.Input<boolean>;
};

export type Security = {
dbSecurityGroup: aws.ec2.SecurityGroup;
dbSubnetGroup?: aws.rds.SubnetGroup;
};

export type Storage = {
allocatedStorage?: pulumi.Input<number>;
maxAllocatedStorage?: pulumi.Input<number>;
};

export type Args = Instance &
Security &
Storage & {
replicateSourceDb: pulumi.Input<string>;
monitoringRole?: aws.iam.Role;
parameterGroupName?: pulumi.Input<string>;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
};
}

const defaults = {
multiAz: false,
applyImmediately: false,
allocatedStorage: 20,
maxAllocatedStorage: 100,
instanceClass: 'db.t4g.micro',
allowMajorVersionUpgrade: false,
autoMinorVersionUpgrade: true,
engineVersion: '17.2',
};

export class DatabaseReplica extends pulumi.ComponentResource {
name: string;
instance: aws.rds.Instance;

constructor(
name: string,
args: DatabaseReplica.Args,
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:DatabaseReplica', name, {}, opts);

this.name = name;

this.instance = this.createDatabaseInstance(args, opts);

this.registerOutputs();
}

private createDatabaseInstance(
args: DatabaseReplica.Args,
opts: pulumi.ComponentResourceOptions,
) {
const argsWithDefaults = mergeWithDefaults(defaults, args);

const monitoringOptions = argsWithDefaults.monitoringRole
? {
monitoringInterval: 60,
monitoringRoleArn: argsWithDefaults.monitoringRole.arn,
performanceInsightsEnabled: true,
performanceInsightsRetentionPeriod: 7,
}
: {};

const instance = new aws.rds.Instance(
`${this.name}-rds`,
{
identifierPrefix: `${this.name}-`,
engine: 'postgres',
engineVersion: argsWithDefaults.engineVersion,
allocatedStorage: argsWithDefaults.allocatedStorage,
maxAllocatedStorage: argsWithDefaults.maxAllocatedStorage,
instanceClass: argsWithDefaults.instanceClass,
vpcSecurityGroupIds: [argsWithDefaults.dbSecurityGroup.id],
dbSubnetGroupName: argsWithDefaults.dbSubnetGroup?.name,
multiAz: argsWithDefaults.multiAz,
applyImmediately: argsWithDefaults.applyImmediately,
allowMajorVersionUpgrade: argsWithDefaults.allowMajorVersionUpgrade,
autoMinorVersionUpgrade: argsWithDefaults.autoMinorVersionUpgrade,
replicateSourceDb: argsWithDefaults.replicateSourceDb,
parameterGroupName: argsWithDefaults.parameterGroupName,
storageEncrypted: true,
publiclyAccessible: false,
skipFinalSnapshot: true,
...monitoringOptions,
tags: { ...commonTags, ...argsWithDefaults.tags },
},
{ parent: this, dependsOn: opts.dependsOn },
);

return instance;
}
}
53 changes: 50 additions & 3 deletions src/v2/components/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import * as aws from '@pulumi/aws-v7';
import * as awsNative from '@pulumi/aws-native';
import * as awsx from '@pulumi/awsx-v3';
import * as pulumi from '@pulumi/pulumi';
import { Password } from '../password';
import { commonTags } from '../../shared/common-tags';
import { DatabaseReplica } from './database-replica';
import { mergeWithDefaults } from '../../shared/merge-with-defaults';
import { Password } from '../password';

export namespace Database {
export type Instance = {
Expand All @@ -27,6 +28,20 @@ export namespace Database {
maxAllocatedStorage?: pulumi.Input<number>;
};

export type ReplicaConfig = Partial<
Omit<
DatabaseReplica.Args,
'replicateSourceDb' | keyof DatabaseReplica.Security
>
> & {
/*
* Enables monitoring for the replica instance and
* reuses the same monitoring role from the primary instance
* if you don't provide a custom `monitoringRole`.
*/
enableMonitoring?: pulumi.Input<boolean>;
};

export type Args = Instance &
Credentials &
Storage & {
Expand All @@ -35,6 +50,8 @@ export namespace Database {
snapshotIdentifier?: pulumi.Input<string>;
parameterGroupName?: pulumi.Input<string>;
kmsKeyId?: pulumi.Input<string>;
createReplica?: pulumi.Input<boolean>;
replicaConfig?: ReplicaConfig;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
Expand Down Expand Up @@ -63,6 +80,7 @@ export class Database extends pulumi.ComponentResource {
kmsKeyId: pulumi.Output<string>;
monitoringRole?: aws.iam.Role;
encryptedSnapshotCopy?: aws.rds.SnapshotCopy;
replica?: DatabaseReplica;

constructor(
name: string,
Expand All @@ -74,8 +92,14 @@ export class Database extends pulumi.ComponentResource {
this.name = name;

const argsWithDefaults = mergeWithDefaults(defaults, args);
const { vpc, kmsKeyId, enableMonitoring, snapshotIdentifier } =
argsWithDefaults;
const {
vpc,
kmsKeyId,
enableMonitoring,
snapshotIdentifier,
createReplica,
replicaConfig = {},
} = argsWithDefaults;

this.vpc = pulumi.output(vpc);
this.dbSubnetGroup = this.createSubnetGroup();
Expand All @@ -102,6 +126,10 @@ export class Database extends pulumi.ComponentResource {

this.instance = this.createDatabaseInstance(argsWithDefaults);

if (createReplica) {
this.replica = this.createDatabaseReplica(replicaConfig);
}

this.registerOutputs();
}

Expand Down Expand Up @@ -206,6 +234,25 @@ export class Database extends pulumi.ComponentResource {
);
}

private createDatabaseReplica(config: Database.Args['replicaConfig'] = {}) {
const monitoringRole = config.enableMonitoring
? config.monitoringRole || this.monitoringRole
: undefined;

const replica = new DatabaseReplica(
`${this.name}-replica`,
{
replicateSourceDb: this.instance.dbInstanceIdentifier.apply(id => id!),
dbSecurityGroup: this.dbSecurityGroup,
monitoringRole,
...config,
},
{ parent: this, dependsOn: [this.instance] },
);

return replica;
}

private createDatabaseInstance(args: Database.Args) {
const monitoringOptions =
args.enableMonitoring && this.monitoringRole
Expand Down
125 changes: 125 additions & 0 deletions tests/database/configurable-replica-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {
DescribeDBInstancesCommand,
ListTagsForResourceCommand,
} from '@aws-sdk/client-rds';
import * as assert from 'node:assert';
import { DatabaseTestContext } from './test-context';
import { it } from 'node:test';

export function testConfigurableReplica(ctx: DatabaseTestContext) {
it('should create a primary instance with a configurable replica', async () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const { dbInstanceIdentifier } = configurableReplicaDb.instance;

const command = new DescribeDBInstancesCommand({
DBInstanceIdentifier: dbInstanceIdentifier,
});

const { DBInstances } = await ctx.clients.rds.send(command);
assert.ok(
DBInstances &&
DBInstances.length === 1 &&
DBInstances[0].DBInstanceIdentifier === dbInstanceIdentifier,
'Primary database instance should be created',
);
});

it('should create a replica', async () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const { identifier } = configurableReplicaDb.replica.instance;

assert.ok(configurableReplicaDb.replica, 'Replica should be defined');

const command = new DescribeDBInstancesCommand({
DBInstanceIdentifier: identifier,
});
const { DBInstances } = await ctx.clients.rds.send(command);
assert.ok(
DBInstances &&
DBInstances.length === 1 &&
DBInstances[0].DBInstanceIdentifier === identifier,
'Replica instance should be created',
);
});

it('should properly configure replica instance', () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const replicaInstance = configurableReplicaDb.replica.instance;

assert.strictEqual(
replicaInstance.applyImmediately,
ctx.config.applyImmediately,
'Apply immediately argument should be set correctly',
);
assert.strictEqual(
replicaInstance.allowMajorVersionUpgrade,
ctx.config.allowMajorVersionUpgrade,
'Allow major version upgrade argument should be set correctly',
);
assert.strictEqual(
replicaInstance.autoMinorVersionUpgrade,
ctx.config.autoMinorVersionUpgrade,
'Auto minor version upgrade argument should be set correctly',
);
});

it('should properly configure replica monitoring options', () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const replicaInstance = configurableReplicaDb.replica.instance;
const primaryInstance = configurableReplicaDb.instance;

assert.strictEqual(
replicaInstance.performanceInsightsEnabled,
true,
'Performance insights should be enabled',
);
assert.strictEqual(
replicaInstance.performanceInsightsRetentionPeriod,
7,
'Performance insights retention period should be set correctly',
);
assert.strictEqual(
replicaInstance.monitoringInterval,
60,
'Monitoring interval should be set correctly',
);
assert.strictEqual(
replicaInstance.monitoringRoleArn,
primaryInstance.monitoringRoleArn,
'Replica instance should use the same monitoring role as the primary instance',
);
});

it('should properly configure replica parameter group', () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const replicaInstance = configurableReplicaDb.replica.instance;
const paramGroup = ctx.outputs.paramGroup.value;

assert.strictEqual(
replicaInstance.parameterGroupName,
paramGroup.name,
'Parameter group name should be set correctly',
);
});

it('should properly configure replica tags', async () => {
const configurableReplicaDb = ctx.outputs.configurableReplicaDb.value;
const replicaInstance = configurableReplicaDb.replica.instance;

const command = new ListTagsForResourceCommand({
ResourceName: replicaInstance.arn,
});
const { TagList } = await ctx.clients.rds.send(command);
assert.ok(TagList && TagList.length > 0, 'Tags should exist');

Object.entries(ctx.config.tags).map(([Key, Value]) => {
const tag = TagList.find(tag => tag.Key === Key);
assert.ok(tag, `${Key} tag should exist`);
assert.strictEqual(
tag.Value,
Value,
`${Key} tag should be set correctly`,
);
});
});
}
Loading