diff --git a/src/v2/components/database/builder.ts b/src/v2/components/database/builder.ts index 9cd343c..368a0c9 100644 --- a/src/v2/components/database/builder.ts +++ b/src/v2/components/database/builder.ts @@ -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; @@ -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( @@ -96,6 +105,14 @@ 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( + 'To enable monitoring on read replica provide monitoring role or enable monitoring on the primary instance.', + ); + } + } + if (!this.vpc) { throw new Error( 'VPC not provided. Make sure to call DatabaseBuilder.withVpc().', @@ -114,6 +131,8 @@ export class DatabaseBuilder { kmsKeyId: this.kmsKeyId, parameterGroupName: this.parameterGroupName, tags: this.tags, + createReplica: this.createReplica, + replicaConfig: this.replicaConfig, }, opts, ); diff --git a/src/v2/components/database/database-replica.ts b/src/v2/components/database/database-replica.ts new file mode 100644 index 0000000..5fd2b91 --- /dev/null +++ b/src/v2/components/database/database-replica.ts @@ -0,0 +1,113 @@ +import * as aws from '@pulumi/aws-v7'; +import * as pulumi from '@pulumi/pulumi'; +import { commonTags } from '../../../constants'; + +export namespace DatabaseReplica { + export type Instance = { + engineVersion?: pulumi.Input; + multiAz?: pulumi.Input; + instanceClass?: pulumi.Input; + allowMajorVersionUpgrade?: pulumi.Input; + autoMinorVersionUpgrade?: pulumi.Input; + applyImmediately?: pulumi.Input; + }; + + export type Security = { + dbSecurityGroup: aws.ec2.SecurityGroup; + dbSubnetGroup?: aws.rds.SubnetGroup; + }; + + export type Storage = { + allocatedStorage?: pulumi.Input; + maxAllocatedStorage?: pulumi.Input; + }; + + export type Args = Instance & + Security & + Storage & { + replicateSourceDb: pulumi.Input; + monitoringRole?: aws.iam.Role; + parameterGroupName?: pulumi.Input; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; + }; + + export type Config = Partial> & { + enableMonitoring?: pulumi.Input; + }; +} + +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 = Object.assign({}, 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; + } +} diff --git a/src/v2/components/database/index.ts b/src/v2/components/database/index.ts index 3ae2783..625f892 100644 --- a/src/v2/components/database/index.ts +++ b/src/v2/components/database/index.ts @@ -2,8 +2,9 @@ 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 '../../../constants'; +import { DatabaseReplica } from './database-replica'; +import { Password } from '../password'; export namespace Database { export type Instance = { @@ -34,6 +35,8 @@ export namespace Database { snapshotIdentifier?: pulumi.Input; parameterGroupName?: pulumi.Input; kmsKeyId?: pulumi.Input; + createReplica?: pulumi.Input; + replicaConfig?: DatabaseReplica.Config; tags?: pulumi.Input<{ [key: string]: pulumi.Input; }>; @@ -62,6 +65,7 @@ export class Database extends pulumi.ComponentResource { kmsKeyId: pulumi.Output; monitoringRole?: aws.iam.Role; encryptedSnapshotCopy?: aws.rds.SnapshotCopy; + replica?: DatabaseReplica; constructor( name: string, @@ -73,8 +77,14 @@ export class Database extends pulumi.ComponentResource { this.name = name; const argsWithDefaults = Object.assign({}, 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(); @@ -101,6 +111,10 @@ export class Database extends pulumi.ComponentResource { this.instance = this.createDatabaseInstance(argsWithDefaults); + if (createReplica) { + this.replica = this.createDatabaseReplica(replicaConfig); + } + this.registerOutputs(); } @@ -205,6 +219,25 @@ export class Database extends pulumi.ComponentResource { ); } + private createDatabaseReplica(config: DatabaseReplica.Config) { + 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 diff --git a/src/v2/index.ts b/src/v2/index.ts index 9aed6c0..081da80 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -7,6 +7,7 @@ export { UpstashRedis } from './components/redis/upstash-redis'; export { Vpc } from './components/vpc'; export { Database } from './components/database'; export { DatabaseBuilder } from './components/database/builder'; +export { DatabaseReplica } from './components/database/database-replica'; export { AcmCertificate } from './components/acm-certificate'; export { Password } from './components/password'; export { CloudFront } from './components/cloudfront'; diff --git a/tests/database/configurable-replica-db.test.ts b/tests/database/configurable-replica-db.test.ts new file mode 100644 index 0000000..ee2843b --- /dev/null +++ b/tests/database/configurable-replica-db.test.ts @@ -0,0 +1,146 @@ +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 storage', () => { + 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`, + ); + }); + }); +} diff --git a/tests/database/index.test.ts b/tests/database/index.test.ts index b9d02fc..15d4c79 100644 --- a/tests/database/index.test.ts +++ b/tests/database/index.test.ts @@ -1,3 +1,4 @@ +import { cleanupReplicas, cleanupSnapshots } from './util'; import { describe, before, after } from 'node:test'; import { DescribeDBInstancesCommand, @@ -13,7 +14,6 @@ import { } from '@aws-sdk/client-ec2'; import * as assert from 'node:assert'; import * as automation from '../automation'; -import { cleanupSnapshots } from './util'; import * as config from './infrastructure/config'; import { DatabaseTestContext } from './test-context'; import { EC2Client } from '@aws-sdk/client-ec2'; @@ -24,6 +24,8 @@ import { KMSClient } from '@aws-sdk/client-kms'; import { RDSClient } from '@aws-sdk/client-rds'; import { requireEnv } from '../util'; import { testConfigurableDb } from './configurable-db.test'; +import { testConfigurableReplica } from './configurable-replica-db.test'; +import { testReplicaDb } from './replica-db.test'; import { testSnapshotDb } from './snapshot-db.test'; const programArgs: InlineProgramArgs = { @@ -50,6 +52,7 @@ describe('Database component deployment', () => { }); after(async () => { + await cleanupReplicas(ctx); await automation.destroy(programArgs); await cleanupSnapshots(ctx); }); @@ -209,4 +212,6 @@ describe('Database component deployment', () => { describe('With configurable options', () => testConfigurableDb(ctx)); describe('With snapshot', () => testSnapshotDb(ctx)); + describe('With replica', () => testReplicaDb(ctx)); + describe('With configurable replica', () => testConfigurableReplica(ctx)); }); diff --git a/tests/database/infrastructure/config.ts b/tests/database/infrastructure/config.ts index d43d12b..845a660 100644 --- a/tests/database/infrastructure/config.ts +++ b/tests/database/infrastructure/config.ts @@ -12,5 +12,5 @@ export const dbPassword = 'dbpassword'; export const applyImmediately = true; export const allowMajorVersionUpgrade = true; export const autoMinorVersionUpgrade = false; -export const allocatedStorage = 10; -export const maxAllocatedStorage = 50; +export const allocatedStorage = 50; +export const maxAllocatedStorage = 200; diff --git a/tests/database/infrastructure/index.ts b/tests/database/infrastructure/index.ts index ccfdaf0..1815f7c 100644 --- a/tests/database/infrastructure/index.ts +++ b/tests/database/infrastructure/index.ts @@ -92,6 +92,42 @@ const snapshotDb = snapshot.apply(snapshot => { .build({ parent }); }); +const replicaDb = new studion.DatabaseBuilder(`${config.appName}-replica-db`) + .withInstance({ + dbName: config.dbName, + }) + .withCredentials({ + username: config.dbUsername, + }) + .withReplica() + .withVpc(vpc.vpc) + .build({ parent }); + +const configurableReplicaDb = new studion.DatabaseBuilder( + `${config.appName}-config-replica-db`, +) + .withInstance({ + dbName: config.dbName, + }) + .withCredentials({ + username: config.dbUsername, + }) + .withParameterGroup(paramGroup.name) + .withMonitoring() + .withTags(config.tags) + .withReplica({ + enableMonitoring: true, + parameterGroupName: paramGroup.name, + applyImmediately: config.applyImmediately, + allowMajorVersionUpgrade: config.allowMajorVersionUpgrade, + autoMinorVersionUpgrade: config.autoMinorVersionUpgrade, + allocatedStorage: config.allocatedStorage, + maxAllocatedStorage: config.maxAllocatedStorage, + tags: config.tags, + }) + .withVpc(vpc.vpc) + .build({ parent }); + export { vpc, defaultDb, @@ -100,4 +136,6 @@ export { configurableDb, snapshot, snapshotDb, + replicaDb, + configurableReplicaDb, }; diff --git a/tests/database/replica-db.test.ts b/tests/database/replica-db.test.ts new file mode 100644 index 0000000..bc73ea9 --- /dev/null +++ b/tests/database/replica-db.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'node:assert'; +import { DatabaseTestContext } from './test-context'; +import { DescribeDBInstancesCommand } from '@aws-sdk/client-rds'; +import { it } from 'node:test'; + +export function testReplicaDb(ctx: DatabaseTestContext) { + it('should create a primary instance with a replica', async () => { + const replicaDb = ctx.outputs.replicaDb.value; + const { dbInstanceIdentifier } = replicaDb.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 replicaDb = ctx.outputs.replicaDb.value; + const { identifier } = replicaDb.replica.instance; + + assert.ok(replicaDb.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 associate primary instance with a replica', async () => { + const replicaDb = ctx.outputs.replicaDb.value; + + const dbInstance = replicaDb.instance; + const replicaInstance = replicaDb.replica.instance; + + assert.strictEqual( + replicaInstance.replicateSourceDb, + dbInstance.dbInstanceIdentifier, + 'Replica instance should have correct source db instance identifier', + ); + + const command = new DescribeDBInstancesCommand({ + DBInstanceIdentifier: dbInstance.dbInstanceIdentifier, + }); + + const { DBInstances } = await ctx.clients.rds.send(command); + assert.ok( + DBInstances && DBInstances.length === 1, + 'Database instance should be created', + ); + const [DBInstance] = DBInstances; + const { ReadReplicaDBInstanceIdentifiers } = DBInstance; + assert.ok( + ReadReplicaDBInstanceIdentifiers && + ReadReplicaDBInstanceIdentifiers.length === 1, + 'Database instance should have associated read replica instance', + ); + const [ReadReplicaDBInstanceIdentifier] = ReadReplicaDBInstanceIdentifiers; + assert.strictEqual( + ReadReplicaDBInstanceIdentifier, + replicaInstance.identifier, + 'Database instance should have correct replica instance associated', + ); + }); + + it('should configure replica instance with correct defaults', () => { + const replicaDb = ctx.outputs.replicaDb.value; + + const primaryInstance = replicaDb.instance; + const replicaInstance = replicaDb.replica.instance; + + assert.partialDeepStrictEqual( + replicaInstance, + { + multiAz: false, + applyImmediately: false, + allocatedStorage: 20, + maxAllocatedStorage: 100, + instanceClass: 'db.t4g.micro', + performanceInsightsEnabled: false, + allowMajorVersionUpgrade: false, + autoMinorVersionUpgrade: true, + engineVersion: '17.2', + engine: 'postgres', + storageEncrypted: true, + publiclyAccessible: false, + skipFinalSnapshot: true, + vpcSecurityGroupIds: primaryInstance.vpcSecurityGroups, + dbSubnetGroupName: primaryInstance.dbSubnetGroupName, + parameterGroupName: primaryInstance.dbParameterGroupName, + }, + 'Replica instance should be configured correctly', + ); + }); +} diff --git a/tests/database/util.ts b/tests/database/util.ts index e60a2e4..576be42 100644 --- a/tests/database/util.ts +++ b/tests/database/util.ts @@ -1,9 +1,14 @@ import { + DBInstanceNotFoundFault, + DeleteDBInstanceCommand, DeleteDBSnapshotCommand, + DescribeDBInstancesCommand, DescribeDBSnapshotsCommand, } from '@aws-sdk/client-rds'; +import { backOff } from '../util'; import { createSpinner } from 'nanospinner'; import { DatabaseTestContext } from './test-context'; +import { next as studion } from '@studion/infra-code-blocks'; export async function cleanupSnapshots(ctx: DatabaseTestContext) { const spinner = createSpinner('Deleting snapshots...').start(); @@ -12,6 +17,8 @@ export async function cleanupSnapshots(ctx: DatabaseTestContext) { ctx.outputs.defaultDb.value, ctx.outputs.configurableDb.value, ctx.outputs.snapshotDb.value, + ctx.outputs.replicaDb.value, + ctx.outputs.configurableReplicaDb.value, ]; await Promise.all( dbs.map(db => deleteSnapshot(ctx, db.instance.dbInstanceIdentifier)), @@ -40,3 +47,75 @@ async function deleteSnapshot( }); await ctx.clients.rds.send(deleteCommand); } + +export async function cleanupReplicas(ctx: DatabaseTestContext) { + const spinner = createSpinner('Deleting replicas...').start(); + + const dbs = [ + ctx.outputs.replicaDb.value, + ctx.outputs.configurableReplicaDb.value, + ]; + await Promise.all(dbs.map(db => deleteReplica(ctx, db))); + + spinner.success({ text: 'Replicas deleted' }); +} + +async function deleteReplica(ctx: DatabaseTestContext, db: studion.Database) { + const DBInstanceIdentifier = db.replica!.instance + .identifier as unknown as string; + const deleteCommand = new DeleteDBInstanceCommand({ + DBInstanceIdentifier, + SkipFinalSnapshot: true, + }); + await ctx.clients.rds.send(deleteCommand); + + // Wait for replica to be deleted + await backOff( + async () => { + try { + const describeCommand = new DescribeDBInstancesCommand({ + DBInstanceIdentifier, + }); + const { DBInstances } = await ctx.clients.rds.send(describeCommand); + + if (!DBInstances || !DBInstances.length) { + return; + } + + const [DBInstance] = DBInstances; + if (DBInstance.DBInstanceStatus === 'deleting') { + throw new Error('DB instance still deleting'); + } + } catch (err: unknown) { + if (err instanceof DBInstanceNotFoundFault) { + return; + } + + throw new Error('Something went wrong'); + } + }, + { numOfAttempts: 10 }, + ); + + // Wait for primary instance to exit modifying state + await backOff( + async () => { + const DBInstanceIdentifier = db.instance + .dbInstanceIdentifier as unknown as string; + const describeCommand = new DescribeDBInstancesCommand({ + DBInstanceIdentifier, + }); + const { DBInstances } = await ctx.clients.rds.send(describeCommand); + + if (!DBInstances || !DBInstances.length) { + throw new Error('DB instance not found'); + } + + const [DBInstance] = DBInstances; + if (DBInstance.DBInstanceStatus === 'modifying') { + throw new Error('DB instance still modifying'); + } + }, + { numOfAttempts: 10 }, + ); +} diff --git a/tests/util.ts b/tests/util.ts index 4b9b642..79aa41a 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -19,8 +19,11 @@ export function requireEnv(name: string): string { return value; } -export function backOff(request: () => Promise): Promise { - return backOffFn(request, backOffDefaults); +export function backOff( + request: () => Promise, + opts: BackoffOptions = {}, +): Promise { + return backOffFn(request, { ...backOffDefaults, ...opts }); } export function unwrapOutputs>(