serverless-tag-resources 3.0.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.0.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": [
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+ssh://git@bitbucket.org:datamartcl/dm-serverless-tag-resources-plugin.git"
17
+ "url": "git+ssh://git@bitbucket.org/datamartcl/dm-serverless-tag-resources-plugin.git"
18
18
  },
19
19
  "keywords": [
20
20
  "serverless",
@@ -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"
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+
3
+ const {
4
+ ResourceGroupsTaggingAPIClient,
5
+ UntagResourcesCommand,
6
+ } = require("@aws-sdk/client-resource-groups-tagging-api");
7
+ const {
8
+ CloudFormationClient,
9
+ DescribeStacksCommand,
10
+ DescribeStackResourcesCommand,
11
+ } = require("@aws-sdk/client-cloudformation");
12
+ const { getClient } = require("../aws-clients");
13
+
14
+ /**
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.
18
+ *
19
+ * This runs post-deploy to clean up tags injected by SFW (e.g., STAGE)
20
+ * that we cannot prevent at template/deploy time.
21
+ */
22
+ async function removeUnwantedTags(config, stackName, tagKeysToRemove, log) {
23
+ const cfnClient = getClient(CloudFormationClient, config);
24
+ const taggingClient = getClient(ResourceGroupsTaggingAPIClient, config);
25
+
26
+ // Collect ARNs: stack + all resources
27
+ const arns = [];
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);
42
+ if (arn) arns.push(arn);
43
+ }
44
+
45
+ if (arns.length === 0) return;
46
+
47
+ // UntagResources accepts max 20 ARNs per call
48
+ const batchSize = 20;
49
+ let untagged = 0;
50
+ let failed = 0;
51
+
52
+ for (let i = 0; i < arns.length; i += batchSize) {
53
+ const batch = arns.slice(i, i + batchSize);
54
+ try {
55
+ const result = await taggingClient.send(
56
+ new UntagResourcesCommand({
57
+ ResourceARNList: batch,
58
+ TagKeys: tagKeysToRemove,
59
+ })
60
+ );
61
+ const failures = Object.keys(result.FailedResourcesMap || {}).length;
62
+ untagged += batch.length - failures;
63
+ failed += failures;
64
+ } catch (err) {
65
+ // Some resource types don't support tagging API — skip
66
+ failed += batch.length;
67
+ }
68
+ }
69
+
70
+ log(`TAGGING: Removed [${tagKeysToRemove.join(", ")}] from ${untagged} resources (${failed} skipped)`);
71
+ }
72
+
73
+ /**
74
+ * Resolve the ARN of a stack resource.
75
+ * PhysicalResourceId is sometimes the ARN, sometimes just the name/ID.
76
+ */
77
+ function resolveArn(resource) {
78
+ const physicalId = resource.PhysicalResourceId;
79
+ if (!physicalId) return null;
80
+
81
+ // Already an ARN
82
+ if (physicalId.startsWith("arn:")) return physicalId;
83
+
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];
91
+
92
+ // Build ARN by resource type
93
+ const type = resource.ResourceType;
94
+ const builders = {
95
+ "AWS::Lambda::Function": () =>
96
+ `arn:${partition}:lambda:${region}:${account}:function:${physicalId}`,
97
+ "AWS::SNS::Topic": () =>
98
+ `arn:${partition}:sns:${region}:${account}:${physicalId}`,
99
+ "AWS::Events::EventBus": () =>
100
+ `arn:${partition}:events:${region}:${account}:event-bus/${physicalId}`,
101
+ "AWS::Logs::LogGroup": () =>
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}`,
116
+ };
117
+
118
+ const builder = builders[type];
119
+ return builder ? builder() : null;
120
+ }
121
+
122
+ module.exports = { removeUnwantedTags };
@@ -90,6 +90,7 @@ async function updateTagsPostDeploy(config, stackName, stackTags, stage, partiti
90
90
  log(`TAGGING: ERROR post-deploy ${type} ${logicalId}: ${err.message}`);
91
91
  }
92
92
  }
93
+
93
94
  }
94
95
 
95
96
  module.exports = { updateTagsPostDeploy };
package/src/tags.js CHANGED
@@ -3,15 +3,19 @@
3
3
  /**
4
4
  * Tag building module.
5
5
  *
6
- * Produces both legacy (PascalCase) and new datamart:* tags
7
- * during the transition period. Auto-generates:
8
- * - Stage / datamart:environment (from deployment stage)
9
- * - Resource / datamart:resource (from CloudFormation LogicalID)
6
+ * Auto-generates datamart:* tags only (no legacy PascalCase):
7
+ * - datamart:environment (from deployment stage)
8
+ * - datamart:resource (from CloudFormation LogicalID)
9
+ *
10
+ * Legacy tags (Stage, Resource) removed in v3.1.0 per tagging policy v2.1.
10
11
  */
11
12
 
13
+ // Tags injected by SFW that should be removed post-deploy
14
+ const TAGS_TO_REMOVE = ["STAGE"];
15
+
12
16
  /**
13
17
  * Build list-based tags [{Key, Value}] for CloudFormation resources.
14
- * Merges stackTags + auto-generated tags. Does not overwrite existing keys.
18
+ * Merges stackTags + auto-generated datamart:* tags.
15
19
  */
16
20
  function buildListTags(stackTags, stage, logicalId) {
17
21
  const tags = [];
@@ -25,11 +29,9 @@ function buildListTags(stackTags, stage, logicalId) {
25
29
  }
26
30
  }
27
31
 
28
- // Auto-generated pairs (legacy + datamart)
32
+ // Auto-generated datamart:* tags only
29
33
  const autoTags = [
30
- { Key: "Stage", Value: stage },
31
34
  { Key: "datamart:environment", Value: stage },
32
- { Key: "Resource", Value: logicalId },
33
35
  { Key: "datamart:resource", Value: logicalId },
34
36
  ];
35
37
 
@@ -57,10 +59,8 @@ function buildDictTags(stackTags, stage, logicalId) {
57
59
  }
58
60
  }
59
61
 
60
- // Auto-generated pairs (legacy + datamart) — don't overwrite user-provided
61
- if (!tags["Stage"]) tags["Stage"] = stage;
62
+ // Auto-generated datamart:* tags only — don't overwrite user-provided
62
63
  if (!tags["datamart:environment"]) tags["datamart:environment"] = stage;
63
- if (!tags["Resource"]) tags["Resource"] = logicalId;
64
64
  if (!tags["datamart:resource"]) tags["datamart:resource"] = logicalId;
65
65
 
66
66
  return tags;
@@ -75,4 +75,4 @@ function excludeAwsTags(tags) {
75
75
  );
76
76
  }
77
77
 
78
- module.exports = { buildListTags, buildDictTags, excludeAwsTags };
78
+ module.exports = { buildListTags, buildDictTags, excludeAwsTags, TAGS_TO_REMOVE };