diff --git a/typescript/ssm-document-association/README.md b/typescript/ssm-document-association/README.md new file mode 100644 index 000000000..4b50b734e --- /dev/null +++ b/typescript/ssm-document-association/README.md @@ -0,0 +1,89 @@ +# SSM Document Association + + +--- + +![Stability: Stable](https://img.shields.io/badge/stability-Stable-success.svg?style=for-the-badge) + +> **This is a stable example. It should successfully build out of the box** +> +> This example is built on Construct Libraries marked "Stable" and does not have any infrastructure prerequisites to build. +--- + + +## Overview + +An example that shows how to create an SSM document and associate it with targets that meet certain conditions — in this case, based on a tag and value. Additionally, an EC2 instance is deployed with this specific tag-value combination, so the document will be executed on that instance. The document will write the current timestamp to a file on the instance every 30 minutes. + +## How it works + +1. SSM Document is created with a command to write the current timestamp to a file. +2. SSM Document Association is created with a target tag, parameter, and schedule. +3. An EC2 instance is created with the same tag-value combination as the SSM Document Association target. +4. You can connect to the EC2 instance using AWS Session Manager. +5. Verify the existence of the file with the timestamp. + + +## Build and Deploy + +1. Ensure aws-cdk is installed and your AWS account/region is [bootstrapped](https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html). + +```bash +npm install -g aws-cdk +cdk bootstrap +``` + +2. Build and deploy. +_You will need to have [Docker](https://docs.docker.com/get-docker/) installed and running._ + +```bash +npm run build +cdk deploy +``` + +You should see some useful outputs in the terminal: + +```bash +✅ SsmDocumentAssociationStack + +✨ Deployment time: 175.86s + +Outputs: +SsmDocumentAssociationStack.DocumentName = WriteTimeToFile +SsmDocumentAssociationStack.InstanceId = +Stack ARN: + +✨ Total time: 67.29s +``` + +## Try it out + +1. Deploy the stack and connect to the EC2 instance using AWS Session Manager. + +2. Verify the existence of the file with the timestamp. + +```bash +$ ls /opt/aws/time_records/ +time_20250414_195134.txt +$ cat /opt/aws/time_records/time_20250414_195134.txt +Mon Apr 14 19:51:34 UTC 2025 +``` + +3. Try again, 30 minutes later, and see the new file created. + +```bash +$ ls /opt/aws/time_records/ +time_20250414_195134.txt time_20250414_201930.txt +$ cat /opt/aws/time_records/time_20250414_201930.txt +Mon Apr 14 20:19:30 UTC 2025 +``` + + +## Useful commands + + * `npm run build` compile typescript to js + * `npm run watch` watch for changes and compile + * `npm run test` perform the jest unit tests + * `cdk deploy` deploy this stack to your default AWS account/region + * `cdk diff` compare deployed stack with current state + * `cdk synth` emits the synthesized CloudFormation template diff --git a/typescript/ssm-document-association/bin/ssm-document-association.ts b/typescript/ssm-document-association/bin/ssm-document-association.ts new file mode 100644 index 000000000..08610f204 --- /dev/null +++ b/typescript/ssm-document-association/bin/ssm-document-association.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { SsmDocumentAssociationStack } from '../lib/ssm-document-association-stack'; + +const app = new cdk.App(); +new SsmDocumentAssociationStack(app, 'SsmDocumentAssociationStack'); \ No newline at end of file diff --git a/typescript/ssm-document-association/cdk.json b/typescript/ssm-document-association/cdk.json new file mode 100644 index 000000000..0a780e4d6 --- /dev/null +++ b/typescript/ssm-document-association/cdk.json @@ -0,0 +1,90 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/ssm-document-association.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": true, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true + } +} diff --git a/typescript/ssm-document-association/lib/ssm-document-association-stack.ts b/typescript/ssm-document-association/lib/ssm-document-association-stack.ts new file mode 100644 index 000000000..4ad0eb10f --- /dev/null +++ b/typescript/ssm-document-association/lib/ssm-document-association-stack.ts @@ -0,0 +1,111 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as ssm from 'aws-cdk-lib/aws-ssm'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; + +export class SsmDocumentAssociationStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Create an SSM Document that writes current time to a new file + const ssmDocument = new ssm.CfnDocument(this, 'TimeWriterDocument', { + name: 'WriteTimeToFile', + documentType: 'Command', + content: { + schemaVersion: '2.2', + description: 'Write current timestamp to a new file', + parameters: { + DirectoryPath: { + type: 'String', + description: 'Directory where the time files will be written', + default: '/tmp/time_logs' + } + }, + mainSteps: [ + { + action: 'aws:runShellScript', + name: 'writeTimeToNewFile', + inputs: { + runCommand: [ + 'mkdir -p {{DirectoryPath}}', + 'TIMESTAMP=$(date +"%Y%m%d_%H%M%S")', + 'FILENAME="time_$TIMESTAMP.txt"', + 'FILEPATH="{{DirectoryPath}}/$FILENAME"', + 'echo "Creating new time file: $FILEPATH"', + 'date > $FILEPATH', + 'echo "Current time written to $FILEPATH: $(cat $FILEPATH)"', + 'echo "Total files in directory: $(ls -1 {{DirectoryPath}} | wc -l)"', + 'echo "Operation completed on $(hostname)"' + ] + } + } + ] + } + }); + + // Create an association for the document + // Apply the document to all EC2 instances with the tag Environment:Development + // The association will run every 30 minutes + new ssm.CfnAssociation(this, 'DocumentAssociation', { + name: ssmDocument.ref, + targets: [ + { + key: 'tag:Environment', + values: ['Development'] + } + ], + parameters: { + // overwrite default parameter + 'DirectoryPath': ['/opt/aws/time_records'] + }, + scheduleExpression: 'rate(30 minutes)' + }); + + + // Testing infrastructure + // A VPC + EC2 + Connect using AWS Session Manager + // No NAT / Private Subnets + const vpc = new ec2.Vpc(this, 'SSMDocumentTestVpc', { + maxAzs: 1, + natGateways: 0, + subnetConfiguration: [ + { + name: 'public', + subnetType: ec2.SubnetType.PUBLIC, + } + ] + }); + + const role = new iam.Role(this, 'EC2SSMRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore') + ] + }); + + // Create an EC2 instance with Environment tag set to Development + // Use AMI that contains SSM agent + const instance = new ec2.Instance(this, 'SSMTestInstance', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.NANO), + machineImage: ec2.MachineImage.latestAmazonLinux2023(), + role, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + }); + + // Add the Environment:Development tag that matches our SSM Document association + cdk.Tags.of(instance).add('Environment', 'Development'); + + // Outputs + new cdk.CfnOutput(this, 'DocumentName', { + value: ssmDocument.ref, + description: 'The name of the SSM document' + }); + + new cdk.CfnOutput(this, 'InstanceId', { + value: instance.instanceId, + description: 'The ID of the test EC2 instance' + }); + } +} diff --git a/typescript/ssm-document-association/package.json b/typescript/ssm-document-association/package.json new file mode 100644 index 000000000..b91a01e3a --- /dev/null +++ b/typescript/ssm-document-association/package.json @@ -0,0 +1,25 @@ +{ + "name": "ssm-document-association", + "version": "0.1.0", + "bin": { + "ssm-document-association": "bin/ssm-document-association.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "22.7.9", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "aws-cdk": "2.1007.0", + "ts-node": "^10.9.2", + "typescript": "~5.6.3" + }, + "dependencies": { + "aws-cdk-lib": "2.186.0", + "constructs": "^10.0.0" + } +} diff --git a/typescript/ssm-document-association/tsconfig.json b/typescript/ssm-document-association/tsconfig.json new file mode 100644 index 000000000..aaa7dc510 --- /dev/null +++ b/typescript/ssm-document-association/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": [ + "es2020", + "dom" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +}