Open
Description
Describe the feature
Deploying new stack is peace of cake.
But updating existing stack faces many restrictions of CDK and Cloudformation.
This idea is to divides cdk deploy into multiple migration deploys so that you can overcome those restrictions.
This idea is inspired by DB migration , but quite different.
Use Case
- Adding/Deleting multiple Dynamo GSI to existing table (add one GSI at a time)
- Recreate named resource which does not support replacing (delete and recreate)
- Update SSL Certificate with ELB Attached (Detach ELB, Update Cert, Attache ELB)
Proposed Solution
- Store deployed migration version information in cloudformation tag
- cdk deploy task runs multiple cdk deploys with incremental changes until stack reaches target version
I'm not export in CDK CLI implementation. So here is proof of concept code for versioning
// Use semantic versioning as example.
// Version label can be any unique string sequence
export const VersionLabel = {
CREATE_DYNAMODB: "1",
ADD_GSI1: "2",
ADD_GSI2: "3",
ADD_GSI3: "4",
DELETE_GSI2: "5",
} as const
export type TVersionLabel = typeof VersionLabel[keyof typeof VersionLabel];
type TMigrationOptions<T> = {
initialVersion?: T,// Version for initial deploy. Default: target version
currentVersion?: T, // Currently deployed version
targetVersion?: T, // Keep updating stacks until it has target version, Default: latest version
activeVersions?: T[]
}
// Core Logic
function generateMigrations<T>(params: TMigrationOptions<T>): T[][] {
const migrations: T[][] = []
const {activeVersions} = params
if (activeVersions === undefined) {
// No migration
return migrations
}
//validate
if (params.initialVersion && !activeVersions.includes(params.initialVersion)) {
throw new Error(`Invalid initialVersion=${params.initialVersion}`)
}
if (params.currentVersion && !activeVersions.includes(params.currentVersion)) {
throw new Error(`Invalid currentVersion=${params.currentVersion}`)
}
if (params.targetVersion && !activeVersions.includes(params.targetVersion)) {
throw new Error(`Invalid targetVersion=${params.targetVersion}`)
}
if (activeVersions.length !== new Set(activeVersions).size) {
throw new Error(`Duplicate version in activeVersions`)
}
//
const latestVersion = activeVersions[activeVersions.length - 1]
const targetVersion = params.targetVersion ?? latestVersion
let currentVersion = params.currentVersion
if (currentVersion === undefined) {
currentVersion = params.initialVersion ?? targetVersion
}
// deploy migration from currentVersion to targetVersion
const upToVersion = (v: T): T[] => {
return activeVersions.slice(0, activeVersions.indexOf(v) + 1)
}
if (currentVersion === targetVersion) {
migrations.push(upToVersion(targetVersion))
} else {
let i = currentVersion
while (true) {
const iIndex = activeVersions.indexOf(i)
const tIndex = activeVersions.indexOf(targetVersion)
if (iIndex === tIndex) {
break
} else if (iIndex < tIndex) {
// Migrate Up
i = activeVersions[iIndex + 1]
} else {
// Migrate Down
i = activeVersions[iIndex - 1]
}
migrations.push(upToVersion(i))
}
}
return migrations
}
// Utility
function check(params: {
readonly activeVersions: TVersionLabel[],
readonly since?: TVersionLabel, // inclusive
readonly until?: TVersionLabel // exclusive, there may be better term
}): boolean {
const {activeVersions, since, until} = params
let isTarget = true
if (since !== undefined) {
isTarget &&= activeVersions.includes(since)
}
if (until !== undefined) {
isTarget &&= (!activeVersions.includes(until)) || (activeVersions[activeVersions.length - 1] === until)
}
return isTarget
}
function mock(activeVersions: TVersionLabel[]): string[] {
const resources: string[] = []
resources.push("Table") // no version control
if (check({activeVersions, since: VersionLabel.ADD_GSI1})) {
resources.push("GSI1")
}
if (check({activeVersions, since: VersionLabel.ADD_GSI2, until: VersionLabel.DELETE_GSI2})) {
resources.push("GSI2")
}
if (check({activeVersions, since: VersionLabel.ADD_GSI3})) {
resources.push("GSI3")
}
return resources
}
function deploy(params: TMigrationOptions<TVersionLabel>) {
console.log(`-- deploy initialVersion:${params.initialVersion} currentVersion:${params.currentVersion} targetVersion:${params.targetVersion}`)
for (const activeVersions of generateMigrations(params)) {
console.log(` migration deploy v=${activeVersions[activeVersions.length - 1]} active=(${activeVersions.join(",")}) resources=(${mock(activeVersions).join(",")})`)
}
}
function main() {
const activeVersions: TVersionLabel[] = Object.values(VersionLabel)
// treat bugfix version as optional
console.log(`Versions: ${activeVersions.join(" -> ")}`)
deploy({
activeVersions,
initialVersion: undefined,
currentVersion: undefined,
targetVersion: undefined
})
deploy({
activeVersions,
initialVersion: undefined,
currentVersion: undefined,
targetVersion: VersionLabel.ADD_GSI3,
})
deploy({
activeVersions,
currentVersion: VersionLabel.ADD_GSI1,
targetVersion: undefined,
})
deploy({
activeVersions,
currentVersion: VersionLabel.ADD_GSI1,
targetVersion: VersionLabel.ADD_GSI3,
})
deploy({
activeVersions,
currentVersion: VersionLabel.DELETE_GSI2,
targetVersion: VersionLabel.ADD_GSI1,
})
}
main()
Result
Versions: 1 -> 2 -> 3 -> 4 -> 5
-- deploy initialVersion:undefined currentVersion:undefined targetVersion:undefined
migration deploy v=5 active=(1,2,3,4,5) resources=(Table,GSI1,GSI2,GSI3)
-- deploy initialVersion:undefined currentVersion:undefined targetVersion:4
migration deploy v=4 active=(1,2,3,4) resources=(Table,GSI1,GSI2,GSI3)
-- deploy initialVersion:undefined currentVersion:2 targetVersion:undefined
migration deploy v=3 active=(1,2,3) resources=(Table,GSI1,GSI2)
migration deploy v=4 active=(1,2,3,4) resources=(Table,GSI1,GSI2,GSI3)
migration deploy v=5 active=(1,2,3,4,5) resources=(Table,GSI1,GSI2,GSI3)
-- deploy initialVersion:undefined currentVersion:2 targetVersion:4
migration deploy v=3 active=(1,2,3) resources=(Table,GSI1,GSI2)
migration deploy v=4 active=(1,2,3,4) resources=(Table,GSI1,GSI2,GSI3)
-- deploy initialVersion:undefined currentVersion:5 targetVersion:2
migration deploy v=4 active=(1,2,3,4) resources=(Table,GSI1,GSI2,GSI3)
migration deploy v=3 active=(1,2,3) resources=(Table,GSI1,GSI2)
migration deploy v=2 active=(1,2) resources=(Table,GSI1)
Other Information
- Migration may require run multiple cdk deploys for single deploy task
- You can also run down migration
- Unlike DB migration, you don’t need version for every single change
- Unlike DB migration, you need to keep old resources in code to make migration work
Acknowledgements
- I may be able to implement this feature request
- This feature might incur a breaking change
CDK version used
2.95.1
Environment details (OS name and version, etc.)
OSX 13.6(22G120)