Skip to content

(aws-cdk): Support multiple migration deploy using versioning #27636

Open
@tomohisaota

Description

@tomohisaota

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions