Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for resolving dynamic ssm values #268

Merged
merged 4 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 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,18 @@ 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,
cpuType: ec2.AmazonLinuxCpuType.ARM_64,
});
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be made slightly more elegant (but also feel free to disregard), if the functions such as parseSSMDynamicSecureStringReference returned an error code instead of throwing an exception. Then they could be combined by trying it out sequentially to parse case A, case B, or case C. In particular the glue code here would not need to perform tests on v.startsWith anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this idea. I updated it to have a single function for all dynamic values with fallthrough.

let returnValue = value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly better to just use lift? It also will take care of promises, not just Output values, which do arise.

const f = (value: any) => {
    // safely assume value is not an output here
    if (typeof value === 'string) {
       // try transforming refs
    } else {
       return value;
    }
};
return lift(f, value);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using lift requires some additional knowledge/handling of the types going into lift (e.g arrays need to be handled as arrays). I took the necessary pieces from lift to make this better.

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
Loading