Skip to content

Commit

Permalink
Add support for resolving dynamic ssm values
Browse files Browse the repository at this point in the history
  • Loading branch information
corymhall committed Dec 3, 2024
1 parent bd78e66 commit aea1309
Show file tree
Hide file tree
Showing 11 changed files with 576 additions and 3 deletions.
13 changes: 13 additions & 0 deletions examples/ec2-instance/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as aws from '@pulumi/aws';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
Expand All @@ -6,6 +7,7 @@ import * as pulumicdk from '@pulumi/cdk';
import { Asset } from 'aws-cdk-lib/aws-s3-assets';
import * as pulumi from '@pulumi/pulumi';

const region = aws.config.requireRegion();
const config = new pulumi.Config();
const prefix = config.get('prefix') ?? pulumi.getStack();
export class Ec2CdkStack extends pulumicdk.Stack {
Expand Down Expand Up @@ -83,6 +85,17 @@ export class Ec2CdkStack extends pulumicdk.Stack {
new cdk.CfnOutput(this, 'ssh command', {
value: 'ssh -i cdk-key.pem -o IdentitiesOnly=yes ec2-user@' + ec2Instance.instancePublicIp,
});

const ssmName = ec2.AmazonLinuxImage.ssmParameterName({
generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2023,
});
new ec2.Instance(this, 'ssm-instance', {
vpc,
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
machineImage: ec2.MachineImage.genericLinux({
[region]: `{{resolve:ssm:${ssmName}}}`,
}),
});
}
}

Expand Down
31 changes: 31 additions & 0 deletions integration/examples_nodejs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,37 @@ func TestReplaceOnChanges(t *testing.T) {
integration.ProgramTest(t, &test)
}

func TestSsmDynamic(t *testing.T) {
test := getJSBaseOptions(t).
With(integration.ProgramTestOptions{
Dir: filepath.Join(getCwd(t), "ssm-dynamic"),
EditDirs: []integration.EditDir{
{
Dir: filepath.Join(getCwd(t), "ssm-dynamic/step2"),
Additive: true,
ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
t.Logf("\nOutputs: %v\n\n", stack.Outputs)

stringValue := stack.Outputs["stringValue"].(string)
assert.Equal(t, "testvalue", stringValue)

stringListValue := stack.Outputs["stringListValue"].([]interface{})
assert.Equal(t, []interface{}{"abcd", "xyz"}, stringListValue)

dynamicStringValue := stack.Outputs["dynamicStringValue"].(string)
assert.Equal(t, "testvalue", dynamicStringValue)

dyanmicStringListValue := stack.Outputs["dynamicStringListValue"].([]interface{})
assert.Equal(t, []interface{}{"abcd", "xyz"}, dyanmicStringListValue)

},
},
},
})

integration.ProgramTest(t, &test)
}

func TestRemovalPolicy(t *testing.T) {
// Since we are creating two tests we have to set `NoParallel` on each test
// and set parallel here.
Expand Down
3 changes: 3 additions & 0 deletions integration/ssm-dynamic/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: pulumi-aws-ssm-dynamic
runtime: nodejs
description: ssm-dynamic integration test
35 changes: 35 additions & 0 deletions integration/ssm-dynamic/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as pulumi from '@pulumi/pulumi';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as pulumicdk from '@pulumi/cdk';

const config = new pulumi.Config();
const prefix = config.get('prefix') ?? pulumi.getStack();
class SsmDynamicStack extends pulumicdk.Stack {
public readonly stringValue: pulumi.Output<string>;
public readonly stringListValue: pulumi.Output<string[]>;
constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
super(app, id, options);

const stringParam = new ssm.StringParameter(this, 'testparam', {
parameterName: `${prefix}-param`,
stringValue: 'testvalue',
});
this.stringValue = this.asOutput(stringParam.stringValue);

const listParam = new ssm.StringListParameter(this, 'testparamlist', {
parameterName: `${prefix}-listparam`,
stringListValue: ['abcd', 'xyz'],
});
this.stringListValue = this.asOutput(listParam.stringListValue);
}
}

