Automating Migrations for Multiple Databases using Flyway
During development you need a fast, automated way to build multiple copies of a database on any development or test server, with each database at the right version. This article provides a PowerShell automation script for Flyway that will do the job.
Why Automate Flyway?
Although Flyway is easy to use interactively from the console or command-line, it was designed to be easy for developers; it is the natural way of using it. You can use a variety of scripting languages to do this. PowerShell is ideal. With PowerShell and Flyway, it becomes far easier to manage any number of databases to keep them updated at the required version.
Keeping track of database versions
Building or migrating a database reliably requires accuracy. A migration will only work if you are certain of the version of the installed target database. Flyway maintains the current version and the version history in the database of every copy of the database. Flyway does a lot of the checking for potential problems for you. It will ensure that the migration files are done in the right order and make sure that none of the migration steps are ever altered. Flyway will, for example, calculate the checksum of each migration step, and if it has changed it will produce an error:
Migration checksum mismatch for migration version xxxx -> Applied to database : -845035031 -> Resolved locally : 956117173. Either revert the changes to the migration, or run repair to update the schema history.
Scaling up the number of databases
Reality in database development can be daunting. Imagine that you have four copies of your database in the test cell that need to be at the version of the current release candidate. You have four development machines that need to be at the version of the current build, different from the release. Then we have the application developers who require the database that goes with the current release. There is a developer trying to trace a bug reported in a previous release that has been superseded but might still occur sporadically. He needs a database at that release level.
If you’re a developer reading this, and are suffering an involuntary cringe, you will understand that it isn’t quick or easy to manage database development ‘manually’. Flyway, however, can make light of this, whether you are wedded to a migration, static or hybrid approach to source control. If you have the scripts to ‘version-hop’ between versions of the database, Flyway will oblige. All you need to do is to define the versions that each copy of the database should be at, and Flyway will check to make sure they are at that version, and upgrade to the correct version if necessary. This article will show a script that does this. It uses a centralized system that ensures that every database is at the correct level. We can illustrate this as follows:
Batch process or PowerShell?
The simplest approach, perhaps, is to run a batch that just runs the command all the necessary times. To save you having to type in all the parameters at the long command line, you can set the configuration for Flyway in a global (machine-level) config file, user-level config file or at the level of the current directory. However, when working this way, it is hard to check the overall configuration that was used, which can also make debugging hard.
I’ll show how to use the config-file approach in another article, Batch Processing using Flyway, illustrated with a DOS batch script. In this article, I use PowerShell, which I find more convenient for doing Flyway automation because you can fine-tune each operation or run a series of operations. For instance, you may want to do a ‘Flyway Info’ after every operation, or a series of operations after a clean.
Also, rather than use config files, I ‘splat’ the configuration to the command-line, as parameters. I find that this makes automation less prone because you can be more certain of exactly what configuration items Flyway is using.
The design of the PowerShell automation script
I use an alias to invoke Flyway, and pass parameters to Flyaway by splatting an array of parameters. This makes it neater to add parameters conditionally, and to check the script during a debugging session.
The script I show in this article generates a PowerShell object to control the process, which stores all config details regarding the list of databases to be migrated, the actions to be performed and so on. However, in a production system, you’d want to write this information out to a JSON file, stored in version control, so that you can maintain it using a proper JSON editor.
It is very easy to read the PowerShell object in and write them to a JSON file, and then and use the path to this JSON file to specify the databases, the actions required and the version. This means you’d also have a central way to maintain the global flags, placeholders and config details, as well as those that are specific to each database.
Handling credentials
You never have to store a password in this file. The script will store and retrieve any password for a User ID and server in an encrypted form. It will, if it can, get the credentials from file, and will only ask you once for credentials if it can’t get them from file. It then stores them securely for future use, in your user area. If you change your password, you just delete the relevant file in your user area.
Getting database migration reports
Flyway can be persuaded to produce PowerShell-friendly reports. I’ve written a separate article, Using Flyway Output in PowerShell, to explain in more detail how to process this information to produce reports. I’ve shown in this example how, if you specify JSON output, you can save a JSON report to disk, and to also take the details from the standard output of Flyway and store it in an array. If you don’t specify JSON, the text is sent on to standard output.
Specifying Flyway actions, flags and settings
The script is designed so that you can specify one or more default actions for a group of databases or specify one or more actions for one or more databases. The same goes for flags and settings. Actions are the general migration function that you wish Flyway to perform and flags control what Flyway does.
Actions:
migrate
: Migrates the databaseclean
: Drops all objects in the configured schemasinfo
: Prints the information about applied, current and pending migrationsvalidate
: Validates the applied migrations against the ones on theclasspath
undo
: [teams only] Undoes the most recently applied versioned migrationbaseline
: Baselines an existing database at thebaselineVersion
repair
: Repairs the schema history table
Flags:
-X
– Print debug output-q
– Suppress all output, except for errors and warnings-n
– Suppress prompting for a user and password-v
– Print the Flyway version and exit-?
– Print this usage info and exit-community
– Run the Flyway Community Edition (default)-teams
– Run the Flyway Teams Edition
Settings can almost all be specified via config files, environment variables, and parameters. There are too many to list here, but I’ve put them in the body of the script because they are more likely to be used routinely.
Any of them can all be either specified as the default for all the databases in the list, or they can be done for the individual database. Local flags are added to the list of flags for the database, and the same is true of settings. Actions are handled differently. The local list of actions that are specific to the database take precedence over the global action list. The difference that this is an ordered list of actions.
Settings, unlike flags and actions, are key-value pairs so they are represented in the object passed to the script as a hashtable. For local settings, all you need to do is to add them as hashtables. In PowerShell this would be:
1 |
Settings=@{target = '1.1.5';'installedBy'='Philip J. Factor';'connectRetries'=3}; |
In JSON, this would be …
1 2 3 4 5 |
"Settings": { "connectRetries": 3, "installedBy": "Philip J. Factor", "target": "1.1.5" } |
Running multiple database migrations at once: the script
One of the commonest tasks I get Flyway to do is to build a database to a specified version. It isn’t easy to do this purely by a migration because this will only work if you are certain of the version of the installed target database. And, of course, development databases are highly unlikely to be unsullied, at the specified version, because the whole purpose of development databases is to sully them.
Once you’ve made changes, they are no longer at a specific version, so they can’t be migrated. This is also the case if you want to regress the database to an earlier version. I therefore find myself doing the Clean > migrate > Info sequence of actions on a database. The GlobalActions
configuration setting determines the actions that will run on a database, by default, but you can override this at the database level simply by listing the required actions in order, for each database, like this:
1 |
Actions=@('migrate', 'info') |
So here is the script. I’ve set it, as a demonstration, to work on the project PubsAndFly, in the folder PubsFlywaySecondMigration. It is set up to run on three different servers and will migrate any copies of the Pubs
database you specify to the version you specify.
The only mandatory parameters that you need to pass to Flyway command-line are the database connection string, for which you’ll need to fill in the server, database, User ID and port, and the path to your project folder containing the migration files (and in this case, also ensure there is a subfolder within it called Reports).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
function Join-PipelineStrings <# simple function to turn a stream of strings into one long string #> { [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)][string]$String, [Parameter(Position = 1)][string]$Delimiter = "`n" ) BEGIN {$items = @() } PROCESS { $items += $String } END { return ($items -join $Delimiter) } } <# Machine-readable output Add -outputType=json to the argument list to print JSON instead of human-readable output. Errors are included in the JSON payload instead of being sent to stderr. Writing to a file Add -outputFile=/my/output.txt Options (Format: -key=value) ------- driver : Fully qualified classname of the JDBC driver url : Jdbc url to use to connect to the database user : User to use to connect to the database password : Password to use to connect to the database connectRetries : Maximum number of retries when attempting to connect to the database initSql : SQL statements to run to initialize a new database connection schemas : Comma-separated list of the schemas managed by Flyway table : Name of Flyway's schema history table locations : Classpath locations to scan recursively for migrations resolvers : Comma-separated list of custom MigrationResolvers skipDefaultResolvers : Skips default resolvers (jdbc, sql and Spring-jdbc) sqlMigrationPrefix : File name prefix for versioned SQL migrations undoSqlMigrationPrefix : [teams] File name prefix for undo SQL migrations repeatableSqlMigrationPrefix : File name prefix for repeatable SQL migrations sqlMigrationSeparator : File name separator for SQL migrations sqlMigrationSuffixes : Comma-separated list of file name suffixes for SQL migrations stream : [teams] Stream SQL migrations when executing them batch : [teams] Batch SQL statements when executing them mixed : Allow mixing transactional and non-transactional statements encoding : Encoding of SQL migrations placeholderReplacement : Whether placeholders should be replaced placeholders : Placeholders to replace in sql migrations placeholderPrefix : Prefix of every placeholder placeholderSuffix : Suffix of every placeholder lockRetryCount : The maximum number of retries when trying to obtain a lock jdbcProperties : Properties to pass to the JDBC driver object installedBy : Username that will be recorded in the schema history table target : Target version up to which Flyway should use migrations cherryPick : [teams] Comma separated list of migrations that Flyway should consider when migrating skipExecutingMigrations : [teams] Whether Flyway should skip actually executing the contents of the migrations outOfOrder : Allows migrations to be run "out of order" callbacks : Comma-separated list of FlywayCallback classes, or locations to scan for FlywayCallback classes skipDefaultCallbacks : Skips default callbacks (sql) validateOnMigrate : Validate when running migrate validateMigrationNaming : Validate file names of SQL migrations (including callbacks) ignoreMissingMigrations : Allow missing migrations when validating ignoreIgnoredMigrations : Allow ignored migrations when validating ignorePendingMigrations : Allow pending migrations when validating ignoreFutureMigrations : Allow future migrations when validating cleanOnValidationError : Automatically clean on a validation error cleanDisabled : Whether to disable clean baselineVersion : Version to tag schema with when executing baseline baselineDescription : Description to tag schema with when executing baseline baselineOnMigrate : Baseline on migrate against uninitialized non-empty schema configFiles : Comma-separated list of config files to use configFileEncoding : Encoding to use when loading the config files jarDirs : Comma-separated list of dirs for Jdbc drivers & Java migrations createSchemas : Whether Flyway should attempt to create the schemas specified in the schemas property dryRunOutput : [teams] File where to output the SQL statements of a migration dry run errorOverrides : [teams] Rules to override specific SQL states and errors codes oracle.sqlplus : [teams] Enable Oracle SQL*Plus command support licenseKey : [teams] Your Flyway license key color : Whether to colorize output. Values: always, never, or auto (default) outputFile : Send output to the specified file alongside the console outputType : Serialise the output in the given format, Values: json #> # flyway -? #Means get help! #create an alias for the commandline Flyway, IMPORTANT set to your path Set-Alias Flyway 'C:\ProgramData\chocolatey\lib\flyway.commandline\tools\flyway-7.3.1\flyway.cmd' -Scope local # and here are our project details. The project folder $configuration = @{ # to describe the database for monitoring systems and reporting 'ProjectFolder' ='MyPathTo\Github\PubsAndFlyway\PubsFlywaySecondMigration'; # where the migration scripts are stored 'GlobalSettings' =@{ 'outputType'='json'; 'outputFile'='<projectFolder>\Reports\<server><database>.json';#the macros in angle brackets get filled in # the outputfile you can specify the current database with the macro <database>, likewise <server> and <date> 'placeholders.ProjectName' = 'Publications'; # this gets used for reporting 'placeholders.ProjectDescription' = 'A sample project to show how to build a database and fill it with data'; 'placeholders.Datasource' = 'Pubs'} 'GlobalActions' =@('clean','migrate', 'info');#a list of flyway actions you wish to perform, in order <# emigrate, clean, info, validate, undo, baseline or repair #> 'GlobalFlags'=@('community'); # 'X','q','n','v','?','community','teams' 'Databases' = @( @{ Server = 'MyFirstServer' # the name of the server Database = 'PubsEight'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.6'}; Flags=@('community'); # 'X','q','n','v','?','community','teams' }, @{ Server = 'MySecondServer' # the name of the server Database = 'PubsSeven'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '50657'; Settings=@{target = '1.1.3'}; Flags=@('community'); # 'X','q','n','v','?','community','teams' }, @{ Server = 'MySecondServer' # the name of the server Database = 'PubsTwo'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '50657'; Settings=@{target = '1.1.6'}; Flags=@('community'); # 'X','q','n','v','?','community','teams' }, @{ Server = 'MyThirdServer' # the name of the server Database = 'PubsOne'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.6'}; }, @{ Server = 'MyThirdServer' # the name of the server Database = 'PubsSix'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.6'}; }, @{ Server = 'MyFirstServer' # the name of the server Database = 'PubsFive'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.4'}; Actions=@('clean', 'migrate', 'info') Flags=@('q'); # 'X','q','n','v','?','community','teams' }, @{ Server = 'MyThirdServer' # the name of the server Database = 'PubsThree'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.3'}; }, @{ Server = 'MyThirdServer' # the name of the server Database = 'PubsFour'; # The name of the database we are currently migrating Username = 'MyUserID' # only if you need username and password (no domain authentication) Port = '1433'; Settings=@{target = '1.1.5'}; } ); } $HistoryOfResults=@() #a powershell array of objects that stores the output of Flyway <# turn the common args into commandline arguments #> $CommonArgs=$configuration.GlobalSettings.GetEnumerator()|foreach{"-$($_.Name)=$($_.Value)"} <# now iterate through all the databases #> $configuration.Databases | foreach{ #for every database that you've specified ... $MyHistory=@() #have a new history for every database $currentObject=$_ #the object specifying the database and what to do with it $AnError=$False #assume the best $Server = $currentObject.Server # the name of the server $Database = $currentObject.Database; # The name of the database we are currently migrating #escape these names just in case they're used in a filepart as part of a name $EscapedDatabase=$Database.Split([IO.Path]::GetInvalidFileNameChars()) -join '_' $EscapedServer=$Server.Split([IO.Path]::GetInvalidFileNameChars()) -join '_' <# you only need this username and password if there is no domain authentication #> $username = $currentObject.Username; $Settings = $currentObject.Settings; $port = $currentObject.Port; $DataSource = $configuration.DataSource; #where you got the data from # to describe the database for monitoring systems in an extended property $ProjectFolder = $configuration.ProjectFolder # where the migration scripts are stored # add a bit of error-checking. Is the project directory there if (-not (Test-Path "$ProjectFolder")) { Write-warning "Sorry, but I couldn't find a project directory at the $ProjectFolder location"; $AnError=$true } else { # ...and is the script directory there? if (-not (Test-Path "$ProjectFolder\Scripts")) { Write-warning "Sorry, but I couldn't find a scripts directory at the $ProjectFolder\Scripts location" $AnError=$true }} # now we get the password if necessary if ($username -ne '') #then it is using SQL Server Credentials { # we see if we've got these stored already $SqlEncryptedPasswordFile = "$env:USERPROFILE\$($username)-$SourceServer.xml" # test to see if we know about the password in a secure string stored in the user area if (Test-Path -path $SqlEncryptedPasswordFile -PathType leaf) { #has already got this set for this login so fetch it $SqlCredentials = Import-CliXml $SqlEncryptedPasswordFile } else #then we have to ask the user for it (once only) { # hasn't got this set for this login $SqlCredentials = get-credential -Credential $UserName # Save in the user area $SqlCredentials | Export-CliXml -Path $SqlEncryptedPasswordFile <# Export-Clixml only exports encrypted credentials on Windows. otherwise it just offers some obfuscation but does not provide encryption. #> } $Uid = $SqlCredentials.UserName; $Pwd = $SqlCredentials.GetNetworkCredential().password $FlyWayArgs = #create the basic details that Flyway needs @("-url=jdbc:sqlserver://$($Server):$port;databaseName=$Database", "-locations=filesystem:$ProjectFolder\Scripts", <# the migration folder #> "-user=$($SqlCredentials.UserName)", "-password=$($SqlCredentials.GetNetworkCredential().password)") } else { $FlyWayArgs = #create the basic details that Flyway needs @("-url=jdbc:sqlserver://$($Server):$port;databaseName=$Database;integratedSecurity=true". "-locations=filesystem:$ProjectFolder\Scripts")<# the migration folder #> } $FlyWayArgs += $CommonArgs | forEach{ #Do any necessary macro substitution $_ -replace '<database>', $EscapedDatabase -replace #the 'escaped' name of the database '<server>', $EscapedServer -replace #the 'escaped' name of the server (to make a legal filename) '<date>',(Get-Date).ToString('MM-dd-yyyy') -replace #the current date '<projectFolder>', $configuration.ProjectFolder #the path to the project folder }<# the project variables that we reference with placeholders #> if ($configuration.GlobalFlags -ne $null) #add any flags that are to be used for all databases {$FlyWayArgs += $configuration.GlobalFlags|foreach{"-$($_)"}}; if ($currentObject.Flags -ne $null) #add any flags that are to be used for this database {$FlyWayArgs += $currentObject.Flags|foreach{"-$($_)"}}; #work out whetherthe user has specified json output $WantsJSON= (($configuration.GlobalSettings.outputType -eq 'json') -or ($Settings.outputType -eq 'json')) #now add all the settings $FlywayArgs+= $Settings.GetEnumerator()|foreach{"-$($_.Name)=$($_.Value)"} If (-not ($AnError)) { if ($CurrentObject.Actions.count -gt 0 ) { $Myhistory+=$CurrentObject.Actions| #Do whatever action(s) you've specified foreach {flyway $_ @FlywayArgs| Join-PipelineStrings} } elseif ($configuration.GlobalActions.count -gt 0 ) { $Myhistory+=$configuration.GlobalActions|#Do all the global action(s) foreach {flyway $_ @FlywayArgs| Join-PipelineStrings} } else {$Myhistory+=flyway info @FlywayArgs|Join-PipelineStrings} } else {Write-Warning "could not process job for $database on server $server"} if ($wantsJSON) #then add the returned flyway object to the array of objects {$HistoryOfResults += $MyHistory|convertfrom-json | #add the name of the server add-member -Type NoteProperty -PassThru -Name 'server' -Value $Server} $MyHistory |foreach{write-output "$($_)`n"} #output what flyway sent. } #the history of what happened should be in the $HistoryOfResults object. From this #it is possible to generate a report of the outcomes. |
Conclusions
Why automate a task like this? I suspect that it isn’t just my intolerance of boredom that comes from doing an unstimulating and repetitive task. Once a task such as creating copies of a database becomes easy to perform, it can open up possibilities for testing, and allow developers more freedom to experiment. It can alert you to problems far earlier and allow you to include all those precautionary tasks, such as generating build scripts that you know are useful but you somehow never get around to if you perform every task ‘by hand’.
Flyway is built from the ground-up with the philosophy of automating database development tasks, and I hope that I’ve provided you with enough in this article to get you started with PowerShell scripts for Flyway.