Repository: udondan/cdk-ssm-document (language: python)

Issue:
Add support for Document Attachments
### Problem statement : 
Cannot create a SSM Document that can be used with AWS Distributor since attachments cannot be added to the definition. 

See an example of what the SSM Document looks like for a Distributor package using Cloudformation: 

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-document.html#aws-resource-ssm-document--examples--Create_a__Distributor_package


### Solution: 
* Update lib to allow for an optional Attachments property at the Document level
   * Also will likely want to create AttachmentSource child object https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_AttachmentsSource.html
* Update lambda when create/update is called to pass the Attachments if they are defined

Happy to create a PR to implement this. 


Discussion / hints:
> Happy to create a PR to implement this.

Sounds good! 👍🏼 

Fix patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a7ed7e1..65815f7 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -40,11 +40,11 @@ jobs:
         uses: udondan/jsii-publish@v0.12.0
         with:
           PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
-
-      - name: Publish to NuGet
-        uses: udondan/jsii-publish@v0.12.0
-        with:
-          NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }}
+#
+#      - name: Publish to NuGet
+#        uses: udondan/jsii-publish@v0.12.0
+#        with:
+#          NUGET_TOKEN: ${{ secrets.NUGET_TOKEN }}
 #
 #      - name: Publish to Maven GitHub
 #        uses: udondan/jsii-publish@v0.12.0
