Skip to content

Commit

Permalink
Merge pull request #15 from reapit/construct/remote-parameters
Browse files Browse the repository at this point in the history
construct: add cross-region-stack-export
  • Loading branch information
joshbalfour committed Oct 24, 2023
2 parents 9854fa9 + c40e7cb commit e2eedb1
Show file tree
Hide file tree
Showing 33 changed files with 615 additions and 101 deletions.
4 changes: 4 additions & 0 deletions packages/constructs/cross-region-stack-export/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
src
tests
.eslintrc.js
tsconfig.json
52 changes: 52 additions & 0 deletions packages/constructs/cross-region-stack-export/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@reapit-cdk/cross-region-stack-export",
"version": "0.0.0",
"description": "Allows you to share values between stack across regions and accounts.",
"homepage": "https://github.com/reapit/ts-cdk-constructs/blob/main/packages/constructs/cross-region-stack-export",
"readme": "https://github.com/reapit/ts-cdk-constructs/blob/main/packages/constructs/cross-region-stack-export/readme.md",
"bugs": {
"url": "https://github.com/reapit/ts-cdk-constructs/issues"
},
"license": "MIT",
"author": {
"name": "Josh Balfour",
"email": "jbalfour@reapit.com"
},
"repository": {
"url": "https://github.com/reapit/ts-cdk-constructs.git"
},
"scripts": {
"build": "reapit-cdk-tsup --lambda",
"check": "yarn run root:check -p $(pwd)",
"lint": "reapit-cdk-eslint",
"test": "yarn run root:test -- $(pwd)",
"prepack": "reapit-version-package && yarn build",
"integ": "yarn run root:integ -- $(pwd)",
"jsii:build": "rpt-cdk-jsii",
"jsii:publish": "rpt-cdk-jsii --publish"
},
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"main": "dist/index.js",
"types": "dist/index.d.ts"
},
"peerDependencies": {
"aws-cdk-lib": "^2.96.2",
"constructs": "^10.2.70"
},
"devDependencies": {
"@aws-sdk/client-ssm": "3.414.0",
"@aws-sdk/client-sts": "3.414.0",
"@reapit-cdk/custom-resource-wrapper": "workspace:^",
"@reapit-cdk/eslint-config": "workspace:^",
"@reapit-cdk/integration-tests": "workspace:^",
"@reapit-cdk/jsii": "workspace:^",
"@reapit-cdk/tsup": "workspace:^",
"@reapit-cdk/version-package": "workspace:^",
"aws-cdk-lib": "^2.96.2",
"aws-lambda": "^1.0.7",
"aws-sdk-client-mock": "^3.0.0",
"constructs": "^10.2.70"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { PhysicalName, Stack, Token } from 'aws-cdk-lib'
import { AccountPrincipal, ManagedPolicy, Role } from 'aws-cdk-lib/aws-iam'
import { StringParameter } from 'aws-cdk-lib/aws-ssm'
import { RemoteParameters } from './remote-parameters'
import { Construct } from 'constructs'

export class CrossRegionStackImport<ExportKey> extends Construct {
exporter: CrossRegionStackExport<ExportKey>
parameters: RemoteParameters
constructor(scope: Construct, id: string, fromExporter: CrossRegionStackExport<ExportKey>, roleArn: string) {
super(scope, id)
this.exporter = fromExporter

const stack = Stack.of(this)
this.parameters = new RemoteParameters(this, 'parameters', {
path: this.exporter.parameterPath,
region: this.exporter.sourceStack.region,
role: Role.fromRoleArn(this, 'readOnlyRole', roleArn),
})
stack.addDependency(this.exporter.sourceStack)
}

getValue(stackExport: ExportKey | string) {
return this.parameters.get(this.exporter.getParameterName(stackExport))
}
}

export class CrossRegionStackExport<ExportKey> extends Construct {
parameterPath: string
sourceStack: Stack

constructor(scope: Construct, id: string) {
super(scope, id)
const stack = Stack.of(scope)
this.sourceStack = stack
if (Token.isUnresolved(stack.account)) {
throw new Error('stack account is unresolved')
}
if (Token.isUnresolved(stack.region)) {
throw new Error('stack region is unresolved')
}
this.parameterPath = `/${stack.account}/${stack.region}/${stack.stackName}/exports`
}

getParameterName(stackExport: ExportKey | string) {
return `${this.parameterPath}/${stackExport}`
}

setValue(id: ExportKey | string, value: string) {
new StringParameter(this, id as string, {
parameterName: this.getParameterName(id),
stringValue: value,
})
}

getImporter(scope: Construct, id: string) {
const { account } = Stack.of(scope)
const cdkReadOnlyRole = new Role(this, `${account}-readOnlyRole`, {
assumedBy: new AccountPrincipal(account),
roleName: PhysicalName.GENERATE_IF_NEEDED,
managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMReadOnlyAccess')],
})
return new CrossRegionStackImport<ExportKey>(scope, id, this, cdkReadOnlyRole.roleArn)
}
}
2 changes: 2 additions & 0 deletions packages/constructs/cross-region-stack-export/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './remote-parameters'
export * from './cross-region-stack-export'
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'
import { Parameter, SSMClient, paginateGetParametersByPath } from '@aws-sdk/client-ssm'

const getSsmClient = async (region: string, roleArn?: string, sessionName?: string): Promise<SSMClient> => {
if (roleArn && sessionName) {
const client = new STSClient({})
console.log('assuming role', roleArn)
const res = await client.send(
new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: sessionName,
}),
)
const { Credentials } = res
if (!Credentials) {
throw new Error(`no credentials after assuming role ${roleArn}`)
}
const { AccessKeyId, Expiration, SecretAccessKey, SessionToken } = Credentials
if (!AccessKeyId || !SecretAccessKey) {
throw new Error(`no AccessKeyId and/or SecretAccessKey after assuming role ${roleArn}`)
}
console.log('role assumed', roleArn)
return new SSMClient({
region,
credentials: {
accessKeyId: AccessKeyId,
secretAccessKey: SecretAccessKey,
expiration: Expiration,
sessionToken: SessionToken,
},
})
}