const app = new pulumicdk.App('app', (scope: pulumicdk.App) => {
const stack = new SsmDynamicStack(scope, `${prefix}-misc`);
return {
stringValue: stack.stringValue,
stringListValue: stack.stringListValue,
};
});
export const stringValue = app.outputs['stringValue'];
export const stringListValue = app.outputs['stringListValue'];
15 changes: 15 additions & 0 deletions integration/ssm-dynamic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "pulumi-aws-cdk",
"devDependencies": {
"@types/node": "^10.0.0"
},
"dependencies": {
"@pulumi/aws": "^6.0.0",
"@pulumi/aws-native": "^1.11.0",
"@pulumi/cdk": "^0.5.0",
"@pulumi/pulumi": "^3.0.0",
"aws-cdk-lib": "2.149.0",
"constructs": "10.3.0",
"esbuild": "^0.24.0"
}
}
57 changes: 57 additions & 0 deletions integration/ssm-dynamic/step2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as pulumi from '@pulumi/pulumi';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as pulumicdk from '@pulumi/cdk';
import { CfnDynamicReference, CfnDynamicReferenceService } from 'aws-cdk-lib';

const config = new pulumi.Config();
const prefix = config.get('prefix') ?? pulumi.getStack();
class SsmDynamicStack extends pulumicdk.Stack {
public readonly stringValue: pulumi.Output<string>;
public readonly stringListValue: pulumi.Output<string[]>;
public readonly dynamicStringValue: pulumi.Output<string>;
public readonly dynamicStringListValue: pulumi.Output<string[]>;
constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
super(app, id, options);

const stringParam = new ssm.StringParameter(this, 'testparam', {
parameterName: `${prefix}-param`,
stringValue: 'testvalue',
});
this.stringValue = this.asOutput(stringParam.stringValue);

const listParam = new ssm.StringListParameter(this, 'testparamlist', {
parameterName: `${prefix}-listparam`,
stringListValue: ['abcd', 'xyz'],
});
this.stringListValue = this.asOutput(listParam.stringListValue);

const stringValue = new CfnDynamicReference(CfnDynamicReferenceService.SSM, `${prefix}-param`).toString();
const stringDynamicParam = new ssm.StringParameter(this, 'stringDynamicParam', {
stringValue: stringValue,
});
this.dynamicStringValue = this.asOutput(stringDynamicParam.stringValue);

const stringListValue = new CfnDynamicReference(
CfnDynamicReferenceService.SSM,
`${prefix}-listparam`,
).toString();
const stringListDynamicParam = new ssm.StringParameter(this, 'stringListDynamicParam', {
stringValue: stringListValue,
});
this.dynamicStringListValue = this.asOutput(stringListDynamicParam.stringValue).apply((v) => v.split(','));
}
}

const app = new pulumicdk.App('app', (scope: pulumicdk.App) => {
const stack = new SsmDynamicStack(scope, `${prefix}-misc`);
return {
stringValue: stack.stringValue,
stringListValue: stack.stringListValue,
dynamicStringValue: stack.dynamicStringValue,
dynamicStringListValue: stack.dynamicStringListValue,
};
});
export const stringValue = app.outputs['stringValue'];
export const stringListValue = app.outputs['stringListValue'];
export const dynamicStringValue = app.outputs['dynamicStringValue'];
export const dynamicStringListValue = app.outputs['dynamicStringListValue'];
18 changes: 18 additions & 0 deletions integration/ssm-dynamic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2019",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"./*.ts"
]
}
2 changes: 2 additions & 0 deletions src/converters/app-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as intrinsics from './intrinsics';
import { CloudFormationParameter, CloudFormationParameterLogicalId, CloudFormationParameterWithId } from '../cfn';
import { Metadata, PulumiResource } from '../pulumi-metadata';
import { PulumiProvider } from '../types';
import { processSSMReferenceValue } from './ssm-dynamic';

/**
* AppConverter will convert all CDK resources into Pulumi resources.
Expand Down Expand Up @@ -411,6 +412,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr
.reduce((result, [k, v]) => {
let value = this.processIntrinsics(v);
value = processSecretsManagerReferenceValue(this.stackResource, value);
value = processSSMReferenceValue(this.stackResource, value);
return {
...result,
[k]: value,
Expand Down
160 changes: 160 additions & 0 deletions src/converters/ssm-dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
import { containsEventuals } from '../types';

/**
* The regular expression used to match an SSM plaintext dynamic reference.
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references-ssm.html#dynamic-references-ssm-pattern
*/
const SSM_PLAINTEXT_DYNAMIC_REGEX = /{{resolve:ssm:([a-zA-Z0-9_.\-/]+(?::\d+)?)}}/;

/**
* The regular expression used to match an SSM SecureString dynamic reference.
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references-ssm-secure-strings.html#dynamic-references-ssm-secure-pattern
*/
const SSM_SECURE_DYNAMIC_REGEX = /{{resolve:ssm-secure:([a-zA-Z0-9_.\-/]+(?::\d+)?)}}/;

