serverless-tag-resources 3.1.0 → 3.1.2

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.2",
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,126 @@ 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");
13
+ const { SKIP_TYPES } = require("../resource-classifier");
8
14
 
9
15
  /**
10
- * Remove specific tags from all resources in a CloudFormation stack.
11
- * Uses Resource Groups Tagging API for broad coverage across resource types.
16
+ * Remove unwanted tags from the stack itself and all its resources.
17
+ * Uses Resource Groups Tagging API (UntagResources) which works on
18
+ * both CF stacks and individual resources.
12
19
  *
13
- * This is used to clean up tags injected by Serverless Framework (e.g., STAGE)
14
- * that conflict with the datamart:* tagging policy.
20
+ * No UpdateStack only tag removal, zero infrastructure changes.
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
20
26
  const arns = [];
21
- for (const resource of stackResources) {
22
- const arn = buildArn(resource, partition, region);
23
- if (arn) arns.push(arn);
24
- }
25
27
 
26
- if (arns.length === 0) {
27
- log("TAGGING: No resources with ARNs to untag");
28
- return;
28
+ // 1. Get stack ARN
29
+ const stackResult = await cfnClient.send(
30
+ new DescribeStacksCommand({ StackName: stackName })
31
+ );
32
+ const stackArn = stackResult.Stacks?.[0]?.StackId;
33
+ if (stackArn) arns.push(stackArn);
34
+
35
+ // 2. Get resource ARNs (deduplicated, excluding types that don't support tags)
36
+ const seen = new Set();
37
+ const resourceResult = await cfnClient.send(
38
+ new DescribeStackResourcesCommand({ StackName: stackName })
39
+ );
40
+ for (const resource of resourceResult.StackResources || []) {
41
+ if (SKIP_TYPES.has(resource.ResourceType)) continue;
42
+ const arn = resolveArn(resource);
43
+ if (arn && !seen.has(arn)) {
44
+ seen.add(arn);
45
+ arns.push(arn);
46
+ }
29
47
  }
30
48
 
49
+ if (arns.length === 0) return;
50
+
31
51
  // UntagResources accepts max 20 ARNs per call
32
52
  const batchSize = 20;
33
53
  let untagged = 0;
54
+ let skipped = 0;
34
55
 
35
56
  for (let i = 0; i < arns.length; i += batchSize) {
36
57
  const batch = arns.slice(i, i + batchSize);
37
58
  try {
38
- await taggingClient.send(
59
+ const result = await taggingClient.send(
39
60
  new UntagResourcesCommand({
40
61
  ResourceARNList: batch,
41
62
  TagKeys: tagKeysToRemove,
42
63
  })
43
64
  );
44
- untagged += batch.length;
65
+ const failedMap = result.FailedResourcesMap || {};
66
+ const failures = Object.keys(failedMap).length;
67
+ untagged += batch.length - failures;
68
+ skipped += failures;
45
69
  } 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}`);
70
+ skipped += batch.length;
48
71
  }
49
72
  }
50
73
 
51
- log(`TAGGING: Removed [${tagKeysToRemove.join(", ")}] from ${untagged} resources`);
74
+ log(`TAGGING: Removed [${tagKeysToRemove.join(", ")}] from ${untagged} resources (${skipped} skipped)`);
52
75
  }
53
76
 
54
77
  /**
55
- * Build ARN from CloudFormation StackResource.
56
- * Uses PhysicalResourceId which is usually the ARN or resource ID.
78
+ * Resolve the ARN of a stack resource.
79
+ * PhysicalResourceId is sometimes the ARN, sometimes just the name/ID.
57
80
  */
58
- function buildArn(resource, partition, region) {
81
+ function resolveArn(resource) {
59
82
  const physicalId = resource.PhysicalResourceId;
60
83
  if (!physicalId) return null;
61
84
 
62
- // If it's already an ARN, use it directly
85
+ // Already an ARN
63
86
  if (physicalId.startsWith("arn:")) return physicalId;
64
87
 
65
- // For some resource types, we can construct the ARN
66
- const accountId = extractAccountId(resource);
67
- const type = resource.ResourceType;
88
+ // Extract account from stack ARN
89
+ const stackId = resource.StackId || "";
90
+ const parts = stackId.split(":");
91
+ if (parts.length < 5) return null;
92
+ const partition = parts[1];
93
+ const region = parts[3];
94
+ const account = parts[4];
68
95
 
69
- const arnBuilders = {
96
+ // Build ARN by resource type
97
+ const type = resource.ResourceType;
98
+ const builders = {
70
99
  "AWS::Lambda::Function": () =>
71
- `arn:${partition}:lambda:${region}:${accountId}:function:${physicalId}`,
100
+ `arn:${partition}:lambda:${region}:${account}:function:${physicalId}`,
72
101
  "AWS::SNS::Topic": () =>
73
- `arn:${partition}:sns:${region}:${accountId}:${physicalId}`,
74
- "AWS::SQS::Queue": () => null, // Queue URL, not name — skip
102
+ `arn:${partition}:sns:${region}:${account}:${physicalId}`,
75
103
  "AWS::Events::EventBus": () =>
76
- `arn:${partition}:events:${region}:${accountId}:event-bus/${physicalId}`,
77
- "AWS::Events::Rule": () => null, // Complex ARN — skip
104
+ `arn:${partition}:events:${region}:${account}:event-bus/${physicalId}`,
105
+ "AWS::Events::Rule": () =>
106
+ `arn:${partition}:events:${region}:${account}:rule/${physicalId}`,
107
+ "AWS::Logs::LogGroup": () =>
108
+ `arn:${partition}:logs:${region}:${account}:log-group:${physicalId}`,
109
+ "AWS::IAM::Role": () =>
110
+ `arn:${partition}:iam::${account}:role/${physicalId}`,
111
+ "AWS::S3::Bucket": () =>
112
+ `arn:${partition}:s3:::${physicalId}`,
113
+ "AWS::SSM::Parameter": () =>
114
+ `arn:${partition}:ssm:${region}:${account}:parameter${physicalId.startsWith("/") ? "" : "/"}${physicalId}`,
115
+ "AWS::KMS::Key": () =>
116
+ `arn:${partition}:kms:${region}:${account}:key/${physicalId}`,
117
+ "AWS::CodeBuild::Project": () =>
118
+ `arn:${partition}:codebuild:${region}:${account}:project/${physicalId}`,
78
119
  "AWS::ApiGateway::RestApi": () =>
79
120
  `arn:${partition}:apigateway:${region}::/restapis/${physicalId}`,
80
- "AWS::Logs::LogGroup": () =>
81
- `arn:${partition}:logs:${region}:${accountId}:log-group:${physicalId}`,
121
+ "AWS::WAFv2::WebACL": () =>
122
+ `arn:${partition}:wafv2:${region}:${account}:regional/webacl/${physicalId}`,
82
123
  };
83
124
 
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] : "";
125
+ const builder = builders[type];
126
+ return builder ? builder() : null;
95
127
  }
96
128
 
97
- module.exports = { removeTagsFromStackResources };
129
+ 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 };
@@ -264,6 +264,7 @@ const SKIP_TYPES = new Set([
264
264
  // Glue
265
265
  "AWS::Glue::Database",
266
266
  "AWS::Glue::Classifier",
267
+ "AWS::Glue::Crawler",
267
268
  "AWS::Glue::Connection",
268
269
  "AWS::Glue::DataCatalogEncryptionSettings",
269
270
  "AWS::Glue::Partition",