if (roleArn && !sessionName) {
throw new Error('recieved roleArn but no sessionName')
}

console.log('no role, not assuming')
return new SSMClient({
region,
})
}

const getPaginatedParams = async (ssmClient: SSMClient, path: string): Promise<Parameter[]> => {
console.log('getting params from', path)
const paginator = paginateGetParametersByPath(
{
client: ssmClient,
},
{
Path: path,
Recursive: true,
WithDecryption: true,
MaxResults: 100,
},
)
const results = []
for await (const page of paginator) {
results.push(...(page.Parameters || []))
console.log('paging', results.length)
}
return results
}

export const getParameters = async (region: string, path: string, roleArn: string, sessionName: string) => {
const ssmClient = await getSsmClient(region, roleArn, sessionName)

const params: Record<string, string> = {}
const results = await getPaginatedParams(ssmClient, path)

results.forEach(({ Name, Value }) => {
if (Name && Value) {
params[Name] = Value
}
})

console.log('done')
return params
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getParameters } from './get-parameters'
import { customResourceWrapper } from '@reapit-cdk/custom-resource-wrapper'

export const onEvent = customResourceWrapper({
onCreate: ({ stackName, regionName, parameterPath, role }) =>
getParameters(regionName, parameterPath, role, stackName),
onUpdate: ({ stackName, regionName, parameterPath, role }) =>
getParameters(regionName, parameterPath, role, stackName),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Stack, CustomResource, aws_iam as iam, aws_logs as logs, custom_resources as cr, Duration } from 'aws-cdk-lib'
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'
import { Construct } from 'constructs'
import * as path from 'path'

/**
* Properties of the RemoteParameters
*/
export interface RemoteParametersProps {
// /**
// * The remote CDK stack to get the parameters from.
// */
// readonly stack: cdk.Stack;
/**
* The region code of the remote stack.
*/
readonly region: string
/**
* The assumed role used to get remote parameters.
*/
readonly role?: iam.IRole
/**
* The parameter path.
*/
readonly path: string
/**
* Indicate whether always update the custom resource to get the new stack output
* @default true
*/
readonly alwaysUpdate?: boolean
}

/**
* Represents the RemoteParameters of the remote CDK stack
*/
export class RemoteParameters extends Construct {
/**
* The parameters in the SSM parameter store for the remote stack.
*/
readonly parameters: CustomResource

constructor(scope: Construct, id: string, props: RemoteParametersProps) {
super(scope, id)

const onEvent = new Function(this, 'func', {
handler: 'lambda.onEvent',
timeout: Duration.seconds(60),
runtime: Runtime.NODEJS_18_X,
code: Code.fromAsset(path.join(__dirname, '..', 'dist', 'lambda')),
})

const myProvider = new cr.Provider(this, 'MyProvider', {
onEventHandler: onEvent,
logRetention: logs.RetentionDays.ONE_DAY,
})

onEvent.addToRolePolicy(
new iam.PolicyStatement({
actions: ['ssm:GetParametersByPath'],
resources: ['*'],
}),
)

this.parameters = new CustomResource(this, 'SsmParameters', {
serviceToken: myProvider.serviceToken,
properties: {
stackName: Stack.of(this).stackName,
regionName: props.region,
parameterPath: props.path,
randomString: props.alwaysUpdate === false ? undefined : randomString(),
role: props.role?.roleArn,
},
})

if (props.role) {
myProvider.onEventHandler.addToRolePolicy(
new iam.PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [props.role.roleArn],
}),
)
}
}

/**
* Get the parameter.
* @param key output key
*/
public get(key: string) {
return this.parameters.getAttString(key)
}
}

function randomString() {
// Crazy
return Math.random()
.toString(36)
.replace(/[^a-z0-9]+/g, '')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Template } from 'aws-cdk-lib/assertions'
import * as cdk from 'aws-cdk-lib'
import { RemoteParameters } from '../src'

const synth = () => {
const app = new cdk.App()
const stack = new cdk.Stack(app)
const remoteParameters = new RemoteParameters(stack, 'params', {
path: 'asdf',
region: 'eu-west-2',
})
const template = Template.fromStack(stack)
return {
remoteParameters,
template,
stack,
}
}

describe('active-ruleset', () => {
test('synthesizes', () => {
const { remoteParameters, template } = synth()
expect(remoteParameters).toBeDefined()
expect(template).toBeDefined()
})
})
Loading

0 comments on commit e2eedb1

Please sign in to comment.