export interface SSMDynamicReference {
/**
* The name of the parameter you want to reference.
* This will also include the version if specified.
*/
parameterName: string;
}

/**
* Parses an SSM plaintext dynamic reference and returns the parameter name.
*
* @param value - The value which contains the SSM plaintext dynamic reference
* @returns The parameter name
*/
export function parseSSMDynamicSecureStringReference(value: string): SSMDynamicReference {
const match = value.match(SSM_SECURE_DYNAMIC_REGEX);
if (!match) {
throw new Error(`Failed to parse SSM SecureString dynamic reference: ${value}`);
}

const [_, parameterName] = match;
return {
parameterName,
};
}

/**
* Parses an SSM SecureString dynamic reference and returns the parameter name.
*
* @param value - The value which contains the SSM SecureString dynamic reference
* @returns The parameter name
*/
export function parseSSMDynamicPlaintextReference(value: string): SSMDynamicReference {
const match = value.match(SSM_PLAINTEXT_DYNAMIC_REGEX);
if (!match) {
throw new Error(`Failed to parse SSM plaintext dynamic reference: ${value}`);
}

const [_, parameterName] = match;
return {
parameterName,
};
}

/**
* Resolves an SSM plaintext dynamic reference
*
* @param parent - The parent resource for the SSM parameter function
* @param value - The value which contains the SSM plaintext dynamic reference
* @returns The parameter value as a pulumi output
*/
export function resolveSSMDynamicPlaintextReference(
parent: pulumi.Resource,
value: string,
): pulumi.Output<string | string[]> {
// This shouldn't happen because we currently only call this where we know we have a string
// but adding this for completeness
if (containsEventuals(value)) {
throw new Error('SSM dynamic references cannot contain unresolved values');
}

const parts = parseSSMDynamicPlaintextReference(value);
return aws.ssm
.getParameterOutput(
{
name: parts.parameterName,
// we don't want to return a decrypted SecureString value
// SecureString types are handled elsewhere
withDecryption: false,
},
{ parent },
)
.apply((v) => {
switch (v.type) {
// CDK/CloudFormation will return a string for both String and StringList types
case 'String':
case 'StringList':
return v.value;
default:
throw new Error(`Unsupported SSM parameter type: ${v.type}`);
}
});
}

/**
* Resolves an SSM SecureString dynamic reference
*
* @param parent - The parent resource for the SSM parameter function
* @param value - The value which contains the SSM SecureString dynamic reference
* @returns The parameter value as a pulumi secret output
*/
export function resolveSSMDynamicSecureStringReference(parent: pulumi.Resource, value: string): pulumi.Output<string> {
// This shouldn't happen because we currently only call this where we know we have a string
// but adding this for completeness
if (containsEventuals(value)) {
throw new Error('SSM dynamic references cannot contain unresolved values');
}

const parts = parseSSMDynamicSecureStringReference(value);
return aws.ssm
.getParameterOutput(
{
name: parts.parameterName,
withDecryption: true,
},
{ parent },
)
.apply((v) => {
switch (v.type) {
case 'SecureString':
return pulumi.secret(v.value);
default:
throw new Error(`Unsupported SSM parameter type: ${v.type}`);
}
});
}

/**
* Used to process a value that may contain a ssm dynamic reference
*
* The value may be a pulumi output (typically if the value contains resource references) or a string.
*
* @param parent - The parent resource
* @param value - A fully resolved value that may contain a ssm dynamic reference
* @returns A secret output if the value is a ssm dynamic reference, otherwise the original value
*/
export function processSSMReferenceValue(parent: pulumi.Resource, value: any): any {
let returnValue = value;
if (pulumi.Output.isInstance(value)) {
returnValue = value.apply((v) => {
if (typeof v === 'string' && v.startsWith('{{resolve:ssm:')) {
return resolveSSMDynamicPlaintextReference(parent, v);
} else if (typeof v === 'string' && v.startsWith('{{resolve:ssm-secure:')) {
return resolveSSMDynamicSecureStringReference(parent, v);
}
return v;
});
} else if (typeof value === 'string' && value.startsWith('{{resolve:ssm-secure:')) {
returnValue = resolveSSMDynamicSecureStringReference(parent, value);
} else if (typeof value === 'string' && value.startsWith('{{resolve:ssm:')) {
returnValue = resolveSSMDynamicPlaintextReference(parent, value);
}
return returnValue;
}
Loading

0 comments on commit aea1309

Please sign in to comment.