diff --git a/README.md b/README.md
index fb6dcb4..bb41ae6 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,72 @@ export class TestStack extends cdk.Stack {
 }
 ```
 
+### Creating a distributor package
+
+```typescript
+import * as cdk from 'aws-cdk-lib';
+import { Construct } from 'constructs';
+import { Document } from 'cdk-ssm-document';
+import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
+import * as s3 from 'aws-cdk-lib/aws-s3';
+import * as statement from 'cdk-iam-floyd';
+import fs = require('fs');
+import path = require('path');
+
+
+export class TestStack extends cdk.Stack {
+  constructor(scope: cdk.Construct, id: string, props: cdk.StackProps) {
+    super(scope, id, props);
+
+    const bucketName = `${cdk.Stack.of(this).account}-cdk-ssm-document-storage`;
+    const bucket = new s3.Bucket(this, 'DistributorPackages', {
+      bucketName: bucketName,
+    });
+    const packageDeploy = new s3deploy.BucketDeployment(this, 'distribution-packages', {
+      sources: [s3deploy.Source.asset('../location/to/distributor/packages')],
+      destinationBucket: bucket
+    });
+
+    file = path.join(
+      __dirname,
+      '../location/to/distributor/packages/v1/manifest.json'
+    );
+    const docE = new Document(this, `SSM-Distribution-Package`, {
+      documentType: 'Package',
+      name: 'Test-Distribution-Package',
+      content: fs.readFileSync(file).toString(),
+      versionName: '1.0-Custom-Name',
+      attachments: [
+        { key: "SourceUrl", values: [`s3://${bucketName}/v1`] }
+      ]
+    });
+
+    /**
+     * The owner/creator of the document must have read access to the
+     * s3 files that make up a distribution. Since that is the lambda in this
+     * case we must give it `GetObject` permissions before they will can become `Active`.
+     *
+     * If access is not granted to the role that created the document you may see
+     * an error like the following :
+     *
+     * ```
+     * Permanent download error: Source URL 's3://cdk-ssm-document-storage/v1/package.zip' reported:
+     * Access Denied (Service: Amazon S3; Status Code: 403;
+     * Error Code: AccessDenied; Request  *ID:DES1XEHZTJ9R; S3 Extended Request ID:
+     * A+u8sTGQ6bZpAwl2eXDLq4KTkoeYyQR2XEV+I=; Proxy: null)
+     * ```
+     */
+    doc.lambda.role?.addToPrincipalPolicy(
+      new statement.S3() //
+        .allow()
+        .toGetObject()
+        .onObject(bucket.bucketName, '*')
+    );
+    doc.node.addDependency(packageDeploy);
+  }
+}
+```
+
 ## Deploying many documents in a single stack
 
 When you want to create multiple documents in the same stack, you will quickly exceed the SSM API rate limit. One ugly but working solution for this is to ensure that only a single document is created/updated at a time by adding resource dependencies. When document C depends on document B and B depends on document A, the documents will be created/updated in that order.
diff --git a/VERSION b/VERSION
index 4a36342..fd2a018 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-3.0.0
+3.1.0
diff --git a/lambda/index.ts b/lambda/index.ts
index 314832f..666b6fc 100644
--- a/lambda/index.ts
+++ b/lambda/index.ts
@@ -55,6 +55,8 @@ function Create(event: Event): Promise<Event> {
         DocumentType: event.ResourceProperties.DocumentType,
         TargetType: event.ResourceProperties.TargetType || defaultTargetType,
         Tags: makeTags(event, event.ResourceProperties),
+        VersionName: event.ResourceProperties.VersionName,
+        Attachments: pascalizeKeys(event.ResourceProperties.Attachments || []),
       },
       function (err: AWS.AWSError, data: AWS.SSM.CreateDocumentResult) {
         if (err) {
@@ -98,8 +100,12 @@ function updateDocument(event: Event): Promise<Event> {
     if (
       JSON.stringify(event.ResourceProperties.Content) ==
         JSON.stringify(event.OldResourceProperties.Content) &&
+      JSON.stringify(event.ResourceProperties.Attachments) ==
+        JSON.stringify(event.OldResourceProperties.Attachments) &&
       (event.ResourceProperties.TargetType || defaultTargetType) ==
-        (event.OldResourceProperties.TargetType || defaultTargetType)
+        (event.OldResourceProperties.TargetType || defaultTargetType) &&
+      event.ResourceProperties.VersionName ==
+        event.OldResourceProperties.VersionName
     ) {
       logger.info(
         `No changes detected on document ${event.ResourceProperties.Name} itself`
@@ -112,6 +118,8 @@ function updateDocument(event: Event): Promise<Event> {
         Content: JSON.stringify(event.ResourceProperties.Content),
         TargetType: event.ResourceProperties.TargetType || defaultTargetType,
         DocumentVersion: '$LATEST',
+        VersionName: event.ResourceProperties.VersionName,
+        Attachments: pascalizeKeys(event.ResourceProperties.Attachments || []),
       },
       function (err: AWS.AWSError, data: AWS.SSM.UpdateDocumentResult) {
         if (err && err.code == 'DuplicateDocumentContent') {
@@ -157,6 +165,38 @@ function updateDocumentAddTags(event: Event): Promise<Event> {
   });
 }
 
+const pascalizeKeys = (obj: any): any => {
+  const isObject = (o: any) =>
+    Object.prototype.toString.apply(o) === '[object Object]';
+  const isArray = (o: any) =>
+    Object.prototype.toString.apply(o) === '[object Array]';
+
+  let transformedObj = isArray(obj) ? [] : {};
+
+  for (let key in obj) {
+    // replace the following with any transform function
+    const transformedKey = toPascalCase(key);
+
+    if (isObject(obj[key]) || isArray(obj[key])) {
+      transformedObj[transformedKey] = pascalizeKeys(obj[key]);
+    } else {
+      transformedObj[transformedKey] = obj[key];
+    }
+  }
+  return transformedObj;
+};
+
+function toPascalCase(string: string) {
+  return string
+    .replace(new RegExp(/[-_]+/, 'g'), ' ')
+    .replace(new RegExp(/[^\w\s]/, 'g'), '')
+    .replace(
+      new RegExp(/\s+(.)(\w*)/, 'g'),
+      ($1, $2, $3) => `${$2.toUpperCase() + $3.toLowerCase()}`
+    )
+    .replace(new RegExp(/\w/), (s) => s.toUpperCase());
+}
+
 function updateDocumentRemoveTags(event: Event): Promise<Event> {
   logger.info(
     `Attempting to remove some tags for SSM document ${event.ResourceProperties.Name}`
diff --git a/lib/index.ts b/lib/index.ts
index 541fdf1..1e27981 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -76,6 +76,37 @@ export interface DocumentMainStep {
   [key: string]: any;
 }
 
+/**
+ * An SSM document attachment source
+ */
+export interface AttachmentSource {
+  /**
+   * The key of a key-value pair that identifies the location of an attachment to a document.
+   */
+  readonly key?: 'SourceUrl' | 'S3FileUrl' | 'AttachmentReference';
+
+  /**
+   * The name of the document attachment file.
+   */
+  readonly name?: string;
+
+  /**
+   * The value of a key-value pair that identifies the location of an attachment to a document. The format for Value depends on the type of key you specify.
+   *
+   * For the key SourceUrl, the value is an S3 bucket location. For example:
+   * "Values": [ "s3://doc-example-bucket/my-folder" ]
+   * For the key S3FileUrl, the value is a file in an S3 bucket. For example:
+   * "Values": [ "s3://doc-example-bucket/my-folder/my-file.py" ]
+   *   For the key AttachmentReference, the value is constructed from the name of another SSM document in your account, a version number of that document, and a file attached to that document version that you want to reuse. For example:
+   * "Values": [ "MyOtherDocument/3/my-other-file.py" ]
+   * However, if the SSM document is shared with you from another account, the full SSM document ARN must be specified instead of the document name only. For example:
+   * "Values": [ "arn:aws:ssm:us-east-2:111122223333:document/OtherAccountDocument/3/their-file.py" ]
+   * Type: Array of strings
+   * Array Members: Fixed number of 1 item.
+   */
+  readonly values?: string[];
+}
+
 /**
  * The content of the SSM document. The syntax of your document is defined by the schema version used to create it.
  *
@@ -145,6 +176,16 @@ export interface DocumentProps extends cdk.StackProps {
    * Content of the SSM document. Can be passed as string or as object
    */
   readonly content: string | DocumentContent;
+
+  /**
+   * A list of key-value pairs that describe attachments to a version of a document.
+   */
+  readonly attachments?: AttachmentSource[];
+
+  /**
+   * An optional field specifying the version of the artifact you are creating with the document. For example, "Release 12, Update 6". This value is unique across all versions of a document, and can't be changed.
+   */
+  readonly versionName?: string;
 }
 
 /**
@@ -201,6 +242,8 @@ export class Document extends Construct implements cdk.ITaggable {
         content: content,
         documentType: props.documentType || 'Command',
         targetType: props.targetType || '/',
+        attachments: props.attachments,
+        versionName: props.versionName,
         StackName: stack,
         tags: cdk.Lazy.any({
           produce: () => this.tags.renderTags(),
diff --git a/package.json b/package.json
index 3e8ec75..8f59a6a 100644
--- a/package.json
+++ b/package.json
@@ -53,17 +53,17 @@
     }
   },
   "devDependencies": {
-    "@types/aws-lambda": "^8.10.68",
+    "@types/aws-lambda": "^8.10.92",
     "@types/js-yaml": "^3.12.5",
-    "@types/node": "14.14.16",
+    "@types/node": "17.0.15",
     "aws-cdk-lib": "^2.0.0",
-    "aws-lambda": "^1.0.6",
-    "aws-sdk": "^2.817.0",
+    "aws-lambda": "^1.0.7",
+    "aws-sdk": "^2.1069.0",
     "constructs": "^10.0.0",
-    "jsii": "^1.16.0",
-    "jsii-pacmak": "^1.16.0",
-    "ts-node": "^9.1.1",
-    "typescript": "~4.1.3",
+    "jsii": "^1.52.1",
+    "jsii-pacmak": "^1.52.1",
+    "ts-node": "^10.4.0",
+    "typescript": "^4.5.5",
     "cdk-iam-floyd": "^0.300.0",
     "aws-cloudformation-custom-resource": "^2.0.1"
   },


Test patch:
diff --git a/test/Makefile b/test/Makefile
index 7f29a63..9f2f477 100644
--- a/test/Makefile
+++ b/test/Makefile
@@ -13,15 +13,15 @@ build: install lambda
 
 deploy: build
 	@echo Deploying application...
-	@npm run cdk -- deploy --require-approval never
+	@AWS_REGION=us-east-1 npm run cdk -- deploy --require-approval never
 
 diff: build
 	@echo Running diff...
-	@npm run cdk -- diff
+	@AWS_REGION=us-east-1 npm run cdk -- diff
 
 DESTROY: build
 	@echo Destroying application...
-	@npm run cdk -- destroy --force
+	@AWS_REGION=us-east-1 npm run cdk -- destroy --force
 
 lambda:
 	@echo Building Lambda...
diff --git a/test/documents/distributor/v1/manifest.json b/test/documents/distributor/v1/manifest.json
new file mode 100644
index 0000000..a75279b
--- /dev/null
+++ b/test/documents/distributor/v1/manifest.json
@@ -0,0 +1,22 @@
+{
+  "files": {
+    "package.zip": {
+      "checksums": {
+        "sha256": "7a8feee92ba9e75c924ca89db4d9fa0d205693b43e6205782be9f46f258167be"
+      }
+    }
+  },
+  "publisher": "test-cdk-ssm-document",
+  "schemaVersion": "2.0",
+  "description": "doing 1.0 things",
+  "packages": {
+    "_any": {
+      "_any": {
+        "x86_64": {
+          "file": "package.zip"
+        }
+      }
+    }
+  },
+  "version": "1.0"
+}
diff --git a/test/documents/distributor/v1/package.zip b/test/documents/distributor/v1/package.zip
new file mode 100644
index 0000000..ad5a7dc
Binary files /dev/null and b/test/documents/distributor/v1/package.zip differ
diff --git a/test/documents/distributor/v2/manifest.json b/test/documents/distributor/v2/manifest.json
new file mode 100644
index 0000000..85ae98b
--- /dev/null
+++ b/test/documents/distributor/v2/manifest.json
@@ -0,0 +1,22 @@
+{
+  "files": {
+    "package-v2.zip": {
+      "checksums": {
+        "sha256": "c38917dee41299a8aa6bc10d1d7c7139c4ab72474ae832cd524eb15da51315d9"
+      }
+    }
+  },
+  "publisher": "test-cdk-ssm-document",
+  "schemaVersion": "2.0",
+  "description": "doing 2.0 things",
+  "packages": {
+    "_any": {
+      "_any": {
+        "x86_64": {
+          "file": "package-v2.zip"
+        }
+      }
+    }
+  },
+  "version": "2.0"
+}
diff --git a/test/documents/distributor/v2/package-v2.zip b/test/documents/distributor/v2/package-v2.zip
new file mode 100644
index 0000000..a0d5ce1
Binary files /dev/null and b/test/documents/distributor/v2/package-v2.zip differ
diff --git a/test/lib/test-stack.ts b/test/lib/test-stack.ts
index ce61f34..392d026 100644
--- a/test/lib/test-stack.ts
+++ b/test/lib/test-stack.ts
@@ -1,4 +1,7 @@
 import * as cdk from 'aws-cdk-lib';
+import * as s3 from 'aws-cdk-lib/aws-s3';
+import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
+import * as statement from 'cdk-iam-floyd';
 import { Construct } from 'constructs';
 import fs = require('fs');
 import path = require('path');
@@ -8,7 +11,6 @@ import { Document } from '../../lib';
 export class TestStack extends cdk.Stack {
   constructor(scope: Construct, id: string, props?: cdk.StackProps) {
     super(scope, id, props);
-
     let file = path.join(
       __dirname,
       '../documents/command/hello-world-yaml.yml'
@@ -61,6 +63,68 @@ export class TestStack extends cdk.Stack {
       },
     });
 
+    /**
+     * Distributor example.
+     *
+     * Requires a bucket to hold install/update/uninstall scripts.
+     */
+    const bucketName = `${cdk.Stack.of(this).account}-cdk-ssm-document-storage`;
+    const bucket = new s3.Bucket(this, 'DistributorPackages', {
+      bucketName: bucketName,
+      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
+      enforceSSL: true,
+      encryption: s3.BucketEncryption.KMS_MANAGED,
+      // Makes for easy destroy and rerun of this stack over and over.
+      removalPolicy: cdk.RemovalPolicy.DESTROY,
+      autoDeleteObjects: true,
+    });
+    const packageDeploy = new s3deploy.BucketDeployment(
+      this,
+      'distribution-packages',
+      {
+        sources: [s3deploy.Source.asset('../test/documents/distributor')],
+        destinationBucket: bucket,
+      }
+    );
+
+    let attachments: { [key: string]: any } = {};
+    // flip this condition to test an attachment update
+    if (true) {
+      file = path.join(__dirname, '../documents/distributor/v1/manifest.json');
+      attachments = {
+        versionName: '1.0-Custom-Name',
+        attachments: [{ key: 'SourceUrl', values: [`s3://${bucketName}/v1`] }],
+      };
+    } else {
+      file = path.join(__dirname, '../documents/distributor/v2/manifest.json');
+      attachments = {
+        versionName: '2.0-Better-Than_Sliced_Bread',
+        attachments: [{ key: 'SourceUrl', values: [`s3://${bucketName}/v2`] }],
+      };
+    }
+
+    const docE = new Document(this, `SSM-Distribution-Package`, {
+      documentType: 'Package',
+      name: 'Test-Distribution-Package',
+      content: fs.readFileSync(file).toString(),
+      ...attachments,
+    });
+
+    /**
+     * The owner/creator of the document must have read access to the
+     * s3 files that make up a distribution. Since that is the lambda in this
+     * case we must give it `GetObject` permissions before they will can become
+     * `Active`.
+     */
+    docE.lambda.role?.addToPrincipalPolicy(
+      new statement.S3() //
+        .allow()
+        .toGetObject()
+        .onObject(bucket.bucketName, '*')
+    );
+
+    docE.node.addDependency(docD);
+    docE.node.addDependency(packageDeploy);
     docD.node.addDependency(docC);
     docC.node.addDependency(docB);
     docB.node.addDependency(docA);
diff --git a/test/package.json b/test/package.json
index 502d177..1e25e60 100644
--- a/test/package.json
+++ b/test/package.json
@@ -9,14 +9,13 @@
     "cdk": "cdk"
   },
   "devDependencies": {
-    "@types/node": "14.14.16",
     "aws-cdk": "^2.0.0",
-    "ts-node": "^9.1.1",
-    "typescript": "~4.1.3"
+    "ts-node": "^10.4.0",
+    "typescript": "^4.5.5"
   },
   "dependencies": {
     "aws-cdk-lib": "2.x",
-    "cdk-iam-floyd": "0.301.0",
+    "cdk-iam-floyd": "0.313.0",
     "cdk-ssm-document": "file:.."
   }
 }
diff --git a/test/tsconfig.json b/test/tsconfig.json
index ec75123..d20513e 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -17,7 +17,8 @@
     "inlineSources": true,
     "experimentalDecorators": true,
     "strictPropertyInitialization": false,
-    "typeRoots": ["./node_modules/@types"]
+    "typeRoots": ["./node_modules/@types"],
+    "types": ["node"]
   },
   "exclude": ["cdk.out"]
 }