serverless-tag-resources 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  const { tagResources } = require("./src/template-tagger");
4
4
  const { updateTagsPostDeploy } = require("./src/post-deploy-tagger");
5
+ const { removeUnwantedTags } = require("./src/post-deploy/untag");
5
6
  const { validateTags } = require("./src/validation");
6
7
  const { configFromProvider } = require("./src/aws-clients");
8
+ const { TAGS_TO_REMOVE } = require("./src/tags");
7
9
 
8
10
  class TagResourcesServerlessPlugin {
9
11
  constructor(serverless, options) {
@@ -77,6 +79,7 @@ class TagResourcesServerlessPlugin {
77
79
  const awsProvider = this.serverless.getProvider("aws");
78
80
  const stackName = awsProvider.naming.getStackName();
79
81
 
82
+ // Phase 1: Tag resources that CF doesn't cover natively
80
83
  await updateTagsPostDeploy(
81
84
  this.awsConfig,
82
85
  stackName,
@@ -87,6 +90,16 @@ class TagResourcesServerlessPlugin {
87
90
  this._log.bind(this)
88
91
  );
89
92
 
93
+ // Phase 2: Remove unwanted tags (STAGE injected by SFW)
94
+ if (TAGS_TO_REMOVE.length > 0) {
95
+ await removeUnwantedTags(
96
+ this.awsConfig,
97
+ stackName,
98
+ TAGS_TO_REMOVE,
99
+ this._log.bind(this)
100
+ );
101
+ }
102
+
90
103
  this._log("TAGGING: Post-deploy tagging complete");
91
104
  }
92
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-tag-resources",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Datamart: Tag all AWS resources with dual legacy + datamart:* tag support",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -35,7 +35,8 @@
35
35
  "@aws-sdk/client-firehose": "^3.700.0",
36
36
  "@aws-sdk/client-pinpoint": "^3.700.0",
37
37
  "@aws-sdk/client-rds": "^3.700.0",
38
- "@aws-sdk/client-ssm": "^3.700.0"
38
+ "@aws-sdk/client-ssm": "^3.700.0",
39
+ "@aws-sdk/client-resource-groups-tagging-api": "^3.700.0"
39
40
  },
40
41
  "devDependencies": {
41
42
  "jest": "^30.3.0"
@@ -4,94 +4,119 @@ const {
4
4
  ResourceGroupsTaggingAPIClient,
5
5
  UntagResourcesCommand,
6
6
  } = require("@aws-sdk/client-resource-groups-tagging-api");
7
+ const {
8
+ CloudFormationClient,
9
+ DescribeStacksCommand,
10
+ DescribeStackResourcesCommand,
11
+ } = require("@aws-sdk/client-cloudformation");
7
12
  const { getClient } = require("../aws-clients");
8
13
 
9
14
  /**
10
- * Remove specific tags from all resources in a CloudFormation stack.
11
- * Uses Resource Groups Tagging API for broad coverage across resource types.
15
+ * Remove unwanted tags from the stack itself and all its resources.
16
+ * Uses Resource Groups Tagging API (UntagResources) which works on
17
+ * both CF stacks and individual resources.
12
18
  *
13
- * This is used to clean up tags injected by Serverless Framework (e.g., STAGE)
14
- * that conflict with the datamart:* tagging policy.
19
+ * This runs post-deploy to clean up tags injected by SFW (e.g., STAGE)
20
+ * that we cannot prevent at template/deploy time.
15
21
  */
16
- async function removeTagsFromStackResources(config, stackResources, tagKeysToRemove, partition, region, log) {
22
+ async function removeUnwantedTags(config, stackName, tagKeysToRemove, log) {
23
+ const cfnClient = getClient(CloudFormationClient, config);
17
24
  const taggingClient = getClient(ResourceGroupsTaggingAPIClient, config);
18
25
 
19
- // Collect ARNs of all stack resources
26
+ // Collect ARNs: stack + all resources
20
27
  const arns = [];
21
- for (const resource of stackResources) {
22
- const arn = buildArn(resource, partition, region);
28
+
29
+ // 1. Get stack ARN
30
+ const stackResult = await cfnClient.send(
31
+ new DescribeStacksCommand({ StackName: stackName })
32
+ );
33
+ const stackArn = stackResult.Stacks?.[0]?.StackId;
34
+ if (stackArn) arns.push(stackArn);
35
+
36
+ // 2. Get all resource ARNs
37
+ const resourceResult = await cfnClient.send(
38
+ new DescribeStackResourcesCommand({ StackName: stackName })
39
+ );
40
+ for (const resource of resourceResult.StackResources || []) {
41
+ const arn = resolveArn(resource);
23
42
  if (arn) arns.push(arn);
24
43
  }
25
44
 
26
- if (arns.length === 0) {
27
- log("TAGGING: No resources with ARNs to untag");
28
- return;
29
- }
45
+ if (arns.length === 0) return;
30
46
 
31
47
  // UntagResources accepts max 20 ARNs per call
32
48
  const batchSize = 20;
33
49
  let untagged = 0;
50
+ let failed = 0;
34
51
 
35
52
  for (let i = 0; i < arns.length; i += batchSize) {
36
53
  const batch = arns.slice(i, i + batchSize);
37
54
  try {
38
- await taggingClient.send(
55
+ const result = await taggingClient.send(
39
56
  new UntagResourcesCommand({
40
57
  ResourceARNList: batch,
41
58
  TagKeys: tagKeysToRemove,
42
59
  })
43
60
  );
44
- untagged += batch.length;
61
+ const failures = Object.keys(result.FailedResourcesMap || {}).length;
62
+ untagged += batch.length - failures;
63
+ failed += failures;
45
64
  } catch (err) {
46
- // Some resource types don't support tagging API — skip gracefully
47
- log(`TAGGING: WARN untag batch ${i}-${i + batch.length}: ${err.message}`);
65
+ // Some resource types don't support tagging API — skip
66
+ failed += batch.length;
48
67
  }
49
68
  }
50
69
 
51
- log(`TAGGING: Removed [${tagKeysToRemove.join(", ")}] from ${untagged} resources`);
70
+ log(`TAGGING: Removed [${tagKeysToRemove.join(", ")}] from ${untagged} resources (${failed} skipped)`);
52
71
  }
53
72
 
54
73
  /**
55
- * Build ARN from CloudFormation StackResource.
56
- * Uses PhysicalResourceId which is usually the ARN or resource ID.
74
+ * Resolve the ARN of a stack resource.
75
+ * PhysicalResourceId is sometimes the ARN, sometimes just the name/ID.
57
76
  */
58
- function buildArn(resource, partition, region) {
77
+ function resolveArn(resource) {
59
78
  const physicalId = resource.PhysicalResourceId;
60
79
  if (!physicalId) return null;
61
80
 
62
- // If it's already an ARN, use it directly
81
+ // Already an ARN
63
82
  if (physicalId.startsWith("arn:")) return physicalId;
64
83
 
65
- // For some resource types, we can construct the ARN
66
- const accountId = extractAccountId(resource);
67
- const type = resource.ResourceType;
84
+ // Extract account from stack ARN
85
+ const stackId = resource.StackId || "";
86
+ const parts = stackId.split(":");
87
+ if (parts.length < 5) return null;
88
+ const partition = parts[1];
89
+ const region = parts[3];
90
+ const account = parts[4];
68
91
 
69
- const arnBuilders = {
92
+ // Build ARN by resource type
93
+ const type = resource.ResourceType;
94
+ const builders = {
70
95
  "AWS::Lambda::Function": () =>
71
- `arn:${partition}:lambda:${region}:${accountId}:function:${physicalId}`,
96
+ `arn:${partition}:lambda:${region}:${account}:function:${physicalId}`,
72
97
  "AWS::SNS::Topic": () =>
73
- `arn:${partition}:sns:${region}:${accountId}:${physicalId}`,
74
- "AWS::SQS::Queue": () => null, // Queue URL, not name — skip
98
+ `arn:${partition}:sns:${region}:${account}:${physicalId}`,
75
99
  "AWS::Events::EventBus": () =>
76
- `arn:${partition}:events:${region}:${accountId}:event-bus/${physicalId}`,
77
- "AWS::Events::Rule": () => null, // Complex ARN — skip
78
- "AWS::ApiGateway::RestApi": () =>
79
- `arn:${partition}:apigateway:${region}::/restapis/${physicalId}`,
100
+ `arn:${partition}:events:${region}:${account}:event-bus/${physicalId}`,
80
101
  "AWS::Logs::LogGroup": () =>
81
- `arn:${partition}:logs:${region}:${accountId}:log-group:${physicalId}`,
102
+ `arn:${partition}:logs:${region}:${account}:log-group:${physicalId}`,
103
+ "AWS::IAM::Role": () =>
104
+ `arn:${partition}:iam::${account}:role/${physicalId}`,
105
+ "AWS::IAM::ManagedPolicy": () =>
106
+ `arn:${partition}:iam::${account}:policy/${physicalId}`,
107
+ "AWS::S3::Bucket": () =>
108
+ `arn:${partition}:s3:::${physicalId}`,
109
+ "AWS::SSM::Parameter": () =>
110
+ `arn:${partition}:ssm:${region}:${account}:parameter${physicalId.startsWith("/") ? "" : "/"}${physicalId}`,
111
+ "AWS::KMS::Key": () =>
112
+ `arn:${partition}:kms:${region}:${account}:key/${physicalId}`,
113
+ "AWS::KMS::Alias": () => null, // aliases don't support tagging
114
+ "AWS::CodeBuild::Project": () =>
115
+ `arn:${partition}:codebuild:${region}:${account}:project/${physicalId}`,
82
116
  };
83
117
 
84
- const builder = arnBuilders[type];
85
- if (builder) return builder();
86
-
87
- return null;
88
- }
89
-
90
- function extractAccountId(resource) {
91
- // StackId contains the account ID
92
- const stackId = resource.StackId || "";
93
- const parts = stackId.split(":");
94
- return parts.length >= 5 ? parts[4] : "";
118
+ const builder = builders[type];
119
+ return builder ? builder() : null;
95
120
  }
96
121
 
97
- module.exports = { removeTagsFromStackResources };
122
+ module.exports = { removeUnwantedTags };
@@ -5,9 +5,8 @@ const {
5
5
  DescribeStackResourcesCommand,
6
6
  } = require("@aws-sdk/client-cloudformation");
7
7
  const { getClient } = require("./aws-clients");
8
- const { buildListTags, buildDictTags, TAGS_TO_REMOVE } = require("./tags");
8
+ const { buildListTags, buildDictTags } = require("./tags");
9
9
  const { needsPostDeployTagging, DICT_BASED_TYPES, API_ONLY_TYPES, RELATED_TYPES } = require("./resource-classifier");
10
- const { removeTagsFromStackResources } = require("./post-deploy/untag");
11
10
 
12
11
  const { tagSSMParameter } = require("./post-deploy/ssm");
13
12
  const { tagPinpointApp } = require("./post-deploy/pinpoint");
@@ -92,11 +91,6 @@ async function updateTagsPostDeploy(config, stackName, stackTags, stage, partiti
92
91
  }
93
92
  }
94
93
 
95
- // Remove unwanted tags injected by SFW (STAGE)
96
- if (TAGS_TO_REMOVE.length > 0) {
97
- log(`TAGGING: Removing unwanted tags: ${TAGS_TO_REMOVE.join(", ")}`);
98
- await removeTagsFromStackResources(config, allResources, TAGS_TO_REMOVE, partition, region, log);
99
- }
100
94
  }
101
95
 
102
96
  module.exports = { updateTagsPostDeploy };