Skip to content

Commit d5ecdf3

Browse files
authored
SNS Tags update (#222)
* Testing SNS tags update * SNS publisher update topic tags * Adding tags update * Adding tests * Adding aws-sdk sts * Minor test changes * Localstack enabling sts * Implementing tags update + simple tests * Adding stsClient as a new dependency to avoid creation * Tests fixes * Adding stsUtils + tests * sns publisher tag update tests * Import error fix * test improvement * SNS and sqs consumer is able to update tags * sns sqs consumer tag update tests * Release prepare * lint fix * Test fix * Test fix * Improving tests * Caching caller identity * Exposing clearCachedCallerIdentity
1 parent 8452a15 commit d5ecdf3

21 files changed

+517
-75
lines changed

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ services:
1515
- '127.0.0.1:4566:4566' # LocalStack Gateway
1616
- '127.0.0.1:4510-4559:4510-4559' # external services port range
1717
environment:
18-
- SERVICES=sns,sqs,s3
18+
- SERVICES=sns,sqs,s3,sts
1919
- DEBUG=0
2020
- DATA_DIR=${DATA_DIR-}
2121
- DOCKER_HOST=unix:///var/run/docker.sock

packages/sns/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ services:
77
- '127.0.0.1:4566:4566' # LocalStack Gateway
88
- '127.0.0.1:4510-4559:4510-4559' # external services port range
99
environment:
10-
- SERVICES=sns,sqs,s3
10+
- SERVICES=sns,sqs,s3,sts
1111
- DEBUG=0
1212
- DATA_DIR=${DATA_DIR-}
1313
- DOCKER_HOST=unix:///var/run/docker.sock

packages/sns/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export {
3333
findSubscriptionByTopicAndQueue,
3434
getSubscriptionAttributes,
3535
} from './lib/utils/snsUtils'
36+
export { clearCachedCallerIdentity } from './lib/utils/stsUtils'
3637

3738
export { subscribeToTopic } from './lib/utils/snsSubscriber'
3839
export { initSns, initSnsSqs } from './lib/utils/snsInitter'

packages/sns/lib/sns/AbstractSnsService.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { CreateTopicCommandInput, SNSClient, Tag } from '@aws-sdk/client-sn
22
import type { QueueDependencies, QueueOptions } from '@message-queue-toolkit/core'
33
import { AbstractQueueService } from '@message-queue-toolkit/core'
44

5+
import type { STSClient } from '@aws-sdk/client-sts'
56
import type { SNS_MESSAGE_BODY_TYPE } from '../types/MessageTypes'
67
import { deleteSns, initSns } from '../utils/snsInitter'
78

@@ -10,6 +11,7 @@ export const SNS_MESSAGE_MAX_SIZE = 256 * 1024 // 256KB
1011

1112
export type SNSDependencies = QueueDependencies & {
1213
snsClient: SNSClient
14+
stsClient: STSClient
1315
}
1416

1517
export type SNSTopicAWSConfig = CreateTopicCommandInput
@@ -31,6 +33,7 @@ export type SNSTopicConfig = {
3133
export type ExtraSNSCreationParams = {
3234
queueUrlsWithSubscribePermissionsPrefix?: string | readonly string[]
3335
allowedSourceOwner?: string
36+
forceTagUpdate?: boolean
3437
}
3538

3639
export type SNSCreationConfig = {
@@ -59,21 +62,28 @@ export abstract class AbstractSnsService<
5962
SNSOptionsType
6063
> {
6164
protected readonly snsClient: SNSClient
65+
protected readonly stsClient: STSClient
6266
// @ts-ignore
6367
protected topicArn: string
6468

6569
constructor(dependencies: DependenciesType, options: SNSOptionsType) {
6670
super(dependencies, options)
6771

6872
this.snsClient = dependencies.snsClient
73+
this.stsClient = dependencies.stsClient
6974
}
7075

7176
public async init() {
7277
if (this.deletionConfig && this.creationConfig) {
73-
await deleteSns(this.snsClient, this.deletionConfig, this.creationConfig)
78+
await deleteSns(this.snsClient, this.stsClient, this.deletionConfig, this.creationConfig)
7479
}
7580

76-
const initResult = await initSns(this.snsClient, this.locatorConfig, this.creationConfig)
81+
const initResult = await initSns(
82+
this.snsClient,
83+
this.stsClient,
84+
this.locatorConfig,
85+
this.creationConfig,
86+
)
7787
this.topicArn = initResult.topicArn
7888
this.isInitted = true
7989
}

packages/sns/lib/sns/AbstractSnsSqsConsumer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import { deleteSnsSqs, initSnsSqs } from '../utils/snsInitter'
1212
import { readSnsMessage } from '../utils/snsMessageReader'
1313
import type { SNSSubscriptionOptions } from '../utils/snsSubscriber'
1414

15+
import type { STSClient } from '@aws-sdk/client-sts'
1516
import type { SNSCreationConfig, SNSOptions, SNSTopicLocatorType } from './AbstractSnsService'
1617

1718
export type SNSSQSConsumerDependencies = SQSConsumerDependencies & {
1819
snsClient: SNSClient
20+
stsClient: STSClient
1921
}
2022
export type SNSSQSCreationConfig = SQSCreationConfig & SNSCreationConfig
2123

@@ -53,6 +55,7 @@ export abstract class AbstractSnsSqsConsumer<
5355
> {
5456
private readonly subscriptionConfig?: SNSSubscriptionOptions
5557
private readonly snsClient: SNSClient
58+
private readonly stsClient: STSClient
5659

5760
// @ts-ignore
5861
protected topicArn: string
@@ -74,13 +77,15 @@ export abstract class AbstractSnsSqsConsumer<
7477

7578
this.subscriptionConfig = options.subscriptionConfig
7679
this.snsClient = dependencies.snsClient
80+
this.stsClient = dependencies.stsClient
7781
}
7882

7983
override async init(): Promise<void> {
8084
if (this.deletionConfig && this.creationConfig && this.subscriptionConfig) {
8185
await deleteSnsSqs(
8286
this.sqsClient,
8387
this.snsClient,
88+
this.stsClient,
8489
this.deletionConfig,
8590
this.creationConfig.queue,
8691
this.creationConfig.topic,
@@ -95,6 +100,7 @@ export abstract class AbstractSnsSqsConsumer<
95100
const initSnsSqsResult = await initSnsSqs(
96101
this.sqsClient,
97102
this.snsClient,
103+
this.stsClient,
98104
this.locatorConfig,
99105
this.creationConfig,
100106
this.subscriptionConfig,

packages/sns/lib/sns/SnsPublisherManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export class SnsPublisherManager<
7979
newPublisherOptions: options.newPublisherOptions,
8080
publisherDependencies: {
8181
snsClient: dependencies.snsClient,
82+
stsClient: dependencies.stsClient,
8283
logger: dependencies.logger,
8384
errorReporter: dependencies.errorReporter,
8485
},

packages/sns/lib/utils/snsInitter.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { deleteQueue, getQueueAttributes } from '@message-queue-toolkit/sqs'
88
import type { SNSCreationConfig, SNSTopicLocatorType } from '../sns/AbstractSnsService'
99
import type { SNSSQSQueueLocatorType } from '../sns/AbstractSnsSqsConsumer'
1010

11+
import type { STSClient } from '@aws-sdk/client-sts'
1112
import type { Either } from '@lokalise/node-core'
1213
import { type TopicResolutionOptions, isCreateTopicCommand } from '../types/TopicTypes'
1314
import type { SNSSubscriptionOptions } from './snsSubscriber'
@@ -24,6 +25,7 @@ import {
2425
export async function initSnsSqs(
2526
sqsClient: SQSClient,
2627
snsClient: SNSClient,
28+
stsClient: STSClient,
2729
locatorConfig?: SNSSQSQueueLocatorType,
2830
creationConfig?: SNSCreationConfig & SQSCreationConfig,
2931
subscriptionConfig?: SNSSubscriptionOptions,
@@ -59,6 +61,7 @@ export async function initSnsSqs(
5961
const { subscriptionArn, topicArn, queueUrl } = await subscribeToTopic(
6062
sqsClient,
6163
snsClient,
64+
stsClient,
6265
creationConfig.queue,
6366
topicResolutionOptions,
6467
subscriptionConfig,
@@ -132,6 +135,7 @@ export async function initSnsSqs(
132135
export async function deleteSnsSqs(
133136
sqsClient: SQSClient,
134137
snsClient: SNSClient,
138+
stsClient: STSClient,
135139
deletionConfig: DeletionConfig,
136140
queueConfiguration: CreateQueueCommandInput,
137141
topicConfiguration: CreateTopicCommandInput | undefined,
@@ -152,6 +156,7 @@ export async function deleteSnsSqs(
152156
const { subscriptionArn } = await subscribeToTopic(
153157
sqsClient,
154158
snsClient,
159+
stsClient,
155160
queueConfiguration,
156161
topicConfiguration ?? topicLocator!,
157162
subscriptionConfiguration,
@@ -176,13 +181,14 @@ export async function deleteSnsSqs(
176181
if (!topicName) {
177182
throw new Error('Failed to resolve topic name')
178183
}
179-
await deleteTopic(snsClient, topicName)
184+
await deleteTopic(snsClient, stsClient, topicName)
180185
}
181186
await deleteSubscription(snsClient, subscriptionArn)
182187
}
183188

184189
export async function deleteSns(
185190
snsClient: SNSClient,
191+
stsClient: STSClient,
186192
deletionConfig: DeletionConfig,
187193
creationConfig: SNSCreationConfig,
188194
) {
@@ -200,11 +206,12 @@ export async function deleteSns(
200206
throw new Error('topic.Name must be set for automatic deletion')
201207
}
202208

203-
await deleteTopic(snsClient, creationConfig.topic.Name)
209+
await deleteTopic(snsClient, stsClient, creationConfig.topic.Name)
204210
}
205211

206212
export async function initSns(
207213
snsClient: SNSClient,
214+
stsClient: STSClient,
208215
locatorConfig?: SNSTopicLocatorType,
209216
creationConfig?: SNSCreationConfig,
210217
) {
@@ -227,9 +234,10 @@ export async function initSns(
227234
'When locatorConfig for the topic is not specified, creationConfig of the topic is mandatory',
228235
)
229236
}
230-
const topicArn = await assertTopic(snsClient, creationConfig.topic!, {
237+
const topicArn = await assertTopic(snsClient, stsClient, creationConfig.topic!, {
231238
queueUrlsWithSubscribePermissionsPrefix: creationConfig.queueUrlsWithSubscribePermissionsPrefix,
232239
allowedSourceOwner: creationConfig.allowedSourceOwner,
240+
forceTagUpdate: creationConfig.forceTagUpdate,
233241
})
234242
return {
235243
topicArn,

packages/sns/lib/utils/snsSubscriber.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { assertQueue } from '@message-queue-toolkit/sqs'
88

99
import type { ExtraSNSCreationParams } from '../sns/AbstractSnsService'
1010

11+
import type { STSClient } from '@aws-sdk/client-sts'
1112
import {
1213
type TopicResolutionOptions,
1314
isCreateTopicCommand,
@@ -21,8 +22,9 @@ export type SNSSubscriptionOptions = Omit<
2122
> & { updateAttributesIfExists: boolean }
2223

2324
async function resolveTopicArnToSubscribeTo(
24-
topicConfiguration: TopicResolutionOptions,
2525
snsClient: SNSClient,
26+
stsClient: STSClient,
27+
topicConfiguration: TopicResolutionOptions,
2628
extraParams: (ExtraSNSCreationParams & ExtraSQSCreationParams & ExtraParams) | undefined,
2729
) {
2830
//If topicArn is present, let's use it and return early.
@@ -32,9 +34,10 @@ async function resolveTopicArnToSubscribeTo(
3234

3335
//If input configuration is capable of creating a topic, let's create it and return its ARN.
3436
if (isCreateTopicCommand(topicConfiguration)) {
35-
return await assertTopic(snsClient, topicConfiguration, {
37+
return await assertTopic(snsClient, stsClient, topicConfiguration, {
3638
queueUrlsWithSubscribePermissionsPrefix: extraParams?.queueUrlsWithSubscribePermissionsPrefix,
3739
allowedSourceOwner: extraParams?.allowedSourceOwner,
40+
forceTagUpdate: extraParams?.forceTagUpdate,
3841
})
3942
}
4043

@@ -45,12 +48,18 @@ async function resolveTopicArnToSubscribeTo(
4548
export async function subscribeToTopic(
4649
sqsClient: SQSClient,
4750
snsClient: SNSClient,
51+
stsClient: STSClient,
4852
queueConfiguration: CreateQueueCommandInput,
4953
topicConfiguration: TopicResolutionOptions,
5054
subscriptionConfiguration: SNSSubscriptionOptions,
5155
extraParams?: ExtraSNSCreationParams & ExtraSQSCreationParams & ExtraParams,
5256
) {
53-
const topicArn = await resolveTopicArnToSubscribeTo(topicConfiguration, snsClient, extraParams)
57+
const topicArn = await resolveTopicArnToSubscribeTo(
58+
snsClient,
59+
stsClient,
60+
topicConfiguration,
61+
extraParams,
62+
)
5463

5564
const { queueUrl, queueArn } = await assertQueue(sqsClient, queueConfiguration, {
5665
topicArnsWithPublishPermissionsPrefix: extraParams?.topicArnsWithPublishPermissionsPrefix,

packages/sns/lib/utils/snsUtils.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
type CreateTopicCommandInput,
33
type SNSClient,
4+
TagResourceCommand,
45
paginateListTopics,
56
} from '@aws-sdk/client-sns'
67
import {
@@ -12,12 +13,14 @@ import {
1213
SetTopicAttributesCommand,
1314
UnsubscribeCommand,
1415
} from '@aws-sdk/client-sns'
15-
import type { Either } from '@lokalise/node-core'
16+
import { type Either, isError } from '@lokalise/node-core'
1617
import { calculateOutgoingMessageSize as sqsCalculateOutgoingMessageSize } from '@message-queue-toolkit/sqs'
1718

1819
import type { ExtraSNSCreationParams } from '../sns/AbstractSnsService'
1920

21+
import type { STSClient } from '@aws-sdk/client-sts'
2022
import { generateTopicSubscriptionPolicy } from './snsAttributeUtils'
23+
import { buildTopicArn } from './stsUtils'
2124

2225
type AttributesResult = {
2326
attributes?: Record<string, string>
@@ -79,16 +82,23 @@ export async function getSubscriptionAttributes(
7982

8083
export async function assertTopic(
8184
snsClient: SNSClient,
85+
stsClient: STSClient,
8286
topicOptions: CreateTopicCommandInput,
8387
extraParams?: ExtraSNSCreationParams,
8488
) {
85-
const command = new CreateTopicCommand(topicOptions)
86-
const response = await snsClient.send(command)
87-
88-
if (!response.TopicArn) {
89-
throw new Error('No topic arn in response')
89+
let topicArn: string
90+
try {
91+
const command = new CreateTopicCommand(topicOptions)
92+
const response = await snsClient.send(command)
93+
if (!response.TopicArn) throw new Error('No topic arn in response')
94+
topicArn = response.TopicArn
95+
} catch (err) {
96+
// We only manually build ARN in case of tag update
97+
if (!extraParams?.forceTagUpdate) throw err
98+
// To build ARN we need topic name and error should be "topic already exist with different tags"
99+
if (!topicOptions.Name || !isTopicAlreadyExistWithDifferentTagsError(err)) throw err
100+
topicArn = await buildTopicArn(stsClient, topicOptions.Name)
90101
}
91-
const topicArn = response.TopicArn
92102

93103
if (extraParams?.queueUrlsWithSubscribePermissionsPrefix || extraParams?.allowedSourceOwner) {
94104
const setTopicAttributesCommand = new SetTopicAttributesCommand({
@@ -102,21 +112,28 @@ export async function assertTopic(
102112
})
103113
await snsClient.send(setTopicAttributesCommand)
104114
}
115+
if (extraParams?.forceTagUpdate && topicOptions.Tags) {
116+
const tagTopicCommand = new TagResourceCommand({
117+
ResourceArn: topicArn,
118+
Tags: topicOptions.Tags,
119+
})
120+
await snsClient.send(tagTopicCommand)
121+
}
105122

106123
return topicArn
107124
}
108125

109-
export async function deleteTopic(client: SNSClient, topicName: string) {
126+
export async function deleteTopic(snsClient: SNSClient, stsClient: STSClient, topicName: string) {
110127
try {
111-
const topicArn = await assertTopic(client, {
128+
const topicArn = await assertTopic(snsClient, stsClient, {
112129
Name: topicName,
113130
})
114131

115-
const command = new DeleteTopicCommand({
116-
TopicArn: topicArn,
117-
})
118-
119-
await client.send(command)
132+
await snsClient.send(
133+
new DeleteTopicCommand({
134+
TopicArn: topicArn,
135+
}),
136+
)
120137
} catch (_) {
121138
// we don't care it operation has failed
122139
}
@@ -178,3 +195,15 @@ export async function getTopicArnByName(snsClient: SNSClient, topicName?: string
178195
*/
179196
export const calculateOutgoingMessageSize = (message: unknown) =>
180197
sqsCalculateOutgoingMessageSize(message)
198+
199+
const isTopicAlreadyExistWithDifferentTagsError = (error: unknown) =>
200+
!!error &&
201+
isError(error) &&
202+
'Error' in error &&
203+
!!error.Error &&
204+
typeof error.Error === 'object' &&
205+
'Code' in error.Error &&
206+
'Message' in error.Error &&
207+
typeof error.Error.Message === 'string' &&
208+
error.Error.Code === 'InvalidParameter' &&
209+
error.Error.Message.includes('already exists with different tags')

0 commit comments

Comments
 (0)