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

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

Open
2 tasks
tomohisaota opened this issue Oct 21, 2023 · 2 comments
Open
2 tasks

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

tomohisaota opened this issue Oct 21, 2023 · 2 comments
Labels
@aws-cdk/aws-cloudformation Related to AWS CloudFormation effort/large Large work item – several weeks of effort feature-request A feature should be added or improved. p3

Comments

@tomohisaota
Copy link

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)

@tomohisaota tomohisaota added feature-request A feature should be added or improved. needs-triage This issue or PR still needs to be triaged. labels Oct 21, 2023
@github-actions github-actions bot added the @aws-cdk/aws-cloudformation Related to AWS CloudFormation label Oct 21, 2023
@indrora indrora added p2 effort/large Large work item – several weeks of effort and removed needs-triage This issue or PR still needs to be triaged. labels Oct 23, 2023
@indrora
Copy link
Contributor

indrora commented Oct 23, 2023

This would be a massive departure from the mechanism that CDK uses: The CDK application that you build ideally has zero knowledge of the current state of the stacks that you have built. This is to facilitate the process where the time between cdk synth and cdk deploy could be hours, days, even months.

@indrora indrora added the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Oct 23, 2023
@tomohisaota
Copy link
Author

That would be ideal. I agree.
But on the other hand, there are some cases that stack update depends on current deploy status.
Destroy + Deploy should work, but that is not ideal either.

I came up with this idea when I was adding 3 Dynamo GSIs for 23 stacks. It require 23 * 3 deploys.
I have added gsiVersion variable to my stack, and keep updating stack with manually modifying the variable.
It would be nice if we can automate the "manually modifying the variable" process.

How about creating migration stack to update main stack?
That way, migration stack does not need to know current state of the migration stack.
Just like CDK based Elastic Beanstalk create separate stack for Elastic Beanstalk

@github-actions github-actions bot removed the response-requested Waiting on additional info and feedback. Will move to "closing-soon" in 7 days. label Oct 24, 2023
@pahud pahud added p3 and removed p2 labels Jun 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
@aws-cdk/aws-cloudformation Related to AWS CloudFormation effort/large Large work item – several weeks of effort feature-request A feature should be added or improved. p3
Projects
None yet
Development

No branches or pull requests

3 participants